metis-desk/modules/identity/src/api/application/identity.use-cases.ts

168 lines
5.2 KiB
TypeScript
Raw Normal View History

import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type {
BootstrapAdminRequestDTO,
ChangePasswordRequestDTO,
CreateUserRequestDTO,
LoginRequestDTO,
ResetPasswordRequestDTO,
UpdateUserRequestDTO,
} from "../../common";
import { IdentityError } from "../domain/identity-error";
import type { User } from "../domain/user.aggregate";
import type { IPasswordHasher, IUserRepository } from "./identity-ports";
import type { UserInputMapper } from "./user-input.mapper";
/** Contexto interno obtenido de la sesión del servidor, nunca del body. */
export type IdentityContext = Readonly<{ userId: string; authVersion: number }>;
2026-10-10 10:01:58 +00:00
export class IdentityUseCases {
constructor(
private readonly users: IUserRepository,
private readonly passwords: IPasswordHasher,
private readonly inputs: UserInputMapper,
private readonly dummyPasswordHash: string
) {}
2026-10-10 10:01:58 +00:00
async session(context: IdentityContext | undefined): Promise<User | undefined> {
if (!context) {
return undefined;
}
const id = UniqueID.create(context.userId);
2026-10-10 10:01:58 +00:00
if (!id.isSuccess) {
return undefined;
}
const user = (await this.users.findById(id.data)).getOrUndefined();
2026-10-10 10:01:58 +00:00
return user?.active && user.authVersion === context.authVersion ? user : undefined;
}
2026-10-10 10:01:58 +00:00
async setupRequired() {
return this.users.setupRequired();
}
2026-10-10 10:01:58 +00:00
2026-10-24 23:19:17 +00:00
/** Capacidad no delegable: se consulta el usuario vigente, nunca un privilegio del cliente. */
async canManageGlobalResources(context: IdentityContext | undefined) {
const actor = await this.session(context);
if (!actor) {
return Result.fail(new IdentityError("UNAUTHENTICATED"));
}
return actor.isInitialAdmin && (await this.users.isInitialAdministrator(actor.id))
? Result.ok(undefined)
: Result.fail(new IdentityError("FORBIDDEN"));
}
async bootstrap(dto: BootstrapAdminRequestDTO) {
if (!(await this.users.setupRequired())) {
return Result.fail(new IdentityError("SETUP_CLOSED"));
}
const user = await this.inputs.map(dto, true);
2026-10-10 10:01:58 +00:00
return user.isSuccess ? this.users.bootstrap(user.data) : user;
}
2026-10-10 10:01:58 +00:00
async login(dto: LoginRequestDTO): Promise<Result<User, IdentityError>> {
const email = EmailAddress.create(dto.email);
2026-10-10 10:01:58 +00:00
if (!email.isSuccess) {
return Result.fail(new IdentityError("INVALID_CREDENTIALS"));
}
const user = (await this.users.findByEmail(email.data)).getOrUndefined();
const valid = await this.passwords.verify(
dto.password,
user?.passwordHash.toPrimitive() ?? this.dummyPasswordHash
);
2026-10-10 10:01:58 +00:00
if (!(valid && user?.active)) {
return Result.fail(new IdentityError("INVALID_CREDENTIALS"));
}
2026-10-10 10:01:58 +00:00
return Result.ok(user);
}
2026-10-10 10:01:58 +00:00
private async administrator(context: IdentityContext) {
const actor = await this.session(context);
2026-10-10 10:01:58 +00:00
return actor?.isInitialAdmin ? Result.ok(actor) : Result.fail(new IdentityError("FORBIDDEN"));
}
2026-10-10 10:01:58 +00:00
async list(context: IdentityContext) {
const access = await this.administrator(context);
2026-10-10 10:01:58 +00:00
return access.isSuccess ? Result.ok(await this.users.list()) : access;
}
2026-10-10 10:01:58 +00:00
async create(dto: CreateUserRequestDTO, context: IdentityContext) {
const access = await this.administrator(context);
2026-10-10 10:01:58 +00:00
if (!access.isSuccess) {
return access;
}
const user = await this.inputs.map(dto, false);
2026-10-10 10:01:58 +00:00
return user.isSuccess ? this.users.create(user.data) : user;
}
2026-10-10 10:01:58 +00:00
async update(id: string, dto: UpdateUserRequestDTO, context: IdentityContext) {
const access = await this.administrator(context);
2026-10-10 10:01:58 +00:00
if (!access.isSuccess) {
return access;
}
const userId = UniqueID.create(id);
const profile = this.inputs.mapProfile(dto);
2026-10-10 10:01:58 +00:00
if (!(userId.isSuccess && profile.isSuccess)) {
return Result.fail(new IdentityError("INVALID_REQUEST"));
}
2026-10-10 10:01:58 +00:00
return this.users.update(userId.data, (user) =>
user.update({ ...profile.data, active: dto.active })
);
}
2026-10-10 10:01:58 +00:00
async resetPassword(id: string, dto: ResetPasswordRequestDTO, context: IdentityContext) {
const access = await this.administrator(context);
2026-10-10 10:01:58 +00:00
if (!access.isSuccess) {
return access;
}
const userId = UniqueID.create(id);
2026-10-10 10:01:58 +00:00
if (!userId.isSuccess) {
return Result.fail(new IdentityError("INVALID_REQUEST"));
}
if (userId.data.equals(access.data.id)) {
return Result.fail(new IdentityError("ADMIN_PROTECTED"));
}
const hash = await this.inputs.hashPassword(dto.password);
2026-10-10 10:01:58 +00:00
return this.users.update(userId.data, (user) => Result.ok(user.changePassword(hash)));
}
2026-10-10 10:01:58 +00:00
async changePassword(dto: ChangePasswordRequestDTO, context: IdentityContext) {
const actor = await this.session(context);
2026-10-10 10:01:58 +00:00
if (!actor) {
return Result.fail(new IdentityError("UNAUTHENTICATED"));
}
if (!(await this.passwords.verify(dto.currentPassword, actor.passwordHash.toPrimitive()))) {
return Result.fail(new IdentityError("INVALID_CREDENTIALS"));
}
const hash = await this.inputs.hashPassword(dto.password);
2026-10-10 10:01:58 +00:00
return this.users.update(actor.id, (user) => {
if (user.authVersion !== actor.authVersion) {
return Result.fail(new IdentityError("UNAUTHENTICATED"));
}
2026-10-10 10:01:58 +00:00
return Result.ok(user.changePassword(hash));
});
}
}