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, ">") .replace(/\"/g, """) .replace(/'/g, "'"); } function buildActionEmailHtml({ title, intro, buttonLabel, actionLink, customMessage, }: { title: string; intro: string; buttonLabel: string; actionLink: string; customMessage?: string; }) { const safeActionLink = escapeHtml(actionLink); const customMsgHtml = customMessage ? `
${escapeHtml(customMessage).replace(/\n/g, "
")}
` : ""; return `

${escapeHtml(title)}

${customMsgHtml}

${escapeHtml(intro)}

${escapeHtml(buttonLabel)}

If the button does not open, copy and paste this secure link into your browser:

${safeActionLink}

If you did not request this, you can safely ignore this email.

`; } 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(); (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(); (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(); (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(); 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(); 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" }, } ); } });