import { randomUUID } from 'node:crypto' import type { AuthenticatorTransportFuture, CredentialDeviceType } from '@simplewebauthn/server' import type { AuthUser, ManagedUser, PasskeySummary, PublicContact, UserRole } from '~~/shared/auth' import { encodeBase64Url, toIsoString } from './base64url' import { ensureDatabaseReady } from './db-init' import { getSqlClient } from './postgres' type DbUserRow = { id: string username: string full_name: string phone_number: string | null role: UserRole password_hash: string must_change_password: boolean is_active: boolean created_by: string | null created_at: Date | string last_login_at: Date | string | null passkey_count: number | string } type DbPasskeyRow = { id: string user_id: string credential_id: string public_key: string counter: number | string device_type: CredentialDeviceType backed_up: boolean transports: AuthenticatorTransportFuture[] | string label: string created_at: Date | string last_used_at: Date | string | null } export interface UserAuthRecord extends AuthUser { passwordHash: string createdBy: string | null } export interface PasskeyRecord { id: string userId: string credentialId: string publicKey: string counter: number deviceType: CredentialDeviceType backedUp: boolean transports: AuthenticatorTransportFuture[] label: string createdAt: string lastUsedAt: string | null } function parseInteger(value: number | string): number { return typeof value === 'number' ? value : Number.parseInt(value, 10) } function parseTransports(value: AuthenticatorTransportFuture[] | string): AuthenticatorTransportFuture[] { if (Array.isArray(value)) { return value } try { const parsed = JSON.parse(value) return Array.isArray(parsed) ? parsed : [] } catch { return [] } } function mapAuthUser(row: DbUserRow): AuthUser { const passkeyCount = parseInteger(row.passkey_count) return { id: row.id, username: row.username, fullName: row.full_name, phoneNumber: row.phone_number, role: row.role, isActive: row.is_active, mustChangePassword: row.must_change_password, needsPasskeySetup: passkeyCount === 0, passkeyCount, createdAt: toIsoString(row.created_at) ?? new Date().toISOString(), lastLoginAt: toIsoString(row.last_login_at) } } function mapManagedUser(row: DbUserRow): ManagedUser { return { ...mapAuthUser(row), createdBy: row.created_by } } function mapUserAuthRecord(row: DbUserRow): UserAuthRecord { return { ...mapAuthUser(row), passwordHash: row.password_hash, createdBy: row.created_by } } function mapPasskeyRecord(row: DbPasskeyRow): PasskeyRecord { return { id: row.id, userId: row.user_id, credentialId: row.credential_id, publicKey: row.public_key, counter: parseInteger(row.counter), deviceType: row.device_type, backedUp: row.backed_up, transports: parseTransports(row.transports), label: row.label, createdAt: toIsoString(row.created_at) ?? new Date().toISOString(), lastUsedAt: toIsoString(row.last_used_at) } } function mapPasskeySummary(row: DbPasskeyRow): PasskeySummary { const record = mapPasskeyRecord(row) return { id: record.id, label: record.label, createdAt: record.createdAt, lastUsedAt: record.lastUsedAt, deviceType: record.deviceType, backedUp: record.backedUp } } export async function getUserById(userId: string): Promise { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` select users.id, users.username, users.full_name, users.phone_number, users.role, users.password_hash, users.must_change_password, users.is_active, users.created_by, users.created_at, users.last_login_at, coalesce(passkey_totals.passkey_count, 0) as passkey_count from users left join ( select user_id, count(*)::int as passkey_count from user_passkeys group by user_id ) as passkey_totals on passkey_totals.user_id = users.id where users.id = ${userId} limit 1 ` return row ? mapUserAuthRecord(row) : null } export async function getUserByUsername(username: string): Promise { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` select users.id, users.username, users.full_name, users.phone_number, users.role, users.password_hash, users.must_change_password, users.is_active, users.created_by, users.created_at, users.last_login_at, coalesce(passkey_totals.passkey_count, 0) as passkey_count from users left join ( select user_id, count(*)::int as passkey_count from user_passkeys group by user_id ) as passkey_totals on passkey_totals.user_id = users.id where users.username = ${username} limit 1 ` return row ? mapUserAuthRecord(row) : null } export async function listUsers(): Promise { await ensureDatabaseReady() const sql = getSqlClient() const rows = await sql` select users.id, users.username, users.full_name, users.phone_number, users.role, users.password_hash, users.must_change_password, users.is_active, users.created_by, users.created_at, users.last_login_at, coalesce(passkey_totals.passkey_count, 0) as passkey_count from users left join ( select user_id, count(*)::int as passkey_count from user_passkeys group by user_id ) as passkey_totals on passkey_totals.user_id = users.id order by case when users.role = 'super_admin' then 0 else 1 end, users.created_at asc ` return rows.map(mapManagedUser) } export async function listPublicContacts(): Promise { await ensureDatabaseReady() const sql = getSqlClient() const rows = await sql[]>` select users.id, users.full_name, users.phone_number, users.role from users where users.is_active = true and users.phone_number is not null and users.phone_number <> '' order by case when users.role = 'super_admin' then 0 else 1 end, users.full_name asc ` return rows.map((row) => ({ id: row.id, fullName: row.full_name, phoneNumber: row.phone_number || '', role: row.role })) } export async function getPublicContactById(contactId: string): Promise { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql[]>` select users.id, users.full_name, users.phone_number, users.role from users where users.id = ${contactId} and users.is_active = true and users.phone_number is not null and users.phone_number <> '' limit 1 ` if (!row) { return null } return { id: row.id, fullName: row.full_name, phoneNumber: row.phone_number || '', role: row.role } } export async function createUser(input: { username: string fullName: string phoneNumber: string role: UserRole passwordHash: string createdBy: string }): Promise { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` insert into users ( id, username, full_name, phone_number, role, password_hash, must_change_password, is_active, created_by ) values ( ${randomUUID()}, ${input.username}, ${input.fullName}, ${input.phoneNumber}, ${input.role}, ${input.passwordHash}, true, true, ${input.createdBy} ) returning id, username, full_name, phone_number, role, password_hash, must_change_password, is_active, created_by, created_at, last_login_at, 0::int as passkey_count ` return mapUserAuthRecord(row) } export async function updateUserProfile(input: { userId: string fullName: string phoneNumber: string role: UserRole }) { await ensureDatabaseReady() const sql = getSqlClient() await sql` update users set full_name = ${input.fullName}, phone_number = ${input.phoneNumber}, role = ${input.role}, updated_at = now() where id = ${input.userId} ` return getUserById(input.userId) } export async function updateUserPassword(input: { userId: string passwordHash: string mustChangePassword: boolean }) { await ensureDatabaseReady() const sql = getSqlClient() await sql` update users set password_hash = ${input.passwordHash}, must_change_password = ${input.mustChangePassword}, updated_at = now() where id = ${input.userId} ` } export async function updateLastLogin(userId: string) { await ensureDatabaseReady() const sql = getSqlClient() await sql` update users set last_login_at = now(), updated_at = now() where id = ${userId} ` } export async function listUserPasskeys(userId: string): Promise { await ensureDatabaseReady() const sql = getSqlClient() const rows = await sql` select id, user_id, credential_id, public_key, counter, device_type, backed_up, transports, label, created_at, last_used_at from user_passkeys where user_id = ${userId} order by created_at asc ` return rows.map(mapPasskeySummary) } export async function getCredentialForVerification(credentialId: string): Promise { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` select id, user_id, credential_id, public_key, counter, device_type, backed_up, transports, label, created_at, last_used_at from user_passkeys where credential_id = ${credentialId} limit 1 ` return row ? mapPasskeyRecord(row) : null } export async function listCredentialDescriptors(userId: string) { await ensureDatabaseReady() const sql = getSqlClient() const rows = await sql[]>` select credential_id, transports from user_passkeys where user_id = ${userId} order by created_at asc ` return rows.map((row) => ({ id: row.credential_id, transports: parseTransports(row.transports) })) } export async function createUserPasskey(input: { userId: string credentialId: string publicKey: Uint8Array counter: number deviceType: CredentialDeviceType backedUp: boolean transports: AuthenticatorTransportFuture[] label: string }) { await ensureDatabaseReady() const sql = getSqlClient() await sql` insert into user_passkeys ( id, user_id, credential_id, public_key, counter, device_type, backed_up, transports, label ) values ( ${randomUUID()}, ${input.userId}, ${input.credentialId}, ${encodeBase64Url(input.publicKey)}, ${input.counter}, ${input.deviceType}, ${input.backedUp}, ${sql.json(input.transports)}, ${input.label} ) ` } export async function updateCredentialCounter(input: { credentialId: string counter: number deviceType: CredentialDeviceType backedUp: boolean }) { await ensureDatabaseReady() const sql = getSqlClient() await sql` update user_passkeys set counter = ${input.counter}, device_type = ${input.deviceType}, backed_up = ${input.backedUp}, last_used_at = now() where credential_id = ${input.credentialId} ` }