refactor(api): extract user procedures
This commit is contained in:
@@ -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
@@ -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 };
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user