import { PermissionOverrides, SystemRole, resolvePermissions, type ColumnPreferences, } from "@capakraken/shared/types"; import { dashboardLayoutSchema, normalizeDashboardLayout, } from "@capakraken/shared/schemas"; import { Prisma } from "@capakraken/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure, publicProcedure } from "../trpc.js"; import { createAuditEntry } from "../lib/audit.js"; export const userRouter = createTRPCRouter({ /** Lightweight user list for task assignment (ADMIN + MANAGER) */ listAssignable: managerProcedure.query(async ({ ctx }) => { return ctx.db.user.findMany({ select: { id: true, name: true, email: true, }, orderBy: { name: "asc" }, }); }), list: adminProcedure.query(async ({ ctx }) => { return ctx.db.user.findMany({ select: { id: true, name: true, email: true, systemRole: true, createdAt: true, lastLoginAt: true, lastActiveAt: true, permissionOverrides: true, totpEnabled: true, }, orderBy: { name: "asc" }, }); }), /** Count of users active in the last 5 minutes */ activeCount: adminProcedure.query(async ({ ctx }) => { const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000); const count = await ctx.db.user.count({ where: { lastActiveAt: { gte: fiveMinAgo } }, }); return { count }; }), me: protectedProcedure.query(async ({ ctx }) => { const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true, name: true, email: true, systemRole: true, permissionOverrides: true, createdAt: true, }, }), "User", ); return user; }), create: adminProcedure .input( z.object({ email: z.string().email(), name: z.string().min(1), systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER), password: z.string().min(8), }), ) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.user.findUnique({ where: { email: input.email } }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: "User with this email already exists" }); } const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); const user = await ctx.db.user.create({ data: { email: input.email, name: input.name, systemRole: input.systemRole, passwordHash, }, select: { id: true, name: true, email: true, systemRole: true }, }); // Auto-link to a resource with matching email (if one exists and isn't already linked) const matchingResource = await ctx.db.resource.findFirst({ where: { email: input.email, userId: null }, select: { id: true }, }); if (matchingResource) { await ctx.db.resource.update({ where: { id: matchingResource.id }, data: { userId: user.id }, }); } void createAuditEntry({ db: ctx.db, entityType: "User", entityId: user.id, entityName: `${user.name} (${user.email})`, action: "CREATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), after: user as unknown as Record, source: "ui", }); return user; }), setPassword: adminProcedure .input( z.object({ userId: z.string(), password: z.string().min(8, "Password must be at least 8 characters"), }), ) .mutation(async ({ ctx, input }) => { const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, name: true, email: true }, }), "User", ); const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); await ctx.db.user.update({ where: { id: input.userId }, data: { passwordHash }, }); void createAuditEntry({ db: ctx.db, entityType: "User", entityId: user.id, entityName: `${user.name} (${user.email})`, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), source: "ui", summary: "Password reset by admin", }); return { success: true }; }), updateRole: adminProcedure .input( z.object({ id: z.string(), systemRole: z.nativeEnum(SystemRole), }), ) .mutation(async ({ ctx, input }) => { const before = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.id }, select: { id: true, name: true, email: true, systemRole: true }, }), "User", ); const updated = await ctx.db.user.update({ where: { id: input.id }, data: { systemRole: input.systemRole }, select: { id: true, name: true, email: true, systemRole: true }, }); void createAuditEntry({ db: ctx.db, entityType: "User", entityId: updated.id, entityName: `${updated.name} (${updated.email})`, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), before: before as unknown as Record, after: updated as unknown as Record, source: "ui", summary: `Changed role from ${before.systemRole} to ${updated.systemRole}`, }); return updated; }), updateName: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1, "Name is required").max(200), }), ) .mutation(async ({ ctx, input }) => { const before = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.id }, select: { id: true, name: true, email: true }, }), "User", ); const updated = await ctx.db.user.update({ where: { id: input.id }, data: { name: input.name }, select: { id: true, name: true, email: true }, }); void createAuditEntry({ db: ctx.db, entityType: "User", entityId: updated.id, entityName: `${updated.name} (${updated.email})`, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), before: before as unknown as Record, after: updated as unknown as Record, source: "ui", summary: `Changed name from "${before.name}" to "${updated.name}"`, }); return updated; }), // ─── Resource Linking ────────────────────────────────────────────────── linkResource: adminProcedure .input(z.object({ userId: z.string(), resourceId: z.string().nullable() })) .mutation(async ({ ctx, input }) => { await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true }, }), "User", ); if (input.resourceId) { await findUniqueOrThrow( ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { id: true }, }), "Resource", ); // Unlink any resource previously linked to this user await ctx.db.resource.updateMany({ where: { userId: input.userId }, data: { userId: null }, }); // Link the new resource await ctx.db.resource.update({ where: { id: input.resourceId }, data: { userId: input.userId }, }); } else { // Unlink await ctx.db.resource.updateMany({ where: { userId: input.userId }, data: { userId: null }, }); } return { success: true }; }), autoLinkAllByEmail: adminProcedure.mutation(async ({ ctx }) => { // Find all users without a linked resource, then match by email const unlinkedUsers = await ctx.db.user.findMany({ where: { resource: null }, select: { id: true, email: true }, }); let linked = 0; for (const user of unlinkedUsers) { const resource = await ctx.db.resource.findFirst({ where: { email: user.email, userId: null }, select: { id: true }, }); if (resource) { await ctx.db.resource.update({ where: { id: resource.id }, data: { userId: user.id }, }); linked++; } } return { linked, checked: unlinkedUsers.length }; }), getDashboardLayout: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { dashboardLayout: true, updatedAt: true }, }); return { layout: user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : null, updatedAt: user?.updatedAt ?? null, }; }), saveDashboardLayout: protectedProcedure .input(z.object({ layout: dashboardLayoutSchema })) .mutation(async ({ ctx, input }) => { const updated = await ctx.db.user.update({ where: { email: ctx.session.user?.email ?? "" }, data: { dashboardLayout: input.layout as unknown as import("@capakraken/db").Prisma.InputJsonValue }, select: { updatedAt: true }, }); return { updatedAt: updated.updatedAt }; }), // ─── Favorite Projects ────────────────────────────────────────────────── getFavoriteProjectIds: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { favoriteProjectIds: true }, }); return ((user?.favoriteProjectIds as string[] | null) ?? []) as string[]; }), toggleFavoriteProject: protectedProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { const user = await ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { favoriteProjectIds: true }, }); const current = ((user?.favoriteProjectIds as string[] | null) ?? []) as string[]; const next = current.includes(input.projectId) ? current.filter((id) => id !== input.projectId) : [...current, input.projectId]; await ctx.db.user.update({ where: { id: ctx.dbUser!.id }, data: { favoriteProjectIds: next as unknown as Prisma.InputJsonValue }, }); return { favoriteProjectIds: next, added: !current.includes(input.projectId) }; }), setPermissions: adminProcedure .input( z.object({ userId: z.string(), overrides: z .object({ granted: z.array(z.string()).optional(), denied: z.array(z.string()).optional(), chapterIds: z.array(z.string()).optional(), }) .nullable(), }), ) .mutation(async ({ ctx, input }) => { const before = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, name: true, email: true, permissionOverrides: true }, }), "User", ); const user = await ctx.db.user.update({ where: { id: input.userId }, data: { permissionOverrides: input.overrides ?? Prisma.DbNull }, }); void createAuditEntry({ db: ctx.db, entityType: "User", entityId: input.userId, entityName: `${before.name} (${before.email})`, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), before: { permissionOverrides: before.permissionOverrides } as unknown as Record, after: { permissionOverrides: input.overrides } as unknown as Record, source: "ui", summary: input.overrides ? `Set permission overrides (granted: ${input.overrides.granted?.length ?? 0}, denied: ${input.overrides.denied?.length ?? 0})` : "Cleared permission overrides", }); return user; }), resetPermissions: adminProcedure .input(z.object({ userId: z.string() })) .mutation(async ({ ctx, input }) => { const before = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, name: true, email: true, permissionOverrides: true }, }), "User", ); const updated = await ctx.db.user.update({ where: { id: input.userId }, data: { permissionOverrides: Prisma.DbNull }, }); void createAuditEntry({ db: ctx.db, entityType: "User", entityId: input.userId, entityName: `${before.name} (${before.email})`, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), before: { permissionOverrides: before.permissionOverrides } as unknown as Record, after: { permissionOverrides: null } as unknown as Record, source: "ui", summary: "Reset permission overrides to role defaults", }); return updated; }), getColumnPreferences: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { columnPreferences: true }, }); return (user?.columnPreferences ?? {}) as ColumnPreferences; }), setColumnPreferences: protectedProcedure .input(z.object({ view: z.enum(["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"]), visible: z.array(z.string()).optional(), sort: z.object({ field: z.string(), dir: z.enum(["asc", "desc"]) }).nullable().optional(), rowOrder: z.array(z.string()).nullable().optional(), })) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { columnPreferences: true }, }); const prefs = (existing?.columnPreferences ?? {}) as ColumnPreferences; const prev = (prefs[input.view] as import("@capakraken/shared").ViewPreferences | undefined) ?? { visible: [] }; // Merge: only overwrite fields that were explicitly provided const merged: import("@capakraken/shared").ViewPreferences = { visible: input.visible ?? prev.visible, }; // sort: null = clear, undefined = keep existing, value = set if (input.sort !== null && input.sort !== undefined) { merged.sort = input.sort; } else if (input.sort === undefined && prev.sort != null) { merged.sort = prev.sort; } // rowOrder: null = clear, undefined = keep existing, value = set if (input.rowOrder !== null && input.rowOrder !== undefined) { merged.rowOrder = input.rowOrder; } else if (input.rowOrder === undefined && prev.rowOrder != null) { merged.rowOrder = prev.rowOrder; } prefs[input.view] = merged; await ctx.db.user.update({ where: { id: ctx.dbUser!.id }, data: { columnPreferences: prefs as Prisma.InputJsonValue }, }); return { ok: true }; }), getEffectivePermissions: adminProcedure .input(z.object({ userId: z.string() })) .query(async ({ ctx, input }) => { const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { systemRole: true, permissionOverrides: true }, }), "User", ); const permissions = resolvePermissions( user.systemRole as SystemRole, user.permissionOverrides as PermissionOverrides | null, ); return { systemRole: user.systemRole, effectivePermissions: Array.from(permissions), overrides: user.permissionOverrides as PermissionOverrides | null, }; }), // ─── TOTP / MFA ───────────────────────────────────────────────────────────── /** Generate a new TOTP secret for the current user (not yet enabled). */ generateTotpSecret: protectedProcedure.mutation(async ({ ctx }) => { const { TOTP, Secret } = await import("otpauth"); const secret = new Secret({ size: 20 }); const totp = new TOTP({ issuer: "CapaKraken", label: ctx.session.user?.email ?? ctx.dbUser!.id, algorithm: "SHA1", digits: 6, period: 30, secret, }); // Store the secret (not yet enabled) await ctx.db.user.update({ where: { id: ctx.dbUser!.id }, data: { totpSecret: secret.base32 }, }); const uri = totp.toString(); return { secret: secret.base32, uri }; }), /** Verify a TOTP token and enable MFA for the current user. */ verifyAndEnableTotp: protectedProcedure .input(z.object({ token: z.string().length(6) })) .mutation(async ({ ctx, input }) => { const user = await ctx.db.user.findUniqueOrThrow({ where: { id: ctx.dbUser!.id }, select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true }, }); if (!user.totpSecret) { throw new TRPCError({ code: "BAD_REQUEST", message: "No TOTP secret generated. Call generateTotpSecret first." }); } if (user.totpEnabled) { throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is already enabled." }); } const { TOTP, Secret } = await import("otpauth"); const totp = new TOTP({ issuer: "CapaKraken", label: user.email, algorithm: "SHA1", digits: 6, period: 30, secret: Secret.fromBase32(user.totpSecret), }); const delta = totp.validate({ token: input.token, window: 1 }); if (delta === null) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid TOTP token." }); } await ctx.db.user.update({ where: { id: user.id }, data: { totpEnabled: true }, }); void createAuditEntry({ db: ctx.db, entityType: "User", entityId: user.id, entityName: `${user.name} (${user.email})`, action: "UPDATE", userId: user.id, source: "ui", summary: "Enabled TOTP MFA", }); return { enabled: true }; }), /** Admin override: disable TOTP for a specific user. */ disableTotp: adminProcedure .input(z.object({ userId: z.string() })) .mutation(async ({ ctx, input }) => { const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, name: true, email: true, totpEnabled: true }, }), "User", ); await ctx.db.user.update({ where: { id: input.userId }, data: { totpEnabled: false, totpSecret: null }, }); void createAuditEntry({ db: ctx.db, entityType: "User", entityId: user.id, entityName: `${user.name} (${user.email})`, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), source: "ui", summary: "Disabled TOTP MFA (admin override)", }); return { disabled: true }; }), /** Verify a TOTP token (used during the login flow — public procedure). */ verifyTotp: publicProcedure .input(z.object({ userId: z.string(), token: z.string().length(6) })) .mutation(async ({ ctx, input }) => { const user = await ctx.db.user.findUniqueOrThrow({ where: { id: input.userId }, select: { id: true, totpSecret: true, totpEnabled: true }, }); if (!user.totpEnabled || !user.totpSecret) { throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is not enabled for this user." }); } const { TOTP, Secret } = await import("otpauth"); const totp = new TOTP({ issuer: "CapaKraken", label: user.id, algorithm: "SHA1", digits: 6, period: 30, secret: Secret.fromBase32(user.totpSecret), }); const delta = totp.validate({ token: input.token, window: 1 }); if (delta === null) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." }); } return { valid: true }; }), /** Get MFA status for the current user. */ getMfaStatus: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.db.user.findUniqueOrThrow({ where: { id: ctx.dbUser!.id }, select: { totpEnabled: true }, }); return { totpEnabled: user.totpEnabled }; }), });