/** * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Copyright (c) Infonomic Company Limited */ import { toAdminUser } from '../admin-users/dto.js' import { ERR_ADMIN_USER_EMAIL_IN_USE, ERR_ADMIN_USER_VERSION_CONFLICT, } from '../admin-users/errors.js' import { hashPassword, verifyPassword } from '../auth/password.js' import { ERR_ADMIN_ACCOUNT_INVALID_CURRENT_PASSWORD, ERR_ADMIN_ACCOUNT_NOT_FOUND, } from './errors.js' import type { AdminUsersRepository } from '../admin-users/repository.js' import type { AccountResponse, ChangeAccountPasswordRequest, UpdateAccountRequest, } from './schemas.js' /** * Self-service business logic for the currently signed-in admin user. * * Reuses `AdminUsersRepository` rather than introducing a parallel * repository — the underlying table is the same, and self-service is * just a narrower surface over it. The narrowing is structural: * * - Every method takes `actorId` (sourced server-side from the * authenticated `RequestContext`) and uses it as the target id. * Callers cannot supply a target id; commands look it up from * `actor.id` and pass it in. * - `updateAccount` excludes `is_super_admin`, `is_enabled`, and * `is_email_verified` from the writable surface. The schema * already strips them, but the service signature reinforces it. * - `changePassword` verifies the *current* password before swapping * in the new hash. A hijacked session cannot use this flow to lock * out the legitimate owner. * * Native adapter password writes atomically advance the session generation and * revoke every refresh session. External providers own their own revocation. */ export class AdminAccountService { readonly #repo: AdminUsersRepository constructor(deps: { repo: AdminUsersRepository }) { this.#repo = deps.repo } async getAccount(actorId: string): Promise { const row = await this.#repo.getById(actorId) if (!row) throw ERR_ADMIN_ACCOUNT_NOT_FOUND() return toAdminUser(row) } async updateAccount(actorId: string, request: UpdateAccountRequest): Promise { const current = await this.#repo.getById(actorId) if (!current) throw ERR_ADMIN_ACCOUNT_NOT_FOUND() if (request.patch.email != null && request.patch.email !== current.email) { const owner = await this.#repo.getByEmail(request.patch.email) if (owner && owner.id !== actorId) throw ERR_ADMIN_USER_EMAIL_IN_USE() } const row = await this.#repo.update(actorId, request.vid, request.patch) return toAdminUser(row) } async setPreferredLocale(actorId: string, locale: string | null): Promise { const current = await this.#repo.getById(actorId) if (!current) throw ERR_ADMIN_ACCOUNT_NOT_FOUND() await this.#repo.setPreferredLocale(actorId, locale) // Re-read for the post-write vid + updated_at — the vid-less repo // method bumps `vid`, so the response carries the fresh shape callers // need for any subsequent vid-gated edit. const updated = await this.#repo.getById(actorId) if (!updated) throw ERR_ADMIN_ACCOUNT_NOT_FOUND() return toAdminUser(updated) } async changePassword( actorId: string, request: ChangeAccountPasswordRequest ): Promise { // Pull the row *with* the password hash so we can verify the // supplied current password before persisting a new one. The // sign-in-shaped row is treated as ephemeral here — the hash // string is never propagated outside this method. const withHash = await this.#repo.getByIdForSignIn(actorId) if (!withHash) throw ERR_ADMIN_ACCOUNT_NOT_FOUND() const ok = await verifyPassword(request.currentPassword, withHash.password_hash) if (!ok) throw ERR_ADMIN_ACCOUNT_INVALID_CURRENT_PASSWORD() if (withHash.vid !== request.vid) throw ERR_ADMIN_USER_VERSION_CONFLICT() const newHash = await hashPassword(request.newPassword) const row = await this.#repo.setPasswordHash(actorId, request.vid, newHash) return toAdminUser(row) } }