feat(identity): add Spanish localization for account management

- Introduced `es.json` for Spanish translations in account management.
- Created account routes and controllers for managing user accounts.
- Implemented hooks and API calls for fetching and updating account profiles.
- Developed forms and validation schemas for account profile and password management.
- Added error handling and feedback mechanisms for account actions.
- Built UI components for account settings page, including password change dialog.
- Integrated translations for new UI components and error messages.
This commit is contained in:
David Arranz 2026-08-18 00:56:04 +02:00
parent fbf26d4745
commit 64b90888c3
41 changed files with 1483 additions and 19 deletions

View File

@ -36,6 +36,16 @@ export const AppTopbar = () => {
navigate("/login", { replace: true });
};
const handleOpenAccount = () => {
navigate("/account");
};
const handleLogoutClick = () => {
handleLogout().catch(() => {
navigate("/login", { replace: true });
});
};
return (
<header className="flex h-14 shrink-0 items-center border-b border-border bg-background">
<div className="flex h-full min-w-0 flex-1 items-center gap-3 px-4">
@ -144,12 +154,13 @@ export const AppTopbar = () => {
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
<DropdownMenuLabel>{t("web.accountMenu.label")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Perfil</DropdownMenuItem>
<DropdownMenuItem>Preferencias</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleLogout()}>
Cerrar sesión
<DropdownMenuItem onClick={handleOpenAccount}>
{t("web.accountMenu.openAccount")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogoutClick}>
{t("web.accountMenu.logout")}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>

View File

@ -138,6 +138,11 @@
"note": "Monthly billing is only calculated when the API returns every invoice from the month in a bounded batch and using a single currency.",
"link": "Open issued invoices"
}
},
"accountMenu": {
"label": "My account",
"openAccount": "Open my account",
"logout": "Sign out"
}
}
}

View File

@ -138,6 +138,11 @@
"note": "La facturación mensual se calcula solo cuando la API devuelve todas las facturas del mes en un lote acotado y con una única divisa.",
"link": "Abrir facturas emitidas"
}
},
"accountMenu": {
"label": "Mi cuenta",
"openAccount": "Abrir mi cuenta",
"logout": "Cerrar sesión"
}
}
}

View File

@ -70,17 +70,9 @@ La pantalla no selecciona el target mediante `companyId`.
### 3.2 `/account`
`/account` queda reservado para configuración personal del usuario.
`/account` queda reservado para configuración personal del usuario autenticado.
Ejemplos futuros:
```txt
/account/profile
/account/preferences
/account/security
```
No forma parte de este diseño y no debe mezclarse con `/settings`.
En la implementación actual se resuelve como pantalla única y no debe mezclarse con `/settings`.
Backend ya preparado actualmente para el subtree de usuario:
@ -99,6 +91,26 @@ Regla de acceso:
→ no requiere X-Company-Id
```
Composición funcional actual de la pantalla:
```txt
/account
├─ información personal
│ ├─ name
│ ├─ email
│ └─ language_code
├─ seguridad
│ └─ cambio de contraseña mediante dialog
└─ sesión
└─ logout reutilizado
```
Restricciones conocidas de la V1 actual:
- el avatar solo se muestra; no existe upload real confirmado para exponer edición en la UI
- el backend mantiene `avatar_url` en el contrato por compatibilidad actual
- enable/disable global de usuarios sigue siendo una capacidad de dominio pendiente de exposición administrativa
No se introduce `scope: "company" | "user"` en V1.
---

View File

@ -30,6 +30,8 @@ function shouldAttachCompanyHeader(pathname: string | null) {
"/identity/auth/refresh",
"/identity/auth/logout",
"/identity/auth/session",
"/identity/account",
"/identity/account/change-password",
"/companies/available",
];

View File

@ -75,10 +75,28 @@ Reglas actuales:
- el cambio de email es inmediato y sin verificación adicional
- `avatar_url` es una referencia externa; `identity` no implementa subida de archivos
- la UI web de `/account` no expone todavía edición de avatar porque falta una infraestructura real de upload
- cambio de contraseña exige `current_password`
- cambio de contraseña revoca todos los refresh tokens persistidos de la cuenta
- los access tokens ya emitidos siguen siendo válidos hasta expirar, porque hoy no existe un subsistema de invalidación inmediata de access tokens
### UX actual de `/account`
La pantalla web `/account` se presenta como una única vista de self-service para el usuario autenticado.
Contenido visible actual:
- información personal: `name`, `email`, `language_code`
- avatar en modo solo lectura, con fallback por iniciales
- seguridad mediante dialog para cambio de contraseña
- cierre de sesión reutilizando el flujo de logout existente
Pendientes explícitos:
- upload real de avatar
- enable/disable global administrable por un actor autorizado
- administración global de memberships por compañía
## Estado global de Account
`Account.status` representa el estado global de autenticación de la identidad:

View File

@ -23,6 +23,7 @@
},
"dependencies": {
"@erp/core": "workspace:*",
"@repo/i18next": "workspace:*",
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-logger": "workspace:*",
@ -52,4 +53,4 @@
"rimraf": "^6.1.3",
"typescript": "^6.0.2"
}
}
}

View File

@ -0,0 +1,142 @@
{
"account": {
"title": "My account",
"description": "Manage your personal details, preferences, and security.",
"loading": "Loading your account...",
"status": {
"active": "Active",
"disabled": "Disabled",
"pending_verification": "Pending verification"
},
"sections": {
"personal": {
"title": "Personal information",
"description": "Update your name, email, and the language you want to use by default.",
"fields": {
"name": {
"label": "Name",
"placeholder": "Enter your name"
},
"email": {
"label": "Email",
"placeholder": "name@company.com"
},
"languageCode": {
"label": "Default language",
"description": "It will be used as your account preference.",
"placeholder": "Select a language",
"options": {
"es": "Spanish",
"en": "English"
}
}
}
},
"security": {
"title": "Security",
"description": "Manage how you access your account.",
"passwordTitle": "Password",
"passwordDescription": "Change the password you use to sign in to your account."
},
"session": {
"title": "Session",
"description": "Sign out from your current session in this browser."
}
},
"passwordDialog": {
"title": "Change password",
"description": "Enter your current password and the new password you want to use.",
"actions": {
"cancel": "Cancel"
},
"fields": {
"currentPassword": {
"label": "Current password",
"description": "We need it to confirm the change.",
"placeholder": "Enter your current password"
},
"newPassword": {
"label": "New password",
"description": "It must contain at least 12 characters.",
"placeholder": "Enter the new password"
},
"confirmPassword": {
"label": "Confirm new password",
"description": "It must match the new password.",
"placeholder": "Repeat the new password"
}
}
},
"actions": {
"saveProfile": "Save changes",
"savingProfile": "Saving changes...",
"resetProfile": "Reset changes",
"changePassword": "Change password",
"changingPassword": "Changing password...",
"logout": "Sign out"
},
"feedback": {
"idle": "No pending changes.",
"savingProfile": "Saving changes.",
"changingPassword": "Updating password.",
"profileSuccess": {
"title": "Changes saved",
"message": "Your personal details were updated successfully."
},
"passwordSuccess": {
"title": "Password updated",
"message": "Your password was changed successfully."
},
"logoutSuccess": {
"title": "Signed out",
"message": "You have successfully signed out from the ERP."
},
"validation": {
"title": "Review the data",
"message": "Fix the highlighted fields before continuing."
},
"noProfileChanges": {
"title": "No changes",
"message": "There are no pending changes in your account."
},
"noPasswordChanges": {
"title": "No changes",
"message": "Enter a new password before continuing."
}
},
"pageError": {
"actions": {
"retry": "Retry",
"retrying": "Retrying..."
},
"forbidden": {
"title": "You do not have access to your account",
"description": "Your session no longer has permission to read this profile."
},
"notFound": {
"title": "Your account is no longer available",
"description": "The authenticated identity could not be found."
},
"network": {
"title": "Could not connect to the server",
"description": "Check your connection and try again."
},
"timeout": {
"title": "The request is taking too long",
"description": "The server did not respond within the expected time."
},
"server": {
"title": "Could not load your account",
"description": "An internal error occurred while retrieving your profile."
},
"unavailable": {
"title": "Service temporarily unavailable",
"description": "The identity service is unavailable right now."
},
"unknown": {
"title": "Could not load your account",
"description": "An unexpected error occurred while opening your profile."
}
}
}
}

View File

@ -0,0 +1,142 @@
{
"account": {
"title": "Mi cuenta",
"description": "Gestiona tus datos personales, preferencias y seguridad.",
"loading": "Cargando tu cuenta...",
"status": {
"active": "Activa",
"disabled": "Deshabilitada",
"pending_verification": "Pendiente de verificación"
},
"sections": {
"personal": {
"title": "Información personal",
"description": "Actualiza tu nombre, tu email y el idioma que prefieres usar por defecto.",
"fields": {
"name": {
"label": "Nombre",
"placeholder": "Introduce tu nombre"
},
"email": {
"label": "Email",
"placeholder": "nombre@empresa.com"
},
"languageCode": {
"label": "Idioma por defecto",
"description": "Se aplicará como preferencia de tu cuenta.",
"placeholder": "Selecciona un idioma",
"options": {
"es": "Español",
"en": "English"
}
}
}
},
"security": {
"title": "Seguridad",
"description": "Gestiona cómo accedes a tu cuenta.",
"passwordTitle": "Contraseña",
"passwordDescription": "Cambia la contraseña que utilizas para acceder a tu cuenta."
},
"session": {
"title": "Sesión",
"description": "Cierra tu sesión actual en este navegador."
}
},
"passwordDialog": {
"title": "Cambiar contraseña",
"description": "Introduce tu contraseña actual y la nueva contraseña que quieres usar.",
"actions": {
"cancel": "Cancelar"
},
"fields": {
"currentPassword": {
"label": "Contraseña actual",
"description": "La necesitamos para confirmar el cambio.",
"placeholder": "Introduce tu contraseña actual"
},
"newPassword": {
"label": "Nueva contraseña",
"description": "Debe tener al menos 12 caracteres.",
"placeholder": "Introduce la nueva contraseña"
},
"confirmPassword": {
"label": "Confirmar nueva contraseña",
"description": "Debe coincidir con la nueva contraseña.",
"placeholder": "Repite la nueva contraseña"
}
}
},
"actions": {
"saveProfile": "Guardar cambios",
"savingProfile": "Guardando cambios...",
"resetProfile": "Restablecer cambios",
"changePassword": "Cambiar contraseña",
"changingPassword": "Cambiando contraseña...",
"logout": "Cerrar sesión"
},
"feedback": {
"idle": "Sin cambios pendientes.",
"savingProfile": "Guardando cambios.",
"changingPassword": "Actualizando contraseña.",
"profileSuccess": {
"title": "Cambios guardados",
"message": "Tus datos personales se han actualizado correctamente."
},
"passwordSuccess": {
"title": "Contraseña actualizada",
"message": "Tu contraseña se ha cambiado correctamente."
},
"logoutSuccess": {
"title": "Sesión cerrada",
"message": "Has salido correctamente del ERP."
},
"validation": {
"title": "Revisa los datos",
"message": "Corrige los campos marcados antes de continuar."
},
"noProfileChanges": {
"title": "Sin cambios",
"message": "No hay cambios pendientes en tu cuenta."
},
"noPasswordChanges": {
"title": "Sin cambios",
"message": "Introduce una nueva contraseña antes de continuar."
}
},
"pageError": {
"actions": {
"retry": "Reintentar",
"retrying": "Reintentando..."
},
"forbidden": {
"title": "No tienes acceso a tu cuenta",
"description": "Tu sesión ya no tiene permisos para consultar este perfil."
},
"notFound": {
"title": "Tu cuenta ya no está disponible",
"description": "No se ha encontrado la identidad autenticada."
},
"network": {
"title": "No se pudo conectar con el servidor",
"description": "Comprueba tu conexión e inténtalo de nuevo."
},
"timeout": {
"title": "La carga está tardando demasiado",
"description": "El servidor no respondió dentro del tiempo esperado."
},
"server": {
"title": "No se pudo cargar tu cuenta",
"description": "Se produjo un error interno al recuperar tu perfil."
},
"unavailable": {
"title": "Servicio temporalmente no disponible",
"description": "El servicio de identidad no está disponible en este momento."
},
"unknown": {
"title": "No se pudo cargar tu cuenta",
"description": "Ha ocurrido un error inesperado al abrir tu perfil."
}
}
}
}

View File

@ -0,0 +1,23 @@
import { lazy } from "react";
import type { ModuleClientParams, ModuleRouteObject } from "@erp/core/client";
const AccountPage = lazy(() =>
import("./account").then((module) => ({
default: module.AccountPage,
}))
);
export const IdentityAccountRoutes = (_params: ModuleClientParams): ModuleRouteObject[] => {
return [
{
path: "account",
handle: {
layout: "app-sidebar",
protected: true,
requireCompany: false,
},
element: <AccountPage />,
},
];
};

View File

@ -0,0 +1 @@
export * from "./use-account-page-controller";

View File

@ -0,0 +1,302 @@
import { applyValidationErrorCollection } from "@erp/core";
import { formHasAnyDirty, getUserErrorMessage } from "@erp/core/client";
import { useHookForm } from "@erp/core/hooks";
import { useIdentityAuthSession } from "@erp/identity/client";
import { isValidationErrorCollection } from "@repo/rdx-ddd";
import {
focusFirstInputFormError,
getFirstFormErrorMessage,
preventEnterKeySubmitForm,
showErrorToast,
showSuccessToast,
showWarningToast,
} from "@repo/rdx-ui/helpers";
import { useCallback, useEffect, useId, useMemo, useState, type SubmitEvent } from "react";
import type { FieldErrors } from "react-hook-form";
import { useTranslation } from "../../i18n";
import {
type AccountPasswordForm,
AccountPasswordFormSchema,
type AccountProfileForm,
AccountProfileFormSchema,
} from "../entities";
import {
useChangeCurrentAccountPasswordMutation,
useCurrentAccountQuery,
useUpdateCurrentAccountMutation,
} from "../shared";
import {
buildAccountPageErrorViewModel,
buildAccountProfileForm,
buildUpdateAccountProfileParams,
} from "../utils";
const EMPTY_PROFILE_FORM: AccountProfileForm = {
name: "",
email: "",
avatarUrl: "",
languageCode: "es",
};
const EMPTY_PASSWORD_FORM: AccountPasswordForm = {
currentPassword: "",
newPassword: "",
confirmPassword: "",
};
export const useAccountPageController = () => {
const { t } = useTranslation();
const { account: sessionAccount, logout, updateAccount } = useIdentityAuthSession();
const profileFormId = useId();
const passwordFormId = useId();
const [passwordSuccessVersion, setPasswordSuccessVersion] = useState(0);
const accountQuery = useCurrentAccountQuery();
const updateProfileMutation = useUpdateCurrentAccountMutation();
const changePasswordMutation = useChangeCurrentAccountPasswordMutation();
const profileInitialValues = useMemo<AccountProfileForm>(() => {
if (!accountQuery.data) {
return EMPTY_PROFILE_FORM;
}
return buildAccountProfileForm(accountQuery.data);
}, [accountQuery.data]);
const profileForm = useHookForm<AccountProfileForm>({
resolverSchema: AccountProfileFormSchema,
initialValues: profileInitialValues,
disabled: updateProfileMutation.isPending,
});
const passwordForm = useHookForm<AccountPasswordForm>({
resolverSchema: AccountPasswordFormSchema,
initialValues: EMPTY_PASSWORD_FORM,
disabled: changePasswordMutation.isPending,
});
useEffect(() => {
if (!accountQuery.data || profileForm.formState.isDirty) {
return;
}
profileForm.reset(buildAccountProfileForm(accountQuery.data), {
keepDirty: false,
});
}, [accountQuery.data, profileForm, profileForm.formState.isDirty]);
const pageError = useMemo(() => {
if (!accountQuery.error) {
return null;
}
return buildAccountPageErrorViewModel(accountQuery.error, t);
}, [accountQuery.error, t]);
const inlineProfileError = useMemo(() => {
if (!updateProfileMutation.error) {
return null;
}
const userMessage = getUserErrorMessage(updateProfileMutation.error);
return userMessage;
}, [updateProfileMutation.error]);
const inlinePasswordError = useMemo(() => {
if (!changePasswordMutation.error) {
return null;
}
const userMessage = getUserErrorMessage(changePasswordMutation.error);
return userMessage;
}, [changePasswordMutation.error]);
const resetProfileForm = useCallback(() => {
if (!accountQuery.data) {
return;
}
profileForm.reset(buildAccountProfileForm(accountQuery.data), {
keepDirty: false,
});
}, [accountQuery.data, profileForm]);
const resetPasswordForm = useCallback(() => {
passwordForm.reset(EMPTY_PASSWORD_FORM, {
keepDirty: false,
});
}, [passwordForm]);
const clearPasswordDialogState = useCallback(() => {
passwordForm.reset(EMPTY_PASSWORD_FORM, {
keepDirty: false,
keepErrors: false,
keepTouched: false,
keepIsSubmitted: false,
keepSubmitCount: false,
});
passwordForm.clearErrors();
changePasswordMutation.reset();
}, [changePasswordMutation, passwordForm]);
const profileSubmitHandler = profileForm.handleSubmit(
async (formData) => {
if (!formHasAnyDirty(profileForm.formState.dirtyFields)) {
showWarningToast(
t("account.feedback.noProfileChanges.title"),
t("account.feedback.noProfileChanges.message")
);
return;
}
try {
const updated = await updateProfileMutation.mutateAsync(
buildUpdateAccountProfileParams({
form: formData,
dirtyFields: profileForm.formState.dirtyFields,
})
);
updateAccount({
id: updated.id,
email: updated.email,
name: updated.name,
avatarUrl: updated.avatarUrl,
languageCode: updated.languageCode,
});
profileForm.reset(buildAccountProfileForm(updated), {
keepDirty: false,
});
showSuccessToast(
t("account.feedback.profileSuccess.title"),
t("account.feedback.profileSuccess.message")
);
} catch (error: unknown) {
if (isValidationErrorCollection(error)) {
applyValidationErrorCollection(profileForm, error);
focusFirstInputFormError(profileForm.formState.errors, profileForm);
showWarningToast(
t("account.feedback.validation.title"),
t("account.feedback.validation.message")
);
return;
}
const userMessage = getUserErrorMessage(error);
showErrorToast(userMessage.title, userMessage.description);
}
},
(errors: FieldErrors<AccountProfileForm>) => {
focusFirstInputFormError(errors, profileForm);
showWarningToast(
t("account.feedback.validation.title"),
getFirstFormErrorMessage(errors, profileForm) ?? t("account.feedback.validation.message")
);
}
);
const passwordSubmitHandler = passwordForm.handleSubmit(
async (formData) => {
if (!formHasAnyDirty(passwordForm.formState.dirtyFields)) {
showWarningToast(
t("account.feedback.noPasswordChanges.title"),
t("account.feedback.noPasswordChanges.message")
);
return;
}
try {
await changePasswordMutation.mutateAsync({
current_password: formData.currentPassword,
new_password: formData.newPassword,
});
passwordForm.reset(EMPTY_PASSWORD_FORM, {
keepDirty: false,
});
setPasswordSuccessVersion((value) => value + 1);
showSuccessToast(
t("account.feedback.passwordSuccess.title"),
t("account.feedback.passwordSuccess.message")
);
} catch (error: unknown) {
if (isValidationErrorCollection(error)) {
applyValidationErrorCollection(passwordForm, error);
focusFirstInputFormError(passwordForm.formState.errors, passwordForm);
showWarningToast(
t("account.feedback.validation.title"),
t("account.feedback.validation.message")
);
return;
}
const userMessage = getUserErrorMessage(error);
showErrorToast(userMessage.title, userMessage.description);
}
},
(errors: FieldErrors<AccountPasswordForm>) => {
focusFirstInputFormError(errors, passwordForm);
showWarningToast(
t("account.feedback.validation.title"),
getFirstFormErrorMessage(errors, passwordForm) ?? t("account.feedback.validation.message")
);
}
);
const onProfileSubmit = (event: SubmitEvent<HTMLFormElement>) => {
event.stopPropagation();
profileSubmitHandler(event);
};
const onPasswordSubmit = (event: SubmitEvent<HTMLFormElement>) => {
event.stopPropagation();
passwordSubmitHandler(event);
};
const retry = useCallback(async () => {
await accountQuery.refetch();
}, [accountQuery]);
const handleLogout = useCallback(() => {
logout()
.then(() => {
showSuccessToast(
t("account.feedback.logoutSuccess.title"),
t("account.feedback.logoutSuccess.message")
);
})
.catch((error: unknown) => {
const userMessage = getUserErrorMessage(error);
showErrorToast(userMessage.title, userMessage.description);
});
}, [logout, t]);
return {
account: accountQuery.data,
sessionAccount,
profileForm,
profileFormId,
passwordForm,
passwordFormId,
pageError,
inlineProfileError,
inlinePasswordError,
isInitialLoading: accountQuery.isLoading,
isRetrying: accountQuery.isFetching && !accountQuery.isLoading,
isSavingProfile: updateProfileMutation.isPending,
isChangingPassword: changePasswordMutation.isPending,
onFormKeyDown: preventEnterKeySubmitForm,
onProfileSubmit,
onPasswordSubmit,
retry,
resetProfileForm,
resetPasswordForm,
clearPasswordDialogState,
passwordSuccessVersion,
handleLogout,
};
};

View File

@ -0,0 +1,9 @@
import type { PageErrorStateVariant } from "@erp/core/components";
export interface AccountPageErrorViewModel {
variant: PageErrorStateVariant;
title: string;
description: string;
reference?: string;
canRetry: boolean;
}

View File

@ -0,0 +1,22 @@
import { z } from "zod/v4";
export const AccountPasswordFormSchema = z
.object({
currentPassword: z.string().min(1, "La contraseña actual es obligatoria."),
newPassword: z
.string()
.min(1, "La nueva contraseña es obligatoria.")
.min(12, "La nueva contraseña debe tener al menos 12 caracteres."),
confirmPassword: z.string().min(1, "Confirma la nueva contraseña."),
})
.superRefine((formData, ctx) => {
if (formData.newPassword !== formData.confirmPassword) {
ctx.addIssue({
code: "custom",
path: ["confirmPassword"],
message: "Las contraseñas no coinciden.",
});
}
});
export type AccountPasswordForm = z.infer<typeof AccountPasswordFormSchema>;

View File

@ -0,0 +1,11 @@
import { EmailSchema, LanguageCodeSchema, URLSchema } from "@erp/core";
import { z } from "zod/v4";
export const AccountProfileFormSchema = z.object({
name: z.string().trim().min(1),
email: EmailSchema,
avatarUrl: z.union([z.literal(""), URLSchema]),
languageCode: LanguageCodeSchema,
});
export type AccountProfileForm = z.infer<typeof AccountProfileFormSchema>;

View File

@ -0,0 +1,3 @@
export * from "./account-page-error-view-model.entity";
export * from "./account-password-form.schema";
export * from "./account-profile-form.schema";

View File

@ -0,0 +1 @@
export { AccountPage } from "./ui/pages/account-page";

View File

@ -0,0 +1,21 @@
import type {
GetAccountProfileResponseDTO,
UpdateAccountProfileResponseDTO,
} from "@erp/identity/common";
import type { AccountProfile } from "../entities";
export const AccountProfileAdapter = {
fromDto(
dto: GetAccountProfileResponseDTO | UpdateAccountProfileResponseDTO
): AccountProfile {
return {
id: dto.id,
email: dto.email,
name: dto.name,
avatarUrl: dto.avatar_url,
languageCode: dto.language_code,
status: dto.status,
};
},
};

View File

@ -0,0 +1 @@
export * from "./account-profile.adapter";

View File

@ -0,0 +1,16 @@
import type { IDataSource } from "@erp/core/client";
import type {
ChangeAccountPasswordRequestDTO,
ChangeAccountPasswordResponseDTO,
} from "@erp/identity/common";
export function changeCurrentAccountPassword(
dataSource: IDataSource,
params: ChangeAccountPasswordRequestDTO
): Promise<ChangeAccountPasswordResponseDTO> {
return dataSource.custom<ChangeAccountPasswordRequestDTO, ChangeAccountPasswordResponseDTO>({
path: "identity/account/change-password",
method: "post",
data: params,
});
}

View File

@ -0,0 +1,10 @@
import type { IDataSource } from "@erp/core/client";
import type { GetAccountProfileResponseDTO } from "@erp/identity/common";
export function getCurrentAccount(dataSource: IDataSource): Promise<GetAccountProfileResponseDTO> {
return dataSource.custom<Record<string, never>, GetAccountProfileResponseDTO>({
path: "identity/account",
method: "get",
data: {},
});
}

View File

@ -0,0 +1,3 @@
export * from "./change-current-account-password.api";
export * from "./get-current-account.api";
export * from "./update-current-account.api";

View File

@ -0,0 +1,16 @@
import type { IDataSource } from "@erp/core/client";
import type {
UpdateAccountProfileRequestDTO,
UpdateAccountProfileResponseDTO,
} from "@erp/identity/common";
export function updateCurrentAccount(
dataSource: IDataSource,
params: UpdateAccountProfileRequestDTO
): Promise<UpdateAccountProfileResponseDTO> {
return dataSource.custom<UpdateAccountProfileRequestDTO, UpdateAccountProfileResponseDTO>({
path: "identity/account",
method: "patch",
data: params,
});
}

View File

@ -0,0 +1,8 @@
export interface AccountProfile {
id: string;
email: string;
name: string;
avatarUrl: string | null;
languageCode: string;
status: "active" | "disabled" | "pending_verification";
}

View File

@ -0,0 +1 @@
export * from "./account-profile.entity";

View File

@ -0,0 +1,4 @@
export * from "./keys";
export * from "./use-change-current-account-password-mutation";
export * from "./use-current-account-query";
export * from "./use-update-current-account-mutation";

View File

@ -0,0 +1 @@
export const CURRENT_ACCOUNT_QUERY_KEY = () => ["identity", "account", "current"] as const;

View File

@ -0,0 +1,29 @@
import { useDataSource } from "@erp/core/hooks";
import {
ChangeAccountPasswordRequestSchema,
ChangeAccountPasswordResponseSchema,
type ChangeAccountPasswordRequestDTO,
type ChangeAccountPasswordResponseDTO,
} from "@erp/identity/common";
import { useMutation } from "@tanstack/react-query";
import { changeCurrentAccountPassword } from "../api";
export const useChangeCurrentAccountPasswordMutation = () => {
const dataSource = useDataSource();
return useMutation<ChangeAccountPasswordResponseDTO, Error, ChangeAccountPasswordRequestDTO>({
mutationKey: ["identity", "account", "change-password"],
mutationFn: async (params) => {
const request = ChangeAccountPasswordRequestSchema.parse(params);
const response = await changeCurrentAccountPassword(dataSource, request);
const parsed = ChangeAccountPasswordResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Invalid change password response");
}
return parsed.data;
},
});
};

View File

@ -0,0 +1,27 @@
import { useDataSource } from "@erp/core/hooks";
import { GetAccountProfileResponseSchema } from "@erp/identity/common";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { AccountProfileAdapter } from "../adapters";
import type { AccountProfile } from "../entities";
import { getCurrentAccount } from "../api";
import { CURRENT_ACCOUNT_QUERY_KEY } from "./keys";
export const useCurrentAccountQuery = (): UseQueryResult<AccountProfile, DefaultError> => {
const dataSource = useDataSource();
return useQuery<AccountProfile, DefaultError>({
queryKey: CURRENT_ACCOUNT_QUERY_KEY(),
queryFn: async () => {
const response = await getCurrentAccount(dataSource);
const parsed = GetAccountProfileResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Invalid current account response");
}
return AccountProfileAdapter.fromDto(parsed.data);
},
});
};

View File

@ -0,0 +1,36 @@
import { useDataSource } from "@erp/core/hooks";
import {
UpdateAccountProfileRequestSchema,
UpdateAccountProfileResponseSchema,
type UpdateAccountProfileRequestDTO,
} from "@erp/identity/common";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AccountProfileAdapter } from "../adapters";
import type { AccountProfile } from "../entities";
import { updateCurrentAccount } from "../api";
import { CURRENT_ACCOUNT_QUERY_KEY } from "./keys";
export const useUpdateCurrentAccountMutation = () => {
const dataSource = useDataSource();
const queryClient = useQueryClient();
return useMutation<AccountProfile, Error, UpdateAccountProfileRequestDTO>({
mutationKey: ["identity", "account", "update"],
mutationFn: async (params) => {
const request = UpdateAccountProfileRequestSchema.parse(params);
const response = await updateCurrentAccount(dataSource, request);
const parsed = UpdateAccountProfileResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Invalid update current account response");
}
return AccountProfileAdapter.fromDto(parsed.data);
},
onSuccess: async (account) => {
await queryClient.setQueryData(CURRENT_ACCOUNT_QUERY_KEY(), account);
},
});
};

View File

@ -0,0 +1,4 @@
export * from "./adapters";
export * from "./api";
export * from "./entities";
export * from "./hooks";

View File

@ -0,0 +1,398 @@
import { ErrorAlert, PageErrorState } from "@erp/core/components";
import { UnsavedChangesProvider } from "@erp/core/hooks";
import {
AppContent,
CancelActionButton,
RdxEditorSection,
RdxEditorStickyFooter,
RhfSubmitActionButton,
SelectField,
TextField,
} from "@repo/rdx-ui/components";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Spinner,
} from "@repo/shadcn-ui/components";
import { useEffect, useState } from "react";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { useAccountPageController } from "../../controllers";
import type { AccountPasswordForm, AccountProfileForm } from "../../entities";
const AccountLoadingState = ({ label }: { label: string }) => {
return (
<AppContent className="py-2">
<div
aria-busy="true"
className="flex min-h-64 items-center justify-center text-sm text-muted-foreground"
role="status"
>
{label}
</div>
</AppContent>
);
};
const AccountErrorState = ({
canRetry,
description,
isRetrying,
onRetry,
reference,
retryLabel,
retryingLabel,
title,
variant,
}: {
canRetry: boolean;
description: string;
isRetrying: boolean;
onRetry: () => void;
reference?: string;
retryLabel: string;
retryingLabel: string;
title: string;
variant: React.ComponentProps<typeof PageErrorState>["variant"];
}) => {
return (
<AppContent className="py-2">
<div className="mx-auto w-full max-w-4xl">
<PageErrorState
description={description}
primaryAction={
canRetry
? {
label: isRetrying ? retryingLabel : retryLabel,
onClick: onRetry,
isLoading: isRetrying,
}
: undefined
}
reference={reference}
title={title}
variant={variant}
/>
</div>
</AppContent>
);
};
const AccountPasswordDialog = ({
ctrl,
description,
isOpen,
onOpenChange,
t,
}: {
ctrl: ReturnType<typeof useAccountPageController>;
description: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
t: ReturnType<typeof useTranslation>["t"];
}) => {
useEffect(() => {
if (!isOpen) {
return;
}
ctrl.passwordForm.setFocus("currentPassword");
}, [ctrl.passwordForm, isOpen]);
return (
<Dialog onOpenChange={onOpenChange} open={isOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("account.passwordDialog.title")}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<FormProvider {...ctrl.passwordForm}>
<form
className="space-y-5"
id={ctrl.passwordFormId}
noValidate
onKeyDown={ctrl.onFormKeyDown}
onSubmit={ctrl.onPasswordSubmit}
>
{ctrl.inlinePasswordError ? (
<ErrorAlert
message={ctrl.inlinePasswordError.description}
title={ctrl.inlinePasswordError.title}
/>
) : null}
<div className="space-y-5">
<TextField<AccountPasswordForm>
autoComplete="current-password"
className="w-full"
description={t("account.passwordDialog.fields.currentPassword.description")}
disabled={ctrl.isChangingPassword}
inputClassName="bg-background"
label={t("account.passwordDialog.fields.currentPassword.label")}
name="currentPassword"
placeholder={t("account.passwordDialog.fields.currentPassword.placeholder")}
required
typePreset="password"
/>
<TextField<AccountPasswordForm>
autoComplete="new-password"
className="w-full"
description={t("account.passwordDialog.fields.newPassword.description")}
disabled={ctrl.isChangingPassword}
inputClassName="bg-background"
label={t("account.passwordDialog.fields.newPassword.label")}
name="newPassword"
placeholder={t("account.passwordDialog.fields.newPassword.placeholder")}
required
typePreset="password"
/>
<TextField<AccountPasswordForm>
autoComplete="new-password"
className="w-full"
description={t("account.passwordDialog.fields.confirmPassword.description")}
disabled={ctrl.isChangingPassword}
inputClassName="bg-background"
label={t("account.passwordDialog.fields.confirmPassword.label")}
name="confirmPassword"
placeholder={t("account.passwordDialog.fields.confirmPassword.placeholder")}
required
typePreset="password"
/>
</div>
</form>
</FormProvider>
<DialogFooter>
<Button
disabled={ctrl.isChangingPassword}
onClick={() => onOpenChange(false)}
type="button"
variant="outline"
>
{t("account.passwordDialog.actions.cancel")}
</Button>
<Button disabled={ctrl.isChangingPassword} form={ctrl.passwordFormId} type="submit">
{ctrl.isChangingPassword ? (
<>
<Spinner aria-hidden className="mr-2 size-4" />
{t("account.actions.changingPassword")}
</>
) : (
t("account.actions.changePassword")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const AccountPage = () => {
const { t } = useTranslation();
const ctrl = useAccountPageController();
const [isPasswordDialogOpen, setPasswordDialogOpen] = useState(false);
useEffect(() => {
if (!ctrl.passwordSuccessVersion) {
return;
}
setPasswordDialogOpen(false);
ctrl.clearPasswordDialogState();
}, [ctrl.clearPasswordDialogState, ctrl.passwordSuccessVersion]);
if (ctrl.isInitialLoading) {
return <AccountLoadingState label={t("account.loading")} />;
}
if (ctrl.pageError) {
return (
<AccountErrorState
canRetry={ctrl.pageError.canRetry}
description={ctrl.pageError.description}
isRetrying={ctrl.isRetrying}
onRetry={ctrl.retry}
reference={ctrl.pageError.reference}
retryLabel={t("account.pageError.actions.retry")}
retryingLabel={t("account.pageError.actions.retrying")}
title={ctrl.pageError.title}
variant={ctrl.pageError.variant}
/>
);
}
if (!ctrl.account) {
return null;
}
const handlePasswordDialogOpenChange = (open: boolean) => {
if (!open) {
ctrl.clearPasswordDialogState();
setPasswordDialogOpen(false);
return;
}
ctrl.clearPasswordDialogState();
setPasswordDialogOpen(true);
};
return (
<AppContent className="py-2">
<div className="max-w-4xl space-y-6">
<FormProvider {...ctrl.profileForm}>
<UnsavedChangesProvider isDirty={ctrl.profileForm.formState.isDirty}>
<div className="space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{t("account.title")}</h1>
<p className="max-w-3xl text-sm text-muted-foreground">
{t("account.description")}
</p>
</div>
{ctrl.inlineProfileError ? (
<ErrorAlert
message={ctrl.inlineProfileError.description}
title={ctrl.inlineProfileError.title}
/>
) : null}
<form
className="flex flex-col gap-6"
id={ctrl.profileFormId}
noValidate
onKeyDown={ctrl.onFormKeyDown}
onSubmit={ctrl.onProfileSubmit}
>
<RdxEditorSection
description={t("account.sections.personal.description")}
title={t("account.sections.personal.title")}
variant="panel"
>
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-12">
<TextField<AccountProfileForm>
className="md:col-span-6"
disabled={ctrl.isSavingProfile}
inputClassName="bg-background"
label={t("account.sections.personal.fields.name.label")}
name="name"
placeholder={t("account.sections.personal.fields.name.placeholder")}
required
/>
<TextField<AccountProfileForm>
className="md:col-span-6"
disabled={ctrl.isSavingProfile}
inputClassName="bg-background"
label={t("account.sections.personal.fields.email.label")}
name="email"
placeholder={t("account.sections.personal.fields.email.placeholder")}
required
typePreset="email"
/>
<SelectField<AccountProfileForm>
className="md:col-span-12"
description={t("account.sections.personal.fields.languageCode.description")}
disabled={ctrl.isSavingProfile}
inputClassName="bg-background"
items={[
{
value: "es",
label: t("account.sections.personal.fields.languageCode.options.es"),
},
{
value: "en",
label: t("account.sections.personal.fields.languageCode.options.en"),
},
]}
label={t("account.sections.personal.fields.languageCode.label")}
name="languageCode"
placeholder={t("account.sections.personal.fields.languageCode.placeholder")}
/>
</div>
</RdxEditorSection>
</form>
<p aria-live="polite" className="sr-only">
{ctrl.isSavingProfile
? t("account.feedback.savingProfile")
: t("account.feedback.idle")}
</p>
<RdxEditorStickyFooter
primary={
<RhfSubmitActionButton
busyLabel={t("account.actions.savingProfile")}
disabled={ctrl.isSavingProfile || !ctrl.profileForm.formState.isDirty}
formId={ctrl.profileFormId}
isBusy={ctrl.profileForm.formState.isSubmitting}
label={t("account.actions.saveProfile")}
/>
}
secondary={
<CancelActionButton
disabled={ctrl.isSavingProfile || !ctrl.profileForm.formState.isDirty}
label={t("account.actions.resetProfile")}
onCancel={ctrl.resetProfileForm}
variant="outline"
/>
}
/>
</div>
</UnsavedChangesProvider>
</FormProvider>
<RdxEditorSection
description={t("account.sections.security.description")}
title={t("account.sections.security.title")}
variant="panel"
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h2 className="text-base font-medium">{t("account.sections.security.passwordTitle")}</h2>
<p className="text-sm text-muted-foreground">
{t("account.sections.security.passwordDescription")}
</p>
</div>
<Button onClick={() => handlePasswordDialogOpenChange(true)} type="button">
{t("account.actions.changePassword")}
</Button>
</div>
</RdxEditorSection>
<RdxEditorSection
description={t("account.sections.session.description")}
title={t("account.sections.session.title")}
variant="panel"
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground">
{t("account.sections.session.description")}
</p>
<Button onClick={ctrl.handleLogout} type="button" variant="outline">
{t("account.actions.logout")}
</Button>
</div>
</RdxEditorSection>
</div>
<AccountPasswordDialog
ctrl={ctrl}
description={t("account.passwordDialog.description")}
isOpen={isPasswordDialogOpen}
onOpenChange={handlePasswordDialogOpenChange}
t={t}
/>
</AppContent>
);
};

View File

@ -0,0 +1,66 @@
import { type ApiClientError, isApiClientError } from "@erp/core/client";
import type { AccountPageErrorViewModel } from "../entities";
const toVariant = (error: ApiClientError): AccountPageErrorViewModel["variant"] => {
switch (error.kind) {
case "forbidden":
return "forbidden";
case "notFound":
return "notFound";
case "network":
return "network";
case "timeout":
return "timeout";
case "unavailable":
return "unavailable";
case "server":
return "server";
default:
return "unknown";
}
};
const toMessageKey = (variant: AccountPageErrorViewModel["variant"]) => {
switch (variant) {
case "forbidden":
return "account.pageError.forbidden";
case "notFound":
return "account.pageError.notFound";
case "network":
return "account.pageError.network";
case "timeout":
return "account.pageError.timeout";
case "unavailable":
return "account.pageError.unavailable";
case "server":
return "account.pageError.server";
default:
return "account.pageError.unknown";
}
};
export const buildAccountPageErrorViewModel = (
error: unknown,
t: (key: string) => string
): AccountPageErrorViewModel => {
if (isApiClientError(error)) {
const variant = toVariant(error);
const key = toMessageKey(variant);
return {
variant,
title: t(`${key}.title`),
description: t(`${key}.description`),
reference: error.correlationId,
canRetry: true,
};
}
return {
variant: "unknown",
title: t("account.pageError.unknown.title"),
description: t("account.pageError.unknown.description"),
canRetry: true,
};
};

View File

@ -0,0 +1,11 @@
import type { AccountProfile } from "../shared";
import type { AccountProfileForm } from "../entities";
export const buildAccountProfileForm = (account: AccountProfile): AccountProfileForm => {
return {
name: account.name,
email: account.email,
avatarUrl: account.avatarUrl ?? "",
languageCode: account.languageCode,
};
};

View File

@ -0,0 +1,31 @@
import type { UpdateAccountProfileRequestDTO } from "@erp/identity/common";
import type { FieldNamesMarkedBoolean } from "react-hook-form";
import type { AccountProfileForm } from "../entities";
const hasDirtyFlag = (value: unknown): boolean => value === true;
export const buildUpdateAccountProfileParams = (params: {
form: AccountProfileForm;
dirtyFields: Partial<Readonly<FieldNamesMarkedBoolean<AccountProfileForm>>>;
}): UpdateAccountProfileRequestDTO => {
const request: UpdateAccountProfileRequestDTO = {};
if (hasDirtyFlag(params.dirtyFields.name)) {
request.name = params.form.name.trim();
}
if (hasDirtyFlag(params.dirtyFields.email)) {
request.email = params.form.email;
}
if (hasDirtyFlag(params.dirtyFields.avatarUrl)) {
request.avatar_url = params.form.avatarUrl.trim() === "" ? null : params.form.avatarUrl.trim();
}
if (hasDirtyFlag(params.dirtyFields.languageCode)) {
request.language_code = params.form.languageCode;
}
return request;
};

View File

@ -0,0 +1,3 @@
export * from "./build-account-page-error-view-model";
export * from "./build-account-profile-form";
export * from "./build-update-account-profile-params";

View File

@ -43,6 +43,13 @@ export interface IdentityAuthSessionContextValue {
legalName: string;
tradeName: string | null;
}) => void;
updateAccount: (account: {
id: string;
email: string;
name: string;
avatarUrl: string | null;
languageCode: string;
}) => void;
getAuthenticatedRedirectPath: (params?: {
returnTo?: string | null;
fallbackPath?: string;
@ -279,6 +286,34 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
[]
);
const updateAccount = useCallback(
(account: {
id: string;
email: string;
name: string;
avatarUrl: string | null;
languageCode: string;
}) => {
setSession((currentSession) => {
if (!currentSession || currentSession.account.id !== account.id) {
return currentSession;
}
return {
...currentSession,
account: {
...currentSession.account,
email: account.email,
name: account.name,
avatarUrl: account.avatarUrl,
languageCode: account.languageCode,
},
};
});
},
[]
);
const getAuthenticatedRedirectPath = useCallback(
(params?: { returnTo?: string | null; fallbackPath?: string }) => {
return resolveAuthenticatedRedirectPath({
@ -326,6 +361,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
selectActiveCompany,
changeActiveCompany,
updateActiveCompany,
updateAccount,
getAuthenticatedRedirectPath,
clearSession,
}),
@ -345,6 +381,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
restoreSession,
selectActiveCompany,
updateActiveCompany,
updateAccount,
session,
]
);

View File

@ -0,0 +1,24 @@
import { i18n, registerTranslations } from "@repo/i18next";
import { useEffect } from "react";
import { useTranslation as useI18NextTranslation } from "react-i18next";
import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json";
import { MODULE_NAME } from "./manifest";
export const ensureModuleTranslations = () => {
registerTranslations(MODULE_NAME, "en", enResources);
registerTranslations(MODULE_NAME, "es", esResources);
};
export const useTranslation = () => {
// biome-ignore lint/correctness/useExhaustiveDependencies: registro idempotente del namespace del módulo
useEffect(() => {
ensureModuleTranslations();
}, [i18n]);
return useI18NextTranslation(MODULE_NAME, {
i18n,
});
};

View File

@ -1,17 +1,21 @@
import type { IModuleClient } from "@erp/core/client";
import { IdentityAccountRoutes } from "./account-routes";
import { IdentityAuthRoutes } from "./auth-routes";
import { ensureModuleTranslations } from "./i18n";
const MODULE_NAME = "identity";
export const MODULE_NAME = "identity";
const MODULE_VERSION = "1.0.0";
ensureModuleTranslations();
export const IdentityModuleManifest: IModuleClient = {
name: MODULE_NAME,
version: MODULE_VERSION,
dependencies: ["Core"],
protected: false,
layout: "auth",
routes: (params) => IdentityAuthRoutes(params),
routes: (params) => [...IdentityAuthRoutes(params), ...IdentityAccountRoutes(params)],
};
export default IdentityModuleManifest;

View File

@ -1,5 +1,5 @@
import type { AvailableCompanyDTO } from "../api/dto/available-companies.dto";
import type { ActiveCompany, AuthSession } from "../entities";
import type { ActiveCompany } from "../entities";
export function mapAvailableCompany(dto: AvailableCompanyDTO): ActiveCompany {
return {

View File

@ -969,6 +969,9 @@ importers:
'@erp/core':
specifier: workspace:*
version: link:../core
'@repo/i18next':
specifier: workspace:*
version: link:../../packages/i18n
'@repo/rdx-criteria':
specifier: workspace:*
version: link:../../packages/rdx-criteria