"use server"; import "server-only"; import { query, withTx } from "@/lib/db"; import { createUser as neonAuthCreateUser } from "@/lib/auth"; import { getAdminUser } from "@/lib/admin-permissions"; export type AdminUserRow = { id: string; user_id: string | null; display_name: string | null; email: string; phone_number: string | null; role: "platform_admin" | "brand_admin" | "store_employee" | "staff"; brand_id: string | null; brand_name: string | null; can_manage_products: boolean; can_manage_stops: boolean; can_manage_orders: boolean; can_manage_pickup: boolean; can_manage_messages: boolean; can_manage_refunds: boolean; can_manage_users: boolean; can_manage_water_log: boolean; can_manage_reports: boolean; active: boolean; must_change_password: boolean; created_at: string; last_login: string | null; }; export type CreateAdminUserInput = { email: string; password: string; role: "platform_admin" | "brand_admin" | "store_employee"; brand_id: string | null; display_name?: string; phone_number?: string; flags: { can_manage_products?: boolean; can_manage_stops?: boolean; can_manage_orders?: boolean; can_manage_pickup?: boolean; can_manage_messages?: boolean; can_manage_refunds?: boolean; can_manage_users?: boolean; can_manage_water_log?: boolean; can_manage_reports?: boolean; }; mustChangePassword?: boolean; }; export type UpdateAdminUserInput = { id: string; role?: "platform_admin" | "brand_admin" | "store_employee"; brand_id?: string | null; flags?: Partial<{ can_manage_products: boolean; can_manage_stops: boolean; can_manage_orders: boolean; can_manage_pickup: boolean; can_manage_messages: boolean; can_manage_refunds: boolean; can_manage_users: boolean; can_manage_water_log: boolean; can_manage_reports: boolean; }>; active?: boolean; display_name?: string | null; phone_number?: string | null; }; // ─── Row mapping ──────────────────────────────────────────────────────────── // // `admin_users` schema (after migration 204 + 034 + 037): // id, user_id, display_name, email, phone_number, role, brand_id, // can_manage_ (BOOLEAN each), active, must_change_password, // created_at, last_login, raw_user_meta_data, auth_provider, auth_subject function mapUserRow(row: Record): AdminUserRow { return { id: String(row.id ?? ""), user_id: (row.user_id as string | null) ?? null, display_name: (row.display_name as string | null) ?? null, email: String(row.email ?? ""), phone_number: (row.phone_number as string | null) ?? null, role: (row.role as AdminUserRow["role"]) ?? "store_employee", brand_id: (row.brand_id as string | null) ?? null, brand_name: (row.brand_name as string | null) ?? null, can_manage_products: Boolean(row.can_manage_products ?? false), can_manage_stops: Boolean(row.can_manage_stops ?? false), can_manage_orders: Boolean(row.can_manage_orders ?? false), can_manage_pickup: Boolean(row.can_manage_pickup ?? false), can_manage_messages: Boolean(row.can_manage_messages ?? false), can_manage_refunds: Boolean(row.can_manage_refunds ?? false), can_manage_users: Boolean(row.can_manage_users ?? false), can_manage_water_log: Boolean(row.can_manage_water_log ?? false), can_manage_reports: Boolean(row.can_manage_reports ?? false), active: Boolean(row.active ?? true), must_change_password: Boolean(row.must_change_password ?? false), created_at: String(row.created_at ?? ""), last_login: (row.last_login as string | null) ?? null, }; } // ─── Welcome email (best-effort) ──────────────────────────────────────────── async function sendWelcomeEmailSafe(input: { to: string; name: string; role: "platform_admin" | "brand_admin" | "store_employee"; password: string; brandId?: string; }): Promise<{ sent: boolean; error?: string }> { try { const { sendWelcomeEmail } = await import("@/lib/email-service"); const { getBrandSettings } = await import("@/actions/brand-settings"); const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role; let logoUrl: string | null = null; let brandName = "Route Commerce"; if (input.brandId) { try { const settings = await getBrandSettings(input.brandId); if (settings.success && settings.settings) { logoUrl = settings.settings.logo_url ?? null; brandName = settings.settings.brand_name ?? brandName; } } catch (brandErr) { console.warn( "[createAdminUser] Failed to load brand settings for welcome email:", brandErr, ); } } const ok = await sendWelcomeEmail({ to: input.to, name: input.name, role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee", brandName, tempPassword: input.password, logoUrl: logoUrl ?? undefined, }); return ok ? { sent: true } : { sent: false, error: "sendWelcomeEmail returned false" }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error("[createAdminUser] Welcome email failed (non-fatal):", msg); return { sent: false, error: msg }; } } // ─── Public actions ───────────────────────────────────────────────────────── export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> { try { const sql = brandId ? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number, au.role, au.brand_id, b.name AS brand_name, au.can_manage_products, au.can_manage_stops, au.can_manage_orders, au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds, au.can_manage_users, au.can_manage_water_log, au.can_manage_reports, au.active, au.must_change_password, au.created_at, au.last_login FROM admin_users au LEFT JOIN brands b ON b.id = au.brand_id WHERE au.brand_id = $1 ORDER BY au.created_at DESC` : `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number, au.role, au.brand_id, b.name AS brand_name, au.can_manage_products, au.can_manage_stops, au.can_manage_orders, au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds, au.can_manage_users, au.can_manage_water_log, au.can_manage_reports, au.active, au.must_change_password, au.created_at, au.last_login FROM admin_users au LEFT JOIN brands b ON b.id = au.brand_id ORDER BY au.created_at DESC`; const { rows } = await query>(sql, brandId ? [brandId] : []); return { users: rows.map(mapUserRow), error: null }; } catch (err) { return { users: [], error: err instanceof Error ? err.message : String(err) }; } } export type CreateAdminUserResult = { user: AdminUserRow | null; error: string | null; /** * The temporary password that was set on the Neon Auth account. Only * populated on success. The UI should display this once to the caller * and offer a copy-to-clipboard affordance — the password is never * stored in plaintext and cannot be retrieved again. */ tempPassword?: string; /** * Whether the welcome email was dispatched. Best-effort — the user is * created even if email fails. False here means the caller should * share the password out-of-band. */ emailSent?: boolean; /** * Set when `emailSent` is false. Helps the UI show a useful warning. */ emailError?: string; /** * Which Neon Auth path was used to create the account. "admin" means * the caller's Neon Auth session has `role = 'admin'` and we used the * privileged `auth.admin.createUser` endpoint. "signup" means we had * to fall back to the public `/sign-up/email` endpoint (e.g. the * caller's Neon Auth user is not flagged as admin, or the admin * endpoint rejected the request). The choice is recorded for * auditability. */ authPath?: "admin" | "signup"; }; /** * Create a new admin user. Steps: * * 1. Verify the caller is a `platform_admin`. * 2. Create the Neon Auth account (with the provided password) and * link it to the local `admin_users` row via `user_id`. Tries the * privileged `auth.admin.createUser` first; falls back to the * public `/sign-up/email` + `emailVerified = true` pattern when * the caller's Neon Auth session is not an admin (which is the * common case in dev / when a brand admin's user was not * provisioned as Neon Auth admin). * 3. Insert `admin_user_brands` link if a brand was selected. * 4. Send a welcome email with the temporary password (best-effort). * * The temp password is returned to the caller so the UI can show it * once — this is the only opportunity to share it with the new user, * since Neon Auth stores a hash, not the plaintext. */ export async function createAdminUser( input: CreateAdminUserInput, ): Promise { // 1. Authorization: only platform admins can mint new admin users. const caller = await getAdminUser(); if (!caller) { return { user: null, error: "Not authenticated. Please sign in again." }; } if (caller.role !== "platform_admin") { return { user: null, error: "Only platform admins can create new admin users.", }; } const email = input.email.trim().toLowerCase(); const password = input.password; const displayName = input.display_name ?? email.split("@")[0]; const f = input.flags; let neonAuthUserId: string; let authPath: "admin" | "signup" = "admin"; try { // 2. Create the Neon Auth account. Try the privileged admin // endpoint first. const adminResult = await neonAuthCreateUser({ email, password, name: displayName, }); if (adminResult.data?.user?.id) { neonAuthUserId = String(adminResult.data.user.id); authPath = "admin"; } else if (adminResult.error) { // 2b. Fallback: public sign-up + flip emailVerified to true. The // admin endpoint requires the caller to have role='admin' in // Neon Auth, which is not always true (provision-admin.ts does // not set it). Falling back to sign-up is a safe, well-tested // path — see scripts/seed-admin.ts. const code = adminResult.error.code ?? ""; const isAuthRequired = code === "FAILED_TO_CREATE_USER" || code === "FORBIDDEN" || code === "UNAUTHORIZED" || code === "INTERNAL_SERVER_ERROR" || code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"; if (code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") { return { user: null, error: `A Neon Auth user with email ${email} already exists. Pick a different email or reset the existing user's password.`, }; } if (!isAuthRequired) { return { user: null, error: `Neon Auth createUser failed: ${adminResult.error.message ?? code ?? "unknown error"}`, }; } authPath = "signup"; const signupResult = await signupFallbackCreate(email, password, displayName); if (!signupResult.userId) { return { user: null, error: `Could not create Neon Auth user via fallback sign-up: ${signupResult.error}`, }; } neonAuthUserId = signupResult.userId; } else { return { user: null, error: "Neon Auth createUser returned neither data nor error.", }; } } catch (err) { return { user: null, error: `Neon Auth error: ${err instanceof Error ? err.message : String(err)}`, }; } // 3. Insert the local admin_users row + brand link in a transaction. // We do this AFTER creating the Neon Auth user so a Neon Auth // failure doesn't leave a dangling local row. let insertedRow: Record | null = null; try { const result = await withTx(async (client) => { const ins = await client.query>( `INSERT INTO admin_users (user_id, display_name, email, phone_number, role, brand_id, can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup, can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports, active, must_change_password, auth_provider, auth_subject) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'neon_auth',$17) ON CONFLICT (user_id) DO UPDATE SET display_name = EXCLUDED.display_name, email = EXCLUDED.email, phone_number = EXCLUDED.phone_number, role = EXCLUDED.role, brand_id = EXCLUDED.brand_id, can_manage_products = EXCLUDED.can_manage_products, can_manage_stops = EXCLUDED.can_manage_stops, can_manage_orders = EXCLUDED.can_manage_orders, can_manage_pickup = EXCLUDED.can_manage_pickup, can_manage_messages = EXCLUDED.can_manage_messages, can_manage_refunds = EXCLUDED.can_manage_refunds, can_manage_users = EXCLUDED.can_manage_users, can_manage_water_log= EXCLUDED.can_manage_water_log, can_manage_reports = EXCLUDED.can_manage_reports, active = EXCLUDED.active, must_change_password= EXCLUDED.must_change_password, auth_provider = EXCLUDED.auth_provider RETURNING id, user_id, display_name, email, phone_number, role, brand_id, can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup, can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports, active, must_change_password, created_at, last_login`, [ neonAuthUserId, displayName, email, input.phone_number ?? null, input.role, input.brand_id, f.can_manage_products ?? false, f.can_manage_stops ?? false, f.can_manage_orders ?? false, f.can_manage_pickup ?? false, f.can_manage_messages ?? false, f.can_manage_refunds ?? false, f.can_manage_users ?? false, f.can_manage_water_log ?? false, f.can_manage_reports ?? false, input.mustChangePassword ?? true, neonAuthUserId, ], ); if (!ins.rows[0]) throw new Error("admin_users insert returned no row"); if (input.brand_id) { await client.query( `INSERT INTO admin_user_brands (admin_user_id, brand_id) VALUES ($1, $2) ON CONFLICT (admin_user_id, brand_id) DO NOTHING`, [ins.rows[0].id, input.brand_id], ); } return ins.rows[0]; }); insertedRow = result; } catch (err) { // We created the Neon Auth account but failed to insert the local // row. Don't roll back the auth user — the operator can re-link // it via the admin UI / DB. Surface the error. console.error( "[createAdminUser] Neon Auth user created (id=" + neonAuthUserId + ") but admin_users insert failed:", err, ); return { user: null, error: `Neon Auth user was created but the local admin row failed: ${ err instanceof Error ? err.message : String(err) }. The Neon Auth account is orphaned — link it manually via the admin_users table.`, }; } // 4. Welcome email (best-effort). const emailResult = await sendWelcomeEmailSafe({ to: email, name: displayName, role: input.role, password, brandId: input.brand_id ?? undefined, }); return { user: mapUserRow(insertedRow), error: null, tempPassword: password, emailSent: emailResult.sent, emailError: emailResult.error, authPath, }; } /** * Public sign-up + emailVerified = true fallback. Used when the * caller's Neon Auth session is not flagged as admin, or the admin * endpoint rejected the request for any reason. */ async function signupFallbackCreate( email: string, password: string, name: string, ): Promise<{ userId: string | null; error: string | null }> { const baseUrl = process.env.NEON_AUTH_BASE_URL; if (!baseUrl) { return { userId: null, error: "NEON_AUTH_BASE_URL is not set; cannot fall back to public sign-up.", }; } try { const res = await fetch(`${baseUrl}/sign-up/email`, { method: "POST", headers: { "Content-Type": "application/json", "Origin": process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000", "x-neon-auth-proxy": "node", }, body: JSON.stringify({ email, password, name, callbackURL: "/admin", }), }); const data = (await res.json().catch(() => ({}))) as { user?: { id?: string }; code?: string; message?: string; }; if (!res.ok) { if (data.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") { // Look up the existing user id so we can still link. const existing = await query<{ id: string }>( `SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1`, [email], ); if (existing.rows[0]?.id) { return { userId: existing.rows[0].id, error: null }; } return { userId: null, error: "User already exists but could not be looked up." }; } return { userId: null, error: data.message ?? data.code ?? `Sign-up failed (HTTP ${res.status})`, }; } const userId = data.user?.id; if (!userId) { return { userId: null, error: "Sign-up response missing user.id" }; } // Flip emailVerified so the user can sign in without a verification step. // The platform admin already vetted this email — they're handing the // password to the user directly. try { await query(`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`, [userId]); } catch (verifyErr) { console.warn( "[createAdminUser] Failed to set emailVerified=true (non-fatal):", verifyErr, ); } return { userId, error: null }; } catch (err) { return { userId: null, error: err instanceof Error ? err.message : String(err), }; } } export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> { try { // Build a partial SET clause. Each `can_manage_*` column is set // individually — the input's `flags` partial is spread across them. const sets: string[] = []; const params: unknown[] = []; const push = (col: string, val: unknown) => { params.push(val); sets.push(`${col} = $${params.length}`); }; if (input.role !== undefined) push("role", input.role); if (input.brand_id !== undefined) push("brand_id", input.brand_id); if (input.active !== undefined) push("active", input.active); if (input.display_name !== undefined) push("display_name", input.display_name); if (input.phone_number !== undefined) push("phone_number", input.phone_number); if (input.flags) { for (const [key, val] of Object.entries(input.flags)) { if (val !== undefined) push(key, val); } } if (sets.length === 0) return { user: null, error: "Nothing to update" }; params.push(input.id); const sql = `UPDATE admin_users SET ${sets.join(", ")} WHERE id = $${params.length} RETURNING id, user_id, display_name, email, phone_number, role, brand_id, can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup, can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports, active, must_change_password, created_at, last_login`; const { rows } = await query>(sql, params); if (!rows[0]) return { user: null, error: "User not found" }; return { user: mapUserRow(rows[0]), error: null }; } catch (err) { return { user: null, error: err instanceof Error ? err.message : String(err) }; } } export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> { try { // No Supabase Auth — nothing to delete from the auth service. const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]); return { success: (rowCount ?? 0) > 0, error: null }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } } export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> { try { const { rowCount } = await query( `UPDATE admin_users SET must_change_password = true WHERE id = $1`, [userId], ); return { success: (rowCount ?? 0) > 0, error: null }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } } /** * No auth service anymore (no Supabase, no Auth.js password-reset * endpoint). A platform admin can reset access by deleting + * re-creating the user, or by toggling `must_change_password` via the * UI — the function is preserved as a no-op so call sites keep * compiling. */ export async function sendPasswordResetEmail(_email: string): Promise<{ success: boolean; error: string | null }> { return { success: false, error: "Password reset is handled by a platform admin. Contact them to reset your access.", }; } export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> { try { // Sort by name so the platform admin's brand picker stays stable. const { rows } = await query<{ id: string; name: string }>( `SELECT id, name FROM brands ORDER BY name`, ); return { brands: rows, error: null }; } catch (err) { return { brands: [], error: err instanceof Error ? err.message : String(err) }; } }