- Add User model synced from Authentik headers with admin/reviewer/viewer roles - Add assignment fields (assignedToId, assignedById, reviewedById, etc.) to ResponseCard - Add userId tracking to ActivityLog and Notification models - Create auth.ts with getOrCreateUser() and role mapping from Authentik groups - Create /api/users endpoint and /api/cards/assign batch assignment endpoint - Gate card mutations behind role checks (viewers read-only, reviewers edit assigned only) - Gate Monday.com push behind reviewStatus=reviewed instead of ocr_complete - Add "My Cards" stat card, Assigned To filter, and assignment columns to table - Add Assign button with user picker to batch selection toolbar (admin only) - Update card detail: assignment banner, Mark Complete button, prev/next nav, reassign - Make all field components accept readOnly prop for role-based editing - Gate settings page behind admin role - Add userId to stats API for per-user card counts - Expose dbUser and role through UserProfileProvider context Made-with: Cursor
84 lines
2.4 KiB
TypeScript
84 lines
2.4 KiB
TypeScript
import { prisma } from "./db";
|
|
|
|
export type AppUser = {
|
|
id: string;
|
|
authentikUid: string;
|
|
username: string;
|
|
displayName: string;
|
|
email: string;
|
|
avatarUrl: string;
|
|
role: "admin" | "reviewer" | "viewer";
|
|
};
|
|
|
|
type CacheEntry = { user: AppUser; ts: number };
|
|
const userCache = new Map<string, CacheEntry>();
|
|
const CACHE_TTL_MS = 60_000;
|
|
|
|
function mapGroupsToRole(groups: string[]): "admin" | "reviewer" | "viewer" {
|
|
const lower = groups.map((g) => g.toLowerCase());
|
|
if (lower.some((g) => g.includes("admin"))) return "admin";
|
|
if (lower.some((g) => g.includes("reviewer") || g.includes("review"))) return "reviewer";
|
|
return "viewer";
|
|
}
|
|
|
|
export async function getOrCreateUser(headers: Headers): Promise<AppUser | null> {
|
|
const uid = headers.get("x-authentik-uid") ?? "";
|
|
const username = headers.get("x-authentik-username") ?? "";
|
|
const email = headers.get("x-authentik-email") ?? "";
|
|
|
|
if (!uid && !username && !email) return null;
|
|
|
|
const cacheKey = uid || username || email;
|
|
const cached = userCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
|
|
return cached.user;
|
|
}
|
|
|
|
const name = headers.get("x-authentik-name") ?? "";
|
|
const groupsRaw = headers.get("x-authentik-groups") ?? "";
|
|
const groups = groupsRaw ? groupsRaw.split("|") : [];
|
|
const role = mapGroupsToRole(groups);
|
|
|
|
const dbUser = await prisma.user.upsert({
|
|
where: { authentikUid: uid || `fallback-${username || email}` },
|
|
update: {
|
|
username: username || undefined,
|
|
displayName: name || username || undefined,
|
|
email: email || undefined,
|
|
role,
|
|
},
|
|
create: {
|
|
authentikUid: uid || `fallback-${username || email}`,
|
|
username: username || email,
|
|
displayName: name || username || email,
|
|
email,
|
|
role,
|
|
},
|
|
});
|
|
|
|
const user: AppUser = {
|
|
id: dbUser.id,
|
|
authentikUid: dbUser.authentikUid,
|
|
username: dbUser.username,
|
|
displayName: dbUser.displayName,
|
|
email: dbUser.email,
|
|
avatarUrl: dbUser.avatarUrl,
|
|
role: dbUser.role as AppUser["role"],
|
|
};
|
|
|
|
userCache.set(cacheKey, { user, ts: Date.now() });
|
|
return user;
|
|
}
|
|
|
|
export function requireRole(user: AppUser | null, ...roles: AppUser["role"][]): void {
|
|
if (!user || !roles.includes(user.role)) {
|
|
throw new RoleError("Insufficient permissions");
|
|
}
|
|
}
|
|
|
|
export class RoleError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "RoleError";
|
|
}
|
|
}
|