import { AdminUser, Role, Tenant, TenantUser, UserRole } from "@toolctrl/db/prisma-browser"; import { consumeRecoveryCode, getRecoveryCodeStatus, RecoveryCodeHashEntry } from "~/utils/auth/mfa/recovery-codes.server"; import { hashPassword } from "~/utils/auth/passwordHasher.server"; import Constants from "~/application/Constants"; import { FiltersDto } from "~/application/dtos/data/FiltersDto"; import { PaginationDto } from "~/application/dtos/data/PaginationDto"; import { db } from "~/utils/db.server"; import RowFiltersHelper from "../helpers/RowFiltersHelper"; import { SortedByDto } from "~/application/dtos/data/SortedByDto"; import { Prisma } from "@toolctrl/db/prisma-browser"; import DateUtils from "../shared/DateUtils"; import { DefaultLogActions } from "~/application/dtos/shared/DefaultLogActions"; import { cachified, clearCacheKey } from "../cache.server"; import { createScopedLogger } from "../logger.server"; import { emitPluginHookFireAndForget } from "~/modules/plugins/services/.server/pluginHookPublisher"; import { PLUGIN_HOOK_USER_CREATED } from "@toolctrl/plugin-runtime"; const usersLogger = createScopedLogger("db.users"); export type UserSimple = { id: string; email: string; firstName: string; lastName: string; // avatar: string | null; phone: string | null; locale: string | null; createdAt: Date; }; export type UserWithNames = { id: string; email: string; firstName: string; lastName: string; }; export type UserWithoutPassword = { id: string; email: string; firstName: string; lastName: string; avatar: string | null; phone: string | null; admin?: AdminUser | null; defaultTenantId: string | null; createdAt: Date; locale: string | null; lightOrDarkMode?: string | null; }; export type UserWithRoles = UserSimple & { admin?: AdminUser | null; roles: (UserRole & { role: Role })[]; }; export type UserTotpStatus = { enabled: boolean; pending: boolean; enabledAt: Date | null; pendingCreatedAt: Date | null; }; export type UserTotpPendingEnrollment = { totpPendingSecretEncrypted: string | null; totpPendingCreatedAt: Date | null; }; export type UserMfaRecoveryCodeStatus = { configured: boolean; remainingCount: number; generatedAt: Date | null; lastUsedAt: Date | null; }; export type UserMfaRecoveryCodeConsumeResult = { consumed: boolean; remainingCount: number; usedAt: Date | null; }; export type UserWithDetails = UserWithoutPassword & { admin: AdminUser | null; tenants: (TenantUser & { tenant: Tenant })[]; roles: (UserRole & { role: Role })[]; }; export const includeCreatedBy = { byUser: { select: { id: true, email: true, firstName: true, lastName: true }, }, byApiKey: { select: { id: true, alias: true }, }, byEmail: { select: { id: true, subject: true }, }, byEventWebhookAttempt: { select: { id: true, endpoint: true, message: true }, }, }; export async function adminGetAllTenantUsers(tenantId: string): Promise { return db.user.findMany({ where: { tenants: { some: { tenantId, }, }, }, include: { admin: true, tenants: { include: { tenant: true, }, }, roles: { include: { role: true, }, }, }, }); } export async function getTenantScopedUsersWithDetails(tenantId: string): Promise { return db.user.findMany({ where: { tenants: { some: { tenantId, }, }, }, include: { admin: true, tenants: { where: { tenantId, }, include: { tenant: true, }, }, roles: { where: { tenantId, }, include: { role: true, }, }, }, }); } export async function adminGetAllUsers( filters?: FiltersDto, pagination?: { page: number; pageSize: number; sortedBy?: SortedByDto[] } ): Promise<{ items: UserWithDetails[]; pagination: PaginationDto }> { let where: Prisma.UserWhereInput = RowFiltersHelper.getFiltersCondition(filters); const tenantId = filters?.properties.find((f) => f.name === "tenantId")?.value ?? filters?.query ?? ""; if (tenantId) { where = { OR: [where, { tenants: { some: { tenantId } } }], }; } // lastLogin manual filter const lastLoginFilter = filters?.properties.find((f) => f.name === "lastLogin"); if (lastLoginFilter?.value) { const gte = DateUtils.gteFromFilter(lastLoginFilter.value); const logs = ( await db.log.findMany({ where: { userId: { not: null }, createdAt: { gte }, action: DefaultLogActions.Login }, select: { userId: true }, }) ).map((l) => l.userId); const usersIn: string[] = logs.filter((f) => f !== null).map((m) => m as string); where = { AND: [where, { id: { in: usersIn } }], }; } const isAdminFilter = filters?.properties.find((f) => f.name === "isAdmin"); if (isAdminFilter?.value) { if (isAdminFilter.value === "true") { where = { AND: [where, { admin: { isNot: null } }], }; } else { where = { AND: [where, { admin: null }], }; } } let orderBy: Prisma.UserOrderByWithRelationInput[] = [{ createdAt: "desc" }]; if (pagination?.sortedBy?.length) { pagination.sortedBy = pagination.sortedBy.filter((s) => ["email", "firstName", "lastName", "createdAt"].includes(s.name)); orderBy = pagination.sortedBy.map((s) => { return { [s.name]: s.direction }; }); } const [items, totalItems] = await Promise.all([ db.user.findMany({ skip: pagination ? pagination?.pageSize * (pagination?.page - 1) : undefined, take: pagination ? pagination?.pageSize : undefined, where, include: { admin: true, tenants: { include: { tenant: true, }, }, roles: { include: { role: true, }, }, }, orderBy, }), db.user.count({ where, }), ]); return { items, pagination: { page: pagination?.page ?? 1, pageSize: pagination?.pageSize ?? Constants.DEFAULT_PAGE_SIZE, totalItems, totalPages: Math.ceil(totalItems / (pagination?.pageSize ?? Constants.DEFAULT_PAGE_SIZE)), }, }; } export async function adminGetAllUsersNames(): Promise { return db.user.findMany({ select: { id: true, email: true, firstName: true, lastName: true }, }); } export async function adminGetAllUsersNamesInIds(ids: string[]): Promise { return db.user.findMany({ select: { id: true, email: true, firstName: true, lastName: true }, where: { id: { in: ids }, }, }); } export async function getUsersById(ids: string[]): Promise { return db.user.findMany({ where: { id: { in: ids, }, }, include: { admin: true, tenants: { include: { tenant: true, }, }, roles: { include: { role: true, }, }, }, }); } export async function getAdminUsersInRoles(roles: string[]): Promise { return db.user.findMany({ where: { roles: { some: { role: { id: { in: roles } } } } }, include: { admin: true, tenants: { include: { tenant: true, }, }, roles: { include: { role: true, }, }, }, }); } export async function getUsersByTenant(tenantId: string | null): Promise { let where = {}; if (tenantId) { where = { tenants: { some: { tenantId, }, }, }; } else { where = { admin: { isNot: null, }, }; } return db.user.findMany({ where, include: { admin: true, tenants: { include: { tenant: true, }, }, roles: { include: { role: true, }, }, }, }); } export async function getUser(userId?: string): Promise { if (!userId) { return null; } try { return await cachified({ key: `user:${userId}`, ttl: 1000 * 60 * 60 * 24, getFreshValue: async () => db.user.findUnique({ where: { id: userId }, select: { id: true, email: true, firstName: true, lastName: true, avatar: true, phone: true, admin: true, defaultTenantId: true, createdAt: true, locale: true, lightOrDarkMode: true, }, }), }); } catch (error: any) { usersLogger.warn({ err: error }, "Could not fetch user with mfa snapshot"); return null; } } export async function setUserTotpPendingSecret(userId: string | undefined, encryptedSecret: string, pendingCreatedAt: Date = new Date()) { if (!userId) { return null; } return await db.user .update({ where: { id: userId }, data: { totpPendingSecretEncrypted: encryptedSecret, totpPendingCreatedAt: pendingCreatedAt, }, }) .then((item) => { clearCacheKey(`user:${userId}`); return item; }); } export async function activateUserTotpPendingSecret(userId: string | undefined, encryptedSecret: string, enabledAt: Date = new Date()) { if (!userId) { return null; } return await db.user .update({ where: { id: userId }, data: { totpSecretEncrypted: encryptedSecret, totpEnabledAt: enabledAt, totpPendingSecretEncrypted: null, totpPendingCreatedAt: null, }, }) .then((item) => { clearCacheKey(`user:${userId}`); return item; }); } export async function disableUserTotp(userId: string | undefined) { if (!userId) { return null; } return await db.user .update({ where: { id: userId }, data: { totpSecretEncrypted: null, totpPendingSecretEncrypted: null, totpEnabledAt: null, totpPendingCreatedAt: null, }, }) .then((item) => { clearCacheKey(`user:${userId}`); return item; }); } export async function getUserTotpStatus(userId: string | undefined): Promise { if (!userId) { return null; } const status = await db.user.findUnique({ where: { id: userId }, select: { totpSecretEncrypted: true, totpPendingSecretEncrypted: true, totpEnabledAt: true, totpPendingCreatedAt: true, }, }); if (!status) { return null; } return { enabled: Boolean(status.totpSecretEncrypted && status.totpEnabledAt), pending: Boolean(status.totpPendingSecretEncrypted && status.totpPendingCreatedAt), enabledAt: status.totpEnabledAt, pendingCreatedAt: status.totpPendingCreatedAt, }; } /** * D1 (2FA-Login-Gate) — liefert das VERSCHLUESSELTE TOTP-Secret eines Nutzers, sofern TOTP aktiv * (enabled) ist, sonst null. `verifyTotpCode` erwartet genau dieses verschluesselte Secret und * entschluesselt intern. Wird beim Login-Verify gebraucht, ohne den Klartext zu beruehren. */ export async function getActiveTotpSecretEncrypted(userId: string | undefined): Promise { if (!userId) { return null; } const user = await db.user.findUnique({ where: { id: userId }, select: { totpSecretEncrypted: true, totpEnabledAt: true }, }); if (!user?.totpSecretEncrypted || !user.totpEnabledAt) { return null; } return user.totpSecretEncrypted; } export async function getUserTotpPendingEnrollment(userId: string | undefined): Promise { if (!userId) { return null; } return await db.user.findUnique({ where: { id: userId }, select: { totpPendingSecretEncrypted: true, totpPendingCreatedAt: true, }, }); } export async function setUserMfaRecoveryCodes( userId: string | undefined, entries: RecoveryCodeHashEntry[], generatedAt: Date = new Date() ) { if (!userId) { return null; } return await db.user .update({ where: { id: userId }, data: { mfaRecoveryCodeHashes: entries, mfaRecoveryCodesGeneratedAt: generatedAt, mfaRecoveryCodeLastUsedAt: null, }, }) .then((item) => { clearCacheKey(`user:${userId}`); return item; }); } export async function getUserMfaRecoveryCodeStatus(userId: string | undefined): Promise { if (!userId) { return null; } const user = await db.user.findUnique({ where: { id: userId }, select: { mfaRecoveryCodeHashes: true, mfaRecoveryCodesGeneratedAt: true, mfaRecoveryCodeLastUsedAt: true, }, }); if (!user) { return null; } const status = getRecoveryCodeStatus(user.mfaRecoveryCodeHashes as RecoveryCodeHashEntry[] | null, { generatedAt: user.mfaRecoveryCodesGeneratedAt, lastUsedAt: user.mfaRecoveryCodeLastUsedAt, }); return { configured: status.configured, remainingCount: status.remainingCount, generatedAt: status.generatedAt, lastUsedAt: status.lastUsedAt, }; } export async function consumeUserMfaRecoveryCode( userId: string | undefined, code: string, options: { now?: Date } = {} ): Promise { if (!userId) { return null; } const user = await db.user.findUnique({ where: { id: userId }, select: { mfaRecoveryCodeHashes: true, mfaRecoveryCodesGeneratedAt: true, mfaRecoveryCodeLastUsedAt: true, }, }); const entries = user?.mfaRecoveryCodeHashes as RecoveryCodeHashEntry[] | null | undefined; if (!user || !entries?.length) { return { consumed: false, remainingCount: 0, usedAt: null }; } const result = consumeRecoveryCode(entries, code, { now: options.now }); if (!result.matched || !result.usedAt) { const status = getRecoveryCodeStatus(entries, { generatedAt: user.mfaRecoveryCodesGeneratedAt, lastUsedAt: user.mfaRecoveryCodeLastUsedAt, }); return { consumed: false, remainingCount: status.remainingCount, usedAt: null }; } const updateResult = await db.user.updateMany({ where: { id: userId, mfaRecoveryCodeHashes: { equals: entries as Prisma.InputJsonValue } }, data: { mfaRecoveryCodeHashes: result.entries, mfaRecoveryCodeLastUsedAt: result.usedAt, }, }); if (updateResult.count !== 1) { return { consumed: false, remainingCount: getRecoveryCodeStatus(entries).remainingCount, usedAt: null }; } clearCacheKey(`user:${userId}`); const status = getRecoveryCodeStatus(result.entries, { generatedAt: user.mfaRecoveryCodesGeneratedAt, lastUsedAt: result.usedAt, }); return { consumed: true, remainingCount: status.remainingCount, usedAt: result.usedAt }; } export async function getUserByEmail(email?: string) { if (!email) { return null; } return await db.user.findUnique({ where: { email }, include: { tenants: true, admin: true, }, }); } export async function getUserByEmailWithDetails(email: string): Promise { return await db.user.findUnique({ where: { email }, include: { admin: true, tenants: { include: { tenant: true } }, roles: { include: { role: true } }, }, }); } export async function register(data: { email: string; password: string; firstName: string; lastName: string; active?: boolean; avatarURL?: string; locale?: string; defaultTenantId?: string | null; request: Request; }) { const { email, password, firstName, lastName, active, avatarURL, locale, defaultTenantId } = data; const passwordHash = await hashPassword(password); const user = await db.user.create({ data: { email, passwordHash, firstName, lastName, avatar: avatarURL ?? "", phone: "", active, locale, defaultTenantId, }, }); // Phase 06 #22 — emit plugin hook `user.created` so installed plugins // can react (e.g. provisioning external SSO accounts, notifications). // Fire-and-forget: failures never propagate into user registration. await emitPluginHookFireAndForget( PLUGIN_HOOK_USER_CREATED, { userId: user.id, tenantId: defaultTenantId ?? null, email: user.email, locale: user.locale ?? null, createdAt: user.createdAt.toISOString(), }, { tenantId: defaultTenantId ?? null, dispatchKey: `user:${user.id}:created` } ); return { id: user.id, email, defaultTenantId: user.defaultTenantId, locale }; } export async function updateUserProfile( data: { firstName?: string; lastName?: string; avatar?: string; locale?: string; lightOrDarkMode?: string }, userId?: string ) { if (!userId) { return null; } return await db.user .update({ where: { id: userId }, data, }) .then((item) => { clearCacheKey(`user:${userId}`); return item; }); } export async function updateUserVerifyToken(data: { verifyToken: string; verifyTokenCreatedAt?: Date | null }, userId?: string) { if (!userId) { return null; } return await db.user .update({ where: { id: userId }, data, }) .then((item) => { clearCacheKey(`user:${userId}`); return item; }); } export async function updateUserPassword(data: { passwordHash: string }, userId?: string) { if (!userId) { return null; } return await db.user .update({ where: { id: userId }, data: { ...data, verifyToken: "", verifyTokenCreatedAt: null, }, }) .then((item) => { clearCacheKey(`user:${userId}`); return item; }); } export async function updateUserDefaultTenantId(data: { defaultTenantId: string }, userId: string) { if (!userId) { return null; } return await db.user .update({ where: { id: userId }, data, }) .then((item) => { clearCacheKey(`user:${userId}`); return item; }); } export async function deleteUser(userId: string) { return await db.user .delete({ where: { id: userId }, }) .then((item) => { clearCacheKey(`user:${userId}`); return item; }); }