diff --git a/apps/web/src/layout/app-topbar.tsx b/apps/web/src/layout/app-topbar.tsx index 04d67f7c..4d80128c 100644 --- a/apps/web/src/layout/app-topbar.tsx +++ b/apps/web/src/layout/app-topbar.tsx @@ -36,6 +36,16 @@ export const AppTopbar = () => { navigate("/login", { replace: true }); }; + const handleOpenAccount = () => { + navigate("/account"); + }; + + const handleLogoutClick = () => { + handleLogout().catch(() => { + navigate("/login", { replace: true }); + }); + }; + return (
@@ -144,12 +154,13 @@ export const AppTopbar = () => { - Mi cuenta + {t("web.accountMenu.label")} - Perfil - Preferencias - void handleLogout()}> - Cerrar sesión + + {t("web.accountMenu.openAccount")} + + + {t("web.accountMenu.logout")} diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index 9861fcf8..387e2464 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -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" } } } diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json index 826d4797..5534a740 100644 --- a/apps/web/src/locales/es.json +++ b/apps/web/src/locales/es.json @@ -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" } } } diff --git a/docs/settings/frontend-erp-settings-system-design.md b/docs/settings/frontend-erp-settings-system-design.md index e9699ff6..3292e120 100644 --- a/docs/settings/frontend-erp-settings-system-design.md +++ b/docs/settings/frontend-erp-settings-system-design.md @@ -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. --- diff --git a/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts b/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts index e634554a..0489974a 100644 --- a/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts +++ b/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts @@ -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", ]; diff --git a/modules/identity/README.md b/modules/identity/README.md index 4f1f5ac3..54223cf0 100644 --- a/modules/identity/README.md +++ b/modules/identity/README.md @@ -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: diff --git a/modules/identity/package.json b/modules/identity/package.json index e13946d8..891c50d8 100644 --- a/modules/identity/package.json +++ b/modules/identity/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/modules/identity/src/common/locales/en.json b/modules/identity/src/common/locales/en.json new file mode 100644 index 00000000..cf4b52bf --- /dev/null +++ b/modules/identity/src/common/locales/en.json @@ -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." + } + } + } +} diff --git a/modules/identity/src/common/locales/es.json b/modules/identity/src/common/locales/es.json new file mode 100644 index 00000000..0a253f27 --- /dev/null +++ b/modules/identity/src/common/locales/es.json @@ -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." + } + } + } +} diff --git a/modules/identity/src/web/account-routes.tsx b/modules/identity/src/web/account-routes.tsx new file mode 100644 index 00000000..d0bb512a --- /dev/null +++ b/modules/identity/src/web/account-routes.tsx @@ -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: , + }, + ]; +}; diff --git a/modules/identity/src/web/account/controllers/index.ts b/modules/identity/src/web/account/controllers/index.ts new file mode 100644 index 00000000..97ed41cd --- /dev/null +++ b/modules/identity/src/web/account/controllers/index.ts @@ -0,0 +1 @@ +export * from "./use-account-page-controller"; diff --git a/modules/identity/src/web/account/controllers/use-account-page-controller.ts b/modules/identity/src/web/account/controllers/use-account-page-controller.ts new file mode 100644 index 00000000..9ec3a5cb --- /dev/null +++ b/modules/identity/src/web/account/controllers/use-account-page-controller.ts @@ -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(() => { + if (!accountQuery.data) { + return EMPTY_PROFILE_FORM; + } + + return buildAccountProfileForm(accountQuery.data); + }, [accountQuery.data]); + + const profileForm = useHookForm({ + resolverSchema: AccountProfileFormSchema, + initialValues: profileInitialValues, + disabled: updateProfileMutation.isPending, + }); + + const passwordForm = useHookForm({ + 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) => { + 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) => { + focusFirstInputFormError(errors, passwordForm); + showWarningToast( + t("account.feedback.validation.title"), + getFirstFormErrorMessage(errors, passwordForm) ?? t("account.feedback.validation.message") + ); + } + ); + + const onProfileSubmit = (event: SubmitEvent) => { + event.stopPropagation(); + profileSubmitHandler(event); + }; + + const onPasswordSubmit = (event: SubmitEvent) => { + 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, + }; +}; diff --git a/modules/identity/src/web/account/entities/account-page-error-view-model.entity.ts b/modules/identity/src/web/account/entities/account-page-error-view-model.entity.ts new file mode 100644 index 00000000..c5389e33 --- /dev/null +++ b/modules/identity/src/web/account/entities/account-page-error-view-model.entity.ts @@ -0,0 +1,9 @@ +import type { PageErrorStateVariant } from "@erp/core/components"; + +export interface AccountPageErrorViewModel { + variant: PageErrorStateVariant; + title: string; + description: string; + reference?: string; + canRetry: boolean; +} diff --git a/modules/identity/src/web/account/entities/account-password-form.schema.ts b/modules/identity/src/web/account/entities/account-password-form.schema.ts new file mode 100644 index 00000000..1b735a16 --- /dev/null +++ b/modules/identity/src/web/account/entities/account-password-form.schema.ts @@ -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; diff --git a/modules/identity/src/web/account/entities/account-profile-form.schema.ts b/modules/identity/src/web/account/entities/account-profile-form.schema.ts new file mode 100644 index 00000000..f5e37582 --- /dev/null +++ b/modules/identity/src/web/account/entities/account-profile-form.schema.ts @@ -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; diff --git a/modules/identity/src/web/account/entities/index.ts b/modules/identity/src/web/account/entities/index.ts new file mode 100644 index 00000000..424cc081 --- /dev/null +++ b/modules/identity/src/web/account/entities/index.ts @@ -0,0 +1,3 @@ +export * from "./account-page-error-view-model.entity"; +export * from "./account-password-form.schema"; +export * from "./account-profile-form.schema"; diff --git a/modules/identity/src/web/account/index.ts b/modules/identity/src/web/account/index.ts new file mode 100644 index 00000000..905893a4 --- /dev/null +++ b/modules/identity/src/web/account/index.ts @@ -0,0 +1 @@ +export { AccountPage } from "./ui/pages/account-page"; diff --git a/modules/identity/src/web/account/shared/adapters/account-profile.adapter.ts b/modules/identity/src/web/account/shared/adapters/account-profile.adapter.ts new file mode 100644 index 00000000..d2712712 --- /dev/null +++ b/modules/identity/src/web/account/shared/adapters/account-profile.adapter.ts @@ -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, + }; + }, +}; diff --git a/modules/identity/src/web/account/shared/adapters/index.ts b/modules/identity/src/web/account/shared/adapters/index.ts new file mode 100644 index 00000000..cf6dd5c8 --- /dev/null +++ b/modules/identity/src/web/account/shared/adapters/index.ts @@ -0,0 +1 @@ +export * from "./account-profile.adapter"; diff --git a/modules/identity/src/web/account/shared/api/change-current-account-password.api.ts b/modules/identity/src/web/account/shared/api/change-current-account-password.api.ts new file mode 100644 index 00000000..05264536 --- /dev/null +++ b/modules/identity/src/web/account/shared/api/change-current-account-password.api.ts @@ -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 { + return dataSource.custom({ + path: "identity/account/change-password", + method: "post", + data: params, + }); +} diff --git a/modules/identity/src/web/account/shared/api/get-current-account.api.ts b/modules/identity/src/web/account/shared/api/get-current-account.api.ts new file mode 100644 index 00000000..cdd788e0 --- /dev/null +++ b/modules/identity/src/web/account/shared/api/get-current-account.api.ts @@ -0,0 +1,10 @@ +import type { IDataSource } from "@erp/core/client"; +import type { GetAccountProfileResponseDTO } from "@erp/identity/common"; + +export function getCurrentAccount(dataSource: IDataSource): Promise { + return dataSource.custom, GetAccountProfileResponseDTO>({ + path: "identity/account", + method: "get", + data: {}, + }); +} diff --git a/modules/identity/src/web/account/shared/api/index.ts b/modules/identity/src/web/account/shared/api/index.ts new file mode 100644 index 00000000..1092bc68 --- /dev/null +++ b/modules/identity/src/web/account/shared/api/index.ts @@ -0,0 +1,3 @@ +export * from "./change-current-account-password.api"; +export * from "./get-current-account.api"; +export * from "./update-current-account.api"; diff --git a/modules/identity/src/web/account/shared/api/update-current-account.api.ts b/modules/identity/src/web/account/shared/api/update-current-account.api.ts new file mode 100644 index 00000000..e5296e63 --- /dev/null +++ b/modules/identity/src/web/account/shared/api/update-current-account.api.ts @@ -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 { + return dataSource.custom({ + path: "identity/account", + method: "patch", + data: params, + }); +} diff --git a/modules/identity/src/web/account/shared/entities/account-profile.entity.ts b/modules/identity/src/web/account/shared/entities/account-profile.entity.ts new file mode 100644 index 00000000..49b6bc11 --- /dev/null +++ b/modules/identity/src/web/account/shared/entities/account-profile.entity.ts @@ -0,0 +1,8 @@ +export interface AccountProfile { + id: string; + email: string; + name: string; + avatarUrl: string | null; + languageCode: string; + status: "active" | "disabled" | "pending_verification"; +} diff --git a/modules/identity/src/web/account/shared/entities/index.ts b/modules/identity/src/web/account/shared/entities/index.ts new file mode 100644 index 00000000..4af296c3 --- /dev/null +++ b/modules/identity/src/web/account/shared/entities/index.ts @@ -0,0 +1 @@ +export * from "./account-profile.entity"; diff --git a/modules/identity/src/web/account/shared/hooks/index.ts b/modules/identity/src/web/account/shared/hooks/index.ts new file mode 100644 index 00000000..02dff3f6 --- /dev/null +++ b/modules/identity/src/web/account/shared/hooks/index.ts @@ -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"; diff --git a/modules/identity/src/web/account/shared/hooks/keys.ts b/modules/identity/src/web/account/shared/hooks/keys.ts new file mode 100644 index 00000000..e0ca8a43 --- /dev/null +++ b/modules/identity/src/web/account/shared/hooks/keys.ts @@ -0,0 +1 @@ +export const CURRENT_ACCOUNT_QUERY_KEY = () => ["identity", "account", "current"] as const; diff --git a/modules/identity/src/web/account/shared/hooks/use-change-current-account-password-mutation.ts b/modules/identity/src/web/account/shared/hooks/use-change-current-account-password-mutation.ts new file mode 100644 index 00000000..bfc9c5f1 --- /dev/null +++ b/modules/identity/src/web/account/shared/hooks/use-change-current-account-password-mutation.ts @@ -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({ + 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; + }, + }); +}; diff --git a/modules/identity/src/web/account/shared/hooks/use-current-account-query.ts b/modules/identity/src/web/account/shared/hooks/use-current-account-query.ts new file mode 100644 index 00000000..476846b0 --- /dev/null +++ b/modules/identity/src/web/account/shared/hooks/use-current-account-query.ts @@ -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 => { + const dataSource = useDataSource(); + + return useQuery({ + 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); + }, + }); +}; diff --git a/modules/identity/src/web/account/shared/hooks/use-update-current-account-mutation.ts b/modules/identity/src/web/account/shared/hooks/use-update-current-account-mutation.ts new file mode 100644 index 00000000..d16dad47 --- /dev/null +++ b/modules/identity/src/web/account/shared/hooks/use-update-current-account-mutation.ts @@ -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({ + 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); + }, + }); +}; diff --git a/modules/identity/src/web/account/shared/index.ts b/modules/identity/src/web/account/shared/index.ts new file mode 100644 index 00000000..01b46929 --- /dev/null +++ b/modules/identity/src/web/account/shared/index.ts @@ -0,0 +1,4 @@ +export * from "./adapters"; +export * from "./api"; +export * from "./entities"; +export * from "./hooks"; diff --git a/modules/identity/src/web/account/ui/pages/account-page.tsx b/modules/identity/src/web/account/ui/pages/account-page.tsx new file mode 100644 index 00000000..dfaa9c2f --- /dev/null +++ b/modules/identity/src/web/account/ui/pages/account-page.tsx @@ -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 ( + +
+ {label} +
+
+ ); +}; + +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["variant"]; +}) => { + return ( + +
+ +
+
+ ); +}; + +const AccountPasswordDialog = ({ + ctrl, + description, + isOpen, + onOpenChange, + t, +}: { + ctrl: ReturnType; + description: string; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + t: ReturnType["t"]; +}) => { + useEffect(() => { + if (!isOpen) { + return; + } + + ctrl.passwordForm.setFocus("currentPassword"); + }, [ctrl.passwordForm, isOpen]); + + return ( + + + + {t("account.passwordDialog.title")} + {description} + + + +
+ {ctrl.inlinePasswordError ? ( + + ) : null} + +
+ + 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" + /> + + + 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" + /> + + + 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" + /> +
+ +
+ + + + + +
+
+ ); +}; + +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 ; + } + + if (ctrl.pageError) { + return ( + + ); + } + + if (!ctrl.account) { + return null; + } + + const handlePasswordDialogOpenChange = (open: boolean) => { + if (!open) { + ctrl.clearPasswordDialogState(); + setPasswordDialogOpen(false); + return; + } + + ctrl.clearPasswordDialogState(); + setPasswordDialogOpen(true); + }; + + return ( + +
+ + +
+
+

{t("account.title")}

+

+ {t("account.description")} +

+
+ + {ctrl.inlineProfileError ? ( + + ) : null} + +
+ +
+ + 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 + /> + + + 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" + /> + + + 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")} + /> +
+
+
+ +

+ {ctrl.isSavingProfile + ? t("account.feedback.savingProfile") + : t("account.feedback.idle")} +

+ + + } + secondary={ + + } + /> +
+
+
+ + +
+
+

{t("account.sections.security.passwordTitle")}

+

+ {t("account.sections.security.passwordDescription")} +

+
+ + +
+
+ + +
+

+ {t("account.sections.session.description")} +

+ +
+
+
+ + +
+ ); +}; diff --git a/modules/identity/src/web/account/utils/build-account-page-error-view-model.ts b/modules/identity/src/web/account/utils/build-account-page-error-view-model.ts new file mode 100644 index 00000000..70adbacb --- /dev/null +++ b/modules/identity/src/web/account/utils/build-account-page-error-view-model.ts @@ -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, + }; +}; diff --git a/modules/identity/src/web/account/utils/build-account-profile-form.ts b/modules/identity/src/web/account/utils/build-account-profile-form.ts new file mode 100644 index 00000000..2a88c307 --- /dev/null +++ b/modules/identity/src/web/account/utils/build-account-profile-form.ts @@ -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, + }; +}; diff --git a/modules/identity/src/web/account/utils/build-update-account-profile-params.ts b/modules/identity/src/web/account/utils/build-update-account-profile-params.ts new file mode 100644 index 00000000..0220f307 --- /dev/null +++ b/modules/identity/src/web/account/utils/build-update-account-profile-params.ts @@ -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>>; +}): 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; +}; diff --git a/modules/identity/src/web/account/utils/index.ts b/modules/identity/src/web/account/utils/index.ts new file mode 100644 index 00000000..ac5cefc3 --- /dev/null +++ b/modules/identity/src/web/account/utils/index.ts @@ -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"; diff --git a/modules/identity/src/web/context/identity-auth-session.context.tsx b/modules/identity/src/web/context/identity-auth-session.context.tsx index e949a576..0c8a1fbd 100644 --- a/modules/identity/src/web/context/identity-auth-session.context.tsx +++ b/modules/identity/src/web/context/identity-auth-session.context.tsx @@ -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, ] ); diff --git a/modules/identity/src/web/i18n.ts b/modules/identity/src/web/i18n.ts new file mode 100644 index 00000000..9c0874c3 --- /dev/null +++ b/modules/identity/src/web/i18n.ts @@ -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, + }); +}; diff --git a/modules/identity/src/web/manifest.ts b/modules/identity/src/web/manifest.ts index abe5ebd3..ece219c2 100644 --- a/modules/identity/src/web/manifest.ts +++ b/modules/identity/src/web/manifest.ts @@ -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; diff --git a/modules/identity/src/web/shared/adapters/active-company.adapter.ts b/modules/identity/src/web/shared/adapters/active-company.adapter.ts index 78898ba7..b18fb2cb 100644 --- a/modules/identity/src/web/shared/adapters/active-company.adapter.ts +++ b/modules/identity/src/web/shared/adapters/active-company.adapter.ts @@ -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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20af854d..ae947caf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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