import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { supabase } from "@/integrations/supabase/client"; import { useAuth, type AppRole } from "@/lib/auth"; import { ProtectedLayout } from "@/components/protected-layout"; import { PageContainer, PageHeader } from "@/components/app-shell"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Loader2, UserPlus, Trash2, KeyRound, Briefcase, Search } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { toast } from "sonner"; export const Route = createFileRoute("/admin/users")({ component: () => ( ), }); interface UserRow { id: string; email: string; full_name: string; created_at: string; hourly_rate: number | null; title: string | null; role: AppRole | null; } const ROLE_LABEL: Record = { admin: "Administrator", attorney: "Attorney", paralegal: "Paralegal", legal_clerk: "Legal Clerk", staff: "Staff", }; const TITLE_OPTIONS = [ "Attorney", "Paralegal", "Legal Assistant", "Admin", "Other", ] as const; type UserTitle = (typeof TITLE_OPTIONS)[number]; function UsersPage() { const { user: currentUser } = useAuth(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [inviteOpen, setInviteOpen] = useState(false); const [search, setSearch] = useState(""); const [titleFilter, setTitleFilter] = useState("all"); const load = async () => { setLoading(true); const [{ data: profiles }, { data: rolesData }] = await Promise.all([ supabase.from("profiles").select("id, email, full_name, created_at, hourly_rate, title"), supabase.from("user_roles").select("user_id, role"), ]); const roleMap = new Map(); (rolesData ?? []).forEach((r) => roleMap.set(r.user_id, r.role as AppRole)); const rows: UserRow[] = (profiles ?? []).map((p: any) => ({ id: p.id, email: p.email, full_name: p.full_name, created_at: p.created_at, hourly_rate: p.hourly_rate, title: p.title ?? null, role: roleMap.get(p.id) ?? null, })); rows.sort((a, b) => a.email.localeCompare(b.email)); setUsers(rows); setLoading(false); }; useEffect(() => { load(); }, []); const handleRoleChange = async (userId: string, role: AppRole) => { const { error } = await supabase.functions.invoke("admin-update-role", { body: { user_id: userId, role }, }); if (error) { toast.error("Could not update role", { description: error.message }); return; } toast.success("Role updated"); load(); }; const handleDelete = async (userId: string) => { const { error } = await supabase.functions.invoke("admin-delete-user", { body: { user_id: userId }, }); if (error) { toast.error("Could not delete user", { description: error.message }); return; } toast.success("User deleted"); load(); }; const handleRateSave = async (userId: string, rate: number | null) => { const { error } = await supabase .from("profiles") .update({ hourly_rate: rate }) .eq("id", userId); if (error) { toast.error("Could not save rate", { description: error.message }); return; } toast.success("Rate updated"); load(); }; const handleTitleChange = async (userId: string, title: string | null) => { const { error } = await supabase .from("profiles") .update({ title }) .eq("id", userId); if (error) { toast.error("Could not update title", { description: error.message }); return; } toast.success("Title updated"); load(); }; const filteredUsers = users.filter((u) => { if (titleFilter !== "all") { if (titleFilter === "__none__" ? u.title : u.title !== titleFilter) return false; } const q = search.trim().toLowerCase(); if (!q) return true; return ( u.email.toLowerCase().includes(q) || (u.full_name ?? "").toLowerCase().includes(q) || (u.title ?? "").toLowerCase().includes(q) ); }); const titleCounts = TITLE_OPTIONS.reduce>((acc, t) => { acc[t] = users.filter((u) => u.title === t).length; return acc; }, {}); const noTitleCount = users.filter((u) => !u.title).length; return ( Invite user { setInviteOpen(false); load(); }} /> } /> Team members {filteredUsers.length} of {users.length} shown setSearch(e.target.value)} placeholder="Search by name, email, or title" className="pl-8" /> All titles ({users.length}) No title ({noTitleCount}) {TITLE_OPTIONS.map((t) => ( {t} ({titleCounts[t] ?? 0}) ))} {loading ? ( ) : filteredUsers.length === 0 ? ( No users yet. ) : ( Name Email Title Role Hourly rate Actions {filteredUsers.map((u) => { const isSelf = u.id === currentUser?.id; return ( {u.full_name || โ} {isSelf && ( You )} {u.email} handleTitleChange(u.id, v === "__none__" ? null : v) } > No title {TITLE_OPTIONS.map((t) => ( {t} ))} handleRoleChange(u.id, v as AppRole)} disabled={isSelf} > {(Object.keys(ROLE_LABEL) as AppRole[]).map((r) => ( {ROLE_LABEL[r]} ))} handleRateSave(u.id, v)} /> {!isSelf && ( Delete user? This will permanently remove {u.email} and revoke their access. Cancel handleDelete(u.id)}> Delete )} ); })} )} ); } function RateInput({ value, onSave, }: { value: number | null; onSave: (v: number | null) => void | Promise; }) { const [text, setText] = useState(value != null ? String(value) : ""); useEffect(() => setText(value != null ? String(value) : ""), [value]); const dirty = text !== (value != null ? String(value) : ""); const commit = () => { if (!dirty) return; if (text.trim() === "") return onSave(null); const n = parseFloat(text); if (Number.isNaN(n) || n < 0) { toast.error("Invalid rate"); setText(value != null ? String(value) : ""); return; } onSave(n); }; return ( $ setText(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} placeholder="โ" className="h-8 w-24" /> /hr ); } function SetPasswordDialog({ userId, email }: { userId: string; email: string }) { const [open, setOpen] = useState(false); const [password, setPassword] = useState(""); const [submitting, setSubmitting] = useState(false); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSubmitting(true); const { error } = await supabase.functions.invoke("admin-set-password", { body: { user_id: userId, password }, }); setSubmitting(false); if (error) { toast.error("Could not change password", { description: error.message }); return; } toast.success("Password updated", { description: "Share the new password securely with the user.", }); setPassword(""); setOpen(false); }; return ( Change password Set a new password for {email}. Share it with them through a secure channel. New password setPassword(e.target.value)} minLength={10} required autoFocus /> Min 10 characters. {submitting && } Update password ); } function InviteDialog({ onCreated }: { onCreated: () => void }) { const [email, setEmail] = useState(""); const [fullName, setFullName] = useState(""); const [password, setPassword] = useState(""); const [role, setRole] = useState("attorney"); const [submitting, setSubmitting] = useState(false); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSubmitting(true); const { error } = await supabase.functions.invoke("admin-invite-user", { body: { email: email.trim(), password, full_name: fullName.trim(), role }, }); setSubmitting(false); if (error) { toast.error("Could not create user", { description: error.message }); return; } toast.success("User created", { description: "Share the temporary password securely with them.", }); setEmail(""); setFullName(""); setPassword(""); setRole("attorney"); onCreated(); }; return ( Invite user Create an account with a temporary password. Share it with them through a secure channel. Full name setFullName(e.target.value)} required /> Email setEmail(e.target.value)} required /> Temporary password setPassword(e.target.value)} minLength={10} required /> Min 10 characters. The user can change it after first sign-in. Role setRole(v as AppRole)}> {(Object.keys(ROLE_LABEL) as AppRole[]).map((r) => ( {ROLE_LABEL[r]} ))} {submitting && } Create user ); } interface CaseOption { id: string; case_number: string; title: string; status: string; client_name: string | null; } function ManageCasesDialog({ userId, userName }: { userId: string; userName: string }) { const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [cases, setCases] = useState([]); const [initial, setInitial] = useState>(new Set()); const [selected, setSelected] = useState>(new Set()); const [primary, setPrimary] = useState>(new Set()); const [search, setSearch] = useState(""); const [onlyMine, setOnlyMine] = useState(false); const load = async () => { setLoading(true); const [{ data: caseRows }, { data: teamRows }] = await Promise.all([ supabase .from("cases") .select("id, case_number, title, status, assigned_attorney_id, originating_attorney_id, clients(name)") .is("archived_at", null) .order("case_number", { ascending: false }) .limit(1000), supabase.from("case_team_members").select("case_id").eq("user_id", userId), ]); const opts: CaseOption[] = (caseRows ?? []).map((c: any) => ({ id: c.id, case_number: c.case_number, title: c.title, status: c.status, client_name: c.clients?.name ?? null, })); const prim = new Set( (caseRows ?? []) .filter((c: any) => c.assigned_attorney_id === userId || c.originating_attorney_id === userId) .map((c: any) => c.id), ); const team = new Set((teamRows ?? []).map((r: any) => r.case_id)); const all = new Set([...prim, ...team]); setCases(opts); setPrimary(prim); setInitial(team); setSelected(new Set(all)); setLoading(false); }; useEffect(() => { if (open) load(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const toggle = (id: string, checked: boolean) => { setSelected((prev) => { const next = new Set(prev); if (checked) next.add(id); else next.delete(id); return next; }); }; const filtered = cases.filter((c) => { if (onlyMine && !selected.has(c.id)) return false; if (!search.trim()) return true; const q = search.toLowerCase(); return ( c.case_number.toLowerCase().includes(q) || c.title.toLowerCase().includes(q) || (c.client_name ?? "").toLowerCase().includes(q) ); }); const visibleIds = filtered.map((c) => c.id); const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id)); const toggleAllVisible = (checked: boolean) => { setSelected((prev) => { const next = new Set(prev); visibleIds.forEach((id) => { if (checked) next.add(id); else if (!primary.has(id)) next.delete(id); }); return next; }); }; const onSave = async () => { setSubmitting(true); // Diff against the team-membership set only (primary attorney rows aren't editable here) const target = new Set([...selected].filter((id) => !primary.has(id))); const toAdd = [...target].filter((id) => !initial.has(id)); const toRemove = [...initial].filter((id) => !target.has(id)); try { if (toAdd.length > 0) { const { error } = await supabase .from("case_team_members") .insert(toAdd.map((case_id) => ({ case_id, user_id: userId }))); if (error) throw error; } if (toRemove.length > 0) { const { error } = await supabase .from("case_team_members") .delete() .eq("user_id", userId) .in("case_id", toRemove); if (error) throw error; } toast.success("Case assignments updated", { description: `${toAdd.length} added, ${toRemove.length} removed`, }); setOpen(false); } catch (e: any) { toast.error("Could not update assignments", { description: e.message }); } finally { setSubmitting(false); } }; return ( Assign cases Add or remove {userName} from case teams. Primary attorney assignments are shown but managed on the case itself. setSearch(e.target.value)} placeholder="Search by case number, title, or client" className="pl-8" /> setOnlyMine(!!v)} /> Assigned only {loading ? ( ) : filtered.length === 0 ? ( No cases match. ) : ( toggleAllVisible(!!v)} aria-label="Select all visible" /> Case Client Status {filtered.map((c) => { const isPrimary = primary.has(c.id); const isChecked = selected.has(c.id); return ( toggle(c.id, !!v)} /> {c.case_number} {c.title} {isPrimary && ( Primary attorney )} {c.client_name ?? "โ"} {c.status} ); })} )} {selected.size} assigned ยท {cases.length} total {submitting && } Save assignments ); }
Min 10 characters.
Min 10 characters. The user can change it after first sign-in.