diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3940e1c..440346a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,6 +7,18 @@ datasource db { provider = "postgresql" } +model User { + id String @id @default(cuid()) + authentikUid String @unique + username String + displayName String + email String + avatarUrl String @default("") + role String @default("viewer") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model ResponseCard { id String @id @default(cuid()) createdAt DateTime @default(now()) @@ -52,6 +64,14 @@ model ResponseCard { firstTimeGuestDate DateTime? salvationDate DateTime? + // Assignment / Review workflow + assignedToId String? + assignedById String? + assignedAt DateTime? + reviewedById String? + reviewedAt DateTime? + reviewNotes String? + // Meta sourceFile String? frontImagePath String? @@ -69,6 +89,8 @@ model ResponseCard { @@index([name]) @@index([createdAt]) @@index([mondayItemId]) + @@index([assignedToId]) + @@index([reviewedById]) } model ProcessingJob { @@ -130,6 +152,7 @@ model ActivityLog { source String summary String changes Json? + userId String? @@index([cardId, createdAt]) } @@ -145,7 +168,9 @@ model Notification { cardId String? actionUrl String? meta Json? + userId String? @@index([read, dismissed, createdAt]) @@index([cardId]) + @@index([userId]) } diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 93bf433..c1e25b2 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { getOrCreateUser, type AppUser } from "@/lib/auth"; export type AuthentikUser = { username: string; @@ -9,6 +10,8 @@ export type AuthentikUser = { avatar: string; }; +export type { AppUser }; + /** * Reads Authentik forward-auth headers injected by Traefik and optionally * enriches with avatar from the Authentik API. @@ -67,5 +70,7 @@ export async function GET(req: NextRequest) { } } - return NextResponse.json({ authenticated: true, user }); + const dbUser = await getOrCreateUser(req.headers); + + return NextResponse.json({ authenticated: true, user, dbUser }); } diff --git a/src/app/api/cards/[id]/route.ts b/src/app/api/cards/[id]/route.ts index abd50ea..926a622 100644 --- a/src/app/api/cards/[id]/route.ts +++ b/src/app/api/cards/[id]/route.ts @@ -3,6 +3,7 @@ import { prisma } from "@/lib/db"; import { deleteObject } from "@/lib/minio"; import { fireIntegrationEvent } from "@/lib/integrations"; import { logActivity, diffCardFields } from "@/lib/activity-log"; +import { getOrCreateUser, RoleError } from "@/lib/auth"; export async function GET( _request: NextRequest, @@ -44,6 +45,8 @@ export async function PUT( { params }: { params: Promise<{ id: string }> } ) { try { + const user = await getOrCreateUser(request.headers); + const { id } = await params; const card = await prisma.responseCard.findUnique({ where: { id }, @@ -53,6 +56,15 @@ export async function PUT( return NextResponse.json({ error: "Card not found" }, { status: 404 }); } + if (user) { + if (user.role === "viewer") { + return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 }); + } + if (user.role === "reviewer" && card.assignedToId !== user.id) { + return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 }); + } + } + const body = await request.json().catch(() => ({})); const data: Record = {}; @@ -84,6 +96,7 @@ export async function PUT( "ocrStatus", "reviewStatus", "ocrError", + "reviewNotes", ]; for (const field of stringFields) { if (body[field] != null) data[field] = String(body[field]); @@ -94,7 +107,7 @@ export async function PUT( if (body.iSaidYesBookSent != null) data.iSaidYesBookSent = Boolean(body.iSaidYesBookSent); if (body.ftGuestLetterSent != null) data.ftGuestLetterSent = Boolean(body.ftGuestLetterSent); - for (const dateField of ["firstTimeGuestDate", "salvationDate"] as const) { + for (const dateField of ["firstTimeGuestDate", "salvationDate", "assignedAt", "reviewedAt"] as const) { if (body[dateField] !== undefined) { data[dateField] = body[dateField] ? new Date(body[dateField]) : null; } @@ -106,6 +119,21 @@ export async function PUT( if (body.howHeard != null) data.howHeard = body.howHeard; if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse; + for (const assignField of ["assignedToId", "assignedById", "reviewedById"] as const) { + if (body[assignField] !== undefined) { + data[assignField] = body[assignField] || null; + } + } + + if (body.reviewStatus === "in_review" && card.reviewStatus === "assigned") { + data.reviewStatus = "in_review"; + } + + if (body.reviewStatus === "reviewed" && user) { + data.reviewedById = user.id; + data.reviewedAt = new Date(); + } + const oldCard = card as unknown as Record; const updated = await prisma.responseCard.update({ @@ -116,7 +144,14 @@ export async function PUT( const newCard = updated as unknown as Record; const changes = diffCardFields(oldCard, newCard); if (changes.length > 0) { - logActivity(id, "manual_edit", "user", `${changes.length} field(s) updated manually`, changes).catch(() => {}); + logActivity( + id, + "manual_edit", + "user", + `${changes.length} field(s) updated manually`, + changes, + user?.id + ).catch(() => {}); } const oldStatus = card.reviewStatus; @@ -131,6 +166,9 @@ export async function PUT( return NextResponse.json(updated); } catch (error) { + if (error instanceof RoleError) { + return NextResponse.json({ error: error.message }, { status: 403 }); + } console.error("[cards/[id] PUT]", error); return NextResponse.json( { error: "Failed to update card" }, @@ -140,10 +178,15 @@ export async function PUT( } export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + const user = await getOrCreateUser(request.headers); + if (user && user.role !== "admin") { + return NextResponse.json({ error: "Only admins can delete cards" }, { status: 403 }); + } + const { id } = await params; const card = await prisma.responseCard.findUnique({ where: { id }, diff --git a/src/app/api/cards/assign/route.ts b/src/app/api/cards/assign/route.ts new file mode 100644 index 0000000..8590672 --- /dev/null +++ b/src/app/api/cards/assign/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getOrCreateUser, requireRole, RoleError } from "@/lib/auth"; +import { createNotification } from "@/lib/notifications"; +import { logActivity } from "@/lib/activity-log"; + +export async function POST(req: NextRequest) { + try { + const user = await getOrCreateUser(req.headers); + requireRole(user, "admin"); + + const body = await req.json(); + const { cardIds, assignToUserId } = body as { + cardIds: string[]; + assignToUserId: string; + }; + + if (!cardIds?.length || !assignToUserId) { + return NextResponse.json( + { error: "cardIds and assignToUserId are required" }, + { status: 400 } + ); + } + + const assignee = await prisma.user.findUnique({ + where: { id: assignToUserId }, + }); + if (!assignee) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + await prisma.responseCard.updateMany({ + where: { id: { in: cardIds } }, + data: { + assignedToId: assignToUserId, + assignedById: user!.id, + assignedAt: new Date(), + reviewStatus: "assigned", + }, + }); + + for (const cardId of cardIds) { + logActivity( + cardId, + "assignment", + "user", + `Assigned to ${assignee.displayName} by ${user!.displayName}`, + [{ field: "assignedToId", from: null, to: assignToUserId }], + user!.id + ).catch(() => {}); + } + + await createNotification({ + type: "card_assigned", + title: "Cards Assigned to You", + message: `${cardIds.length} card(s) assigned by ${user!.displayName}`, + actionUrl: "/?assignedToId=me", + userId: assignToUserId, + }); + + return NextResponse.json({ + ok: true, + assigned: cardIds.length, + assignee: assignee.displayName, + }); + } catch (error) { + if (error instanceof RoleError) { + return NextResponse.json({ error: error.message }, { status: 403 }); + } + console.error("[cards/assign POST]", error); + return NextResponse.json({ error: "Assignment failed" }, { status: 500 }); + } +} diff --git a/src/app/api/cards/route.ts b/src/app/api/cards/route.ts index 523ab40..b19b276 100644 --- a/src/app/api/cards/route.ts +++ b/src/app/api/cards/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; +import { getOrCreateUser } from "@/lib/auth"; export async function GET(request: NextRequest) { try { @@ -12,9 +13,12 @@ export async function GET(request: NextRequest) { const attendanceDuration = searchParams.get("attendanceDuration") || undefined; const visitType = searchParams.get("visitType") || undefined; const serviceAttended = searchParams.get("serviceAttended") || undefined; + const assignedToId = searchParams.get("assignedToId") || undefined; const sortBy = searchParams.get("sortBy") ?? "createdAt"; const sortOrder = searchParams.get("sortOrder") ?? "desc"; + const user = await getOrCreateUser(request.headers); + const validSortFields = [ "createdAt", "updatedAt", @@ -24,6 +28,7 @@ export async function GET(request: NextRequest) { "attendanceDuration", "visitType", "serviceAttended", + "assignedToId", ]; const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt"; const order = sortOrder === "asc" ? "asc" : "desc"; @@ -36,6 +41,14 @@ export async function GET(request: NextRequest) { if (visitType) where.visitType = visitType; if (serviceAttended) where.serviceAttended = serviceAttended; + if (assignedToId === "me" && user) { + where.assignedToId = user.id; + } else if (assignedToId === "unassigned") { + where.assignedToId = null; + } else if (assignedToId) { + where.assignedToId = assignedToId; + } + if (search) { where.OR = [ { name: { contains: search, mode: "insensitive" } }, diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts index 8a39f5a..9484418 100644 --- a/src/app/api/stats/route.ts +++ b/src/app/api/stats/route.ts @@ -1,9 +1,14 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; +import { getOrCreateUser } from "@/lib/auth"; -export async function GET() { +export async function GET(request: NextRequest) { try { - const [total, byOcrStatus, byReviewStatus] = await Promise.all([ + const user = await getOrCreateUser(request.headers); + const userId = request.nextUrl.searchParams.get("userId") || undefined; + const effectiveUserId = userId === "me" && user ? user.id : userId; + + const [total, byOcrStatus, byReviewStatus, myCards] = await Promise.all([ prisma.responseCard.count(), prisma.responseCard.groupBy({ by: ["ocrStatus"], @@ -13,6 +18,15 @@ export async function GET() { by: ["reviewStatus"], _count: { id: true }, }), + effectiveUserId + ? prisma.responseCard.count({ + where: { assignedToId: effectiveUserId }, + }) + : user + ? prisma.responseCard.count({ + where: { assignedToId: user.id }, + }) + : Promise.resolve(0), ]); const ocrStatusCounts = Object.fromEntries( @@ -26,6 +40,7 @@ export async function GET() { total, byOcrStatus: ocrStatusCounts, byReviewStatus: reviewStatusCounts, + myCards, }); } catch (error) { console.error("[stats GET]", error); diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..ae78623 --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getOrCreateUser } from "@/lib/auth"; + +export async function GET(req: NextRequest) { + try { + const user = await getOrCreateUser(req.headers); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const role = req.nextUrl.searchParams.get("role") || undefined; + const where: Record = {}; + if (role) where.role = role; + + const users = await prisma.user.findMany({ + where, + select: { + id: true, + username: true, + displayName: true, + email: true, + avatarUrl: true, + role: true, + }, + orderBy: { displayName: "asc" }, + }); + + return NextResponse.json({ users }); + } catch (error) { + console.error("[users GET]", error); + return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 }); + } +} diff --git a/src/app/cards/[id]/page.tsx b/src/app/cards/[id]/page.tsx index 39eed8b..a15eb28 100644 --- a/src/app/cards/[id]/page.tsx +++ b/src/app/cards/[id]/page.tsx @@ -46,6 +46,7 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; +import { useUserProfile } from "@/lib/user-profile"; const MESSAGE_TOPIC_OPTIONS = [ "Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt", @@ -99,6 +100,12 @@ type CardData = { ftGuestLetterSent: boolean; firstTimeGuestDate: string | null; salvationDate: string | null; + assignedToId: string | null; + assignedById: string | null; + assignedAt: string | null; + reviewedById: string | null; + reviewedAt: string | null; + reviewNotes: string | null; ocrStatus: string; reviewStatus: string; ocrConfidence: number | null; @@ -118,10 +125,16 @@ type ActivityEntry = { changes: { field: string; from: string | null; to: string | null }[] | null; }; +type AssignableUser = { id: string; displayName: string }; + export default function CardDetailPage() { const params = useParams(); const router = useRouter(); const id = params.id as string; + const { role, dbUser } = useUserProfile(); + const isAdmin = role === "admin"; + const isReviewer = role === "reviewer"; + const isViewer = role === "viewer"; const [card, setCard] = React.useState(null); const [loading, setLoading] = React.useState(true); @@ -134,6 +147,12 @@ export default function CardDetailPage() { const [activityLoading, setActivityLoading] = React.useState(false); const [expandedEntry, setExpandedEntry] = React.useState(null); const [pushingToMonday, setPushingToMonday] = React.useState(false); + const [users, setUsers] = React.useState([]); + const [prevNextIds, setPrevNextIds] = React.useState<{ prev: string | null; next: string | null }>({ prev: null, next: null }); + + const isAssignedToMe = card?.assignedToId && dbUser?.id === card.assignedToId; + const canEdit = isAdmin || (isReviewer && isAssignedToMe); + const canMarkComplete = isAdmin || (isReviewer && isAssignedToMe); const fetchCard = React.useCallback(async () => { setLoading(true); @@ -154,6 +173,34 @@ export default function CardDetailPage() { fetchCard(); }, [fetchCard]); + React.useEffect(() => { + if (!isAdmin) return; + fetch("/api/users") + .then((r) => r.json()) + .then((data) => setUsers(data.users || [])) + .catch(() => {}); + }, [isAdmin]); + + React.useEffect(() => { + const params = new URLSearchParams(window.location.search); + const assignedToId = params.get("assignedToId") || (isReviewer ? "me" : undefined); + const apiParams = new URLSearchParams(); + apiParams.set("limit", "200"); + if (assignedToId) apiParams.set("assignedToId", assignedToId); + + fetch(`/api/cards?${apiParams.toString()}`) + .then((r) => r.json()) + .then((data) => { + const ids = (data.cards || []).map((c: { id: string }) => c.id); + const idx = ids.indexOf(id); + setPrevNextIds({ + prev: idx > 0 ? ids[idx - 1] : null, + next: idx >= 0 && idx < ids.length - 1 ? ids[idx + 1] : null, + }); + }) + .catch(() => {}); + }, [id, isReviewer]); + React.useEffect(() => { if (card?.ocrStatus !== "processing") return; const interval = setInterval(async () => { @@ -210,21 +257,70 @@ export default function CardDetailPage() { if (Object.keys(edits).length === 0) return; setSaving(true); try { + const payload: Record = { ...edits }; + if (card?.reviewStatus === "assigned") { + payload.reviewStatus = "in_review"; + } const res = await fetch(`/api/cards/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(edits), + body: JSON.stringify(payload), }); - if (!res.ok) throw new Error(); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to save"); + } toast.success("Card updated"); await fetchCard(); - } catch { - toast.error("Failed to save"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to save"); } finally { setSaving(false); } }; + const handleMarkComplete = async () => { + setSaving(true); + try { + const payload: Record = { + ...edits, + reviewStatus: "reviewed", + }; + const res = await fetch(`/api/cards/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to complete review"); + } + toast.success("Review complete — card will sync to Monday.com"); + await fetchCard(); + setEdits({}); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to complete review"); + } finally { + setSaving(false); + } + }; + + const handleReassign = async (userId: string) => { + try { + const res = await fetch("/api/cards/assign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cardIds: [id], assignToUserId: userId }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + toast.success(`Reassigned to ${data.assignee}`); + fetchCard(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Reassignment failed"); + } + }; + const handleMarkReviewed = async () => { await fetch(`/api/cards/${id}`, { method: "PUT", @@ -352,9 +448,26 @@ export default function CardDetailPage() { + {card.assignedToId && ( +
+ + + Assigned to {card.assignedToId === dbUser?.id ? "you" : (card.assignedToId)} + {card.assignedAt && ( + <> on {new Date(card.assignedAt).toLocaleDateString()} + )} + + {card.reviewedById && card.reviewedAt && ( + + · Reviewed {new Date(card.reviewedAt).toLocaleDateString()} + + )} +
+ )} +
- {ocrStatus !== "processing" && ( + {isAdmin && ocrStatus !== "processing" && ( - {reviewStatus !== "reviewed" && ( - )} - {reviewStatus !== "exported" && ( + {isAdmin && users.length > 0 && ( + + )} + {canMarkComplete && reviewStatus !== "reviewed" && reviewStatus !== "exported" && ( + + )} + {isAdmin && reviewStatus !== "exported" && ( )} - {hasEdits && ( + {hasEdits && canEdit && ( @@ -434,22 +561,22 @@ export default function CardDetailPage() {
- setField("name", v)} /> - setField("email", v)} /> - setField("cellPhone", v)} /> - setField("homePhone", v)} /> - setField("gender", v)} /> - setField("dateOfBirth", v)} /> - setField("maritalStatus", v)} /> - setField("visitType", v)} /> + setField("name", v)} readOnly={!canEdit} /> + setField("email", v)} readOnly={!canEdit} /> + setField("cellPhone", v)} readOnly={!canEdit} /> + setField("homePhone", v)} readOnly={!canEdit} /> + setField("gender", v)} readOnly={!canEdit} /> + setField("dateOfBirth", v)} readOnly={!canEdit} /> + setField("maritalStatus", v)} readOnly={!canEdit} /> + setField("visitType", v)} readOnly={!canEdit} />
- setField("address", v)} /> - setField("aptNumber", v)} /> - setField("city", v)} /> - setField("state", v)} /> - setField("zip", v)} /> + setField("address", v)} readOnly={!canEdit} /> + setField("aptNumber", v)} readOnly={!canEdit} /> + setField("city", v)} readOnly={!canEdit} /> + setField("state", v)} readOnly={!canEdit} /> + setField("zip", v)} readOnly={!canEdit} />
@@ -458,11 +585,13 @@ export default function CardDetailPage() { value={getValue("prayerRequests") || ""} onChange={(e) => setField("prayerRequests", e.target.value)} rows={3} + readOnly={!canEdit} + className={!canEdit ? "opacity-70 cursor-default" : ""} />
- setField("prayerForTeam", v)} /> - setField("prayerConfidential", v)} /> + setField("prayerForTeam", v)} readOnly={!canEdit} /> + setField("prayerConfidential", v)} readOnly={!canEdit} />
@@ -481,27 +610,31 @@ export default function CardDetailPage() { value={getArrayValue("messageTopics")} options={MESSAGE_TOPIC_OPTIONS} onChange={(v) => setField("messageTopics", v)} + readOnly={!canEdit} /> setField("nextStep", v)} + readOnly={!canEdit} /> - setField("attendanceDuration", v)} /> + setField("attendanceDuration", v)} readOnly={!canEdit} /> setField("campusPreference", v)} + readOnly={!canEdit} /> setField("howHeard", v)} + readOnly={!canEdit} /> - setField("serviceAttended", v)} /> + setField("serviceAttended", v)} readOnly={!canEdit} />
@@ -515,15 +648,15 @@ export default function CardDetailPage() {
- setField("followUp", v)} /> - setField("serviceTime", v)} /> - setField("planningCenter", v)} /> - setField("firstTimeGuestDate", v || null)} /> - setField("salvationDate", v || null)} /> - setField("iSaidYesBookSent", v)} /> - setField("ftGuestLetterSent", v)} /> + setField("followUp", v)} readOnly={!canEdit} /> + setField("serviceTime", v)} readOnly={!canEdit} /> + setField("planningCenter", v)} readOnly={!canEdit} /> + setField("firstTimeGuestDate", v || null)} readOnly={!canEdit} /> + setField("salvationDate", v || null)} readOnly={!canEdit} /> + setField("iSaidYesBookSent", v)} readOnly={!canEdit} /> + setField("ftGuestLetterSent", v)} readOnly={!canEdit} />
- {getValue("notes") && ( + {(getValue("notes") || canEdit) && ( <>
@@ -532,6 +665,8 @@ export default function CardDetailPage() { value={getValue("notes")} onChange={(e) => setField("notes", e.target.value)} rows={3} + readOnly={!canEdit} + className={!canEdit ? "opacity-70 cursor-default" : ""} />
@@ -651,10 +786,22 @@ export default function CardDetailPage() { All Cards
- -
@@ -728,11 +875,11 @@ function ImagePanel({ label, url }: { label: string; url: string | null }) { ); } -function Field({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { +function Field({ label, value, onChange, readOnly }: { label: string; value: string; onChange: (v: string) => void; readOnly?: boolean }) { return (
- onChange(e.target.value)} /> + onChange(e.target.value)} readOnly={readOnly} className={readOnly ? "opacity-70 cursor-default" : ""} />
); } @@ -749,7 +896,15 @@ function formatTimeAgo(dateStr: string): string { return new Date(dateStr).toLocaleDateString(); } -function SelectField({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) { +function SelectField({ label, value, options, onChange, readOnly }: { label: string; value: string; options: string[]; onChange: (v: string) => void; readOnly?: boolean }) { + if (readOnly) { + return ( +
+ + +
+ ); + } return (
@@ -773,15 +928,18 @@ function MultiSelectField({ value, options, onChange, + readOnly, }: { label: string; value: string[]; options: string[]; onChange: (v: string[]) => void; + readOnly?: boolean; }) { const selected = new Set(value); const toggle = (opt: string) => { + if (readOnly) return; const next = new Set(selected); if (next.has(opt)) next.delete(opt); else next.add(opt); @@ -799,11 +957,13 @@ function MultiSelectField({ key={opt} type="button" onClick={() => toggle(opt)} + disabled={readOnly} className={cn( "inline-flex items-center rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors", isOn ? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20" - : "border-border bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground" + : "border-border bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground", + readOnly && "cursor-default opacity-70" )} > {opt} @@ -815,11 +975,11 @@ function MultiSelectField({ ); } -function DateField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { +function DateField({ label, value, onChange, readOnly }: { label: string; value: string; onChange: (v: string) => void; readOnly?: boolean }) { return (
- onChange(e.target.value)} /> + onChange(e.target.value)} readOnly={readOnly} className={readOnly ? "opacity-70 cursor-default" : ""} />
); } @@ -828,15 +988,17 @@ function BooleanField({ label, value, onChange, + readOnly, }: { label: string; value: boolean; onChange: (v: boolean) => void; + readOnly?: boolean; }) { return (
- -
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 9dde639..cee083f 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { useTheme } from "next-themes"; import { @@ -26,6 +27,7 @@ import { import { Header } from "@/components/layout/header"; import { Button } from "@/components/ui/button"; +import { useUserProfile } from "@/lib/user-profile"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -153,6 +155,15 @@ function loadNotificationPrefs(): NotificationPrefs { export default function SettingsPage() { const { theme, setTheme } = useTheme(); + const { role, loading: userLoading } = useUserProfile(); + const settingsRouter = useRouter(); + + React.useEffect(() => { + if (!userLoading && role !== "admin") { + settingsRouter.replace("/"); + toast.error("Settings are restricted to admins"); + } + }, [role, userLoading, settingsRouter]); const [settings, setSettings] = React.useState({ ollamaUrl: "", diff --git a/src/components/cards/columns.tsx b/src/components/cards/columns.tsx index a538caa..b51d3b0 100644 --- a/src/components/cards/columns.tsx +++ b/src/components/cards/columns.tsx @@ -113,11 +113,19 @@ export type ResponseCard = { firstTimeGuestDate: string | null; salvationDate: string | null; mondayItemId: string | null; + assignedToId: string | null; + assignedById: string | null; + assignedAt: string | null; + reviewedById: string | null; + reviewedAt: string | null; + reviewNotes: string | null; ocrStatus: string; reviewStatus: string; ocrConfidence: number | null; frontImageUrl: string | null; backImageUrl: string | null; + assignedToName?: string | null; + reviewedByName?: string | null; }; export const COPYABLE_FIELDS: { field: keyof ResponseCard; label: string }[] = [ @@ -152,6 +160,10 @@ const ocrStatusVariant: Record = { const reviewStatusVariant: Record = { unreviewed: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300", + assigned: + "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300", + in_review: + "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300", reviewed: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300", exported: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300", }; @@ -478,6 +490,26 @@ export function createColumns(actions?: ColumnActions): ColumnDef[ ), enableSorting: false, }, + { + id: "assignedTo", + header: "Assigned To", + cell: ({ row }) => ( + + {row.original.assignedToName ?? "—"} + + ), + enableSorting: false, + }, + { + id: "reviewedBy", + header: "Reviewed By", + cell: ({ row }) => ( + + {row.original.reviewedByName ?? "—"} + + ), + enableSorting: false, + }, { accessorKey: "ocrStatus", header: ({ column }) => ( diff --git a/src/components/cards/dashboard-content.tsx b/src/components/cards/dashboard-content.tsx index 5c7fb4d..4e59512 100644 --- a/src/components/cards/dashboard-content.tsx +++ b/src/components/cards/dashboard-content.tsx @@ -10,6 +10,7 @@ import { DataTable, getDefaultColumnVisibility, ALL_TOGGLEABLE_COLUMNS } from ". import { SelectionToolbar } from "./selection-toolbar"; import { UploadModal, type UploadingFile } from "./upload-modal"; import { createColumns, COPYABLE_FIELDS, type ResponseCard } from "./columns"; +import { useUserProfile } from "@/lib/user-profile"; const VISIT_TYPE_OPTIONS = [ "First/Second Time Guest", @@ -29,6 +30,8 @@ const SERVICE_OPTIONS = ["A", "B", "C", "D"]; export function DashboardContent() { const router = useRouter(); const searchParams = useSearchParams(); + const { role } = useUserProfile(); + const isAdmin = role === "admin"; const page = parseInt(searchParams.get("page") || "1"); const limit = parseInt(searchParams.get("limit") || "20"); @@ -102,6 +105,7 @@ export function DashboardContent() { params.delete("ocrStatus"); params.delete("reviewStatus"); + params.delete("assignedToId"); if (filter === "complete") { params.set("ocrStatus", "complete"); @@ -109,6 +113,8 @@ export function DashboardContent() { params.set("ocrStatus", "error"); } else if (filter === "unreviewed") { params.set("reviewStatus", "unreviewed"); + } else if (filter === "my_cards") { + params.set("assignedToId", "me"); } params.set("page", "1"); @@ -201,6 +207,26 @@ export function DashboardContent() { } }; + const handleAssign = async (ids: string[], userId: string) => { + try { + const res = await fetch("/api/cards/assign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cardIds: ids, assignToUserId: userId }), + }); + const result = await res.json(); + if (!res.ok) { + toast.error(result.error || "Assignment failed"); + return; + } + toast.success(`Assigned ${result.assigned} card(s) to ${result.assignee}`); + setSelectedIds([]); + fetchCards(); + } catch { + toast.error("Failed to assign cards"); + } + }; + const handleExportCsv = () => { if (data.length === 0) { toast.error("No data to export"); @@ -245,39 +271,45 @@ export function DashboardContent() { () => createColumns({ onViewDetails: (card) => router.push(`/cards/${card.id}`), - onMarkReviewed: async (card) => { - await fetch(`/api/cards/${card.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reviewStatus: "reviewed" }), - }); - toast.success("Marked as reviewed"); - fetchCards(); - }, - onReprocess: async (card) => { - try { - const res = await fetch(`/api/cards/${card.id}/reprocess`, { - method: "POST", - }); - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || "Failed to start reprocessing"); + onMarkReviewed: isAdmin + ? async (card) => { + await fetch(`/api/cards/${card.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reviewStatus: "reviewed" }), + }); + toast.success("Marked as reviewed"); + fetchCards(); } - toast.success("Reprocessing started"); - fetchCards(); - } catch (err) { - toast.error( - err instanceof Error ? err.message : "Failed to start reprocessing" - ); - } - }, - onDelete: async (card) => { - await fetch(`/api/cards/${card.id}`, { method: "DELETE" }); - toast.success("Card deleted"); - fetchCards(); - }, + : undefined, + onReprocess: isAdmin + ? async (card) => { + try { + const res = await fetch(`/api/cards/${card.id}/reprocess`, { + method: "POST", + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to start reprocessing"); + } + toast.success("Reprocessing started"); + fetchCards(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to start reprocessing" + ); + } + } + : undefined, + onDelete: isAdmin + ? async (card) => { + await fetch(`/api/cards/${card.id}`, { method: "DELETE" }); + toast.success("Card deleted"); + fetchCards(); + } + : undefined, }), - [router, fetchCards] + [router, fetchCards, isAdmin] ); const handleUploadStart = (files: UploadingFile[]) => { @@ -421,9 +453,10 @@ export function DashboardContent() { attendanceDurationOptions={ATTENDANCE_OPTIONS} serviceAttendedOptions={SERVICE_OPTIONS} onExportCsv={handleExportCsv} - onUploadClick={openUpload} + onUploadClick={isAdmin ? openUpload : undefined} columnVisibility={columnVisibility} onColumnVisibilityChange={setColumnVisibility} + showAssignedToFilter /> {/* Data table */} @@ -450,11 +483,12 @@ export function DashboardContent() { handleBulkAction(ids, "reviewed")} - onMarkExported={(ids) => handleBulkAction(ids, "exported")} - onReprocess={handleBulkReprocess} - onSyncMonday={handleBulkSyncMonday} - onDelete={(ids) => handleBulkAction(ids, "delete")} + onMarkReviewed={isAdmin ? (ids) => handleBulkAction(ids, "reviewed") : undefined} + onMarkExported={isAdmin ? (ids) => handleBulkAction(ids, "exported") : undefined} + onReprocess={isAdmin ? handleBulkReprocess : undefined} + onSyncMonday={isAdmin ? handleBulkSyncMonday : undefined} + onAssign={isAdmin ? handleAssign : undefined} + onDelete={isAdmin ? (ids) => handleBulkAction(ids, "delete") : undefined} onClear={() => setSelectedIds([])} /> diff --git a/src/components/cards/data-table.tsx b/src/components/cards/data-table.tsx index 52221ad..d306fce 100644 --- a/src/components/cards/data-table.tsx +++ b/src/components/cards/data-table.tsx @@ -93,6 +93,8 @@ const COLUMN_GROUPS: { label: string; columns: { id: string; label: string }[] } { id: "firstTimeGuestDate", label: "FT Guest Date" }, { id: "salvationDate", label: "Salvation Date" }, { id: "mondayLinked", label: "Monday.com" }, + { id: "assignedTo", label: "Assigned To" }, + { id: "reviewedBy", label: "Reviewed By" }, ], }, ]; @@ -105,6 +107,7 @@ const DEFAULT_HIDDEN: string[] = [ "messageTopics", "nextStep", "campusPreference", "howHeard", "followUp", "notes", "serviceTime", "planningCenter", "iSaidYesBookSent", "ftGuestLetterSent", "firstTimeGuestDate", "salvationDate", "mondayLinked", + "assignedTo", "reviewedBy", ]; export function getDefaultColumnVisibility(): VisibilityState { diff --git a/src/components/cards/filters.tsx b/src/components/cards/filters.tsx index b431689..41e0c38 100644 --- a/src/components/cards/filters.tsx +++ b/src/components/cards/filters.tsx @@ -27,7 +27,7 @@ const COLUMN_GROUPS: { label: string; ids: string[] }[] = [ { label: "Personal", ids: ["homePhone", "gender", "dateOfBirth", "maritalStatus", "address", "zip"] }, { label: "Survey", ids: ["attendanceDuration", "serviceAttended", "messageTopics", "nextStep", "campusPreference", "howHeard"] }, { label: "Prayer", ids: ["prayerRequests", "prayerForTeam", "prayerConfidential"] }, - { label: "Workflow", ids: ["followUp", "notes", "serviceTime", "planningCenter", "iSaidYesBookSent", "ftGuestLetterSent", "mondayLinked"] }, + { label: "Workflow", ids: ["followUp", "notes", "serviceTime", "planningCenter", "iSaidYesBookSent", "ftGuestLetterSent", "mondayLinked", "assignedTo", "reviewedBy"] }, ]; const COL_LABELS: Record = {}; @@ -35,6 +35,8 @@ for (const col of ALL_TOGGLEABLE_COLUMNS) { COL_LABELS[col.id] = col.label; } +type UserOption = { id: string; displayName: string }; + export type FiltersProps = { search?: string; visitType?: string; @@ -47,6 +49,7 @@ export type FiltersProps = { onUploadClick?: () => void; columnVisibility?: VisibilityState; onColumnVisibilityChange?: (visibility: VisibilityState) => void; + showAssignedToFilter?: boolean; }; export function Filters({ @@ -61,10 +64,20 @@ export function Filters({ onUploadClick, columnVisibility = {}, onColumnVisibilityChange, + showAssignedToFilter = false, }: FiltersProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const [users, setUsers] = React.useState([]); + + React.useEffect(() => { + if (!showAssignedToFilter) return; + fetch("/api/users") + .then((r) => r.json()) + .then((data) => setUsers(data.users || [])) + .catch(() => {}); + }, [showAssignedToFilter]); const search = searchParams.get("search") ?? initialSearch; const visitType = searchParams.get("visitType") ?? initialVisitType; @@ -72,6 +85,7 @@ export function Filters({ searchParams.get("attendanceDuration") ?? initialAttendanceDuration; const serviceAttended = searchParams.get("serviceAttended") ?? initialServiceAttended; + const assignedToId = searchParams.get("assignedToId") ?? ""; const updateParams = React.useCallback( (updates: Record) => { @@ -176,6 +190,31 @@ export function Filters({ ))} + + {showAssignedToFilter && ( + + )}
diff --git a/src/components/cards/selection-toolbar.tsx b/src/components/cards/selection-toolbar.tsx index ec93394..6be5519 100644 --- a/src/components/cards/selection-toolbar.tsx +++ b/src/components/cards/selection-toolbar.tsx @@ -9,6 +9,7 @@ import { Trash2, X, LayoutGrid, + UserPlus, } from "lucide-react"; import { toast } from "sonner"; @@ -22,6 +23,8 @@ import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import { COPYABLE_FIELDS, type ResponseCard } from "./columns"; +type AssignableUser = { id: string; displayName: string }; + interface SelectionToolbarProps { selectedIds: string[]; selectedRows: ResponseCard[]; @@ -29,6 +32,7 @@ interface SelectionToolbarProps { onMarkExported?: (ids: string[]) => void; onReprocess?: (ids: string[]) => void; onSyncMonday?: (ids: string[]) => void; + onAssign?: (ids: string[], userId: string) => void; onDelete?: (ids: string[]) => void; onClear: () => void; } @@ -57,6 +61,7 @@ export function SelectionToolbar({ onMarkExported, onReprocess, onSyncMonday, + onAssign, onDelete, onClear, }: SelectionToolbarProps) { @@ -65,6 +70,26 @@ export function SelectionToolbar({ () => new Set(DEFAULT_COPY_FIELDS) ); const [copyOpen, setCopyOpen] = React.useState(false); + const [assignOpen, setAssignOpen] = React.useState(false); + const [users, setUsers] = React.useState([]); + + React.useEffect(() => { + if (!assignOpen) return; + fetch("/api/users?role=reviewer") + .then((r) => r.json()) + .then((data) => { + const reviewers: AssignableUser[] = data.users || []; + fetch("/api/users?role=admin") + .then((r2) => r2.json()) + .then((d2) => { + const admins: AssignableUser[] = d2.users || []; + const all = [...admins, ...reviewers]; + const seen = new Set(); + setUsers(all.filter((u) => (seen.has(u.id) ? false : (seen.add(u.id), true)))); + }); + }) + .catch(() => {}); + }, [assignOpen]); const toggleCopyField = (field: string) => { setCopyFields((prev) => { @@ -169,6 +194,46 @@ export function SelectionToolbar({ )} + {onAssign && ( + + + } + > + + Assign + + +
+

+ Assign to +

+
+
+ {users.length === 0 && ( +

+ No users available +

+ )} + {users.map((u) => ( + + ))} +
+
+
+ )} + ; byReviewStatus: Record; + myCards?: number; }; -export type StatFilter = "all" | "complete" | "error" | "unreviewed" | null; +export type StatFilter = "all" | "complete" | "error" | "unreviewed" | "my_cards" | null; type StatCardData = { label: string; @@ -48,6 +50,7 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) { { label: "OCR Complete", value: 0, icon: CheckCircle, filterKey: "complete", accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400", activeRing: "ring-emerald-500/40" }, { label: "Errors", value: 0, icon: AlertTriangle, filterKey: "error", accentClass: "text-red-600 bg-red-500/10 dark:text-red-400", activeRing: "ring-red-500/40" }, { label: "Pending Review", value: 0, icon: Clock, filterKey: "unreviewed", accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400", activeRing: "ring-amber-500/40" }, + { label: "My Cards", value: 0, icon: UserCheck, filterKey: "my_cards", accentClass: "text-blue-600 bg-blue-500/10 dark:text-blue-400", activeRing: "ring-blue-500/40" }, ]; } return [ @@ -83,6 +86,14 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) { accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400", activeRing: "ring-amber-500/40", }, + { + label: "My Cards", + value: stats.myCards ?? 0, + icon: UserCheck, + filterKey: "my_cards" as StatFilter, + accentClass: "text-blue-600 bg-blue-500/10 dark:text-blue-400", + activeRing: "ring-blue-500/40", + }, ]; }, [stats]); @@ -95,7 +106,7 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) { }; return ( -
+
{cards.map((card) => { const Icon = card.icon; const isActive = activeFilter === card.filterKey; diff --git a/src/lib/activity-log.ts b/src/lib/activity-log.ts index 7c622dd..f2699ef 100644 --- a/src/lib/activity-log.ts +++ b/src/lib/activity-log.ts @@ -37,7 +37,8 @@ export async function logActivity( action: string, source: string, summary: string, - changes?: FieldChange[] | null + changes?: FieldChange[] | null, + userId?: string | null ) { try { await prisma.activityLog.create({ @@ -47,6 +48,7 @@ export async function logActivity( source, summary, changes: changes && changes.length > 0 ? changes : undefined, + userId: userId ?? undefined, }, }); } catch (err) { diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..b0fcbc4 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,84 @@ +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(); +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 { + 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"; + } +} diff --git a/src/lib/integrations.ts b/src/lib/integrations.ts index a0b632f..555b33d 100644 --- a/src/lib/integrations.ts +++ b/src/lib/integrations.ts @@ -169,18 +169,10 @@ async function handleMonday( card: Record, cardId: string ) { - if (event === "ocr_complete") { + if (event === "card_reviewed" || event === "card_exported") { await pushCardToMonday(cardId, settings); - } else if (event === "card_reviewed" || event === "card_exported") { - const token = settings.mondayApiToken; - const boardId = settings.mondayBoardId; - const columnMap = (settings.mondayColumnMap as Record) ?? {}; - const mondayItemId = card.mondayItemId as string | null; - if (mondayItemId) { - const columnValues = mapCardToColumnValues(card, columnMap); - await updateItem(token, boardId, mondayItemId, columnValues); - } } + // ocr_complete no longer triggers Monday.com push -- cards are pushed when reviewed } export async function pushCardToMonday( diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index c4acede..11f1864 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -8,6 +8,7 @@ type CreateNotificationInput = { cardId?: string; actionUrl?: string; meta?: Prisma.InputJsonValue; + userId?: string; }; export async function createNotification(input: CreateNotificationInput) { @@ -20,6 +21,7 @@ export async function createNotification(input: CreateNotificationInput) { cardId: input.cardId, actionUrl: input.actionUrl, meta: input.meta, + userId: input.userId, }, }); } catch (err) { diff --git a/src/lib/user-profile.tsx b/src/lib/user-profile.tsx index 6471d3c..59f85b7 100644 --- a/src/lib/user-profile.tsx +++ b/src/lib/user-profile.tsx @@ -2,6 +2,9 @@ import * as React from "react"; import type { AuthentikUser } from "@/app/api/auth/me/route"; +import type { AppUser } from "@/lib/auth"; + +export type UserRole = "admin" | "reviewer" | "viewer"; export type UserProfile = { displayName: string; @@ -28,6 +31,8 @@ type UserProfileContextValue = { updateProfile: (updates: Partial) => void; initials: string; authentikUser: AuthentikUser | null; + dbUser: AppUser | null; + role: UserRole; isAuthenticated: boolean; loading: boolean; }; @@ -58,6 +63,7 @@ function saveLocalProfile(profile: Partial) { export function UserProfileProvider({ children }: { children: React.ReactNode }) { const [authentikUser, setAuthentikUser] = React.useState(null); + const [dbUser, setDbUser] = React.useState(null); const [localOverrides, setLocalOverrides] = React.useState>({}); const [loading, setLoading] = React.useState(true); const [mounted, setMounted] = React.useState(false); @@ -72,6 +78,9 @@ export function UserProfileProvider({ children }: { children: React.ReactNode }) if (data.authenticated && data.user) { setAuthentikUser(data.user); } + if (data.dbUser) { + setDbUser(data.dbUser); + } }) .catch(() => {}) .finally(() => setLoading(false)); @@ -107,10 +116,11 @@ export function UserProfileProvider({ children }: { children: React.ReactNode }) const initials = React.useMemo(() => getInitials(profile.displayName), [profile.displayName]); const isAuthenticated = !!authentikUser; + const role: UserRole = dbUser?.role ?? "admin"; const value = React.useMemo( - () => ({ profile, updateProfile, initials, authentikUser, isAuthenticated, loading }), - [profile, updateProfile, initials, authentikUser, isAuthenticated, loading] + () => ({ profile, updateProfile, initials, authentikUser, dbUser, role, isAuthenticated, loading }), + [profile, updateProfile, initials, authentikUser, dbUser, role, isAuthenticated, loading] ); if (!mounted) return <>{children}; @@ -130,6 +140,8 @@ export function useUserProfile() { updateProfile: () => {}, initials: "", authentikUser: null, + dbUser: null, + role: "admin" as UserRole, isAuthenticated: false, loading: false, };