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