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:
parent
fbf26d4745
commit
64b90888c3
@ -36,6 +36,16 @@ export const AppTopbar = () => {
|
|||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenAccount = () => {
|
||||||
|
navigate("/account");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoutClick = () => {
|
||||||
|
handleLogout().catch(() => {
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 shrink-0 items-center border-b border-border bg-background">
|
<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">
|
<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">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("web.accountMenu.label")}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem>Perfil</DropdownMenuItem>
|
<DropdownMenuItem onClick={handleOpenAccount}>
|
||||||
<DropdownMenuItem>Preferencias</DropdownMenuItem>
|
{t("web.accountMenu.openAccount")}
|
||||||
<DropdownMenuItem onClick={() => void handleLogout()}>
|
</DropdownMenuItem>
|
||||||
Cerrar sesión
|
<DropdownMenuItem onClick={handleLogoutClick}>
|
||||||
|
{t("web.accountMenu.logout")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@ -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.",
|
"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"
|
"link": "Open issued invoices"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"accountMenu": {
|
||||||
|
"label": "My account",
|
||||||
|
"openAccount": "Open my account",
|
||||||
|
"logout": "Sign out"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.",
|
"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"
|
"link": "Abrir facturas emitidas"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"accountMenu": {
|
||||||
|
"label": "Mi cuenta",
|
||||||
|
"openAccount": "Abrir mi cuenta",
|
||||||
|
"logout": "Cerrar sesión"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,17 +70,9 @@ La pantalla no selecciona el target mediante `companyId`.
|
|||||||
|
|
||||||
### 3.2 `/account`
|
### 3.2 `/account`
|
||||||
|
|
||||||
`/account` queda reservado para configuración personal del usuario.
|
`/account` queda reservado para configuración personal del usuario autenticado.
|
||||||
|
|
||||||
Ejemplos futuros:
|
En la implementación actual se resuelve como pantalla única y no debe mezclarse con `/settings`.
|
||||||
|
|
||||||
```txt
|
|
||||||
/account/profile
|
|
||||||
/account/preferences
|
|
||||||
/account/security
|
|
||||||
```
|
|
||||||
|
|
||||||
No forma parte de este diseño y no debe mezclarse con `/settings`.
|
|
||||||
|
|
||||||
Backend ya preparado actualmente para el subtree de usuario:
|
Backend ya preparado actualmente para el subtree de usuario:
|
||||||
|
|
||||||
@ -99,6 +91,26 @@ Regla de acceso:
|
|||||||
→ no requiere X-Company-Id
|
→ 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.
|
No se introduce `scope: "company" | "user"` en V1.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -30,6 +30,8 @@ function shouldAttachCompanyHeader(pathname: string | null) {
|
|||||||
"/identity/auth/refresh",
|
"/identity/auth/refresh",
|
||||||
"/identity/auth/logout",
|
"/identity/auth/logout",
|
||||||
"/identity/auth/session",
|
"/identity/auth/session",
|
||||||
|
"/identity/account",
|
||||||
|
"/identity/account/change-password",
|
||||||
"/companies/available",
|
"/companies/available",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -75,10 +75,28 @@ Reglas actuales:
|
|||||||
|
|
||||||
- el cambio de email es inmediato y sin verificación adicional
|
- el cambio de email es inmediato y sin verificación adicional
|
||||||
- `avatar_url` es una referencia externa; `identity` no implementa subida de archivos
|
- `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 exige `current_password`
|
||||||
- cambio de contraseña revoca todos los refresh tokens persistidos de la cuenta
|
- 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
|
- 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
|
## Estado global de Account
|
||||||
|
|
||||||
`Account.status` representa el estado global de autenticación de la identidad:
|
`Account.status` representa el estado global de autenticación de la identidad:
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@erp/core": "workspace:*",
|
"@erp/core": "workspace:*",
|
||||||
|
"@repo/i18next": "workspace:*",
|
||||||
"@repo/rdx-criteria": "workspace:*",
|
"@repo/rdx-criteria": "workspace:*",
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@repo/rdx-ddd": "workspace:*",
|
||||||
"@repo/rdx-logger": "workspace:*",
|
"@repo/rdx-logger": "workspace:*",
|
||||||
@ -52,4 +53,4 @@
|
|||||||
"rimraf": "^6.1.3",
|
"rimraf": "^6.1.3",
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
142
modules/identity/src/common/locales/en.json
Normal file
142
modules/identity/src/common/locales/en.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
modules/identity/src/common/locales/es.json
Normal file
142
modules/identity/src/common/locales/es.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
modules/identity/src/web/account-routes.tsx
Normal file
23
modules/identity/src/web/account-routes.tsx
Normal 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 />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
1
modules/identity/src/web/account/controllers/index.ts
Normal file
1
modules/identity/src/web/account/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./use-account-page-controller";
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import type { PageErrorStateVariant } from "@erp/core/components";
|
||||||
|
|
||||||
|
export interface AccountPageErrorViewModel {
|
||||||
|
variant: PageErrorStateVariant;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
reference?: string;
|
||||||
|
canRetry: boolean;
|
||||||
|
}
|
||||||
@ -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>;
|
||||||
@ -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>;
|
||||||
3
modules/identity/src/web/account/entities/index.ts
Normal file
3
modules/identity/src/web/account/entities/index.ts
Normal 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";
|
||||||
1
modules/identity/src/web/account/index.ts
Normal file
1
modules/identity/src/web/account/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AccountPage } from "./ui/pages/account-page";
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./account-profile.adapter";
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
3
modules/identity/src/web/account/shared/api/index.ts
Normal file
3
modules/identity/src/web/account/shared/api/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./change-current-account-password.api";
|
||||||
|
export * from "./get-current-account.api";
|
||||||
|
export * from "./update-current-account.api";
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
export interface AccountProfile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
languageCode: string;
|
||||||
|
status: "active" | "disabled" | "pending_verification";
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./account-profile.entity";
|
||||||
4
modules/identity/src/web/account/shared/hooks/index.ts
Normal file
4
modules/identity/src/web/account/shared/hooks/index.ts
Normal 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";
|
||||||
1
modules/identity/src/web/account/shared/hooks/keys.ts
Normal file
1
modules/identity/src/web/account/shared/hooks/keys.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const CURRENT_ACCOUNT_QUERY_KEY = () => ["identity", "account", "current"] as const;
|
||||||
@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
4
modules/identity/src/web/account/shared/index.ts
Normal file
4
modules/identity/src/web/account/shared/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./adapters";
|
||||||
|
export * from "./api";
|
||||||
|
export * from "./entities";
|
||||||
|
export * from "./hooks";
|
||||||
398
modules/identity/src/web/account/ui/pages/account-page.tsx
Normal file
398
modules/identity/src/web/account/ui/pages/account-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
};
|
||||||
3
modules/identity/src/web/account/utils/index.ts
Normal file
3
modules/identity/src/web/account/utils/index.ts
Normal 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";
|
||||||
@ -43,6 +43,13 @@ export interface IdentityAuthSessionContextValue {
|
|||||||
legalName: string;
|
legalName: string;
|
||||||
tradeName: string | null;
|
tradeName: string | null;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
updateAccount: (account: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
languageCode: string;
|
||||||
|
}) => void;
|
||||||
getAuthenticatedRedirectPath: (params?: {
|
getAuthenticatedRedirectPath: (params?: {
|
||||||
returnTo?: string | null;
|
returnTo?: string | null;
|
||||||
fallbackPath?: string;
|
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(
|
const getAuthenticatedRedirectPath = useCallback(
|
||||||
(params?: { returnTo?: string | null; fallbackPath?: string }) => {
|
(params?: { returnTo?: string | null; fallbackPath?: string }) => {
|
||||||
return resolveAuthenticatedRedirectPath({
|
return resolveAuthenticatedRedirectPath({
|
||||||
@ -326,6 +361,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
|
|||||||
selectActiveCompany,
|
selectActiveCompany,
|
||||||
changeActiveCompany,
|
changeActiveCompany,
|
||||||
updateActiveCompany,
|
updateActiveCompany,
|
||||||
|
updateAccount,
|
||||||
getAuthenticatedRedirectPath,
|
getAuthenticatedRedirectPath,
|
||||||
clearSession,
|
clearSession,
|
||||||
}),
|
}),
|
||||||
@ -345,6 +381,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
|
|||||||
restoreSession,
|
restoreSession,
|
||||||
selectActiveCompany,
|
selectActiveCompany,
|
||||||
updateActiveCompany,
|
updateActiveCompany,
|
||||||
|
updateAccount,
|
||||||
session,
|
session,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
24
modules/identity/src/web/i18n.ts
Normal file
24
modules/identity/src/web/i18n.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,17 +1,21 @@
|
|||||||
import type { IModuleClient } from "@erp/core/client";
|
import type { IModuleClient } from "@erp/core/client";
|
||||||
|
|
||||||
|
import { IdentityAccountRoutes } from "./account-routes";
|
||||||
import { IdentityAuthRoutes } from "./auth-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";
|
const MODULE_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
ensureModuleTranslations();
|
||||||
|
|
||||||
export const IdentityModuleManifest: IModuleClient = {
|
export const IdentityModuleManifest: IModuleClient = {
|
||||||
name: MODULE_NAME,
|
name: MODULE_NAME,
|
||||||
version: MODULE_VERSION,
|
version: MODULE_VERSION,
|
||||||
dependencies: ["Core"],
|
dependencies: ["Core"],
|
||||||
protected: false,
|
protected: false,
|
||||||
layout: "auth",
|
layout: "auth",
|
||||||
routes: (params) => IdentityAuthRoutes(params),
|
routes: (params) => [...IdentityAuthRoutes(params), ...IdentityAccountRoutes(params)],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default IdentityModuleManifest;
|
export default IdentityModuleManifest;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { AvailableCompanyDTO } from "../api/dto/available-companies.dto";
|
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 {
|
export function mapAvailableCompany(dto: AvailableCompanyDTO): ActiveCompany {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -969,6 +969,9 @@ importers:
|
|||||||
'@erp/core':
|
'@erp/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core
|
version: link:../core
|
||||||
|
'@repo/i18next':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/i18n
|
||||||
'@repo/rdx-criteria':
|
'@repo/rdx-criteria':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-criteria
|
version: link:../../packages/rdx-criteria
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user