feat: manage-users edge function — admin user CRUD (create/delete)
This commit is contained in:
parent
f264a53c0a
commit
cadcceef7e
|
|
@ -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<string | null> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue