From 9fccd4c29e6dd8127c2656fd94678d60a57e379a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 21:40:50 +0200 Subject: [PATCH] refactor(api): extract user procedures --- .../__tests__/user-procedure-support.test.ts | 249 ++++++ .../api/src/router/user-procedure-support.ts | 499 ++++++++++++ .../user-self-service-procedure-support.ts | 272 +++++++ packages/api/src/router/user.ts | 739 ++---------------- 4 files changed, 1098 insertions(+), 661 deletions(-) create mode 100644 packages/api/src/__tests__/user-procedure-support.test.ts create mode 100644 packages/api/src/router/user-procedure-support.ts create mode 100644 packages/api/src/router/user-self-service-procedure-support.ts diff --git a/packages/api/src/__tests__/user-procedure-support.test.ts b/packages/api/src/__tests__/user-procedure-support.test.ts new file mode 100644 index 0000000..683fb27 --- /dev/null +++ b/packages/api/src/__tests__/user-procedure-support.test.ts @@ -0,0 +1,249 @@ +import { resolvePermissions, SystemRole } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + countActiveUsers, + getEffectiveUserPermissions, + linkUserResource, + listAssignableUsers, +} from "../router/user-procedure-support.js"; +import { + getCurrentMfaStatus, + getCurrentUserProfile, + getDashboardLayout, + setColumnPreferences, + toggleFavoriteProject, +} from "../router/user-self-service-procedure-support.js"; + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn(), +})); + +function createContext(db: Record, overrides: Record = {}) { + return { + db: db as never, + dbUser: { + id: "user_admin", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + session: { + user: { email: "admin@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + ...overrides, + }; +} + +describe("user-procedure-support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists assignable users with the expected lightweight selection", async () => { + const findMany = vi.fn().mockResolvedValue([ + { id: "user_1", name: "Alice", email: "alice@example.com" }, + ]); + + const result = await listAssignableUsers(createContext({ + user: { findMany }, + })); + + expect(result).toEqual([{ id: "user_1", name: "Alice", email: "alice@example.com" }]); + expect(findMany).toHaveBeenCalledWith({ + select: { id: true, name: true, email: true }, + orderBy: { name: "asc" }, + }); + }); + + it("counts only users active within the trailing five minute window", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf()); + const count = vi.fn().mockResolvedValue(4); + + const result = await countActiveUsers(createContext({ + user: { count }, + })); + + expect(result).toEqual({ count: 4 }); + expect(count).toHaveBeenCalledWith({ + where: { lastActiveAt: { gte: new Date("2026-03-30T19:55:00.000Z") } }, + }); + nowSpy.mockRestore(); + }); + + it("reads the current user profile by db user id", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "user_admin", + name: "Admin", + email: "admin@example.com", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + createdAt: new Date("2026-03-30T08:00:00.000Z"), + }); + + const result = await getCurrentUserProfile(createContext({ + user: { findUnique }, + })); + + expect(result).toEqual({ + id: "user_admin", + name: "Admin", + email: "admin@example.com", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + createdAt: new Date("2026-03-30T08:00:00.000Z"), + }); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + select: { + id: true, + name: true, + email: true, + systemRole: true, + permissionOverrides: true, + createdAt: true, + }, + }); + }); + + it("unlinks an existing resource before linking the requested one", async () => { + const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); + const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null }); + const updateMany = vi.fn() + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 1 }); + + const result = await linkUserResource(createContext({ + user: { findUnique: userFindUnique }, + resource: { findUnique: resourceFindUnique, updateMany }, + }), { + userId: "user_1", + resourceId: "resource_1", + }); + + expect(result).toEqual({ success: true }); + expect(updateMany).toHaveBeenNthCalledWith(1, { + where: { userId: "user_1", NOT: { id: "resource_1" } }, + data: { userId: null }, + }); + expect(updateMany).toHaveBeenNthCalledWith(2, { + where: { + id: "resource_1", + OR: [{ userId: null }, { userId: "user_1" }], + }, + data: { userId: "user_1" }, + }); + }); + + it("normalizes dashboard layouts before returning them", async () => { + const findUnique = vi.fn().mockResolvedValue({ + dashboardLayout: { + widgets: [{ id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } }], + }, + updatedAt: new Date("2026-03-30T18:00:00.000Z"), + }); + + const result = await getDashboardLayout(createContext({ + user: { findUnique }, + })); + + expect(result).toEqual({ + layout: { version: 2, gridCols: 12, widgets: [] }, + updatedAt: new Date("2026-03-30T18:00:00.000Z"), + }); + }); + + it("toggles favorite projects without losing the existing list", async () => { + const findUnique = vi.fn().mockResolvedValue({ + favoriteProjectIds: ["project_1"], + }); + const update = vi.fn().mockResolvedValue({}); + + const result = await toggleFavoriteProject(createContext({ + user: { findUnique, update }, + }), { + projectId: "project_2", + }); + + expect(result).toEqual({ + favoriteProjectIds: ["project_1", "project_2"], + added: true, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + data: { favoriteProjectIds: ["project_1", "project_2"] }, + }); + }); + + it("merges column preferences while preserving untouched sort and row order", async () => { + const findUnique = vi.fn().mockResolvedValue({ + columnPreferences: { + resources: { + visible: ["name", "role"], + sort: { field: "name", dir: "asc" }, + rowOrder: ["name", "role", "email"], + }, + }, + }); + const update = vi.fn().mockResolvedValue({ id: "user_admin" }); + + const result = await setColumnPreferences(createContext({ + user: { findUnique, update }, + }), { + view: "resources", + visible: ["name", "email"], + }); + + expect(result).toEqual({ ok: true }); + expect(update).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + data: { + columnPreferences: { + resources: { + visible: ["name", "email"], + sort: { field: "name", dir: "asc" }, + rowOrder: ["name", "role", "email"], + }, + }, + }, + }); + }); + + it("returns effective permissions alongside stored overrides", async () => { + const overrides = { + granted: ["manageProjects"], + denied: ["viewCosts"], + chapterIds: ["chapter_design"], + }; + const findUnique = vi.fn().mockResolvedValue({ + systemRole: SystemRole.MANAGER, + permissionOverrides: overrides, + }); + + const result = await getEffectiveUserPermissions(createContext({ + user: { findUnique }, + }), { + userId: "user_2", + }); + + expect(result).toEqual({ + systemRole: SystemRole.MANAGER, + effectivePermissions: Array.from(resolvePermissions(SystemRole.MANAGER, overrides)), + overrides, + }); + }); + + it("reports MFA status for the current user and throws when the user no longer exists", async () => { + const findUnique = vi.fn() + .mockResolvedValueOnce({ totpEnabled: true }) + .mockResolvedValueOnce(null); + const ctx = createContext({ + user: { findUnique }, + }); + + await expect(getCurrentMfaStatus(ctx)).resolves.toEqual({ totpEnabled: true }); + await expect(getCurrentMfaStatus(ctx)).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "User not found", + }); + }); +}); diff --git a/packages/api/src/router/user-procedure-support.ts b/packages/api/src/router/user-procedure-support.ts new file mode 100644 index 0000000..0256f6f --- /dev/null +++ b/packages/api/src/router/user-procedure-support.ts @@ -0,0 +1,499 @@ +import { Prisma } from "@capakraken/db"; +import { PermissionOverrides, SystemRole, resolvePermissions } 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 type { TRPCContext } from "../trpc.js"; + +export const CreateUserInputSchema = z.object({ + email: z.string().email(), + name: z.string().min(1), + systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER), + password: z.string().min(8), +}); + +export const SetUserPasswordInputSchema = z.object({ + userId: z.string(), + password: z.string().min(8, "Password must be at least 8 characters"), +}); + +export const UpdateUserRoleInputSchema = z.object({ + id: z.string(), + systemRole: z.nativeEnum(SystemRole), +}); + +export const UpdateUserNameInputSchema = z.object({ + id: z.string(), + name: z.string().min(1, "Name is required").max(200), +}); + +export const LinkUserResourceInputSchema = z.object({ + userId: z.string(), + resourceId: z.string().nullable(), +}); + +export const SetUserPermissionsInputSchema = 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(), +}); + +export const UserIdInputSchema = z.object({ + userId: z.string(), +}); + +type UserReadContext = Pick; +type UserMutationContext = UserReadContext; + +function withAuditUser(userId: string | undefined) { + return userId ? { userId } : {}; +} + +export async function listAssignableUsers(ctx: UserReadContext) { + return ctx.db.user.findMany({ + select: { + id: true, + name: true, + email: true, + }, + orderBy: { name: "asc" }, + }); +} + +export async function listUsers(ctx: UserReadContext) { + 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" }, + }); +} + +export async function countActiveUsers(ctx: UserReadContext) { + const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000); + const count = await ctx.db.user.count({ + where: { lastActiveAt: { gte: fiveMinAgo } }, + }); + return { count }; +} + +export async function getCurrentUserProfile(ctx: UserReadContext) { + 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 createUser( + ctx: UserMutationContext, + input: z.infer, +) { + 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 }, + }); + + 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", + ...withAuditUser(ctx.dbUser?.id), + after: user as unknown as Record, + source: "ui", + }); + + return user; +} + +export async function setUserPassword( + ctx: UserMutationContext, + input: z.infer, +) { + 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", + ...withAuditUser(ctx.dbUser?.id), + source: "ui", + summary: "Password reset by admin", + }); + + return { success: true }; +} + +export async function updateUserRole( + ctx: UserMutationContext, + input: z.infer, +) { + 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", + ...withAuditUser(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; +} + +export async function updateUserName( + ctx: UserMutationContext, + input: z.infer, +) { + 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", + ...withAuditUser(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; +} + +export async function linkUserResource( + ctx: UserMutationContext, + input: z.infer, +) { + await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { id: true }, + }), + "User", + ); + + if (input.resourceId) { + const resource = await findUniqueOrThrow( + ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { id: true, userId: true }, + }), + "Resource", + ); + + if (resource.userId && resource.userId !== input.userId) { + throw new TRPCError({ + code: "CONFLICT", + message: "Resource is already linked to another user", + }); + } + + await ctx.db.resource.updateMany({ + where: { + userId: input.userId, + NOT: { id: input.resourceId }, + }, + data: { userId: null }, + }); + + const linkResult = await ctx.db.resource.updateMany({ + where: { + id: input.resourceId, + OR: [ + { userId: null }, + { userId: input.userId }, + ], + }, + data: { userId: input.userId }, + }); + + if (linkResult.count !== 1) { + const [userStillExists, resourceStillExists] = await Promise.all([ + ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { id: true }, + }), + ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { id: true, userId: true }, + }), + ]); + + if (!userStillExists) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + if (!resourceStillExists) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Resource not found", + }); + } + + if (resourceStillExists.userId && resourceStillExists.userId !== input.userId) { + throw new TRPCError({ + code: "CONFLICT", + message: "Resource is already linked to another user", + }); + } + + throw new TRPCError({ + code: "CONFLICT", + message: "Resource link changed during update. Please retry.", + }); + } + } else { + await ctx.db.resource.updateMany({ + where: { userId: input.userId }, + data: { userId: null }, + }); + } + + return { success: true }; +} + +export async function autoLinkUsersByEmail(ctx: UserMutationContext) { + 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 }; +} + +export async function setUserPermissions( + ctx: UserMutationContext, + input: z.infer, +) { + 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", + ...withAuditUser(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; +} + +export async function resetUserPermissions( + ctx: UserMutationContext, + input: z.infer, +) { + 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", + ...withAuditUser(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; +} + +export async function getEffectiveUserPermissions( + ctx: UserMutationContext, + input: z.infer, +) { + 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, + }; +} + +export async function disableTotp( + ctx: UserMutationContext, + input: z.infer, +) { + 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", + ...withAuditUser(ctx.dbUser?.id), + source: "ui", + summary: "Disabled TOTP MFA (admin override)", + }); + + return { disabled: true }; +} diff --git a/packages/api/src/router/user-self-service-procedure-support.ts b/packages/api/src/router/user-self-service-procedure-support.ts new file mode 100644 index 0000000..df5be97 --- /dev/null +++ b/packages/api/src/router/user-self-service-procedure-support.ts @@ -0,0 +1,272 @@ +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 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 }, + }); + + return { + layout: user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : 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 }, + }), + "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." }); + } + + 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 }; +} + +export async function verifyTotp( + ctx: UserPublicContext, + input: z.infer, +) { + const user = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { id: true, totpSecret: true, totpEnabled: true }, + }), + "User", + ); + + 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 }; +} + +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 }; +} diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 3bdb6bd..c182baf 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -1,714 +1,131 @@ -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"; +import { + autoLinkUsersByEmail, + countActiveUsers, + CreateUserInputSchema, + createUser, + disableTotp, + getEffectiveUserPermissions, + LinkUserResourceInputSchema, + linkUserResource, + listAssignableUsers, + listUsers, + SetUserPasswordInputSchema, + setUserPassword, + SetUserPermissionsInputSchema, + setUserPermissions, + UpdateUserNameInputSchema, + updateUserName, + UpdateUserRoleInputSchema, + updateUserRole, + UserIdInputSchema, + resetUserPermissions, +} from "./user-procedure-support.js"; +import { + generateTotpSecret, + getColumnPreferences, + getCurrentMfaStatus, + getCurrentUserProfile, + getDashboardLayout, + getFavoriteProjectIds, + SaveDashboardLayoutInputSchema, + saveDashboardLayout, + SetColumnPreferencesInputSchema, + setColumnPreferences, + ToggleFavoriteProjectInputSchema, + toggleFavoriteProject, + verifyAndEnableTotp as verifyAndEnableTotpSelfService, + VerifyAndEnableTotpInputSchema, + verifyTotp, + VerifyTotpInputSchema, +} from "./user-self-service-procedure-support.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" }, - }); - }), + listAssignable: managerProcedure.query(({ ctx }) => listAssignableUsers(ctx)), - 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" }, - }); - }), + list: adminProcedure.query(({ ctx }) => listUsers(ctx)), /** 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 }; - }), + activeCount: adminProcedure.query(({ ctx }) => countActiveUsers(ctx)), - me: protectedProcedure.query(async ({ ctx }) => { - const user = await findUniqueOrThrow( - ctx.db.user.findUnique({ - where: { id: ctx.dbUser!.id }, - select: { - id: true, - name: true, - email: true, - systemRole: true, - permissionOverrides: true, - createdAt: true, - }, - }), - "User", - ); - - return user; - }), + me: protectedProcedure.query(({ ctx }) => getCurrentUserProfile(ctx)), 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; - }), + .input(CreateUserInputSchema) + .mutation(({ ctx, input }) => createUser(ctx, input)), 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 }; - }), + .input(SetUserPasswordInputSchema) + .mutation(({ ctx, input }) => setUserPassword(ctx, input)), 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; - }), + .input(UpdateUserRoleInputSchema) + .mutation(({ ctx, input }) => updateUserRole(ctx, input)), 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; - }), + .input(UpdateUserNameInputSchema) + .mutation(({ ctx, input }) => updateUserName(ctx, input)), // ─── 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", - ); + .input(LinkUserResourceInputSchema) + .mutation(({ ctx, input }) => linkUserResource(ctx, input)), - if (input.resourceId) { - const resource = await findUniqueOrThrow( - ctx.db.resource.findUnique({ - where: { id: input.resourceId }, - select: { id: true, userId: true }, - }), - "Resource", - ); + autoLinkAllByEmail: adminProcedure.mutation(({ ctx }) => autoLinkUsersByEmail(ctx)), - if (resource.userId && resource.userId !== input.userId) { - throw new TRPCError({ - code: "CONFLICT", - message: "Resource is already linked to another user", - }); - } - - // Unlink any other resource previously linked to this user. - await ctx.db.resource.updateMany({ - where: { - userId: input.userId, - NOT: { id: input.resourceId }, - }, - data: { userId: null }, - }); - - const linkResult = await ctx.db.resource.updateMany({ - where: { - id: input.resourceId, - OR: [ - { userId: null }, - { userId: input.userId }, - ], - }, - data: { userId: input.userId }, - }); - - if (linkResult.count !== 1) { - const [userStillExists, resourceStillExists] = await Promise.all([ - ctx.db.user.findUnique({ - where: { id: input.userId }, - select: { id: true }, - }), - ctx.db.resource.findUnique({ - where: { id: input.resourceId }, - select: { id: true, userId: true }, - }), - ]); - - if (!userStillExists) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "User not found", - }); - } - - if (!resourceStillExists) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Resource not found", - }); - } - - if (resourceStillExists.userId && resourceStillExists.userId !== input.userId) { - throw new TRPCError({ - code: "CONFLICT", - message: "Resource is already linked to another user", - }); - } - - throw new TRPCError({ - code: "CONFLICT", - message: "Resource link changed during update. Please retry.", - }); - } - } 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: { id: ctx.dbUser!.id }, - select: { dashboardLayout: true, updatedAt: true }, - }); - return { - layout: user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : null, - updatedAt: user?.updatedAt ?? null, - }; - }), + getDashboardLayout: protectedProcedure.query(({ ctx }) => getDashboardLayout(ctx)), saveDashboardLayout: protectedProcedure - .input(z.object({ layout: dashboardLayoutSchema })) - .mutation(async ({ ctx, input }) => { - const updated = await ctx.db.user.update({ - where: { id: ctx.dbUser!.id }, - data: { dashboardLayout: input.layout as unknown as import("@capakraken/db").Prisma.InputJsonValue }, - select: { updatedAt: true }, - }); - return { updatedAt: updated.updatedAt }; - }), + .input(SaveDashboardLayoutInputSchema) + .mutation(({ ctx, input }) => saveDashboardLayout(ctx, input)), // ─── 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[]; - }), + getFavoriteProjectIds: protectedProcedure.query(({ ctx }) => getFavoriteProjectIds(ctx)), 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) }; - }), + .input(ToggleFavoriteProjectInputSchema) + .mutation(({ ctx, input }) => toggleFavoriteProject(ctx, input)), 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; - }), + .input(SetUserPermissionsInputSchema) + .mutation(({ ctx, input }) => setUserPermissions(ctx, input)), 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", - ); + .input(UserIdInputSchema) + .mutation(({ ctx, input }) => resetUserPermissions(ctx, input)), - 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; - }), + getColumnPreferences: protectedProcedure.query(({ ctx }) => getColumnPreferences(ctx)), 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 }; - }), + .input(SetColumnPreferencesInputSchema) + .mutation(({ ctx, input }) => setColumnPreferences(ctx, input)), 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, - }; - }), + .input(UserIdInputSchema) + .query(({ ctx, input }) => getEffectiveUserPermissions(ctx, input)), // ─── 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 }; - }), + generateTotpSecret: protectedProcedure.mutation(({ ctx }) => generateTotpSecret(ctx)), /** 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 findUniqueOrThrow( - ctx.db.user.findUnique({ - where: { id: ctx.dbUser!.id }, - select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true }, - }), - "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." }); - } - - 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 }; - }), + .input(VerifyAndEnableTotpInputSchema) + .mutation(({ ctx, input }) => verifyAndEnableTotpSelfService(ctx, input)), /** 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 }; - }), + .input(UserIdInputSchema) + .mutation(({ ctx, input }) => disableTotp(ctx, input)), /** 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 findUniqueOrThrow( - ctx.db.user.findUnique({ - where: { id: input.userId }, - select: { id: true, totpSecret: true, totpEnabled: true }, - }), - "User", - ); - - 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 }; - }), + .input(VerifyTotpInputSchema) + .mutation(({ ctx, input }) => verifyTotp(ctx, input)), /** Get MFA status for the current user. */ - getMfaStatus: protectedProcedure.query(async ({ ctx }) => { - const user = await findUniqueOrThrow( - ctx.db.user.findUnique({ - where: { id: ctx.dbUser!.id }, - select: { totpEnabled: true }, - }), - "User", - ); - return { totpEnabled: user.totpEnabled }; - }), + getMfaStatus: protectedProcedure.query(({ ctx }) => getCurrentMfaStatus(ctx)), });