refactor(api): extract user procedures

This commit is contained in:
2026-03-31 21:40:50 +02:00
parent e34c22f3b0
commit 9fccd4c29e
4 changed files with 1098 additions and 661 deletions
@@ -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<string, unknown>, overrides: Record<string, unknown> = {}) {
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",
});
});
});
@@ -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<TRPCContext, "db" | "dbUser">;
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<typeof CreateUserInputSchema>,
) {
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<string, unknown>,
source: "ui",
});
return user;
}
export async function setUserPassword(
ctx: UserMutationContext,
input: z.infer<typeof SetUserPasswordInputSchema>,
) {
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<typeof UpdateUserRoleInputSchema>,
) {
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<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
summary: `Changed role from ${before.systemRole} to ${updated.systemRole}`,
});
return updated;
}
export async function updateUserName(
ctx: UserMutationContext,
input: z.infer<typeof UpdateUserNameInputSchema>,
) {
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<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
summary: `Changed name from "${before.name}" to "${updated.name}"`,
});
return updated;
}
export async function linkUserResource(
ctx: UserMutationContext,
input: z.infer<typeof LinkUserResourceInputSchema>,
) {
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<typeof SetUserPermissionsInputSchema>,
) {
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<string, unknown>,
after: { permissionOverrides: input.overrides } as unknown as Record<string, unknown>,
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<typeof UserIdInputSchema>,
) {
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<string, unknown>,
after: { permissionOverrides: null } as unknown as Record<string, unknown>,
source: "ui",
summary: "Reset permission overrides to role defaults",
});
return updated;
}
export async function getEffectiveUserPermissions(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
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<typeof UserIdInputSchema>,
) {
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 };
}
@@ -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<TRPCContext, "db" | "dbUser" | "session">;
type UserPublicContext = Pick<TRPCContext, "db">;
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<typeof SaveDashboardLayoutInputSchema>,
) {
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<typeof ToggleFavoriteProjectInputSchema>,
) {
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<typeof SetColumnPreferencesInputSchema>,
) {
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<typeof VerifyAndEnableTotpInputSchema>,
) {
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<typeof VerifyTotpInputSchema>,
) {
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 };
}
+78 -661
View File
@@ -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 { 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({ export const userRouter = createTRPCRouter({
/** Lightweight user list for task assignment (ADMIN + MANAGER) */ /** Lightweight user list for task assignment (ADMIN + MANAGER) */
listAssignable: managerProcedure.query(async ({ ctx }) => { listAssignable: managerProcedure.query(({ ctx }) => listAssignableUsers(ctx)),
return ctx.db.user.findMany({
select: {
id: true,
name: true,
email: true,
},
orderBy: { name: "asc" },
});
}),
list: adminProcedure.query(async ({ ctx }) => { list: adminProcedure.query(({ ctx }) => listUsers(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 */ /** Count of users active in the last 5 minutes */
activeCount: adminProcedure.query(async ({ ctx }) => { activeCount: adminProcedure.query(({ ctx }) => countActiveUsers(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 }) => { me: protectedProcedure.query(({ ctx }) => getCurrentUserProfile(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;
}),
create: adminProcedure create: adminProcedure
.input( .input(CreateUserInputSchema)
z.object({ .mutation(({ ctx, input }) => createUser(ctx, input)),
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<string, unknown>,
source: "ui",
});
return user;
}),
setPassword: adminProcedure setPassword: adminProcedure
.input( .input(SetUserPasswordInputSchema)
z.object({ .mutation(({ ctx, input }) => setUserPassword(ctx, input)),
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 updateRole: adminProcedure
.input( .input(UpdateUserRoleInputSchema)
z.object({ .mutation(({ ctx, input }) => updateUserRole(ctx, input)),
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<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
summary: `Changed role from ${before.systemRole} to ${updated.systemRole}`,
});
return updated;
}),
updateName: adminProcedure updateName: adminProcedure
.input( .input(UpdateUserNameInputSchema)
z.object({ .mutation(({ ctx, input }) => updateUserName(ctx, input)),
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<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
summary: `Changed name from "${before.name}" to "${updated.name}"`,
});
return updated;
}),
// ─── Resource Linking ────────────────────────────────────────────────── // ─── Resource Linking ──────────────────────────────────────────────────
linkResource: adminProcedure linkResource: adminProcedure
.input(z.object({ userId: z.string(), resourceId: z.string().nullable() })) .input(LinkUserResourceInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(({ ctx, input }) => linkUserResource(ctx, input)),
await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true },
}),
"User",
);
if (input.resourceId) { autoLinkAllByEmail: adminProcedure.mutation(({ ctx }) => autoLinkUsersByEmail(ctx)),
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) { getDashboardLayout: protectedProcedure.query(({ ctx }) => getDashboardLayout(ctx)),
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,
};
}),
saveDashboardLayout: protectedProcedure saveDashboardLayout: protectedProcedure
.input(z.object({ layout: dashboardLayoutSchema })) .input(SaveDashboardLayoutInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(({ ctx, input }) => saveDashboardLayout(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 };
}),
// ─── Favorite Projects ────────────────────────────────────────────────── // ─── Favorite Projects ──────────────────────────────────────────────────
getFavoriteProjectIds: protectedProcedure.query(async ({ ctx }) => { getFavoriteProjectIds: protectedProcedure.query(({ ctx }) => getFavoriteProjectIds(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 toggleFavoriteProject: protectedProcedure
.input(z.object({ projectId: z.string() })) .input(ToggleFavoriteProjectInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(({ ctx, input }) => toggleFavoriteProject(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 setPermissions: adminProcedure
.input( .input(SetUserPermissionsInputSchema)
z.object({ .mutation(({ ctx, input }) => setUserPermissions(ctx, input)),
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<string, unknown>,
after: { permissionOverrides: input.overrides } as unknown as Record<string, unknown>,
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 resetPermissions: adminProcedure
.input(z.object({ userId: z.string() })) .input(UserIdInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(({ ctx, input }) => resetUserPermissions(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({ getColumnPreferences: protectedProcedure.query(({ ctx }) => getColumnPreferences(ctx)),
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<string, unknown>,
after: { permissionOverrides: null } as unknown as Record<string, unknown>,
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 setColumnPreferences: protectedProcedure
.input(z.object({ .input(SetColumnPreferencesInputSchema)
view: z.enum(["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"]), .mutation(({ ctx, input }) => setColumnPreferences(ctx, input)),
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 getEffectivePermissions: adminProcedure
.input(z.object({ userId: z.string() })) .input(UserIdInputSchema)
.query(async ({ ctx, input }) => { .query(({ ctx, input }) => getEffectiveUserPermissions(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 ───────────────────────────────────────────────────────────── // ─── TOTP / MFA ─────────────────────────────────────────────────────────────
/** Generate a new TOTP secret for the current user (not yet enabled). */ /** Generate a new TOTP secret for the current user (not yet enabled). */
generateTotpSecret: protectedProcedure.mutation(async ({ ctx }) => { generateTotpSecret: protectedProcedure.mutation(({ ctx }) => generateTotpSecret(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. */ /** Verify a TOTP token and enable MFA for the current user. */
verifyAndEnableTotp: protectedProcedure verifyAndEnableTotp: protectedProcedure
.input(z.object({ token: z.string().length(6) })) .input(VerifyAndEnableTotpInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(({ ctx, input }) => verifyAndEnableTotpSelfService(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 };
}),
/** Admin override: disable TOTP for a specific user. */ /** Admin override: disable TOTP for a specific user. */
disableTotp: adminProcedure disableTotp: adminProcedure
.input(z.object({ userId: z.string() })) .input(UserIdInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(({ ctx, input }) => disableTotp(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). */ /** Verify a TOTP token (used during the login flow — public procedure). */
verifyTotp: publicProcedure verifyTotp: publicProcedure
.input(z.object({ userId: z.string(), token: z.string().length(6) })) .input(VerifyTotpInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(({ ctx, input }) => verifyTotp(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 };
}),
/** Get MFA status for the current user. */ /** Get MFA status for the current user. */
getMfaStatus: protectedProcedure.query(async ({ ctx }) => { getMfaStatus: protectedProcedure.query(({ ctx }) => getCurrentMfaStatus(ctx)),
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id },
select: { totpEnabled: true },
}),
"User",
);
return { totpEnabled: user.totpEnabled };
}),
}); });