Files
2026-06-01 20:19:26 -04:00

1258 lines
48 KiB
TypeScript

import { createClient } from "https://esm.sh/@supabase/supabase-js@2.99.1";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};
function getAppOrigin(req: Request) {
const origin = req.headers.get("origin");
if (origin) return origin.replace(/\/$/, "");
const referer = req.headers.get("referer");
if (referer) {
try {
return new URL(referer).origin;
} catch {
// Ignore malformed referer values
}
}
const forwardedHost = req.headers.get("x-forwarded-host");
if (forwardedHost) {
const forwardedProto = req.headers.get("x-forwarded-proto") || "https";
return `${forwardedProto}://${forwardedHost}`;
}
return null;
}
function getRecoveryLinkOptions(req: Request) {
// Always use production domain for reset links sent via email
const productionOrigin = "https://avria.cloud";
return { redirectTo: `${productionOrigin}/reset-password` };
}
function getInviteLinkOptions(req: Request) {
const productionOrigin = "https://avria.cloud";
return { redirectTo: `${productionOrigin}/reset-password?mode=invite` };
}
function escapeHtml(value: string) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function buildActionEmailHtml({
title,
intro,
buttonLabel,
actionLink,
customMessage,
}: {
title: string;
intro: string;
buttonLabel: string;
actionLink: string;
customMessage?: string;
}) {
const safeActionLink = escapeHtml(actionLink);
const customMsgHtml = customMessage
? `<div style="background-color:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:16px;margin-bottom:20px;font-size:15px;color:#334155;line-height:1.6;">${escapeHtml(customMessage).replace(/\n/g, "<br>")}</div>`
: "";
return `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px;background:#ffffff;">
<h2 style="color:#1e293b;margin:0 0 16px;font-size:24px;line-height:1.25;">${escapeHtml(title)}</h2>
${customMsgHtml}
<p style="color:#334155;font-size:15px;line-height:1.6;margin:0 0 24px;">${escapeHtml(intro)}</p>
<div style="text-align:center;margin:28px 0;">
<a href="${safeActionLink}" style="background-color:#0f172a;color:#ffffff;padding:13px 28px;text-decoration:none;border-radius:6px;font-weight:700;display:inline-block;font-size:15px;line-height:1.2;">${escapeHtml(buttonLabel)}</a>
</div>
<p style="color:#475569;font-size:13px;line-height:1.6;margin:0 0 8px;">If the button does not open, copy and paste this secure link into your browser:</p>
<p style="word-break:break-all;margin:0 0 24px;"><a href="${safeActionLink}" style="color:#0f172a;text-decoration:underline;font-size:13px;line-height:1.6;">${safeActionLink}</a></p>
<p style="color:#64748b;font-size:13px;margin:0;">If you did not request this, you can safely ignore this email.</p>
</div>
`;
}
async function getVerifiedSender(adminClient: any) {
const { data: senders, error } = await adminClient
.from("email_senders")
.select("id")
.eq("is_active", true)
.eq("verified", true)
.order("is_default", { ascending: false })
.order("updated_at", { ascending: false })
.limit(1);
if (error) throw error;
return senders?.[0] ?? null;
}
async function sendManagedEmail({
callerClient,
senderId,
recipient,
subject,
html,
}: {
callerClient: any;
senderId: string;
recipient: string;
subject: string;
html: string;
}) {
const { data, error } = await callerClient.functions.invoke("send-smtp-email", {
body: {
sender_id: senderId,
recipient: [recipient],
subject,
body: html,
html,
debug: false,
},
});
if (error) throw error;
if (data && typeof data === "object" && "success" in data && data.success === false) {
throw new Error((data as { error?: string }).error || "Email send failed");
}
}
function throwIfDbError(error: { message?: string } | null, context: string) {
if (error) throw new Error(`${context}: ${error.message || "Database operation failed"}`);
}
async function syncLegalAssociationAssignments(
adminClient: any,
userId: string,
assocIds: string[],
callerId: string,
) {
const { error: deleteError } = await adminClient
.from("legal_association_assignments")
.delete()
.eq("user_id", userId);
throwIfDbError(deleteError, "Could not clear existing legal association assignments");
if (assocIds.length === 0) return;
const rows = assocIds.map((aid: string) => ({
user_id: userId,
association_id: aid,
created_by: callerId,
}));
const { error: insertError } = await adminClient
.from("legal_association_assignments")
.upsert(rows, { onConflict: "user_id,association_id" });
throwIfDbError(insertError, "Could not save legal association assignments");
}
async function syncMasterBoardAssignments(
adminClient: any,
userId: string,
assocIds: string[],
callerId: string,
) {
const { error: deleteError } = await adminClient
.from("master_board_assignments")
.delete()
.eq("user_id", userId);
throwIfDbError(deleteError, "Could not clear existing master board assignments");
if (assocIds.length === 0) return;
const rows = assocIds.map((aid: string) => ({
user_id: userId,
association_id: aid,
created_by: callerId,
}));
const { error: insertError } = await adminClient
.from("master_board_assignments")
.upsert(rows, { onConflict: "user_id,association_id" });
throwIfDbError(insertError, "Could not save master board assignments");
}
async function listAllAuthUsers(adminClient: any) {
let allAuthUsers: any[] = [];
let page = 1;
while (true) {
const { data, error } = await adminClient.auth.admin.listUsers({ page, perPage: 1000 });
if (error) throw error;
allAuthUsers = allAuthUsers.concat(data.users || []);
if ((data.users || []).length < 1000) break;
page++;
}
return allAuthUsers;
}
async function buildManagedUsers(adminClient: any) {
const [authUsers, profilesRes, rolesRes, ownersRes, boardMembersRes, legalAssignmentsRes, masterBoardAssignmentsRes] = await Promise.all([
listAllAuthUsers(adminClient),
adminClient.from("profiles").select("*"),
adminClient.from("user_roles").select("*"),
adminClient.from("owners").select("id, first_name, last_name, user_id, unit_id, association_id").eq("status", "active").not("user_id", "is", null),
adminClient.from("board_members").select("user_id, association_id, approval_authority").not("user_id", "is", null),
adminClient.from("legal_association_assignments").select("user_id, association_id"),
adminClient.from("master_board_assignments").select("user_id, association_id"),
]);
throwIfDbError(profilesRes.error, "Could not load profiles");
throwIfDbError(rolesRes.error, "Could not load user roles");
throwIfDbError(ownersRes.error, "Could not load owner links");
throwIfDbError(boardMembersRes.error, "Could not load board members");
throwIfDbError(legalAssignmentsRes.error, "Could not load legal assignments");
throwIfDbError(masterBoardAssignmentsRes.error, "Could not load master board assignments");
const roleMap = new Map<string, string[]>();
(rolesRes.data || []).forEach((r: any) => {
if (!roleMap.has(r.user_id)) roleMap.set(r.user_id, []);
roleMap.get(r.user_id)!.push(r.role);
});
const ownerUnitMap = new Map<string, string[]>();
(ownersRes.data || []).forEach((o: any) => {
if (!o.user_id || !o.unit_id) return;
const existing = ownerUnitMap.get(o.user_id) || [];
if (!existing.includes(o.unit_id)) existing.push(o.unit_id);
ownerUnitMap.set(o.user_id, existing);
});
const boardMemberMap = new Map<string, { association_ids: string[]; approval_authority: boolean }>();
(boardMembersRes.data || []).forEach((bm: any) => {
if (!bm.user_id) return;
const existing = boardMemberMap.get(bm.user_id) || { association_ids: [], approval_authority: false };
if (!existing.association_ids.includes(bm.association_id)) existing.association_ids.push(bm.association_id);
if (bm.approval_authority) existing.approval_authority = true;
boardMemberMap.set(bm.user_id, existing);
});
const toAssignmentMap = (rows: any[] = []) => {
const map = new Map<string, string[]>();
rows.forEach((assignment: any) => {
const existing = map.get(assignment.user_id) || [];
if (!existing.includes(assignment.association_id)) existing.push(assignment.association_id);
map.set(assignment.user_id, existing);
});
return map;
};
const legalAssignmentMap = toAssignmentMap(legalAssignmentsRes.data || []);
const masterBoardAssignmentMap = toAssignmentMap(masterBoardAssignmentsRes.data || []);
const profileMap = new Map((profilesRes.data || []).map((p: any) => [p.user_id, p]));
const usersById = new Map<string, any>();
for (const authUser of authUsers) {
const profile: any = profileMap.get(authUser.id) || {};
usersById.set(authUser.id, {
user_id: authUser.id,
full_name: profile.full_name || authUser.user_metadata?.full_name || authUser.user_metadata?.name || null,
email: profile.email || authUser.email || "",
phone: profile.phone || authUser.phone || "",
is_blocked: profile.is_blocked || false,
created_at: profile.created_at || authUser.created_at,
roles: roleMap.get(authUser.id) || [],
linked_unit_ids: ownerUnitMap.get(authUser.id) || [],
board_member_association_ids: boardMemberMap.get(authUser.id)?.association_ids || [],
legal_association_ids: legalAssignmentMap.get(authUser.id) || [],
master_board_association_ids: masterBoardAssignmentMap.get(authUser.id) || [],
is_bill_approver: boardMemberMap.get(authUser.id)?.approval_authority || false,
last_sign_in_at: authUser.last_sign_in_at || null,
auth_email: authUser.email || "",
primary_owner_id: profile.primary_owner_id || null,
primary_association_id: profile.primary_association_id || null,
profile_missing: !profileMap.has(authUser.id),
});
}
for (const profile of profilesRes.data || []) {
if (usersById.has(profile.user_id)) continue;
usersById.set(profile.user_id, {
user_id: profile.user_id,
full_name: profile.full_name || null,
email: profile.email || "",
phone: profile.phone || "",
is_blocked: profile.is_blocked || false,
created_at: profile.created_at,
roles: roleMap.get(profile.user_id) || [],
linked_unit_ids: ownerUnitMap.get(profile.user_id) || [],
board_member_association_ids: boardMemberMap.get(profile.user_id)?.association_ids || [],
legal_association_ids: legalAssignmentMap.get(profile.user_id) || [],
master_board_association_ids: masterBoardAssignmentMap.get(profile.user_id) || [],
is_bill_approver: boardMemberMap.get(profile.user_id)?.approval_authority || false,
last_sign_in_at: null,
primary_owner_id: profile.primary_owner_id || null,
primary_association_id: profile.primary_association_id || null,
});
}
return Array.from(usersById.values()).sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || "")));
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
// Verify the caller
const authHeader = req.headers.get("authorization") || "";
if (!authHeader.startsWith("Bearer ")) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
// Client with caller's JWT to check role
const callerClient = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
});
const token = authHeader.replace("Bearer ", "");
const {
data: claimsData,
error: callerError,
} = await callerClient.auth.getClaims(token);
const callerId = claimsData?.claims?.sub;
if (callerError || !callerId) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// Check if caller has User Management permission.
// Admins/managers pass automatically — verified directly here so the function
// still works when has_feature_permission is missing or errors.
const adminClientForRoles = createClient(supabaseUrl, serviceRoleKey);
const { data: callerRolesData } = await adminClientForRoles
.from("user_roles")
.select("role")
.eq("user_id", callerId);
const callerRoles = (callerRolesData || []).map((r: any) => r.role);
const isAdminOrManager = callerRoles.includes("admin") || callerRoles.includes("manager");
let hasReadAccess: boolean = isAdminOrManager;
if (!isAdminOrManager) {
const { data: rpcData } = await callerClient.rpc("has_feature_permission", {
_user_id: callerId,
_feature_area: "User Management",
_action: "read",
});
hasReadAccess = !!rpcData;
}
if (!hasReadAccess) {
return new Response(JSON.stringify({ error: "User Management access required" }), {
status: 403,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// Helper to check specific action permissions
const checkPermission = async (action: string) => {
if (isAdminOrManager) return true;
const { data: allowed } = await callerClient.rpc("has_feature_permission", {
_user_id: callerId,
_feature_area: "User Management",
_action: action,
});
return !!allowed;
};
// Admin client with service role for user management
const adminClient = createClient(supabaseUrl, serviceRoleKey, {
auth: { autoRefreshToken: false, persistSession: false },
});
const body = await req.json();
const { action } = body;
const recoveryLinkOptions = getRecoveryLinkOptions(req);
const inviteLinkOptions = getInviteLinkOptions(req);
let result: any;
switch (action) {
case "list_users": {
const { data, error } = await adminClient.auth.admin.listUsers({
page: body.page || 1,
perPage: body.perPage || 100,
});
if (error) throw error;
result = data;
break;
}
case "list_managed_users": {
result = { users: await buildManagedUsers(adminClient) };
break;
}
case "create_user": {
if (!(await checkPermission("create"))) throw new Error("Create permission denied");
const { password, full_name, role, owner_id, unit_ids, association_id, association_ids, is_board_member, is_arc_member, is_fining_committee, is_bill_approver } = body;
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email || !password) throw new Error("Email and password required");
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error(`Invalid email format: "${email}"`);
}
// Normalize to array, supporting both association_id (legacy) and association_ids (new)
const assocIds: string[] = Array.isArray(association_ids) && association_ids.length > 0
? association_ids
: (association_id ? [association_id] : []);
// Create auth user
const { data: newUser, error: createError } =
await adminClient.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { full_name: full_name || "" },
});
if (createError) throw createError;
const userId = newUser.user.id;
// Ensure profile exists (trigger should create it, but update name/email)
await adminClient
.from("profiles")
.update({ full_name: full_name || "", email })
.eq("user_id", userId);
// Set primary role
if (role) {
await adminClient
.from("user_roles")
.delete()
.eq("user_id", userId);
await adminClient
.from("user_roles")
.insert({ user_id: userId, role });
}
// Add additional committee roles
const additionalRoles: string[] = [];
if (is_board_member && role !== "board_member") additionalRoles.push("board_member");
if (is_arc_member && role !== "arc_member") additionalRoles.push("arc_member");
if (is_fining_committee && role !== "fining_member") additionalRoles.push("fining_member");
if (additionalRoles.length > 0) {
await adminClient
.from("user_roles")
.insert(additionalRoles.map((r) => ({ user_id: userId, role: r })));
}
// Link to owner if provided, or auto-link by email/name match using first association
const primaryAssocId = assocIds[0] || null;
if (role !== "legal" && owner_id) {
await adminClient
.from("owners")
.update({ user_id: userId })
.eq("id", owner_id);
} else if (role !== "legal" && primaryAssocId && email) {
// Auto-link: find unlinked owner in same association by email match
const { data: emailMatch } = await adminClient
.from("owners")
.select("id")
.eq("association_id", primaryAssocId)
.eq("status", "active")
.is("user_id", null)
.eq("email", email)
.limit(1);
if (emailMatch && emailMatch.length > 0) {
await adminClient
.from("owners")
.update({ user_id: userId })
.eq("id", emailMatch[0].id);
} else if (full_name) {
// Try matching by name (first_name + last_name contains full_name)
const nameParts = (full_name as string).trim().split(/\s+/);
const firstName = nameParts[0] || "";
const lastName = nameParts.slice(1).join(" ") || "";
let query = adminClient
.from("owners")
.select("id")
.eq("association_id", primaryAssocId)
.eq("status", "active")
.is("user_id", null);
if (firstName) query = query.ilike("first_name", `%${firstName}%`);
if (lastName) query = query.ilike("last_name", `%${lastName}%`);
const { data: nameMatch } = await query.limit(1);
if (nameMatch && nameMatch.length > 0) {
await adminClient
.from("owners")
.update({ user_id: userId })
.eq("id", nameMatch[0].id);
}
}
}
// Association-only roles (legal, master_board_member): tag communities, no unit links.
if (role === "legal") {
await syncLegalAssociationAssignments(adminClient, userId, assocIds, callerId);
} else if (role === "master_board_member") {
await syncMasterBoardAssignments(adminClient, userId, assocIds, callerId);
} else if (Array.isArray(unit_ids) && unit_ids.length > 0) {
for (const uid of unit_ids) {
await adminClient
.from("owners")
.update({ user_id: userId })
.eq("unit_id", uid)
.is("user_id", null);
}
}
// Create board_members records for all assigned associations
if ((role === "board_member" || is_board_member) && assocIds.length > 0) {
const boardMemberRows = assocIds.map((aid: string) => ({
association_id: aid,
member_name: full_name || email,
member_email: email,
user_id: userId,
role: "Member",
approval_authority: is_bill_approver || false,
}));
await adminClient.from("board_members").insert(boardMemberRows);
}
// Create arc_committee_members records for all assigned associations when ARC member
if ((role === "arc_member" || is_arc_member) && assocIds.length > 0) {
// Remove any existing rows for this email (avoid duplicates from manual entries)
await adminClient.from("arc_committee_members").delete().ilike("email", email);
const arcRows = assocIds.map((aid: string) => ({
association_id: aid,
name: full_name || email,
email,
is_active: true,
}));
await adminClient.from("arc_committee_members").insert(arcRows);
}
result = { user: newUser.user };
break;
}
case "update_user": {
if (!(await checkPermission("edit"))) throw new Error("Edit permission denied");
const { user_id, email, full_name, role, owner_id, unit_ids, phone, association_id, association_ids, is_board_member, is_arc_member, is_fining_committee, is_bill_approver } = body;
if (!user_id) throw new Error("user_id required");
// Normalize to array
const assocIds: string[] = Array.isArray(association_ids) && association_ids.length > 0
? association_ids
: (association_id ? [association_id] : []);
// Update auth email if changed
if (email) {
const { error } = await adminClient.auth.admin.updateUserById(
user_id,
{ email, email_confirm: true }
);
if (error) throw error;
}
// Update profile
const profileUpdate: any = {};
if (full_name !== undefined) profileUpdate.full_name = full_name;
if (email !== undefined) profileUpdate.email = email;
if (phone !== undefined) profileUpdate.phone = phone;
if (Object.keys(profileUpdate).length > 0) {
await adminClient
.from("profiles")
.update(profileUpdate)
.eq("user_id", user_id);
}
// Update roles: set primary + additional committee roles
if (role) {
await adminClient
.from("user_roles")
.delete()
.eq("user_id", user_id);
const rolesToInsert: { user_id: string; role: string }[] = [
{ user_id, role },
];
if (is_board_member && role !== "board_member") rolesToInsert.push({ user_id, role: "board_member" });
if (is_arc_member && role !== "arc_member") rolesToInsert.push({ user_id, role: "arc_member" });
if (is_fining_committee && role !== "fining_member") rolesToInsert.push({ user_id, role: "fining_member" });
await adminClient
.from("user_roles")
.insert(rolesToInsert);
}
// Sync community assignments. Legal/master_board are association-only; otherwise link by unit_ids/owner_id.
if (role === "legal") {
await adminClient
.from("owners")
.update({ user_id: null })
.eq("user_id", user_id);
await syncMasterBoardAssignments(adminClient, user_id, [], callerId);
await syncLegalAssociationAssignments(adminClient, user_id, assocIds, callerId);
} else if (role === "master_board_member") {
await adminClient
.from("owners")
.update({ user_id: null })
.eq("user_id", user_id);
await syncLegalAssociationAssignments(adminClient, user_id, [], callerId);
await syncMasterBoardAssignments(adminClient, user_id, assocIds, callerId);
} else if (Array.isArray(unit_ids)) {
await syncLegalAssociationAssignments(adminClient, user_id, [], callerId);
await syncMasterBoardAssignments(adminClient, user_id, [], callerId);
// Unlink all existing owners for this user
await adminClient
.from("owners")
.update({ user_id: null })
.eq("user_id", user_id);
// Link owners for the specified units
for (const uid of unit_ids) {
await adminClient
.from("owners")
.update({ user_id: user_id })
.eq("unit_id", uid);
}
} else if (owner_id !== undefined) {
await adminClient
.from("owners")
.update({ user_id: null })
.eq("user_id", user_id);
if (owner_id) {
await adminClient
.from("owners")
.update({ user_id: user_id })
.eq("id", owner_id);
}
}
// Sync board_members records for all associations
// Remove all existing board_members for this user
await adminClient
.from("board_members")
.delete()
.eq("user_id", user_id);
// Re-create for each assigned association if board_member role or flag
if ((role === "board_member" || is_board_member) && assocIds.length > 0) {
const boardMemberRows = assocIds.map((aid: string) => ({
association_id: aid,
member_name: full_name || email || "",
member_email: email || "",
user_id,
role: "Member",
approval_authority: is_bill_approver || false,
}));
await adminClient.from("board_members").insert(boardMemberRows);
}
// Sync arc_committee_members. Always clear existing rows for this user's email,
// then re-create when the user is an ARC member for each assigned association.
if (email) {
await adminClient.from("arc_committee_members").delete().ilike("email", email);
}
if ((role === "arc_member" || is_arc_member) && assocIds.length > 0 && email) {
const arcRows = assocIds.map((aid: string) => ({
association_id: aid,
name: full_name || email,
email,
is_active: true,
}));
await adminClient.from("arc_committee_members").insert(arcRows);
}
result = { success: true };
break;
}
case "delete_user": {
if (!(await checkPermission("delete"))) throw new Error("Delete permission denied");
const { user_id } = body;
if (!user_id) throw new Error("user_id required");
// Unlink from owners and board_members before deleting auth user
await adminClient
.from("owners")
.update({ user_id: null })
.eq("user_id", user_id);
await adminClient
.from("board_members")
.update({ user_id: null })
.eq("user_id", user_id);
await adminClient
.from("legal_association_assignments")
.delete()
.eq("user_id", user_id);
await adminClient
.from("master_board_assignments")
.delete()
.eq("user_id", user_id);
// Delete auth user (cascades to profiles and user_roles)
const { error } = await adminClient.auth.admin.deleteUser(user_id);
if (error) throw error;
result = { success: true };
break;
}
case "bulk_delete_users": {
if (!(await checkPermission("delete"))) throw new Error("Delete permission denied");
const { user_ids } = body;
if (!Array.isArray(user_ids) || user_ids.length === 0) throw new Error("user_ids array required");
const results: { deleted: number; errors: string[] } = { deleted: 0, errors: [] };
for (const uid of user_ids) {
try {
// Unlink from owners and board_members
await adminClient
.from("owners")
.update({ user_id: null })
.eq("user_id", uid);
await adminClient
.from("board_members")
.update({ user_id: null })
.eq("user_id", uid);
await adminClient
.from("legal_association_assignments")
.delete()
.eq("user_id", uid);
await adminClient
.from("master_board_assignments")
.delete()
.eq("user_id", uid);
// Delete auth user (cascades to profiles and user_roles)
const { error: delErr } = await adminClient.auth.admin.deleteUser(uid);
if (delErr) throw delErr;
results.deleted++;
} catch (e: any) {
results.errors.push(`${uid}: ${e.message}`);
}
}
result = results;
break;
}
case "bulk_update_users": {
if (!(await checkPermission("edit"))) throw new Error("Edit permission denied");
const { user_ids, role, association_id, blocked } = body;
if (!Array.isArray(user_ids) || user_ids.length === 0) throw new Error("user_ids array required");
const results: { updated: number; errors: string[] } = { updated: 0, errors: [] };
for (const uid of user_ids) {
try {
// Update role if provided
if (role) {
await adminClient.from("user_roles").delete().eq("user_id", uid);
await adminClient.from("user_roles").insert({ user_id: uid, role });
}
// Update association link if provided.
if (association_id !== undefined) {
await adminClient.from("board_members").delete().eq("user_id", uid);
await adminClient.from("legal_association_assignments").delete().eq("user_id", uid);
await adminClient.from("master_board_assignments").delete().eq("user_id", uid);
if (association_id) {
// Get user's profile for name/email
const { data: profile } = await adminClient
.from("profiles")
.select("full_name, email")
.eq("user_id", uid)
.single();
if (role === "legal") {
await adminClient.from("legal_association_assignments").insert({ user_id: uid, association_id, created_by: callerId });
} else if (role === "master_board_member") {
await adminClient.from("master_board_assignments").insert({ user_id: uid, association_id, created_by: callerId });
} else {
await adminClient.from("board_members").insert({
association_id,
member_name: profile?.full_name || profile?.email || "",
member_email: profile?.email || "",
user_id: uid,
role: "Member",
});
}
}
}
// Update blocked status if provided
if (blocked !== undefined) {
await adminClient
.from("profiles")
.update({ is_blocked: !!blocked })
.eq("user_id", uid);
await adminClient.auth.admin.updateUserById(uid, {
ban_duration: blocked ? "876600h" : "none",
});
}
results.updated++;
} catch (e: any) {
results.errors.push(`${uid}: ${e.message}`);
}
}
result = results;
break;
}
case "sign_in_as_user": {
// Only admins can impersonate
const { data: isAdmin } = await callerClient.rpc("has_role", {
_user_id: callerId,
_role: "admin",
});
if (!isAdmin) throw new Error("Only admins can sign in as another user");
const { user_id } = body;
if (!user_id) throw new Error("user_id required");
// Generate a magic link for the target user
const { data: userData, error: userError } = await adminClient.auth.admin.getUserById(user_id);
if (userError || !userData?.user?.email) throw new Error("Could not find user email");
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({
type: "magiclink",
email: userData.user.email,
});
if (linkError) throw linkError;
// Return the token hash and verification type so the client can exchange it
result = {
success: true,
token_hash: linkData?.properties?.hashed_token,
email: userData.user.email,
};
break;
}
case "reset_password": {
const { user_id, new_password } = body;
if (!user_id || !new_password)
throw new Error("user_id and new_password required");
const { error } = await adminClient.auth.admin.updateUserById(
user_id,
{ password: new_password }
);
if (error) throw error;
result = { success: true };
break;
}
case "block_user": {
const { user_id, blocked } = body;
if (!user_id) throw new Error("user_id required");
// Update profile blocked status
await adminClient
.from("profiles")
.update({ is_blocked: !!blocked })
.eq("user_id", user_id);
// Ban/unban in auth
const { error } = await adminClient.auth.admin.updateUserById(
user_id,
{ ban_duration: blocked ? "876600h" : "none" }
);
if (error) throw error;
result = { success: true, blocked: !!blocked };
break;
}
case "send_reset_email": {
const { email, custom_message } = body;
if (!email) throw new Error("email required");
const { data: linkData, error: linkError } =
await adminClient.auth.admin.generateLink({
type: "recovery",
email,
...(recoveryLinkOptions ? { options: recoveryLinkOptions } : {}),
});
if (linkError) throw linkError;
const resetLink = linkData?.properties?.action_link || "";
if (!resetLink) throw new Error("Could not generate reset link");
const sender = await getVerifiedSender(adminClient);
if (!sender?.id) throw new Error("No verified email sender is configured");
const htmlBody = buildActionEmailHtml({
title: "Password Reset",
intro: "Click the button below to reset your password:",
buttonLabel: "Reset Password",
actionLink: resetLink,
customMessage: custom_message,
});
await sendManagedEmail({
callerClient,
senderId: sender.id,
recipient: email,
subject: "Password Reset Request",
html: htmlBody,
});
result = { success: true, message: "Password reset link sent" };
break;
}
case "invite_user": {
if (!(await checkPermission("create"))) throw new Error("Create permission denied");
const { email } = body;
if (!email) throw new Error("email required");
// Try invite first; if user already exists, send a recovery link instead
const { data, error } = await adminClient.auth.admin.inviteUserByEmail(email, inviteLinkOptions);
if (error && error.message?.includes("already been registered")) {
// User exists — generate a recovery/welcome link and send via SMTP
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({
type: "recovery",
email,
...(recoveryLinkOptions ? { options: recoveryLinkOptions } : {}),
});
if (linkError) throw linkError;
const resetLink = linkData?.properties?.action_link || "";
if (!resetLink) throw new Error("Could not generate invitation link");
const sender = await getVerifiedSender(adminClient);
if (!sender?.id) throw new Error("No verified email sender is configured");
const htmlBody = buildActionEmailHtml({
title: "You've Been Invited",
intro: "You have been invited to access the system. Click the button below to set up your password and get started:",
buttonLabel: "Set Up Password",
actionLink: resetLink,
});
await sendManagedEmail({
callerClient,
senderId: sender.id,
recipient: email,
subject: "You've Been Invited",
html: htmlBody,
});
result = { success: true, message: `Invitation sent to ${email}` };
break;
}
if (error) throw error;
// New user — ensure profile + default role exist
if (data?.user) {
const { data: existingProfile } = await adminClient.from("profiles").select("user_id").eq("user_id", data.user.id).maybeSingle();
if (!existingProfile) {
await adminClient.from("profiles").insert({ user_id: data.user.id, full_name: "", email });
}
const { data: existingRole } = await adminClient.from("user_roles").select("id").eq("user_id", data.user.id).limit(1);
if (!existingRole?.length) {
await adminClient.from("user_roles").insert({ user_id: data.user.id, role: "homeowner" });
}
}
result = { success: true, message: `Invitation sent to ${email}` };
break;
}
case "bulk_send_reset_email": {
const { user_ids } = body;
if (!user_ids?.length) throw new Error("user_ids required");
const results: { sent: number; errors: string[] } = { sent: 0, errors: [] };
const sender = await getVerifiedSender(adminClient);
if (!sender?.id) throw new Error("No verified email sender is configured");
for (const uid of user_ids) {
try {
// Get user email
const { data: userData, error: userErr } = await adminClient.auth.admin.getUserById(uid);
if (userErr || !userData?.user?.email) {
results.errors.push(`${uid}: user not found`);
continue;
}
const { data: linkData, error } = await adminClient.auth.admin.generateLink({
type: "recovery",
email: userData.user.email,
...(recoveryLinkOptions ? { options: recoveryLinkOptions } : {}),
});
if (error) throw error;
const resetLink = linkData?.properties?.action_link || "";
if (!resetLink) throw new Error("Could not generate reset link");
const htmlBody = buildActionEmailHtml({
title: "Password Reset",
intro: "Click the button below to reset your password:",
buttonLabel: "Reset Password",
actionLink: resetLink,
});
await sendManagedEmail({
callerClient,
senderId: sender.id,
recipient: userData.user.email,
subject: "Password Reset Request",
html: htmlBody,
});
results.sent++;
} catch (e: any) {
results.errors.push(`${uid}: ${e.message}`);
}
}
result = results;
break;
}
case "bulk_invite_users": {
if (!(await checkPermission("create"))) throw new Error("Create permission denied");
const { emails } = body;
if (!emails?.length) throw new Error("emails array required");
const results: { sent: number; errors: string[] } = { sent: 0, errors: [] };
const uniqueEmails: string[] = Array.from(new Set(
(emails as unknown[]).map((email) => String(email || "").trim().toLowerCase()).filter(Boolean)
)) as string[];
for (const email of uniqueEmails) {
try {
const { data, error } = await adminClient.auth.admin.inviteUserByEmail(email, inviteLinkOptions);
if (error && error.message?.includes("already been registered")) {
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({
type: "recovery",
email,
...(recoveryLinkOptions ? { options: recoveryLinkOptions } : {}),
});
if (linkError) throw linkError;
const resetLink = linkData?.properties?.action_link || "";
if (!resetLink) throw new Error("Could not generate invitation link");
const sender = await getVerifiedSender(adminClient);
if (!sender?.id) throw new Error("No verified email sender is configured");
await sendManagedEmail({
callerClient,
senderId: sender.id,
recipient: email,
subject: "You've Been Invited",
html: buildActionEmailHtml({
title: "You've Been Invited",
intro: "You have been invited to access the system. Click the button below to set up your password and get started:",
buttonLabel: "Set Up Password",
actionLink: resetLink,
}),
});
results.sent++;
continue;
}
if (error) throw error;
if (data?.user) {
const { data: existingProfile } = await adminClient.from("profiles").select("user_id").eq("user_id", data.user.id).maybeSingle();
if (!existingProfile) {
await adminClient.from("profiles").insert({ user_id: data.user.id, full_name: "", email });
}
const { data: existingRole } = await adminClient.from("user_roles").select("id").eq("user_id", data.user.id).limit(1);
if (!existingRole?.length) {
await adminClient.from("user_roles").insert({ user_id: data.user.id, role: "homeowner" });
}
}
results.sent++;
} catch (e: any) {
results.errors.push(`${email}: ${e.message}`);
}
}
result = results;
break;
}
case "bulk_onboard_owners": {
if (!(await checkPermission("create"))) throw new Error("Create permission denied");
const { owner_ids } = body;
if (!owner_ids?.length) throw new Error("owner_ids required");
const { data: owners, error: ownersError } = await adminClient
.from("owners")
.select("id, first_name, last_name, email, user_id")
.in("id", owner_ids);
if (ownersError) throw ownersError;
const results: { sent: number; linked: number; skipped: number; errors: string[] } = { sent: 0, linked: 0, skipped: 0, errors: [] };
const sender = await getVerifiedSender(adminClient).catch(() => null);
for (const owner of owners || []) {
const email = String(owner.email || "").trim();
const fullName = `${owner.first_name || ""} ${owner.last_name || ""}`.trim();
if (!email) {
results.skipped++;
results.errors.push(`${fullName || owner.id}: missing email`);
continue;
}
try {
let userId = owner.user_id;
let shouldSendSetupEmail = Boolean(userId);
if (userId) {
const { data: linkedUser, error: linkedUserError } = await adminClient.auth.admin.getUserById(userId);
const linkedEmail = String(linkedUser?.user?.email || "").trim().toLowerCase();
if (linkedUserError || !linkedUser?.user || linkedEmail !== email.toLowerCase()) {
userId = null;
shouldSendSetupEmail = false;
}
}
if (!userId) {
const { data, error } = await adminClient.auth.admin.inviteUserByEmail(email, inviteLinkOptions);
if (error && error.message?.includes("already been registered")) {
const { data: usersData } = await adminClient.auth.admin.listUsers({ page: 1, perPage: 1000 });
userId = usersData.users.find((u: any) => String(u.email || "").toLowerCase() === email.toLowerCase())?.id || null;
shouldSendSetupEmail = Boolean(userId);
} else if (error) {
throw error;
} else {
userId = data?.user?.id || null;
results.sent++;
}
}
if (!userId) throw new Error("Could not find or create user");
await adminClient.from("owners").update({ user_id: userId }).eq("id", owner.id);
results.linked++;
const { data: existingProfile } = await adminClient.from("profiles").select("user_id").eq("user_id", userId).maybeSingle();
if (!existingProfile) await adminClient.from("profiles").insert({ user_id: userId, full_name: fullName, email });
else await adminClient.from("profiles").update({ full_name: fullName, email }).eq("user_id", userId);
const { data: existingRole } = await adminClient.from("user_roles").select("id").eq("user_id", userId).eq("role", "homeowner").limit(1);
if (!existingRole?.length) await adminClient.from("user_roles").insert({ user_id: userId, role: "homeowner" });
if (shouldSendSetupEmail) {
if (!sender?.id) throw new Error("No verified email sender is configured");
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({ type: "recovery", email, ...(recoveryLinkOptions ? { options: recoveryLinkOptions } : {}) });
if (linkError) throw linkError;
const resetLink = linkData?.properties?.action_link || "";
if (!resetLink) throw new Error("Could not generate setup link");
await sendManagedEmail({
callerClient,
senderId: sender.id,
recipient: email,
subject: "Set Up Your Homeowner Portal Password",
html: buildActionEmailHtml({ title: "Homeowner Portal Access", intro: "Click the button below to set or update your password for the homeowner portal:", buttonLabel: "Set Password", actionLink: resetLink }),
});
results.sent++;
}
} catch (e: any) {
results.errors.push(`${email}: ${e.message}`);
}
}
result = results;
break;
}
case "sync_auth_users": {
if (!(await checkPermission("create"))) throw new Error("Create permission denied");
const allAuthUsers = await listAllAuthUsers(adminClient);
// Get existing profile user_ids
const { data: existingProfiles } = await adminClient
.from("profiles")
.select("user_id");
const existingIds = new Set((existingProfiles || []).map((p: any) => p.user_id));
// Find auth users without profiles
const missing = allAuthUsers.filter((u: any) => !existingIds.has(u.id));
let synced = 0;
for (const u of missing) {
const fullName = u.user_metadata?.full_name || u.email?.split("@")[0] || "";
const { error: profileError } = await adminClient.from("profiles").insert({
user_id: u.id,
full_name: fullName,
email: u.email || "",
});
throwIfDbError(profileError, `Could not create profile for ${u.email || u.id}`);
// Create default role if none exists
const { data: existingRoles } = await adminClient
.from("user_roles")
.select("id")
.eq("user_id", u.id)
.limit(1);
if (!existingRoles?.length) {
const { error: roleError } = await adminClient.from("user_roles").insert({
user_id: u.id,
role: "homeowner",
});
throwIfDbError(roleError, `Could not create role for ${u.email || u.id}`);
}
synced++;
}
result = { success: true, synced, total_auth: allAuthUsers.length, already_existed: existingIds.size };
break;
}
default:
throw new Error(`Unknown action: ${action}`);
}
return new Response(JSON.stringify(result), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (error: any) {
console.error("Admin user management error:", error);
return new Response(
JSON.stringify({ error: error.message || "Internal server error" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
});