refactor(api): extract user procedures
This commit is contained in:
+78
-661
@@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
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<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
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<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;
|
||||
}),
|
||||
.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<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;
|
||||
}),
|
||||
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)),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user