// Ban/unban/delete a user account — organized-ideas.md §6, Super-Admin only. // // This has to be an Edge Function rather than a Postgres function (unlike // list_users_for_admin or the other privileged functions in this codebase): // touching auth.users needs Supabase Auth's Admin API (the stable, // documented interface for account mutations), not a direct SQL write to // a schema Supabase manages internally and doesn't guarantee stable // across upgrades. Ban is reversible (banned_until, no data touched); // delete cascades to profiles/diagrams via their existing FKs. // // To invoke locally (after `supabase start`), with a real user's access // token in place of ACCESS_TOKEN: // curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/admin-user-action' \ // --header 'apiKey: ' \ // --header 'Authorization: Bearer ACCESS_TOKEN' \ // --header 'Content-Type: application/json' \ // --data '{"action":"ban","userId":"..."}' import "@supabase/functions-js/edge-runtime.d.ts"; import { withSupabase } from "@supabase/server"; type Action = "ban" | "unban" | "delete"; interface RequestBody { action: Action; userId: string; } const VALID_ACTIONS: Action[] = ["ban", "unban", "delete"]; // ~100 years — Supabase Auth's own documented idiom for "indefinite ban" // (there's no literal "forever" value); "none" is the matching unban value. const PERMANENT_BAN_DURATION = "876000h"; export default { fetch: withSupabase({ auth: "user" }, async (req, ctx) => { const callerId = ctx.userClaims!.id; // Confirm the caller is a Super Admin by reading their own profile // through the user-scoped (RLS-respecting) client — this leans on the // same "read your own row" policy every other profile read in the app // uses, rather than trusting anything in the JWT itself (its `role` // claim is just "authenticated", not this app's role column). const { data: callerProfile, error: profileError } = await ctx.supabase .from("profiles") .select("role") .eq("id", callerId) .single(); if (profileError || callerProfile?.role !== "super_admin") { return Response.json({ error: "Only a Super Admin can manage user accounts." }, { status: 403 }); } let body: RequestBody; try { body = await req.json(); } catch { return Response.json({ error: "Invalid request body." }, { status: 400 }); } const { action, userId } = body; if (typeof userId !== "string" || !userId || !VALID_ACTIONS.includes(action)) { return Response.json({ error: "Request must include a valid action and userId." }, { status: 400 }); } if (userId === callerId) { return Response.json({ error: "You cannot perform this action on your own account." }, { status: 400 }); } if (action === "ban") { const { error } = await ctx.supabaseAdmin.auth.admin.updateUserById(userId, { ban_duration: PERMANENT_BAN_DURATION, }); if (error) return Response.json({ error: error.message }, { status: 500 }); } else if (action === "unban") { const { error } = await ctx.supabaseAdmin.auth.admin.updateUserById(userId, { ban_duration: "none" }); if (error) return Response.json({ error: error.message }, { status: 500 }); } else { // Cascades to profiles (and from there, diagrams/owned catalog // entries) via their existing `on delete cascade` foreign keys — // the frontend confirmation for this action says so explicitly, // since it's the one irreversible option here. const { error } = await ctx.supabaseAdmin.auth.admin.deleteUser(userId); if (error) return Response.json({ error: error.message }, { status: 500 }); } return Response.json({ success: true }); }), };