184 lines
6.4 KiB
TypeScript
184 lines
6.4 KiB
TypeScript
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);
|
||
}
|
||
|
||
// 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 — trigger handle_new_user will auto-insert into public.users
|
||
// using user_metadata.role and user_metadata.name
|
||
const { data: authData, error: authError } = await supabase.auth.admin.createUser({
|
||
email,
|
||
email_confirm: true,
|
||
user_metadata: { name, role },
|
||
});
|
||
|
||
if (authError) {
|
||
console.error("auth.createUser error:", authError);
|
||
return jsonResponse(
|
||
{ ok: false, error: "Ошибка создания пользователя: " + authError.message },
|
||
500,
|
||
corsHeaders,
|
||
);
|
||
}
|
||
|
||
const newUserId = authData.user.id;
|
||
|
||
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);
|
||
}
|
||
|
||
// Detach FK references before deleting auth user.
|
||
// Several tables reference public.users via RESTRICT FKs (no ON DELETE CASCADE):
|
||
// order_history, action_logs, suggestions, order_groups.assigned_driver_id, etc.
|
||
// Without detaching, auth.admin.deleteUser fails with "Database error deleting user".
|
||
const detachStatements: Array<[string, string]> = [
|
||
["orders", "manager_id"],
|
||
["orders", "logistician_id"],
|
||
["orders", "assigned_driver_id"],
|
||
["order_logisticians", "assigned_by"],
|
||
["order_history", "user_id"],
|
||
["delivery_slots", "logistician_id"],
|
||
["order_groups", "assigned_driver_id"],
|
||
["action_logs", "performed_by"],
|
||
["suggestions", "author_id"],
|
||
];
|
||
for (const [table, column] of detachStatements) {
|
||
await supabase.from(table).update({ [column]: null }).eq(column, userId);
|
||
}
|
||
|
||
// 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,
|
||
);
|
||
}
|
||
}); |