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