From cadcceef7e9a8fbcce76a07c8b13e1ddebb4b109 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Jun 2026 06:55:55 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20manage-users=20edge=20function=20?= =?UTF-8?q?=E2=80=94=20admin=20user=20CRUD=20(create/delete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supabase/functions/manage-users/index.ts | 194 +++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 supabase/functions/manage-users/index.ts diff --git a/supabase/functions/manage-users/index.ts b/supabase/functions/manage-users/index.ts new file mode 100644 index 0000000..9121ea6 --- /dev/null +++ b/supabase/functions/manage-users/index.ts @@ -0,0 +1,194 @@ +import { + createServiceClient, + getCorsHeaders, + jsonResponse, + preflightResponse, + readJsonBody, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; + +const ADMIN_ROLES = new Set(["admin", "mega_admin"]); + +const isValidEmail = (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); + +/** + * Verify the caller's JWT and return their role from public.users. + * Returns null if not authorized. + */ +async function getCallerRole(request: Request): Promise { + const authHeader = request.headers.get("authorization") || ""; + const token = authHeader.replace(/^Bearer\s+/i, "").trim(); + if (!token) return null; + + const supabase = createServiceClient(); + const { data: userData, error: userError } = await supabase.auth.getUser(token); + if (userError || !userData?.user) return null; + + const userId = userData.user.id; + const { data: userRow, error: roleError } = await supabase + .from("users") + .select("roles(name)") + .eq("id", userId) + .maybeSingle(); + if (roleError || !userRow) return null; + + return userRow.roles?.name || null; +} + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "private"); + } + + const corsHeaders = getCorsHeaders(request, "private"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + // ── Authorization ── + const callerRole = await getCallerRole(request); + if (!callerRole || !ADMIN_ROLES.has(callerRole)) { + return jsonResponse( + { ok: false, error: "Недостаточно прав. Требуется роль admin или mega_admin." }, + 403, + corsHeaders, + ); + } + + const supabase = createServiceClient(); + + // ── POST: create new user ── + if (request.method === "POST") { + const { body } = await readJsonBody<{ email?: string; name?: string; role?: string }>( + request, + { maxBytes: MAX_BODY_BYTES }, + ); + + const email = String(body.email || "").trim().toLowerCase(); + const name = String(body.name || "").trim(); + const role = String(body.role || "").trim().toLowerCase(); + + if (!email || !isValidEmail(email)) { + return jsonResponse({ ok: false, error: "Некорректный email" }, 400, corsHeaders); + } + if (!name) { + return jsonResponse({ ok: false, error: "Имя обязательно" }, 400, corsHeaders); + } + if (!role) { + return jsonResponse({ ok: false, error: "Роль обязательна" }, 400, corsHeaders); + } + + // Resolve role_id from roles table + const { data: roleRow, error: roleErr } = await supabase + .from("roles") + .select("id") + .eq("name", role) + .maybeSingle(); + if (roleErr || !roleRow) { + return jsonResponse({ ok: false, error: "Неизвестная роль: " + role }, 400, corsHeaders); + } + const roleId = roleRow.id; + + // Check if email already exists in public.users + const { data: existingUser } = await supabase + .from("users") + .select("id") + .eq("email", email) + .maybeSingle(); + if (existingUser) { + return jsonResponse({ ok: false, error: "Пользователь с таким email уже существует" }, 409, corsHeaders); + } + + // Create auth user + const { data: authData, error: authError } = await supabase.auth.admin.createUser({ + email, + email_confirm: true, + user_metadata: { name }, + }); + + if (authError) { + console.error("auth.createUser error:", authError); + return jsonResponse( + { ok: false, error: "Ошибка создания auth-пользователя: " + authError.message }, + 500, + corsHeaders, + ); + } + + const newUserId = authData.user.id; + + // Insert into public.users + const { error: usersInsertError } = await supabase.from("users").insert({ + id: newUserId, + email, + name, + role_id: roleId, + }); + + if (usersInsertError) { + console.error("users.insert error:", usersInsertError); + // Rollback auth user + await supabase.auth.admin.deleteUser(newUserId); + return jsonResponse( + { ok: false, error: "Ошибка создания записи пользователя: " + usersInsertError.message }, + 500, + corsHeaders, + ); + } + + return jsonResponse({ ok: true, data: { id: newUserId, email, name, role } }, 201, corsHeaders); + } + + // ── DELETE: remove user ── + if (request.method === "DELETE") { + const url = new URL(request.url); + const userId = url.searchParams.get("id"); + if (!userId) { + return jsonResponse({ ok: false, error: "Параметр id обязателен" }, 400, corsHeaders); + } + + // Get user info before deletion + const { data: userRow } = await supabase + .from("users") + .select("id, email, name") + .eq("id", userId) + .maybeSingle(); + + if (!userRow) { + return jsonResponse({ ok: false, error: "Пользователь не найден" }, 404, corsHeaders); + } + + // Delete auth user (FK ON DELETE CASCADE will remove public.users row) + const { error: authDeleteError } = await supabase.auth.admin.deleteUser(userId); + if (authDeleteError) { + console.error("auth.deleteUser error:", authDeleteError); + // Try deleting public.users directly as fallback + const { error: dbDeleteError } = await supabase.from("users").delete().eq("id", userId); + if (dbDeleteError) { + return jsonResponse( + { ok: false, error: "Ошибка удаления: " + authDeleteError.message }, + 500, + corsHeaders, + ); + } + } + + return jsonResponse({ ok: true, data: { id: userId } }, 200, corsHeaders); + } + + return jsonResponse({ ok: false, error: "Method not allowed" }, 405, corsHeaders); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + return jsonResponse( + { ok: false, error: error instanceof Error ? error.message : "Unexpected error" }, + 500, + corsHeaders, + ); + } +}); \ No newline at end of file