feat: implement account management features
- Added services and use cases for managing account details, including changing passwords and updating profiles. - Introduced error handling for duplicate emails and password mismatches. - Created controllers for handling account-related requests in the Express framework. - Updated API routes to include new account management endpoints. - Enhanced the account profile schema to support email updates. - Implemented tests for account management functionalities to ensure reliability and correctness.
This commit is contained in:
parent
a4f82decc6
commit
fbf26d4745
@ -187,6 +187,44 @@ export async function findCompanyRow(database: Sequelize, companyId: string) {
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function findAccountRow(database: Sequelize, accountId: string) {
|
||||
const rows = await database.query<{
|
||||
id: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
language_code: string;
|
||||
status: string;
|
||||
}>("SELECT * FROM identity_accounts WHERE id = :accountId", {
|
||||
replacements: { accountId },
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function updateAccountStatus(
|
||||
database: Sequelize,
|
||||
params: {
|
||||
accountId: string;
|
||||
status: "active" | "disabled" | "pending_verification";
|
||||
}
|
||||
) {
|
||||
const queryInterface = database.getQueryInterface();
|
||||
|
||||
await queryInterface.bulkUpdate(
|
||||
"identity_accounts",
|
||||
{
|
||||
status: params.status,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{
|
||||
id: params.accountId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function loginByApi(params: {
|
||||
request: (
|
||||
path: string,
|
||||
|
||||
481
apps/server/src/testing/http/identity-account.http.test.ts
Normal file
481
apps/server/src/testing/http/identity-account.http.test.ts
Normal file
@ -0,0 +1,481 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import test from "node:test";
|
||||
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
import {
|
||||
buildCompanyHeaders,
|
||||
createTestAccount,
|
||||
findAccountRow,
|
||||
loginByApi,
|
||||
updateAccountStatus,
|
||||
} from "./http-test-fixtures.ts";
|
||||
import { createHttpTestRuntime } from "./http-test-runtime.ts";
|
||||
|
||||
type ProblemDetails = {
|
||||
title?: string;
|
||||
detail?: string;
|
||||
status?: number;
|
||||
errors?: Array<{ field?: string; path?: string; message?: string }>;
|
||||
};
|
||||
|
||||
type LoginResponseBody = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
|
||||
type AccountResponseBody = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
language_code: string;
|
||||
status: "active" | "disabled" | "pending_verification";
|
||||
};
|
||||
|
||||
test("GET /api/v1/identity/account devuelve el perfil completo de la cuenta autenticada", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const account = await createAuthenticatedAccount(runtime, {
|
||||
name: "Ada Lovelace",
|
||||
languageCode: "en",
|
||||
});
|
||||
|
||||
const response = await runtime.request("/api/v1/identity/account", {
|
||||
headers: buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(response.body, {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
name: "Ada Lovelace",
|
||||
avatar_url: null,
|
||||
language_code: "en",
|
||||
status: "active",
|
||||
} satisfies AccountResponseBody);
|
||||
});
|
||||
|
||||
test("PATCH /api/v1/identity/account actualiza múltiples campos y persiste los cambios", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const account = await createAuthenticatedAccount(runtime, {
|
||||
name: "Original Name",
|
||||
languageCode: "es",
|
||||
});
|
||||
|
||||
const response = await runtime.request("/api/v1/identity/account", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Updated Name",
|
||||
email: `updated-${randomUUID()}@example.com`,
|
||||
avatar_url: "https://cdn.example.com/avatar.png",
|
||||
language_code: "en",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = getAccountBody(response.body);
|
||||
|
||||
assert.equal(body.name, "Updated Name");
|
||||
assert.equal(body.avatar_url, "https://cdn.example.com/avatar.png");
|
||||
assert.equal(body.language_code, "en");
|
||||
|
||||
const persistedAccount = await findAccountRow(runtime.database, account.id);
|
||||
|
||||
assert.ok(persistedAccount);
|
||||
assert.equal(persistedAccount.name, "Updated Name");
|
||||
assert.equal(persistedAccount.email, body.email);
|
||||
assert.equal(persistedAccount.avatar_url, "https://cdn.example.com/avatar.png");
|
||||
assert.equal(persistedAccount.language_code, "en");
|
||||
});
|
||||
|
||||
test("PATCH /api/v1/identity/account permite limpiar avatar_url con null explícito", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const account = await createAuthenticatedAccount(runtime, {
|
||||
name: "Avatar Account",
|
||||
});
|
||||
|
||||
await runtime.request("/api/v1/identity/account", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
avatar_url: "https://cdn.example.com/avatar-before.png",
|
||||
}),
|
||||
});
|
||||
|
||||
const clearResponse = await runtime.request("/api/v1/identity/account", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
avatar_url: null,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(clearResponse.status, 200);
|
||||
assert.equal(getAccountBody(clearResponse.body).avatar_url, null);
|
||||
|
||||
const persistedAccount = await findAccountRow(runtime.database, account.id);
|
||||
|
||||
assert.ok(persistedAccount);
|
||||
assert.equal(persistedAccount.avatar_url, null);
|
||||
});
|
||||
|
||||
test("PATCH /api/v1/identity/account con body vacío conserva el perfil sin cambios", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const account = await createAuthenticatedAccount(runtime, {
|
||||
name: "No Changes",
|
||||
languageCode: "es",
|
||||
});
|
||||
|
||||
const response = await runtime.request("/api/v1/identity/account", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(response.body, {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
name: "No Changes",
|
||||
avatar_url: null,
|
||||
language_code: "es",
|
||||
status: "active",
|
||||
} satisfies AccountResponseBody);
|
||||
});
|
||||
|
||||
test("PATCH /api/v1/identity/account devuelve 409 cuando el email ya existe", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const firstAccount = await createAuthenticatedAccount(runtime);
|
||||
const secondAccount = await createTestAccount(runtime.database, {
|
||||
email: `duplicate-${randomUUID()}@example.com`,
|
||||
password: "Secret123!",
|
||||
});
|
||||
|
||||
const response = await runtime.request("/api/v1/identity/account", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: firstAccount.accessToken,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: secondAccount.email,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
});
|
||||
|
||||
test("PATCH /api/v1/identity/account devuelve 422 cuando el email es inválido", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const account = await createAuthenticatedAccount(runtime);
|
||||
|
||||
const response = await runtime.request("/api/v1/identity/account", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "not-an-email",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 422);
|
||||
assert.equal(getProblemDetails(response.body).title, "Validation Failed");
|
||||
});
|
||||
|
||||
test("POST /api/v1/identity/account/change-password cambia la contraseña y revoca los refresh tokens previos", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const account = await createAuthenticatedAccount(runtime, {
|
||||
password: "CurrentPassword1!",
|
||||
});
|
||||
|
||||
const response = await runtime.request("/api/v1/identity/account/change-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: "CurrentPassword1!",
|
||||
new_password: "NextPassword123!",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(response.body, {
|
||||
success: true,
|
||||
});
|
||||
|
||||
const refreshResponse = await runtime.request("/api/v1/identity/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: account.refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(refreshResponse.status, 401);
|
||||
|
||||
const oldPasswordLogin = await loginByApi({
|
||||
request: runtime.request,
|
||||
email: account.email,
|
||||
password: "CurrentPassword1!",
|
||||
});
|
||||
assert.equal(oldPasswordLogin.status, 401);
|
||||
|
||||
const newPasswordLogin = await loginByApi({
|
||||
request: runtime.request,
|
||||
email: account.email,
|
||||
password: "NextPassword123!",
|
||||
});
|
||||
assert.equal(newPasswordLogin.status, 200);
|
||||
|
||||
const persistedAccount = await findAccountRow(runtime.database, account.id);
|
||||
|
||||
assert.ok(persistedAccount);
|
||||
assert.equal(await bcrypt.compare("NextPassword123!", persistedAccount.password_hash), true);
|
||||
});
|
||||
|
||||
test("POST /api/v1/identity/account/change-password devuelve 422 si la contraseña actual es incorrecta", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const account = await createAuthenticatedAccount(runtime, {
|
||||
password: "CurrentPassword1!",
|
||||
});
|
||||
|
||||
const response = await runtime.request("/api/v1/identity/account/change-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: "WrongPassword1!",
|
||||
new_password: "NextPassword123!",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 422);
|
||||
assert.equal(getProblemDetails(response.body).detail, "Current password is incorrect.");
|
||||
});
|
||||
|
||||
test("POST /api/v1/identity/account/change-password devuelve 422 si la nueva contraseña no cumple el contrato", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const account = await createAuthenticatedAccount(runtime, {
|
||||
password: "CurrentPassword1!",
|
||||
});
|
||||
|
||||
const response = await runtime.request("/api/v1/identity/account/change-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: "CurrentPassword1!",
|
||||
new_password: "short",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 422);
|
||||
});
|
||||
|
||||
test("una cuenta disabled no puede login, refresh, session ni usar self-service y vuelve a autenticarse tras reactivarse", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const disabledAccount = await createTestAccount(runtime.database, {
|
||||
email: `disabled-${randomUUID()}@example.com`,
|
||||
password: "Secret123!",
|
||||
status: "disabled",
|
||||
});
|
||||
|
||||
const disabledLoginResponse = await loginByApi({
|
||||
request: runtime.request,
|
||||
email: disabledAccount.email,
|
||||
password: disabledAccount.password,
|
||||
});
|
||||
|
||||
assert.equal(disabledLoginResponse.status, 401);
|
||||
|
||||
const account = await createAuthenticatedAccount(runtime, {
|
||||
password: "CurrentPassword1!",
|
||||
});
|
||||
|
||||
await updateAccountStatus(runtime.database, {
|
||||
accountId: account.id,
|
||||
status: "disabled",
|
||||
});
|
||||
|
||||
const sessionResponse = await runtime.request("/api/v1/identity/auth/session", {
|
||||
headers: buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
});
|
||||
assert.equal(sessionResponse.status, 401);
|
||||
|
||||
const currentAccountResponse = await runtime.request("/api/v1/identity/account", {
|
||||
headers: buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
});
|
||||
assert.equal(currentAccountResponse.status, 401);
|
||||
|
||||
const updateAccountResponse = await runtime.request("/api/v1/identity/account", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Blocked Update",
|
||||
}),
|
||||
});
|
||||
assert.equal(updateAccountResponse.status, 401);
|
||||
|
||||
const changePasswordResponse = await runtime.request("/api/v1/identity/account/change-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: account.accessToken,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: "CurrentPassword1!",
|
||||
new_password: "NextPassword123!",
|
||||
}),
|
||||
});
|
||||
assert.equal(changePasswordResponse.status, 401);
|
||||
|
||||
const refreshResponse = await runtime.request("/api/v1/identity/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: account.refreshToken,
|
||||
}),
|
||||
});
|
||||
assert.equal(refreshResponse.status, 401);
|
||||
|
||||
await updateAccountStatus(runtime.database, {
|
||||
accountId: account.id,
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const reenabledLoginResponse = await loginByApi({
|
||||
request: runtime.request,
|
||||
email: account.email,
|
||||
password: "CurrentPassword1!",
|
||||
});
|
||||
|
||||
assert.equal(reenabledLoginResponse.status, 200);
|
||||
});
|
||||
|
||||
async function createAuthenticatedAccount(
|
||||
runtime: Awaited<ReturnType<typeof createHttpTestRuntime>>,
|
||||
params?: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
languageCode?: string;
|
||||
}
|
||||
) {
|
||||
const account = await createTestAccount(runtime.database, {
|
||||
email: params?.email ?? `account-${randomUUID()}@example.com`,
|
||||
password: params?.password ?? "Secret123!",
|
||||
name: params?.name,
|
||||
languageCode: params?.languageCode,
|
||||
});
|
||||
|
||||
const loginResponse = await loginByApi({
|
||||
request: runtime.request,
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
});
|
||||
|
||||
assert.equal(loginResponse.status, 200);
|
||||
|
||||
const loginBody = getLoginBody(loginResponse.body);
|
||||
|
||||
return {
|
||||
...account,
|
||||
accessToken: loginBody.access_token,
|
||||
refreshToken: loginBody.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
function getProblemDetails(body: unknown): ProblemDetails {
|
||||
assert.equal(typeof body, "object");
|
||||
assert.notEqual(body, null);
|
||||
return body as ProblemDetails;
|
||||
}
|
||||
|
||||
function getLoginBody(body: unknown): LoginResponseBody {
|
||||
assert.equal(typeof body, "object");
|
||||
assert.notEqual(body, null);
|
||||
return body as LoginResponseBody;
|
||||
}
|
||||
|
||||
function getAccountBody(body: unknown): AccountResponseBody {
|
||||
assert.equal(typeof body, "object");
|
||||
assert.notEqual(body, null);
|
||||
return body as AccountResponseBody;
|
||||
}
|
||||
@ -3,6 +3,9 @@
|
||||
## Endpoints activos
|
||||
|
||||
```http
|
||||
GET /identity/account
|
||||
PATCH /identity/account
|
||||
POST /identity/account/change-password
|
||||
POST /identity/auth/login
|
||||
POST /identity/auth/refresh
|
||||
POST /identity/auth/logout
|
||||
@ -120,6 +123,107 @@ export type LogoutResponseDTO = {
|
||||
- para logout normal, enviar el `refresh_token` actual
|
||||
- si logout falla por expiración de token, el frontend debe limpiar sesión igualmente
|
||||
|
||||
## GET `/identity/account`
|
||||
|
||||
### Headers
|
||||
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```ts
|
||||
export type GetAccountProfileResponseDTO = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
language_code: string;
|
||||
status: "active" | "disabled" | "pending_verification";
|
||||
};
|
||||
```
|
||||
|
||||
### Notas
|
||||
|
||||
- no debe llevar `X-Company-Id`
|
||||
- representa la identidad global del usuario autenticado
|
||||
- no depende de company activa
|
||||
|
||||
## PATCH `/identity/account`
|
||||
|
||||
### Headers
|
||||
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
```ts
|
||||
export type UpdateAccountProfileRequestDTO = {
|
||||
email?: string;
|
||||
name?: string;
|
||||
avatar_url?: string | null;
|
||||
language_code?: string;
|
||||
};
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```ts
|
||||
export type UpdateAccountProfileResponseDTO = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
language_code: string;
|
||||
status: "active" | "disabled" | "pending_verification";
|
||||
};
|
||||
```
|
||||
|
||||
### Reglas
|
||||
|
||||
- no debe llevar `X-Company-Id`
|
||||
- PATCH real: campo omitido no modifica
|
||||
- `avatar_url: null` limpia el avatar
|
||||
- el cambio de email en V1 es inmediato y no requiere verificación adicional
|
||||
- el backend valida unicidad de email
|
||||
- `avatar_url` sigue siendo solo una referencia externa; no existe upload en `identity`
|
||||
|
||||
## POST `/identity/account/change-password`
|
||||
|
||||
### Headers
|
||||
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
```ts
|
||||
export type ChangeAccountPasswordRequestDTO = {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
};
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```ts
|
||||
export type ChangeAccountPasswordResponseDTO = {
|
||||
success: true;
|
||||
};
|
||||
```
|
||||
|
||||
### Reglas
|
||||
|
||||
- no debe llevar `X-Company-Id`
|
||||
- exige `current_password`
|
||||
- usa el hasher actual de `identity`
|
||||
- revoca todos los refresh tokens persistidos de la cuenta
|
||||
- no invalida de forma inmediata access tokens ya emitidos, porque hoy son stateless y no existe un subsistema de revocación inmediata de access tokens
|
||||
|
||||
## GET `/identity/auth/session`
|
||||
|
||||
### Headers
|
||||
@ -181,6 +285,17 @@ export type AvailableCompanyDTO = {
|
||||
```txt
|
||||
400 -> request inválida o UUID de X-Company-Id mal formado
|
||||
401 -> access token ausente/inválido/expirado o credenciales inválidas
|
||||
401 -> cuenta no autenticable (`disabled` o `pending_verification`)
|
||||
403 -> contexto tenant inválido, company no accesible o company no activa
|
||||
409 -> conflicto de unicidad, por ejemplo email ya existente
|
||||
500 -> error inesperado
|
||||
```
|
||||
|
||||
## Administración global de cuentas
|
||||
|
||||
El dominio ya soporta enable/disable global de `Account`, pero no se documentan todavía endpoints HTTP administrativos operativos porque el runtime nuevo no dispone aún de un mecanismo inequívoco y cableado de autorización de administrador global.
|
||||
|
||||
Pendiente explícito:
|
||||
|
||||
- definir y cablear la autorización global real
|
||||
- entonces exponer enable/disable por HTTP
|
||||
|
||||
@ -82,6 +82,23 @@ Ejemplos futuros:
|
||||
|
||||
No forma parte de este diseño y no debe mezclarse con `/settings`.
|
||||
|
||||
Backend ya preparado actualmente para el subtree de usuario:
|
||||
|
||||
```txt
|
||||
GET /identity/account
|
||||
PATCH /identity/account
|
||||
POST /identity/account/change-password
|
||||
```
|
||||
|
||||
Regla de acceso:
|
||||
|
||||
```txt
|
||||
/account
|
||||
→ requiere usuario autenticado
|
||||
→ no requiere empresa activa
|
||||
→ no requiere X-Company-Id
|
||||
```
|
||||
|
||||
No se introduce `scope: "company" | "user"` en V1.
|
||||
|
||||
---
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
Gestiona:
|
||||
|
||||
- `Account`
|
||||
- self-service de `Account`
|
||||
- login
|
||||
- refresh
|
||||
- logout
|
||||
@ -27,10 +28,15 @@ No gestiona:
|
||||
- la ficha operativa completa de empresa
|
||||
- `companySlug` en auth
|
||||
- el tenant dentro del access token
|
||||
- upload físico de avatar
|
||||
- autorización global de superadmin, porque no existe todavía un mecanismo runtime inequívoco
|
||||
|
||||
## Endpoints activos
|
||||
|
||||
```http
|
||||
GET /identity/account
|
||||
PATCH /identity/account
|
||||
POST /identity/account/change-password
|
||||
POST /identity/auth/login
|
||||
POST /identity/auth/refresh
|
||||
POST /identity/auth/logout
|
||||
@ -43,6 +49,72 @@ El endpoint para companies accesibles es:
|
||||
GET /companies/available
|
||||
```
|
||||
|
||||
## Account self-service
|
||||
|
||||
`/identity/account` representa siempre la identidad global del usuario autenticado.
|
||||
|
||||
No requiere:
|
||||
|
||||
- `X-Company-Id`
|
||||
- company activa
|
||||
- contexto tenant
|
||||
|
||||
Sí requiere:
|
||||
|
||||
- access token válido
|
||||
- cuenta autenticable
|
||||
|
||||
Campos editables en V1:
|
||||
|
||||
- `name`
|
||||
- `email`
|
||||
- `avatar_url`
|
||||
- `language_code`
|
||||
|
||||
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
|
||||
- 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
|
||||
|
||||
## Estado global de Account
|
||||
|
||||
`Account.status` representa el estado global de autenticación de la identidad:
|
||||
|
||||
- `active`
|
||||
- `disabled`
|
||||
- `pending_verification`
|
||||
|
||||
Semántica runtime actual:
|
||||
|
||||
- `active` -> puede autenticarse
|
||||
- `disabled` -> no puede login, refresh, session ni self-service
|
||||
- `pending_verification` -> hoy también queda fuera de `canAuthenticate()`
|
||||
|
||||
Esto no debe mezclarse con:
|
||||
|
||||
- membership status
|
||||
- company status
|
||||
- accesibilidad tenant
|
||||
|
||||
## Administración global pendiente
|
||||
|
||||
El dominio ya soporta:
|
||||
|
||||
- `Account.enable()`
|
||||
- `Account.disable()`
|
||||
|
||||
Sin embargo, el runtime actual no expone todavía endpoints HTTP administrativos para enable/disable global porque no existe un mecanismo inequívoco y cableado de "administrador global del ERP" en el módulo nuevo.
|
||||
|
||||
Estado actual:
|
||||
|
||||
- contratos de self-service completados
|
||||
- `common/dto/account` reutilizado y completado
|
||||
- `common/dto/company-users` sigue pendiente válido para futura administración por empresa
|
||||
- enable/disable global queda documentado como pendiente hasta cerrar la autorización global real
|
||||
|
||||
Ese endpoint pertenece al flujo actual. Las reglas detalladas del flujo y los endpoints no válidos se mantienen en la documentación de arquitectura.
|
||||
|
||||
## Access token
|
||||
|
||||
@ -1 +1,3 @@
|
||||
export * from "./contracts";
|
||||
export * from "./services";
|
||||
export * from "./use-cases";
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
export class AccountEmailAlreadyExistsError extends Error {
|
||||
public constructor(message = "Account email already exists.", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "AccountEmailAlreadyExistsError";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export class CurrentPasswordMismatchError extends Error {
|
||||
public constructor(message = "Current password is incorrect.", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "CurrentPasswordMismatchError";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./account-email-already-exists.error";
|
||||
export * from "./current-password-mismatch.error";
|
||||
@ -0,0 +1,95 @@
|
||||
import type { ITransactionManager } from "@erp/core/api";
|
||||
import type { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { IAccountRepository } from "../contracts";
|
||||
import { CurrentPasswordMismatchError } from "../services";
|
||||
import { AccountNotFoundError, type IPasswordHasher, type IRefreshTokenRepository } from "../../authentication";
|
||||
|
||||
export type ChangeCurrentAccountPasswordInput = {
|
||||
accountId: UniqueID;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
updatedAt: UtcDateTime;
|
||||
};
|
||||
|
||||
export type ChangeCurrentAccountPasswordOutput = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
export class ChangeCurrentAccountPasswordUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
accountRepository: IAccountRepository;
|
||||
passwordHasher: IPasswordHasher;
|
||||
refreshTokenRepository: IRefreshTokenRepository;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
|
||||
public async execute(
|
||||
input: ChangeCurrentAccountPasswordInput
|
||||
): Promise<Result<ChangeCurrentAccountPasswordOutput, Error>> {
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
try {
|
||||
const accountResult = await this.deps.accountRepository.findById(input.accountId, transaction);
|
||||
if (accountResult.isFailure) {
|
||||
return Result.fail(accountResult.error);
|
||||
}
|
||||
|
||||
if (accountResult.data.isNone()) {
|
||||
return Result.fail(
|
||||
new AccountNotFoundError(`Account not found for id "${input.accountId.toPrimitive()}".`)
|
||||
);
|
||||
}
|
||||
|
||||
const account = accountResult.data.unwrap();
|
||||
|
||||
const passwordVerificationResult = await this.deps.passwordHasher.verify({
|
||||
plainPassword: input.currentPassword,
|
||||
passwordHash: account.passwordHash,
|
||||
});
|
||||
if (passwordVerificationResult.isFailure) {
|
||||
return Result.fail(passwordVerificationResult.error);
|
||||
}
|
||||
|
||||
if (!passwordVerificationResult.data) {
|
||||
return Result.fail(new CurrentPasswordMismatchError());
|
||||
}
|
||||
|
||||
const nextPasswordHashResult = await this.deps.passwordHasher.hash(input.newPassword);
|
||||
if (nextPasswordHashResult.isFailure) {
|
||||
return Result.fail(nextPasswordHashResult.error);
|
||||
}
|
||||
|
||||
const changeResult = account.changePasswordHash({
|
||||
passwordHash: nextPasswordHashResult.data,
|
||||
updatedAt: input.updatedAt,
|
||||
});
|
||||
if (changeResult.isFailure) {
|
||||
return Result.fail(changeResult.error);
|
||||
}
|
||||
|
||||
if (changeResult.data) {
|
||||
const updateResult = await this.deps.accountRepository.update(account, transaction);
|
||||
if (updateResult.isFailure) {
|
||||
return Result.fail(updateResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
const revokeTokensResult = await this.deps.refreshTokenRepository.revokeAllByAccountId({
|
||||
accountId: input.accountId,
|
||||
revokedAt: input.updatedAt,
|
||||
transaction,
|
||||
});
|
||||
if (revokeTokensResult.isFailure) {
|
||||
return Result.fail(revokeTokensResult.error);
|
||||
}
|
||||
|
||||
return Result.ok({ success: true as const });
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { IAccountRepository } from "../contracts";
|
||||
import { AccountNotFoundError } from "../../authentication";
|
||||
|
||||
export type GetCurrentAccountInput = {
|
||||
accountId: UniqueID;
|
||||
};
|
||||
|
||||
export type CurrentAccountOutput = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
languageCode: string;
|
||||
status: "active" | "disabled" | "pending_verification";
|
||||
};
|
||||
|
||||
export class GetCurrentAccountUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
accountRepository: IAccountRepository;
|
||||
}
|
||||
) {}
|
||||
|
||||
public async execute(input: GetCurrentAccountInput): Promise<Result<CurrentAccountOutput, Error>> {
|
||||
try {
|
||||
const accountResult = await this.deps.accountRepository.findById(input.accountId);
|
||||
if (accountResult.isFailure) {
|
||||
return Result.fail(accountResult.error);
|
||||
}
|
||||
|
||||
if (accountResult.data.isNone()) {
|
||||
return Result.fail(
|
||||
new AccountNotFoundError(`Account not found for id "${input.accountId.toPrimitive()}".`)
|
||||
);
|
||||
}
|
||||
|
||||
const account = accountResult.data.unwrap();
|
||||
|
||||
return Result.ok({
|
||||
id: account.id.toPrimitive(),
|
||||
email: account.email.toPrimitive(),
|
||||
name: account.name.toPrimitive(),
|
||||
avatarUrl: account.avatarUrl.match(
|
||||
(avatarUrl) => avatarUrl.toPrimitive(),
|
||||
() => null
|
||||
),
|
||||
languageCode: account.languageCode.toPrimitive(),
|
||||
status: account.status.toPrimitive() as CurrentAccountOutput["status"],
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./change-current-account-password.use-case";
|
||||
export * from "./get-current-account.use-case";
|
||||
export * from "./update-current-account.use-case";
|
||||
@ -0,0 +1,170 @@
|
||||
import type { ITransactionManager } from "@erp/core/api";
|
||||
import { EmailAddress, LanguageCode, Name, URLAddress, type UniqueID, type UtcDateTime } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { IAccountRepository } from "../contracts";
|
||||
import { AccountEmailAlreadyExistsError } from "../services";
|
||||
import { AccountNotFoundError } from "../../authentication";
|
||||
|
||||
import type { CurrentAccountOutput } from "./get-current-account.use-case";
|
||||
|
||||
export type UpdateCurrentAccountInput = {
|
||||
accountId: UniqueID;
|
||||
email?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string | null;
|
||||
languageCode?: string;
|
||||
updatedAt: UtcDateTime;
|
||||
};
|
||||
|
||||
export class UpdateCurrentAccountUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
accountRepository: IAccountRepository;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
|
||||
public async execute(
|
||||
input: UpdateCurrentAccountInput
|
||||
): Promise<Result<CurrentAccountOutput, Error>> {
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
try {
|
||||
const accountResult = await this.deps.accountRepository.findById(input.accountId, transaction);
|
||||
if (accountResult.isFailure) {
|
||||
return Result.fail(accountResult.error);
|
||||
}
|
||||
|
||||
if (accountResult.data.isNone()) {
|
||||
return Result.fail(
|
||||
new AccountNotFoundError(`Account not found for id "${input.accountId.toPrimitive()}".`)
|
||||
);
|
||||
}
|
||||
|
||||
const account = accountResult.data.unwrap();
|
||||
|
||||
const patchResult = await this.buildPatchProps({
|
||||
accountId: account.id,
|
||||
currentEmail: account.email.toPrimitive(),
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
avatarUrl: input.avatarUrl,
|
||||
languageCode: input.languageCode,
|
||||
updatedAt: input.updatedAt,
|
||||
transaction,
|
||||
});
|
||||
if (patchResult.isFailure) {
|
||||
return Result.fail(patchResult.error);
|
||||
}
|
||||
|
||||
const updateResult = account.updateProfile(patchResult.data);
|
||||
if (updateResult.isFailure) {
|
||||
return Result.fail(updateResult.error);
|
||||
}
|
||||
|
||||
if (updateResult.data) {
|
||||
const saveResult = await this.deps.accountRepository.update(account, transaction);
|
||||
if (saveResult.isFailure) {
|
||||
return Result.fail(saveResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
id: account.id.toPrimitive(),
|
||||
email: account.email.toPrimitive(),
|
||||
name: account.name.toPrimitive(),
|
||||
avatarUrl: account.avatarUrl.match(
|
||||
(avatarUrl) => avatarUrl.toPrimitive(),
|
||||
() => null
|
||||
),
|
||||
languageCode: account.languageCode.toPrimitive(),
|
||||
status: account.status.toPrimitive() as CurrentAccountOutput["status"],
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async buildPatchProps(params: {
|
||||
accountId: UniqueID;
|
||||
currentEmail: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string | null;
|
||||
languageCode?: string;
|
||||
updatedAt: UtcDateTime;
|
||||
transaction?: unknown;
|
||||
}) {
|
||||
const patchProps: {
|
||||
email?: EmailAddress;
|
||||
name?: Name;
|
||||
avatarUrl?: Maybe<URLAddress>;
|
||||
languageCode?: LanguageCode;
|
||||
updatedAt: UtcDateTime;
|
||||
} = {
|
||||
updatedAt: params.updatedAt,
|
||||
};
|
||||
|
||||
if (params.email !== undefined) {
|
||||
const emailResult = EmailAddress.create(params.email);
|
||||
if (emailResult.isFailure) {
|
||||
return Result.fail(emailResult.error);
|
||||
}
|
||||
|
||||
if (params.email !== params.currentEmail) {
|
||||
const existingAccountResult = await this.deps.accountRepository.findByEmail(
|
||||
emailResult.data,
|
||||
params.transaction
|
||||
);
|
||||
if (existingAccountResult.isFailure) {
|
||||
return Result.fail(existingAccountResult.error);
|
||||
}
|
||||
|
||||
if (
|
||||
existingAccountResult.data.isSome() &&
|
||||
existingAccountResult.data.unwrap().id.toPrimitive() !== params.accountId.toPrimitive()
|
||||
) {
|
||||
return Result.fail(
|
||||
new AccountEmailAlreadyExistsError(`Account email "${params.email}" already exists.`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
patchProps.email = emailResult.data;
|
||||
}
|
||||
|
||||
if (params.name !== undefined) {
|
||||
const nameResult = Name.create(params.name);
|
||||
if (nameResult.isFailure) {
|
||||
return Result.fail(nameResult.error);
|
||||
}
|
||||
|
||||
patchProps.name = nameResult.data;
|
||||
}
|
||||
|
||||
if (params.avatarUrl !== undefined) {
|
||||
if (params.avatarUrl === null) {
|
||||
patchProps.avatarUrl = Maybe.none();
|
||||
} else {
|
||||
const avatarUrlResult = URLAddress.create(params.avatarUrl);
|
||||
if (avatarUrlResult.isFailure) {
|
||||
return Result.fail(avatarUrlResult.error);
|
||||
}
|
||||
|
||||
patchProps.avatarUrl = Maybe.some(avatarUrlResult.data);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.languageCode !== undefined) {
|
||||
const languageCodeResult = LanguageCode.create(params.languageCode);
|
||||
if (languageCodeResult.isFailure) {
|
||||
return Result.fail(languageCodeResult.error);
|
||||
}
|
||||
|
||||
patchProps.languageCode = languageCodeResult.data;
|
||||
}
|
||||
|
||||
return Result.ok(patchProps);
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,7 @@ export interface IAccountCreateProps {
|
||||
}
|
||||
|
||||
export type AccountProfilePatchProps = Partial<{
|
||||
email: EmailAddress;
|
||||
name: Name;
|
||||
avatarUrl: Maybe<URLAddress>;
|
||||
languageCode: LanguageCode;
|
||||
@ -119,6 +120,11 @@ export class Account extends AggregateRoot<AccountInternalProps> {
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
if (patch.email && !this.props.email.equals(patch.email)) {
|
||||
this.props.email = patch.email;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (patch.name && !this.props.name.equals(patch.name)) {
|
||||
this.props.name = patch.name;
|
||||
hasChanges = true;
|
||||
|
||||
@ -5,7 +5,7 @@ export * from "./application";
|
||||
export * from "./domain";
|
||||
export * from "./infrastructure";
|
||||
|
||||
import { identityAuthRouter, models } from "./infrastructure";
|
||||
import { identityAccountRouter, identityAuthRouter, models } from "./infrastructure";
|
||||
import { buildIdentityDependencies, buildIdentityPublicServices } from "./infrastructure/di";
|
||||
|
||||
export * from "./infrastructure/persistence/sequelize";
|
||||
@ -58,6 +58,7 @@ export const identityAPIModule: IModuleServer = {
|
||||
|
||||
async start(params) {
|
||||
identityAuthRouter(params);
|
||||
identityAccountRouter(params);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -5,6 +5,14 @@ import {
|
||||
IdentityCompanyAccessChecker,
|
||||
} from "../../application";
|
||||
import type { IAccountRepository } from "../../application/accounts";
|
||||
import {
|
||||
type ChangeCurrentAccountPasswordUseCase,
|
||||
ChangeCurrentAccountPasswordUseCase as ChangeCurrentAccountPasswordUseCaseImpl,
|
||||
type GetCurrentAccountUseCase,
|
||||
GetCurrentAccountUseCase as GetCurrentAccountUseCaseImpl,
|
||||
type UpdateCurrentAccountUseCase,
|
||||
UpdateCurrentAccountUseCase as UpdateCurrentAccountUseCaseImpl,
|
||||
} from "../../application/accounts";
|
||||
import {
|
||||
AccountAuthenticator,
|
||||
type GetCurrentSessionUseCase,
|
||||
@ -49,6 +57,14 @@ export type IdentityInternalDeps = {
|
||||
getCurrentSession: () => GetCurrentSessionUseCase;
|
||||
};
|
||||
};
|
||||
account: {
|
||||
authenticateRequest: ReturnType<typeof identityAuthenticateRequest>;
|
||||
useCases: {
|
||||
getCurrentAccount: () => GetCurrentAccountUseCase;
|
||||
updateCurrentAccount: () => UpdateCurrentAccountUseCase;
|
||||
changeCurrentAccountPassword: () => ChangeCurrentAccountPasswordUseCase;
|
||||
};
|
||||
};
|
||||
companyAccess: {
|
||||
checker: IIdentityCompanyAccessChecker;
|
||||
};
|
||||
@ -132,6 +148,30 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
|
||||
}),
|
||||
},
|
||||
},
|
||||
account: {
|
||||
authenticateRequest: identityAuthenticateRequest({
|
||||
accessTokenVerifier,
|
||||
accountRepository,
|
||||
}),
|
||||
useCases: {
|
||||
getCurrentAccount: () =>
|
||||
new GetCurrentAccountUseCaseImpl({
|
||||
accountRepository,
|
||||
}),
|
||||
updateCurrentAccount: () =>
|
||||
new UpdateCurrentAccountUseCaseImpl({
|
||||
accountRepository,
|
||||
transactionManager,
|
||||
}),
|
||||
changeCurrentAccountPassword: () =>
|
||||
new ChangeCurrentAccountPasswordUseCaseImpl({
|
||||
accountRepository,
|
||||
passwordHasher,
|
||||
refreshTokenRepository,
|
||||
transactionManager,
|
||||
}),
|
||||
},
|
||||
},
|
||||
companyAccess: {
|
||||
checker: companyAccessChecker,
|
||||
},
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
|
||||
import type { UtcDateTime } from "@repo/rdx-ddd";
|
||||
|
||||
import type { ChangeAccountPasswordRequestDTO, ChangeAccountPasswordResponseDTO } from "../../../../common";
|
||||
import type { ChangeCurrentAccountPasswordUseCase } from "../../../application/accounts";
|
||||
import { identityAccountApiErrorMapper } from "../identity-account-api-error-mapper";
|
||||
|
||||
export class ChangeCurrentAccountPasswordController extends ExpressController {
|
||||
public constructor(
|
||||
private readonly useCase: ChangeCurrentAccountPasswordUseCase,
|
||||
private readonly nowFactory: () => UtcDateTime
|
||||
) {
|
||||
super();
|
||||
this.errorMapper = identityAccountApiErrorMapper;
|
||||
this.registerGuards(requireAuthenticatedGuard());
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const user = this.getUser();
|
||||
if (!user?.userId) {
|
||||
return this.unauthorizedError("Unauthorized");
|
||||
}
|
||||
|
||||
const dto = this.req.body as ChangeAccountPasswordRequestDTO;
|
||||
|
||||
const result = await this.useCase.execute({
|
||||
accountId: user.userId,
|
||||
currentPassword: dto.current_password,
|
||||
newPassword: dto.new_password,
|
||||
updatedAt: this.nowFactory(),
|
||||
});
|
||||
|
||||
return result.match(
|
||||
() =>
|
||||
this.ok<ChangeAccountPasswordResponseDTO>({
|
||||
success: true,
|
||||
}),
|
||||
(err) => this.handleError(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
|
||||
|
||||
import type { GetAccountProfileResponseDTO } from "../../../../common";
|
||||
import type { GetCurrentAccountUseCase } from "../../../application/accounts";
|
||||
import { identityAccountApiErrorMapper } from "../identity-account-api-error-mapper";
|
||||
|
||||
export class GetCurrentAccountController extends ExpressController {
|
||||
public constructor(private readonly useCase: GetCurrentAccountUseCase) {
|
||||
super();
|
||||
this.errorMapper = identityAccountApiErrorMapper;
|
||||
this.registerGuards(requireAuthenticatedGuard());
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const user = this.getUser();
|
||||
if (!user?.userId) {
|
||||
return this.unauthorizedError("Unauthorized");
|
||||
}
|
||||
|
||||
const result = await this.useCase.execute({
|
||||
accountId: user.userId,
|
||||
});
|
||||
|
||||
return result.match(
|
||||
(data) =>
|
||||
this.ok<GetAccountProfileResponseDTO>({
|
||||
id: data.id,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
avatar_url: data.avatarUrl,
|
||||
language_code: data.languageCode,
|
||||
status: data.status,
|
||||
}),
|
||||
(err) => this.handleError(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,7 @@
|
||||
export * from "./change-current-account-password.controller";
|
||||
export * from "./get-current-account.controller";
|
||||
export * from "./get-current-session.controller";
|
||||
export * from "./login-with-password.controller";
|
||||
export * from "./logout.controller";
|
||||
export * from "./refresh-session.controller";
|
||||
export * from "./update-current-account.controller";
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
|
||||
import type { UtcDateTime } from "@repo/rdx-ddd";
|
||||
|
||||
import type { UpdateAccountProfileRequestDTO, UpdateAccountProfileResponseDTO } from "../../../../common";
|
||||
import type { UpdateCurrentAccountUseCase } from "../../../application/accounts";
|
||||
import { identityAccountApiErrorMapper } from "../identity-account-api-error-mapper";
|
||||
|
||||
export class UpdateCurrentAccountController extends ExpressController {
|
||||
public constructor(
|
||||
private readonly useCase: UpdateCurrentAccountUseCase,
|
||||
private readonly nowFactory: () => UtcDateTime
|
||||
) {
|
||||
super();
|
||||
this.errorMapper = identityAccountApiErrorMapper;
|
||||
this.registerGuards(requireAuthenticatedGuard());
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const user = this.getUser();
|
||||
if (!user?.userId) {
|
||||
return this.unauthorizedError("Unauthorized");
|
||||
}
|
||||
|
||||
const dto = this.req.body as UpdateAccountProfileRequestDTO;
|
||||
|
||||
const result = await this.useCase.execute({
|
||||
accountId: user.userId,
|
||||
email: dto.email,
|
||||
name: dto.name,
|
||||
avatarUrl: dto.avatar_url,
|
||||
languageCode: dto.language_code,
|
||||
updatedAt: this.nowFactory(),
|
||||
});
|
||||
|
||||
return result.match(
|
||||
(data) =>
|
||||
this.ok<UpdateAccountProfileResponseDTO>({
|
||||
id: data.id,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
avatar_url: data.avatarUrl,
|
||||
language_code: data.languageCode,
|
||||
status: data.status,
|
||||
}),
|
||||
(err) => this.handleError(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import {
|
||||
ApiErrorMapper,
|
||||
ConflictApiError,
|
||||
NotFoundApiError,
|
||||
type ErrorToApiRule,
|
||||
ValidationApiError,
|
||||
} from "@erp/core/api";
|
||||
import { type DuplicateEntityError, isDuplicateEntityError } from "@erp/core/api";
|
||||
|
||||
import { AccountCannotBeUpdatedError } from "../../domain";
|
||||
import { AccountEmailAlreadyExistsError, CurrentPasswordMismatchError } from "../../application/accounts";
|
||||
import { AccountNotFoundError } from "../../application/authentication";
|
||||
|
||||
const accountNotFoundRule: ErrorToApiRule = {
|
||||
priority: 120,
|
||||
matches: (error) => error instanceof AccountNotFoundError,
|
||||
build: (error) => new NotFoundApiError((error as AccountNotFoundError).message || "Account not found."),
|
||||
};
|
||||
|
||||
const accountCannotBeUpdatedRule: ErrorToApiRule = {
|
||||
priority: 120,
|
||||
matches: (error) => error instanceof AccountCannotBeUpdatedError,
|
||||
build: (error) =>
|
||||
new ValidationApiError((error as AccountCannotBeUpdatedError).message || "Account cannot be updated."),
|
||||
};
|
||||
|
||||
const currentPasswordMismatchRule: ErrorToApiRule = {
|
||||
priority: 120,
|
||||
matches: (error) => error instanceof CurrentPasswordMismatchError,
|
||||
build: (error) =>
|
||||
new ValidationApiError((error as CurrentPasswordMismatchError).message || "Current password is incorrect."),
|
||||
};
|
||||
|
||||
const accountEmailAlreadyExistsRule: ErrorToApiRule = {
|
||||
priority: 120,
|
||||
matches: (error) => error instanceof AccountEmailAlreadyExistsError,
|
||||
build: (error) =>
|
||||
new ConflictApiError((error as AccountEmailAlreadyExistsError).message || "Account email already exists."),
|
||||
};
|
||||
|
||||
const genericDuplicateEntityRule: ErrorToApiRule = {
|
||||
priority: 110,
|
||||
matches: isDuplicateEntityError,
|
||||
build: (error) =>
|
||||
new ConflictApiError((error as DuplicateEntityError).message || "Entity already exists."),
|
||||
};
|
||||
|
||||
export const identityAccountApiErrorMapper = ApiErrorMapper.default()
|
||||
.register(genericDuplicateEntityRule)
|
||||
.register(accountEmailAlreadyExistsRule)
|
||||
.register(currentPasswordMismatchRule)
|
||||
.register(accountCannotBeUpdatedRule)
|
||||
.register(accountNotFoundRule);
|
||||
@ -0,0 +1,54 @@
|
||||
import { type NextFunction, type Request, type Response, Router } from "express";
|
||||
import { type StartParams, validateRequest } from "@erp/core/api";
|
||||
|
||||
import {
|
||||
ChangeAccountPasswordRequestSchema,
|
||||
UpdateAccountProfileRequestSchema,
|
||||
} from "../../../common";
|
||||
import type { IdentityInternalDeps } from "../di";
|
||||
import {
|
||||
ChangeCurrentAccountPasswordController,
|
||||
GetCurrentAccountController,
|
||||
UpdateCurrentAccountController,
|
||||
} from "./controllers";
|
||||
import { buildUtcNow } from "./utils";
|
||||
|
||||
export const identityAccountRouter = (params: StartParams) => {
|
||||
const { app, config, getInternal } = params;
|
||||
const deps = getInternal<IdentityInternalDeps>("identity").account;
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
router.use(deps.authenticateRequest);
|
||||
|
||||
router.get("/", (req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new GetCurrentAccountController(deps.useCases.getCurrentAccount());
|
||||
return controller.execute(req, res, next);
|
||||
});
|
||||
|
||||
router.patch(
|
||||
"/",
|
||||
validateRequest(UpdateAccountProfileRequestSchema, "body"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new UpdateCurrentAccountController(
|
||||
deps.useCases.updateCurrentAccount(),
|
||||
() => buildUtcNow()
|
||||
);
|
||||
return controller.execute(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/change-password",
|
||||
validateRequest(ChangeAccountPasswordRequestSchema, "body"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new ChangeCurrentAccountPasswordController(
|
||||
deps.useCases.changeCurrentAccountPassword(),
|
||||
() => buildUtcNow()
|
||||
);
|
||||
return controller.execute(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
app.use(`${config.server.apiBasePath}/identity/account`, router);
|
||||
};
|
||||
@ -1,3 +1,5 @@
|
||||
export * from "./identity-account-api-error-mapper";
|
||||
export * from "./identity-account.routes";
|
||||
export * from "./controllers";
|
||||
export * from "./identity-auth.routes";
|
||||
export * from "./identity-auth-api-error-mapper";
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { LanguageCodeSchema, URLSchema } from "@erp/core";
|
||||
import { EmailSchema, LanguageCodeSchema, URLSchema } from "@erp/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const UpdateAccountProfileRequestSchema = z.object({
|
||||
email: EmailSchema.optional(),
|
||||
name: z.string().min(1).optional(),
|
||||
avatar_url: URLSchema.nullable().optional(),
|
||||
language_code: LanguageCodeSchema.optional(),
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { AccountProfileSchema } from "../shared";
|
||||
|
||||
export const GetAccountProfileResponseSchema = z.object({
|
||||
account: AccountProfileSchema,
|
||||
});
|
||||
export const GetAccountProfileResponseSchema = AccountProfileSchema;
|
||||
|
||||
export type GetAccountProfileResponseDTO = z.infer<typeof GetAccountProfileResponseSchema>;
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { AccountProfileSchema } from "../shared";
|
||||
|
||||
export const UpdateAccountProfileResponseSchema = z.object({
|
||||
account: AccountProfileSchema,
|
||||
});
|
||||
export const UpdateAccountProfileResponseSchema = AccountProfileSchema;
|
||||
|
||||
export type UpdateAccountProfileResponseDTO = z.infer<
|
||||
typeof UpdateAccountProfileResponseSchema
|
||||
>;
|
||||
export type UpdateAccountProfileResponseDTO = z.infer<typeof UpdateAccountProfileResponseSchema>;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { EmailSchema, LanguageCodeSchema, OffsetDateTimeSchema, URLSchema } from "@erp/core";
|
||||
import { EmailSchema, LanguageCodeSchema, URLSchema } from "@erp/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const AccountProfileSchema = z.object({
|
||||
@ -8,8 +8,6 @@ export const AccountProfileSchema = z.object({
|
||||
avatar_url: URLSchema.nullable(),
|
||||
language_code: LanguageCodeSchema,
|
||||
status: z.enum(["active", "disabled", "pending_verification"]),
|
||||
created_at: OffsetDateTimeSchema,
|
||||
updated_at: OffsetDateTimeSchema,
|
||||
});
|
||||
|
||||
export type AccountProfileDTO = z.infer<typeof AccountProfileSchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user