Added admin user management
X-Lovable-Edit-ID: edt-0b51b908-5a64-4587-a8c9-bc0939c0b846 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
+21
-9
@@ -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,
|
||||
@@ -208,12 +229,3 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: () => (
|
||||
<ProtectedLayout adminOnly>
|
||||
<UsersPage />
|
||||
</ProtectedLayout>
|
||||
),
|
||||
});
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
created_at: string;
|
||||
role: AppRole | null;
|
||||
}
|
||||
|
||||
const ROLE_LABEL: Record<AppRole, string> = {
|
||||
admin: "Administrator",
|
||||
attorney: "Attorney",
|
||||
staff: "Staff",
|
||||
};
|
||||
|
||||
function UsersPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<UserRow[]>([]);
|
||||
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<string, AppRole>();
|
||||
(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 (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Users"
|
||||
description="Invite team members and manage their access."
|
||||
actions={
|
||||
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Invite user
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<InviteDialog
|
||||
onCreated={() => {
|
||||
setInviteOpen(false);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif">Team members</CardTitle>
|
||||
<CardDescription>{users.length} total</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">No users yet.</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead className="w-[80px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => {
|
||||
const isSelf = u.id === currentUser?.id;
|
||||
return (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">
|
||||
{u.full_name || <span className="text-muted-foreground">—</span>}
|
||||
{isSelf && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
You
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={u.role ?? undefined}
|
||||
onValueChange={(v) => handleRoleChange(u.id, v as AppRole)}
|
||||
disabled={isSelf}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="No role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(ROLE_LABEL) as AppRole[]).map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{ROLE_LABEL[r]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!isSelf && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete user?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently remove {u.email} and revoke their access.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleDelete(u.id)}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteDialog({ onCreated }: { onCreated: () => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<AppRole>("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 (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-serif">Invite user</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create an account with a temporary password. Share it with them through a secure channel.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-name">Full name</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-email">Email</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-password">Temporary password</Label>
|
||||
<Input
|
||||
id="invite-password"
|
||||
type="text"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={10}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Min 10 characters. The user can change it after first sign-in.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select value={role} onValueChange={(v) => setRole(v as AppRole)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(ROLE_LABEL) as AppRole[]).map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{ROLE_LABEL[r]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create user
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user