189 lines
6.4 KiB
TypeScript
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);
|
|
});
|