import { Prisma } from "@capakraken/db"; import { dashboardLayoutSchema, normalizeDashboardLayout, } from "@capakraken/shared/schemas"; import type { ColumnPreferences } from "@capakraken/shared/types"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; import { totpRateLimiter } from "../middleware/rate-limit.js"; import type { TRPCContext } from "../trpc.js"; export const SaveDashboardLayoutInputSchema = z.object({ layout: dashboardLayoutSchema, }); export const ToggleFavoriteProjectInputSchema = z.object({ projectId: z.string(), }); export const SetColumnPreferencesInputSchema = 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(), }); export const VerifyAndEnableTotpInputSchema = z.object({ token: z.string().length(6), }); export const VerifyTotpInputSchema = z.object({ userId: z.string(), token: z.string().length(6), }); type UserSelfServiceContext = Pick; type UserPublicContext = Pick; export async function getCurrentUserProfile(ctx: UserSelfServiceContext) { return findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { id: true, name: true, email: true, systemRole: true, permissionOverrides: true, createdAt: true, }, }), "User", ); } export async function getDashboardLayout(ctx: UserSelfServiceContext) { const user = await ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { dashboardLayout: true, updatedAt: true }, }); const normalized = user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : null; return { layout: normalized?.widgets.length ? normalized : null, updatedAt: user?.updatedAt ?? null, }; } export async function saveDashboardLayout( ctx: UserSelfServiceContext, input: z.infer, ) { const updated = await ctx.db.user.update({ where: { id: ctx.dbUser!.id }, data: { dashboardLayout: input.layout as unknown as Prisma.InputJsonValue }, select: { updatedAt: true }, }); return { updatedAt: updated.updatedAt }; } export async function getFavoriteProjectIds(ctx: UserSelfServiceContext) { const user = await ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { favoriteProjectIds: true }, }); return ((user?.favoriteProjectIds as string[] | null) ?? []) as string[]; } export async function toggleFavoriteProject( ctx: UserSelfServiceContext, input: z.infer, ) { 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) }; } export async function getColumnPreferences(ctx: UserSelfServiceContext) { const user = await ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { columnPreferences: true }, }); return (user?.columnPreferences ?? {}) as ColumnPreferences; } export async function setColumnPreferences( ctx: UserSelfServiceContext, input: z.infer, ) { 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: [] }; const merged: import("@capakraken/shared").ViewPreferences = { visible: input.visible ?? prev.visible, }; if (input.sort !== null && input.sort !== undefined) { merged.sort = input.sort; } else if (input.sort === undefined && prev.sort != null) { merged.sort = prev.sort; } 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 }; } export async function generateTotpSecret(ctx: UserSelfServiceContext) { 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, }); await ctx.db.user.update({ where: { id: ctx.dbUser!.id }, data: { totpSecret: secret.base32 }, }); return { secret: secret.base32, uri: totp.toString() }; } export async function verifyAndEnableTotp( ctx: UserSelfServiceContext, input: z.infer, ) { const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true, lastTotpAt: true }, }) as Promise<{ id: string; name: string | null; email: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null>, "User", ); 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." }); } // Replay-attack prevention: reject if the same 30-second window was already used if ( user.lastTotpAt != null && Date.now() - user.lastTotpAt.getTime() < 30_000 ) { throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP code already used. Wait for the next code." }); } await (ctx.db.user.update as Function)({ where: { id: user.id }, data: { totpEnabled: true, lastTotpAt: new Date() }, }); 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 }; } export async function verifyTotp( ctx: UserPublicContext, input: z.infer, ) { // Rate limit: max 10 attempts per 30 seconds per userId to prevent brute-force (A01-1) const rl = await totpRateLimiter(input.userId); if (!rl.allowed) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many TOTP attempts. Please wait before trying again." }); } const user = await ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, totpSecret: true, totpEnabled: true, lastTotpAt: true }, }) as { id: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null; // Generic error for both not-found and TOTP-not-enabled to prevent user enumeration if (!user || !user.totpEnabled || !user.totpSecret) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." }); } 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." }); } // Replay-attack prevention: reject if the same 30-second window was already used if ( user.lastTotpAt != null && Date.now() - user.lastTotpAt.getTime() < 30_000 ) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." }); } // Record successful TOTP use to prevent replay within the same window await (ctx.db.user.update as Function)({ where: { id: user.id }, data: { lastTotpAt: new Date() }, }); return { valid: true }; } export async function getCurrentMfaStatus(ctx: UserSelfServiceContext) { const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { totpEnabled: true }, }), "User", ); return { totpEnabled: user.totpEnabled }; }