tasq/supabase/functions/admin_user_management/index.ts

189 lines
6.4 KiB
TypeScript

import { serve } from "https://deno.land/std@0.203.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
// CORS: allow browser-based web clients (adjust origin in production)
// Permit headers the supabase-js client sends (e.g. `apikey`, `x-client-info`) so
// browser preflight requests succeed. Keep origin wide for dev; in production
// prefer echoing the request origin and enabling credentials only when needed.
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info, x-requested-with, Origin, Accept",
"Access-Control-Max-Age": "3600",
};
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
serve(async (req) => {
// Handle CORS preflight quickly so browsers can call this function from localhost/dev
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
if (req.method != "POST") {
return jsonResponse({ error: "Method not allowed" }, 405);
}
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
const anonKey = Deno.env.get("SUPABASE_ANON_KEY") ?? "";
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
if (!supabaseUrl || !anonKey || !serviceKey) {
return jsonResponse({ error: "Missing env configuration" }, 500);
}
const authHeader = req.headers.get("Authorization") ?? "";
const token = authHeader.replace("Bearer ", "").trim();
if (!token) {
return jsonResponse({ error: "Missing access token" }, 401);
}
const authClient = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: `Bearer ${token}` } },
});
const { data: authData, error: authError } =
await authClient.auth.getUser();
// DEBUG: log auth results to help diagnose intermittent 401s from the
// gateway / auth service. Remove these logs before deploying to production.
console.log("admin_user_management: token-snippet", token.slice(0, 16));
console.log("admin_user_management: authData", JSON.stringify(authData));
console.log("admin_user_management: authError", JSON.stringify(authError));
if (authError || !authData?.user) {
// Extract token header (kid/alg) for debugging without revealing the full
// token. This helps confirm which key the gateway/runtime attempted to
// verify against.
let tokenHeader: unknown = null;
try {
const headerB64 = token.split(".")[0] ?? "";
tokenHeader = JSON.parse(atob(headerB64));
} catch (e) {
tokenHeader = { parseError: (e as Error).message };
}
return jsonResponse(
{
error: "Unauthorized",
debug: {
authError: authError?.message ?? null,
tokenHeader,
authDataUser: authData?.user
? {
id: authData.user.id,
email: authData.user.email ?? null,
banned_until: authData.user.banned_until ?? null,
}
: null,
},
},
401,
);
}
const adminClient = createClient(supabaseUrl, serviceKey);
const { data: profile, error: profileError } = await adminClient
.from("profiles")
.select("role")
.eq("id", authData.user.id)
.maybeSingle();
const role = (profile?.role ?? "").toString().toLowerCase();
if (profileError || role != "admin") {
return jsonResponse({ error: "Forbidden" }, 403);
}
let payload: Record<string, unknown> = {};
try {
payload = await req.json();
} catch (_) {
return jsonResponse({ error: "Invalid JSON" }, 400);
}
const action = payload.action as string | undefined;
const userId = payload.userId as string | undefined;
if (!action) {
return jsonResponse({ error: "Missing action" }, 400);
}
// `list_users` does not require a target userId; other actions do.
if (action !== "list_users" && !userId) {
return jsonResponse({ error: "Missing userId" }, 400);
}
if (action === "list_users") {
const offset = Number(payload.offset ?? 0);
const limit = Number(payload.limit ?? 50);
const searchQuery = (payload.searchQuery ?? "").toString().trim();
// Fetch paginated profiles first (enforce server-side pagination)
let profilesQuery = adminClient
.from("profiles")
.select("id, full_name, role")
.order("id", { ascending: true })
.range(offset, offset + limit - 1);
if (searchQuery.length > 0) {
profilesQuery = adminClient
.from("profiles")
.select("id, full_name, role")
.ilike("full_name", `%${searchQuery}%`)
.order("id", { ascending: true })
.range(offset, offset + limit - 1);
}
const { data: profiles, error: profilesError } = await profilesQuery;
if (profilesError) return jsonResponse({ error: profilesError.message }, 500);
const users = [];
for (const p of (profiles ?? [])) {
const { data: userResp } = await adminClient.auth.admin.getUserById(p.id);
users.push({
id: p.id,
full_name: p.full_name ?? null,
role: p.role ?? null,
email: userResp?.user?.email ?? null,
bannedUntil: userResp?.user?.banned_until ?? null,
});
}
return jsonResponse({ users });
}
if (action == "get_user") {
const { data, error } = await adminClient.auth.admin.getUserById(userId);
if (error) {
return jsonResponse({ error: error.message }, 400);
}
return jsonResponse({ user: data.user });
}
if (action == "set_password") {
const password = payload.password as string | undefined;
if (!password || password.length < 8) {
return jsonResponse({ error: "Password must be at least 8 characters" }, 400);
}
const { error } = await adminClient.auth.admin.updateUserById(userId, {
password,
});
if (error) {
return jsonResponse({ error: error.message }, 400);
}
return jsonResponse({ ok: true });
}
if (action == "set_lock") {
const locked = Boolean(payload.locked);
const { error } = await adminClient.auth.admin.updateUserById(userId, {
ban_duration: locked ? "100y" : "0s",
});
if (error) {
return jsonResponse({ error: error.message }, 400);
}
return jsonResponse({ ok: true });
}
return jsonResponse({ error: "Unknown action" }, 400);
});