From afc8449bcdb808ae0f3ed0c0b26e30deed97185b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:32:57 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routeTree.gen.ts | 21 ++ src/routes/admin.users.tsx | 340 ++++++++++++++++++ supabase/functions/admin-delete-user/index.ts | 86 +++++ supabase/functions/admin-invite-user/index.ts | 112 ++++++ supabase/functions/admin-update-role/index.ts | 90 +++++ 5 files changed, 649 insertions(+) create mode 100644 src/routes/admin.users.tsx create mode 100644 supabase/functions/admin-delete-user/index.ts create mode 100644 supabase/functions/admin-invite-user/index.ts create mode 100644 supabase/functions/admin-update-role/index.ts diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 699f9f6..97724ed 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as CasesIndexRouteImport } from './routes/cases.index' import { Route as ClientsClientIdRouteImport } from './routes/clients.$clientId' import { Route as CasesNewRouteImport } from './routes/cases.new' import { Route as CasesCaseIdRouteImport } from './routes/cases.$caseId' +import { Route as AdminUsersRouteImport } from './routes/admin.users' const SetupRoute = SetupRouteImport.update({ id: '/setup', @@ -58,11 +59,17 @@ const CasesCaseIdRoute = CasesCaseIdRouteImport.update({ path: '/cases/$caseId', getParentRoute: () => rootRouteImport, } as any) +const AdminUsersRoute = AdminUsersRouteImport.update({ + id: '/admin/users', + path: '/admin/users', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute '/setup': typeof SetupRoute + '/admin/users': typeof AdminUsersRoute '/cases/$caseId': typeof CasesCaseIdRoute '/cases/new': typeof CasesNewRoute '/clients/$clientId': typeof ClientsClientIdRoute @@ -73,6 +80,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute '/setup': typeof SetupRoute + '/admin/users': typeof AdminUsersRoute '/cases/$caseId': typeof CasesCaseIdRoute '/cases/new': typeof CasesNewRoute '/clients/$clientId': typeof ClientsClientIdRoute @@ -84,6 +92,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/login': typeof LoginRoute '/setup': typeof SetupRoute + '/admin/users': typeof AdminUsersRoute '/cases/$caseId': typeof CasesCaseIdRoute '/cases/new': typeof CasesNewRoute '/clients/$clientId': typeof ClientsClientIdRoute @@ -96,6 +105,7 @@ export interface FileRouteTypes { | '/' | '/login' | '/setup' + | '/admin/users' | '/cases/$caseId' | '/cases/new' | '/clients/$clientId' @@ -106,6 +116,7 @@ export interface FileRouteTypes { | '/' | '/login' | '/setup' + | '/admin/users' | '/cases/$caseId' | '/cases/new' | '/clients/$clientId' @@ -116,6 +127,7 @@ export interface FileRouteTypes { | '/' | '/login' | '/setup' + | '/admin/users' | '/cases/$caseId' | '/cases/new' | '/clients/$clientId' @@ -127,6 +139,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute LoginRoute: typeof LoginRoute SetupRoute: typeof SetupRoute + AdminUsersRoute: typeof AdminUsersRoute CasesCaseIdRoute: typeof CasesCaseIdRoute CasesNewRoute: typeof CasesNewRoute ClientsClientIdRoute: typeof ClientsClientIdRoute @@ -192,6 +205,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CasesCaseIdRouteImport parentRoute: typeof rootRouteImport } + '/admin/users': { + id: '/admin/users' + path: '/admin/users' + fullPath: '/admin/users' + preLoaderRoute: typeof AdminUsersRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -199,6 +219,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LoginRoute: LoginRoute, SetupRoute: SetupRoute, + AdminUsersRoute: AdminUsersRoute, CasesCaseIdRoute: CasesCaseIdRoute, CasesNewRoute: CasesNewRoute, ClientsClientIdRoute: ClientsClientIdRoute, diff --git a/src/routes/admin.users.tsx b/src/routes/admin.users.tsx new file mode 100644 index 0000000..4bf357e --- /dev/null +++ b/src/routes/admin.users.tsx @@ -0,0 +1,340 @@ +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 } from "lucide-react"; +import { toast } from "sonner"; + +export const Route = createFileRoute("/admin/users")({ + component: () => ( + + + + ), +}); + +interface UserRow { + id: string; + email: string; + full_name: string; + created_at: string; + role: AppRole | null; +} + +const ROLE_LABEL: Record = { + admin: "Administrator", + attorney: "Attorney", + staff: "Staff", +}; + +function UsersPage() { + const { user: currentUser } = useAuth(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [inviteOpen, setInviteOpen] = useState(false); + + const load = async () => { + setLoading(true); + const [{ data: profiles }, { data: rolesData }] = await Promise.all([ + supabase.from("profiles").select("id, email, full_name, created_at"), + 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) => ({ + id: p.id, + email: p.email, + full_name: p.full_name, + created_at: p.created_at, + 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(); + }; + + return ( + + + + + + { + setInviteOpen(false); + load(); + }} + /> + + } + /> + + + + Team members + {users.length} total + + + {loading ? ( +
+ +
+ ) : users.length === 0 ? ( +
No users yet.
+ ) : ( + + + + Name + Email + Role + + + + + {users.map((u) => { + const isSelf = u.id === currentUser?.id; + return ( + + + {u.full_name || —} + {isSelf && ( + + You + + )} + + {u.email} + + + + + {!isSelf && ( + + + + + + + Delete user? + + This will permanently remove {u.email} and revoke their access. + + + + Cancel + handleDelete(u.id)}> + Delete + + + + + )} + + + ); + })} + +
+ )} +
+
+
+ ); +} + +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. + + +
+
+ + setFullName(e.target.value)} + required + /> +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + minLength={10} + required + /> +

+ Min 10 characters. The user can change it after first sign-in. +

+
+
+ + +
+ + + +
+
+ ); +} diff --git a/supabase/functions/admin-delete-user/index.ts b/supabase/functions/admin-delete-user/index.ts new file mode 100644 index 0000000..8555f46 --- /dev/null +++ b/supabase/functions/admin-delete-user/index.ts @@ -0,0 +1,86 @@ +// Admin-only edge function to delete a user account. +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + try { + const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; + const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const ANON_KEY = Deno.env.get("SUPABASE_PUBLISHABLE_KEY") ?? Deno.env.get("SUPABASE_ANON_KEY")!; + + const token = (req.headers.get("Authorization") ?? "").replace("Bearer ", ""); + const userClient = createClient(SUPABASE_URL, ANON_KEY, { + global: { headers: { Authorization: `Bearer ${token}` } }, + }); + const { data: userData } = await userClient.auth.getUser(); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const admin = createClient(SUPABASE_URL, SERVICE_ROLE); + const { data: isAdminData } = await admin.rpc("is_admin", { _user_id: userData.user.id }); + if (!isAdminData) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const { user_id } = (await req.json()) as { user_id: string }; + if (!user_id) { + return new Response(JSON.stringify({ error: "Missing user_id" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + if (user_id === userData.user.id) { + return new Response(JSON.stringify({ error: "You cannot delete your own account" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Block deleting the last admin + const { data: targetRoles } = await admin + .from("user_roles") + .select("role") + .eq("user_id", user_id); + const isAdminTarget = (targetRoles ?? []).some((r) => r.role === "admin"); + if (isAdminTarget) { + const { data: admins } = await admin.from("user_roles").select("user_id").eq("role", "admin"); + if ((admins?.length ?? 0) <= 1) { + return new Response( + JSON.stringify({ error: "Cannot delete the only remaining admin" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + } + + const { error } = await admin.auth.admin.deleteUser(user_id); + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ ok: true }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (e) { + return new Response(JSON.stringify({ error: (e as Error).message }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); diff --git a/supabase/functions/admin-invite-user/index.ts b/supabase/functions/admin-invite-user/index.ts new file mode 100644 index 0000000..80ef7d4 --- /dev/null +++ b/supabase/functions/admin-invite-user/index.ts @@ -0,0 +1,112 @@ +// Admin-only edge function to invite a new user (email + password) and assign a role. +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +type Role = "admin" | "attorney" | "staff"; + +interface InviteBody { + email: string; + password: string; + full_name?: string; + role: Role; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + try { + const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; + const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const ANON_KEY = Deno.env.get("SUPABASE_PUBLISHABLE_KEY") ?? Deno.env.get("SUPABASE_ANON_KEY")!; + + const authHeader = req.headers.get("Authorization") ?? ""; + const token = authHeader.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Missing auth token" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Verify caller is admin + const userClient = createClient(SUPABASE_URL, ANON_KEY, { + global: { headers: { Authorization: `Bearer ${token}` } }, + }); + const { data: userData, error: userErr } = await userClient.auth.getUser(); + if (userErr || !userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const admin = createClient(SUPABASE_URL, SERVICE_ROLE); + const { data: isAdminData, error: roleErr } = await admin.rpc("is_admin", { + _user_id: userData.user.id, + }); + if (roleErr || !isAdminData) { + return new Response(JSON.stringify({ error: "Forbidden — admin only" }), { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const body = (await req.json()) as InviteBody; + if (!body.email || !body.password || !body.role) { + return new Response(JSON.stringify({ error: "Missing required fields" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + if (!["admin", "attorney", "staff"].includes(body.role)) { + return new Response(JSON.stringify({ error: "Invalid role" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Create user (auto-confirmed so they can sign in immediately) + const { data: created, error: createErr } = await admin.auth.admin.createUser({ + email: body.email, + password: body.password, + email_confirm: true, + user_metadata: { full_name: body.full_name ?? "" }, + }); + if (createErr || !created.user) { + return new Response(JSON.stringify({ error: createErr?.message ?? "Create failed" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const newUserId = created.user.id; + + // Trigger handle_new_user assigns 'staff' by default. Replace with requested role. + if (body.role !== "staff") { + await admin.from("user_roles").delete().eq("user_id", newUserId); + const { error: roleInsertErr } = await admin + .from("user_roles") + .insert({ user_id: newUserId, role: body.role }); + if (roleInsertErr) { + return new Response(JSON.stringify({ error: roleInsertErr.message }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + } + + return new Response(JSON.stringify({ ok: true, user_id: newUserId }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (e) { + return new Response(JSON.stringify({ error: (e as Error).message }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); diff --git a/supabase/functions/admin-update-role/index.ts b/supabase/functions/admin-update-role/index.ts new file mode 100644 index 0000000..35808b0 --- /dev/null +++ b/supabase/functions/admin-update-role/index.ts @@ -0,0 +1,90 @@ +// Admin-only edge function to set/replace a user's role. +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +type Role = "admin" | "attorney" | "staff"; + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + try { + const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; + const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const ANON_KEY = Deno.env.get("SUPABASE_PUBLISHABLE_KEY") ?? Deno.env.get("SUPABASE_ANON_KEY")!; + + const token = (req.headers.get("Authorization") ?? "").replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Missing auth" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const userClient = createClient(SUPABASE_URL, ANON_KEY, { + global: { headers: { Authorization: `Bearer ${token}` } }, + }); + const { data: userData } = await userClient.auth.getUser(); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const admin = createClient(SUPABASE_URL, SERVICE_ROLE); + const { data: isAdminData } = await admin.rpc("is_admin", { _user_id: userData.user.id }); + if (!isAdminData) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const { user_id, role } = (await req.json()) as { user_id: string; role: Role }; + if (!user_id || !["admin", "attorney", "staff"].includes(role)) { + return new Response(JSON.stringify({ error: "Invalid input" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Prevent demoting the last admin + if (role !== "admin") { + const { data: admins } = await admin + .from("user_roles") + .select("user_id") + .eq("role", "admin"); + const isOnlyAdmin = + (admins?.length ?? 0) === 1 && admins?.[0]?.user_id === user_id; + if (isOnlyAdmin) { + return new Response( + JSON.stringify({ error: "Cannot demote the only remaining admin" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + } + + await admin.from("user_roles").delete().eq("user_id", user_id); + const { error } = await admin.from("user_roles").insert({ user_id, role }); + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ ok: true }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (e) { + return new Response(JSON.stringify({ error: (e as Error).message }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +});