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;
|
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: {
|
export async function loginByApi(params: {
|
||||||
request: (
|
request: (
|
||||||
path: string,
|
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
|
## Endpoints activos
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
GET /identity/account
|
||||||
|
PATCH /identity/account
|
||||||
|
POST /identity/account/change-password
|
||||||
POST /identity/auth/login
|
POST /identity/auth/login
|
||||||
POST /identity/auth/refresh
|
POST /identity/auth/refresh
|
||||||
POST /identity/auth/logout
|
POST /identity/auth/logout
|
||||||
@ -120,6 +123,107 @@ export type LogoutResponseDTO = {
|
|||||||
- para logout normal, enviar el `refresh_token` actual
|
- para logout normal, enviar el `refresh_token` actual
|
||||||
- si logout falla por expiración de token, el frontend debe limpiar sesión igualmente
|
- 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`
|
## GET `/identity/auth/session`
|
||||||
|
|
||||||
### Headers
|
### Headers
|
||||||
@ -181,6 +285,17 @@ export type AvailableCompanyDTO = {
|
|||||||
```txt
|
```txt
|
||||||
400 -> request inválida o UUID de X-Company-Id mal formado
|
400 -> request inválida o UUID de X-Company-Id mal formado
|
||||||
401 -> access token ausente/inválido/expirado o credenciales inválidas
|
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
|
403 -> contexto tenant inválido, company no accesible o company no activa
|
||||||
|
409 -> conflicto de unicidad, por ejemplo email ya existente
|
||||||
500 -> error inesperado
|
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`.
|
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.
|
No se introduce `scope: "company" | "user"` en V1.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
Gestiona:
|
Gestiona:
|
||||||
|
|
||||||
- `Account`
|
- `Account`
|
||||||
|
- self-service de `Account`
|
||||||
- login
|
- login
|
||||||
- refresh
|
- refresh
|
||||||
- logout
|
- logout
|
||||||
@ -27,10 +28,15 @@ No gestiona:
|
|||||||
- la ficha operativa completa de empresa
|
- la ficha operativa completa de empresa
|
||||||
- `companySlug` en auth
|
- `companySlug` en auth
|
||||||
- el tenant dentro del access token
|
- 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
|
## Endpoints activos
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
GET /identity/account
|
||||||
|
PATCH /identity/account
|
||||||
|
POST /identity/account/change-password
|
||||||
POST /identity/auth/login
|
POST /identity/auth/login
|
||||||
POST /identity/auth/refresh
|
POST /identity/auth/refresh
|
||||||
POST /identity/auth/logout
|
POST /identity/auth/logout
|
||||||
@ -43,6 +49,72 @@ El endpoint para companies accesibles es:
|
|||||||
GET /companies/available
|
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.
|
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
|
## Access token
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
export * from "./contracts";
|
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<{
|
export type AccountProfilePatchProps = Partial<{
|
||||||
|
email: EmailAddress;
|
||||||
name: Name;
|
name: Name;
|
||||||
avatarUrl: Maybe<URLAddress>;
|
avatarUrl: Maybe<URLAddress>;
|
||||||
languageCode: LanguageCode;
|
languageCode: LanguageCode;
|
||||||
@ -119,6 +120,11 @@ export class Account extends AggregateRoot<AccountInternalProps> {
|
|||||||
|
|
||||||
let hasChanges = false;
|
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)) {
|
if (patch.name && !this.props.name.equals(patch.name)) {
|
||||||
this.props.name = patch.name;
|
this.props.name = patch.name;
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export * from "./application";
|
|||||||
export * from "./domain";
|
export * from "./domain";
|
||||||
export * from "./infrastructure";
|
export * from "./infrastructure";
|
||||||
|
|
||||||
import { identityAuthRouter, models } from "./infrastructure";
|
import { identityAccountRouter, identityAuthRouter, models } from "./infrastructure";
|
||||||
import { buildIdentityDependencies, buildIdentityPublicServices } from "./infrastructure/di";
|
import { buildIdentityDependencies, buildIdentityPublicServices } from "./infrastructure/di";
|
||||||
|
|
||||||
export * from "./infrastructure/persistence/sequelize";
|
export * from "./infrastructure/persistence/sequelize";
|
||||||
@ -58,6 +58,7 @@ export const identityAPIModule: IModuleServer = {
|
|||||||
|
|
||||||
async start(params) {
|
async start(params) {
|
||||||
identityAuthRouter(params);
|
identityAuthRouter(params);
|
||||||
|
identityAccountRouter(params);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,14 @@ import {
|
|||||||
IdentityCompanyAccessChecker,
|
IdentityCompanyAccessChecker,
|
||||||
} from "../../application";
|
} from "../../application";
|
||||||
import type { IAccountRepository } from "../../application/accounts";
|
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 {
|
import {
|
||||||
AccountAuthenticator,
|
AccountAuthenticator,
|
||||||
type GetCurrentSessionUseCase,
|
type GetCurrentSessionUseCase,
|
||||||
@ -49,6 +57,14 @@ export type IdentityInternalDeps = {
|
|||||||
getCurrentSession: () => GetCurrentSessionUseCase;
|
getCurrentSession: () => GetCurrentSessionUseCase;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
account: {
|
||||||
|
authenticateRequest: ReturnType<typeof identityAuthenticateRequest>;
|
||||||
|
useCases: {
|
||||||
|
getCurrentAccount: () => GetCurrentAccountUseCase;
|
||||||
|
updateCurrentAccount: () => UpdateCurrentAccountUseCase;
|
||||||
|
changeCurrentAccountPassword: () => ChangeCurrentAccountPasswordUseCase;
|
||||||
|
};
|
||||||
|
};
|
||||||
companyAccess: {
|
companyAccess: {
|
||||||
checker: IIdentityCompanyAccessChecker;
|
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: {
|
companyAccess: {
|
||||||
checker: companyAccessChecker,
|
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 "./get-current-session.controller";
|
||||||
export * from "./login-with-password.controller";
|
export * from "./login-with-password.controller";
|
||||||
export * from "./logout.controller";
|
export * from "./logout.controller";
|
||||||
export * from "./refresh-session.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 "./controllers";
|
||||||
export * from "./identity-auth.routes";
|
export * from "./identity-auth.routes";
|
||||||
export * from "./identity-auth-api-error-mapper";
|
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";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export const UpdateAccountProfileRequestSchema = z.object({
|
export const UpdateAccountProfileRequestSchema = z.object({
|
||||||
|
email: EmailSchema.optional(),
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
avatar_url: URLSchema.nullable().optional(),
|
avatar_url: URLSchema.nullable().optional(),
|
||||||
language_code: LanguageCodeSchema.optional(),
|
language_code: LanguageCodeSchema.optional(),
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
import { AccountProfileSchema } from "../shared";
|
import { AccountProfileSchema } from "../shared";
|
||||||
|
|
||||||
export const GetAccountProfileResponseSchema = z.object({
|
export const GetAccountProfileResponseSchema = AccountProfileSchema;
|
||||||
account: AccountProfileSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type GetAccountProfileResponseDTO = z.infer<typeof GetAccountProfileResponseSchema>;
|
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";
|
import { AccountProfileSchema } from "../shared";
|
||||||
|
|
||||||
export const UpdateAccountProfileResponseSchema = z.object({
|
export const UpdateAccountProfileResponseSchema = AccountProfileSchema;
|
||||||
account: AccountProfileSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type UpdateAccountProfileResponseDTO = z.infer<
|
export type UpdateAccountProfileResponseDTO = z.infer<typeof UpdateAccountProfileResponseSchema>;
|
||||||
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";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export const AccountProfileSchema = z.object({
|
export const AccountProfileSchema = z.object({
|
||||||
@ -8,8 +8,6 @@ export const AccountProfileSchema = z.object({
|
|||||||
avatar_url: URLSchema.nullable(),
|
avatar_url: URLSchema.nullable(),
|
||||||
language_code: LanguageCodeSchema,
|
language_code: LanguageCodeSchema,
|
||||||
status: z.enum(["active", "disabled", "pending_verification"]),
|
status: z.enum(["active", "disabled", "pending_verification"]),
|
||||||
created_at: OffsetDateTimeSchema,
|
|
||||||
updated_at: OffsetDateTimeSchema,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AccountProfileDTO = z.infer<typeof AccountProfileSchema>;
|
export type AccountProfileDTO = z.infer<typeof AccountProfileSchema>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user