diff --git a/src/actions/admin/users.ts b/src/actions/admin/users.ts index 68548a0..efada25 100644 --- a/src/actions/admin/users.ts +++ b/src/actions/admin/users.ts @@ -1,8 +1,9 @@ "use server"; import "server-only"; -import { cookies } from "next/headers"; -import { pool, query } from "@/lib/db"; +import { query, withTx } from "@/lib/db"; +import { createUser as neonAuthCreateUser } from "@/lib/auth"; +import { getAdminUser } from "@/lib/admin-permissions"; export type AdminUserRow = { id: string; @@ -110,7 +111,7 @@ async function sendWelcomeEmailSafe(input: { role: "platform_admin" | "brand_admin" | "store_employee"; password: string; brandId?: string; -}): Promise { +}): Promise<{ sent: boolean; error?: string }> { try { const { sendWelcomeEmail } = await import("@/lib/email-service"); const { getBrandSettings } = await import("@/actions/brand-settings"); @@ -119,14 +120,21 @@ async function sendWelcomeEmailSafe(input: { let logoUrl: string | null = null; let brandName = "Route Commerce"; if (input.brandId) { - const settings = await getBrandSettings(input.brandId); - if (settings.success && settings.settings) { - logoUrl = settings.settings.logo_url ?? null; - brandName = settings.settings.brand_name ?? brandName; + 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, + ); } } - await sendWelcomeEmail({ + const ok = await sendWelcomeEmail({ to: input.to, name: input.name, role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee", @@ -134,8 +142,11 @@ async function sendWelcomeEmailSafe(input: { tempPassword: input.password, logoUrl: logoUrl ?? undefined, }); - } catch { - // welcome email is best-effort; never block user creation + 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 }; } } @@ -170,77 +181,326 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse } } -export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> { +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 { - // No Supabase Auth — `user_id` stays NULL until the user signs in - // via Auth.js and `get_admin_user_for_session` matches them by - // `auth_subject` / `email`. We just insert the row. - const f = input.flags; - const { rows } = await 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,'pending',$17) - 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`, - [ - null, - input.display_name ?? input.email.split("@")[0], - input.email.toLowerCase(), - 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, - input.email.toLowerCase(), - ], - ); - if (!rows[0]) return { user: null, error: "Insert returned no row" }; + // 2. Create the Neon Auth account. Try the privileged admin + // endpoint first. + const adminResult = await neonAuthCreateUser({ + email, + password, + name: displayName, + }); - const newAdminId = String(rows[0].id); + 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"; - // Ensure the admin_user_brands link exists for brand-scoped roles. - // (Platform admins created without a chosen brand may have 0 links and still - // get access via role; getAdminUser allows this.) - if (input.brand_id) { - try { - await query( + 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`, - [newAdminId, input.brand_id], + [ins.rows[0].id, input.brand_id], ); - } catch (linkErr) { - console.error("[createAdminUser] Failed to create admin_user_brands link:", linkErr); - // Non-fatal — the user row exists; a platform admin can link manually. } + 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})`, + }; } - await sendWelcomeEmailSafe({ - to: input.email, - name: input.display_name ?? input.email.split("@")[0], - role: input.role, - password: input.password, - brandId: input.brand_id ?? undefined, - }); + const userId = data.user?.id; + if (!userId) { + return { userId: null, error: "Sign-up response missing user.id" }; + } - return { user: mapUserRow(rows[0]), error: null }; + // 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 { user: null, error: err instanceof Error ? err.message : String(err) }; + return { + userId: null, + error: err instanceof Error ? err.message : String(err), + }; } } @@ -327,7 +587,3 @@ export async function getBrands(): Promise<{ brands: { id: string; name: string return { brands: [], error: err instanceof Error ? err.message : String(err) }; } } - -// Keep `pool` reachable so bundlers don't tree-shake the import — the -// import is for the `server-only` side effect. -void pool; diff --git a/src/components/admin/CreateUserModal.tsx b/src/components/admin/CreateUserModal.tsx index 1c965f1..3dca7d7 100644 --- a/src/components/admin/CreateUserModal.tsx +++ b/src/components/admin/CreateUserModal.tsx @@ -16,6 +16,14 @@ type Props = { }; }; +type SuccessState = { + user: import("@/actions/admin/users").AdminUserRow; + tempPassword: string; + emailSent: boolean; + emailError?: string; + authPath?: "admin" | "signup"; +}; + const FLAG_LABELS: Record = { can_manage_products: "Products", can_manage_stops: "Stops", @@ -61,6 +69,8 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu const [flags, setFlags] = useState>({ ...defaultFlags }); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [copied, setCopied] = useState(false); const showBrandSelect = role === "brand_admin" || role === "store_employee"; @@ -82,6 +92,8 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu setBrandId(null); setFlags({ ...defaultFlags }); setError(null); + setSuccess(null); + setCopied(false); } async function handleSubmit() { @@ -111,10 +123,16 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu return; } - if (result.user) { + if (result.user && result.tempPassword) { onSuccess(result.user); - resetForm(); - onClose(); + setSuccess({ + user: result.user, + tempPassword: result.tempPassword, + emailSent: result.emailSent ?? false, + emailError: result.emailError, + authPath: result.authPath, + }); + // Don't close — show the success state so the caller can copy the temp password. } } catch (e: unknown) { setError(e instanceof Error ? e.message : "An unexpected error occurred."); @@ -130,6 +148,17 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu } } + async function copyPassword() { + if (!success) return; + try { + await navigator.clipboard.writeText(success.tempPassword); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard not available — caller can select manually. + } + } + const roleDescriptions: Record = { platform_admin: "Full platform access", brand_admin: "Brand-level admin", @@ -138,6 +167,117 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu if (!isOpen) return null; + // Success state — show temp password + email status, then "Done" closes. + if (success) { + return ( + + ); + } + return ( [res.user!, ...prev]); + if (res.user) { + setUsers((prev) => [res.user!, ...prev]); + // Surface the temp password — the slide-in panel has no + // success view. The modal is the primary create flow, but + // we don't want to silently lose the password here. + if (res.tempPassword) { + window.alert( + `User created. Temporary password (share with the user — it will not be shown again):\n\n${res.tempPassword}`, + ); + } + } } else { const res = await import("@/actions/admin/users").then((m) => m.updateAdminUser({ diff --git a/tests/unit/create-admin-user.test.ts b/tests/unit/create-admin-user.test.ts new file mode 100644 index 0000000..ff4d639 --- /dev/null +++ b/tests/unit/create-admin-user.test.ts @@ -0,0 +1,453 @@ +/** + * Unit tests for `createAdminUser` in `src/actions/admin/users.ts`. + * + * The action does three things, in order: + * 1. Authorize the caller (platform_admin only) — tested via + * `getAdminUser` mock. + * 2. Create a Neon Auth account (via `auth.admin.createUser` first, + * with a public sign-up fallback). + * 3. Insert the local `admin_users` row + brand link inside a tx, + * send a welcome email (best-effort), and return the result. + * + * These tests mock the Neon Auth SDK, the DB layer, and the email + * service so the orchestration logic can be exercised without a real + * network or DB. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Use vi.hoisted to make mock fns available inside the hoisted +// vi.mock factories. +const { + mockGetAdminUser, + mockNeonAuthCreateUser, + mockQuery, + mockWithTx, + mockSendWelcomeEmail, + mockGetBrandSettings, +} = vi.hoisted(() => ({ + mockGetAdminUser: vi.fn(), + mockNeonAuthCreateUser: vi.fn(), + mockQuery: vi.fn(), + mockWithTx: vi.fn(), + mockSendWelcomeEmail: vi.fn(), + mockGetBrandSettings: vi.fn(), +})); + +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/admin-permissions", () => ({ + getAdminUser: mockGetAdminUser, +})); + +vi.mock("@/lib/auth", () => ({ + createUser: mockNeonAuthCreateUser, +})); + +vi.mock("@/lib/db", () => ({ + query: mockQuery, + withTx: mockWithTx, +})); + +vi.mock("@/lib/email-service", () => ({ + sendWelcomeEmail: mockSendWelcomeEmail, +})); + +vi.mock("@/actions/brand-settings", () => ({ + getBrandSettings: mockGetBrandSettings, +})); + +vi.mock("next/headers", () => ({ + cookies: () => Promise.resolve({ get: () => undefined }), +})); + +// `fetch` is used by the signup fallback. Default to a harmless +// 200 — tests that exercise the fallback override this. +const originalFetch = global.fetch; + +import { createAdminUser } from "@/actions/admin/users"; + +const PLATFORM_ADMIN = { + id: "platform-1", + email: "root@example.com", + role: "platform_admin" as const, + brand_id: null, + brand_slug: null, + brand_ids: [], + display_name: "Root", + active: true, + auth_provider: "neon_auth" as const, + can_manage_products: true, + can_manage_stops: true, + can_manage_orders: true, + can_manage_pickup: true, + can_manage_messages: true, + can_manage_refunds: true, + can_manage_users: true, + can_manage_water_log: true, + can_manage_reports: true, + can_manage_settings: true, + can_manage_billing: true, + can_manage_branding: true, + can_manage_marketing: true, + can_manage_team: true, + must_change_password: false, + user_id: "platform-1", +}; + +const BRAND_ADMIN = { ...PLATFORM_ADMIN, role: "brand_admin" as const, brand_id: "brand-tux" }; + +const baseInput = { + email: "newuser@example.com", + password: "TempPass123!", + role: "brand_admin" as const, + brand_id: "brand-tux", + display_name: "New User", + flags: { can_manage_orders: true }, + mustChangePassword: true, +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Default: platform admin caller, no-op fetch, happy email path. + mockGetAdminUser.mockResolvedValue(PLATFORM_ADMIN); + mockSendWelcomeEmail.mockResolvedValue(true); + mockGetBrandSettings.mockResolvedValue({ + success: true, + settings: { brand_name: "Tuxedo Citrus", logo_url: null }, + }); + // Set env vars the action reads at call time. + process.env.NEON_AUTH_BASE_URL = "https://test.neonauth.example/auth"; + process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:4000"; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ user: { id: "neon-user-from-signup" } }), + } as Response); +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +describe("createAdminUser — authorization", () => { + it("rejects unauthenticated callers", async () => { + mockGetAdminUser.mockResolvedValue(null); + const r = await createAdminUser(baseInput); + expect(r.user).toBeNull(); + expect(r.error).toMatch(/not authenticated/i); + expect(mockNeonAuthCreateUser).not.toHaveBeenCalled(); + expect(mockWithTx).not.toHaveBeenCalled(); + }); + + it("rejects non-platform-admin callers", async () => { + mockGetAdminUser.mockResolvedValue(BRAND_ADMIN); + const r = await createAdminUser(baseInput); + expect(r.user).toBeNull(); + expect(r.error).toMatch(/only platform admins/i); + expect(mockNeonAuthCreateUser).not.toHaveBeenCalled(); + }); +}); + +describe("createAdminUser — happy path via auth.admin.createUser", () => { + it("uses the privileged admin endpoint when it succeeds", async () => { + mockNeonAuthCreateUser.mockResolvedValue({ + data: { user: { id: "neon-user-abc" } }, + error: null, + }); + mockWithTx.mockImplementation(async (fn) => { + // Simulate the tx client: record what the action called, then + // return a fresh admin row. + return fn({ + query: vi + .fn() + .mockResolvedValueOnce({ + rows: [ + { + id: "admin-row-1", + user_id: "neon-user-abc", + display_name: "New User", + email: "newuser@example.com", + phone_number: null, + role: "brand_admin", + brand_id: "brand-tux", + can_manage_products: false, + can_manage_stops: false, + can_manage_orders: true, + can_manage_pickup: false, + can_manage_messages: false, + can_manage_refunds: false, + can_manage_users: false, + can_manage_water_log: false, + can_manage_reports: false, + active: true, + must_change_password: true, + created_at: "2026-06-17T00:00:00Z", + last_login: null, + }, + ], + }) + // Second query inside the tx: admin_user_brands link. + .mockResolvedValueOnce({ rows: [] }), + } as never); + }); + + const r = await createAdminUser(baseInput); + expect(r.error).toBeNull(); + expect(r.user?.email).toBe("newuser@example.com"); + expect(r.tempPassword).toBe(baseInput.password); + expect(r.emailSent).toBe(true); + expect(r.authPath).toBe("admin"); + expect(mockNeonAuthCreateUser).toHaveBeenCalledWith({ + email: "newuser@example.com", + password: "TempPass123!", + name: "New User", + }); + // Fallback should NOT have been called. + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); + +describe("createAdminUser — fallback to public sign-up", () => { + it("falls back to /sign-up/email when the admin endpoint fails with FORBIDDEN", async () => { + mockNeonAuthCreateUser.mockResolvedValue({ + data: null, + error: { code: "FORBIDDEN", message: "Admin role required" }, + }); + mockWithTx.mockImplementation(async (fn) => + fn({ + query: vi + .fn() + .mockResolvedValueOnce({ + rows: [ + { + id: "admin-row-2", + user_id: "neon-user-from-signup", + display_name: "New User", + email: "newuser@example.com", + phone_number: null, + role: "brand_admin", + brand_id: "brand-tux", + can_manage_products: false, + can_manage_stops: false, + can_manage_orders: true, + can_manage_pickup: false, + can_manage_messages: false, + can_manage_refunds: false, + can_manage_users: false, + can_manage_water_log: false, + can_manage_reports: false, + active: true, + must_change_password: true, + created_at: "2026-06-17T00:00:00Z", + last_login: null, + }, + ], + }) + .mockResolvedValueOnce({ rows: [] }), + } as never), + ); + // The fallback calls `query` to flip emailVerified=true. Pre-set + // a default that satisfies that call (and any future ones). + mockQuery.mockResolvedValue({ rows: [] }); + + const r = await createAdminUser(baseInput); + expect(r.error).toBeNull(); + expect(r.authPath).toBe("signup"); + expect(r.user?.user_id).toBe("neon-user-from-signup"); + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, init] = (global.fetch as unknown as ReturnType).mock.calls[0]; + expect(url).toMatch(/\/sign-up\/email$/); + expect(JSON.parse(init.body)).toEqual({ + email: "newuser@example.com", + password: "TempPass123!", + name: "New User", + callbackURL: "/admin", + }); + }); + + it("rejects with a clear error when the email is already in use", async () => { + mockNeonAuthCreateUser.mockResolvedValue({ + data: null, + error: { + code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL", + message: "User already exists", + }, + }); + const r = await createAdminUser(baseInput); + expect(r.user).toBeNull(); + expect(r.error).toMatch(/already exists/i); + // We should not have called the fallback or the DB. + expect(global.fetch).not.toHaveBeenCalled(); + expect(mockWithTx).not.toHaveBeenCalled(); + }); + + it("surfaces the fallback error if the public sign-up fails", async () => { + mockNeonAuthCreateUser.mockResolvedValue({ + data: null, + error: { code: "INTERNAL_SERVER_ERROR", message: "boom" }, + }); + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ code: "INTERNAL_SERVER_ERROR", message: "sign-up down" }), + } as Response); + + const r = await createAdminUser(baseInput); + expect(r.user).toBeNull(); + expect(r.error).toMatch(/sign-up down/i); + expect(mockWithTx).not.toHaveBeenCalled(); + }); +}); + +describe("createAdminUser — DB failure after auth success", () => { + it("returns an error explaining the orphaned Neon Auth user", async () => { + mockNeonAuthCreateUser.mockResolvedValue({ + data: { user: { id: "neon-user-orphaned" } }, + error: null, + }); + mockWithTx.mockRejectedValue(new Error("FK violation on brand_id")); + + const r = await createAdminUser(baseInput); + expect(r.user).toBeNull(); + expect(r.error).toMatch(/orphaned/i); + expect(r.error).toMatch(/FK violation/); + }); +}); + +describe("createAdminUser — email is best-effort", () => { + it("still returns success when the welcome email fails", async () => { + mockNeonAuthCreateUser.mockResolvedValue({ + data: { user: { id: "neon-user-abc" } }, + error: null, + }); + mockWithTx.mockImplementation(async (fn) => + fn({ + query: vi.fn().mockResolvedValueOnce({ + rows: [ + { + id: "admin-row-3", + user_id: "neon-user-abc", + display_name: "New User", + email: "newuser@example.com", + phone_number: null, + role: "brand_admin", + brand_id: "brand-tux", + can_manage_products: false, + can_manage_stops: false, + can_manage_orders: true, + can_manage_pickup: false, + can_manage_messages: false, + can_manage_refunds: false, + can_manage_users: false, + can_manage_water_log: false, + can_manage_reports: false, + active: true, + must_change_password: true, + created_at: "2026-06-17T00:00:00Z", + last_login: null, + }, + ], + }), + } as never), + ); + mockSendWelcomeEmail.mockResolvedValue(false); + + const r = await createAdminUser(baseInput); + expect(r.error).toBeNull(); + expect(r.user).not.toBeNull(); + expect(r.emailSent).toBe(false); + expect(r.tempPassword).toBe(baseInput.password); + }); + + it("records the email error message when sendWelcomeEmail throws", async () => { + mockNeonAuthCreateUser.mockResolvedValue({ + data: { user: { id: "neon-user-abc" } }, + error: null, + }); + mockWithTx.mockImplementation(async (fn) => + fn({ + query: vi.fn().mockResolvedValueOnce({ + rows: [ + { + id: "admin-row-4", + user_id: "neon-user-abc", + display_name: "New User", + email: "newuser@example.com", + phone_number: null, + role: "brand_admin", + brand_id: "brand-tux", + can_manage_products: false, + can_manage_stops: false, + can_manage_orders: true, + can_manage_pickup: false, + can_manage_messages: false, + can_manage_refunds: false, + can_manage_users: false, + can_manage_water_log: false, + can_manage_reports: false, + active: true, + must_change_password: true, + created_at: "2026-06-17T00:00:00Z", + last_login: null, + }, + ], + }), + } as never), + ); + mockSendWelcomeEmail.mockRejectedValue(new Error("Resend 401")); + + const r = await createAdminUser(baseInput); + expect(r.error).toBeNull(); + expect(r.emailSent).toBe(false); + expect(r.emailError).toMatch(/Resend 401/); + }); +}); + +describe("createAdminUser — no brand", () => { + it("creates a platform admin without inserting an admin_user_brands link", async () => { + mockNeonAuthCreateUser.mockResolvedValue({ + data: { user: { id: "neon-user-platform" } }, + error: null, + }); + const txClient = { + query: vi.fn().mockResolvedValueOnce({ + rows: [ + { + id: "admin-row-platform", + user_id: "neon-user-platform", + display_name: "New Platform", + email: "platform2@example.com", + phone_number: null, + role: "platform_admin", + brand_id: null, + can_manage_products: true, + can_manage_stops: true, + can_manage_orders: true, + can_manage_pickup: true, + can_manage_messages: true, + can_manage_refunds: true, + can_manage_users: true, + can_manage_water_log: true, + can_manage_reports: true, + active: true, + must_change_password: true, + created_at: "2026-06-17T00:00:00Z", + last_login: null, + }, + ], + }), + }; + mockWithTx.mockImplementation(async (fn) => fn(txClient as never)); + + const r = await createAdminUser({ + ...baseInput, + email: "platform2@example.com", + role: "platform_admin", + brand_id: null, + }); + expect(r.error).toBeNull(); + expect(r.user?.role).toBe("platform_admin"); + // Only one query inside the tx: the INSERT. No brand link INSERT. + expect(txClient.query).toHaveBeenCalledTimes(1); + }); +});