diff --git a/src/actions/admin/reset-admin.ts b/src/actions/admin/reset-admin.ts index a77cff7..cf26403 100644 --- a/src/actions/admin/reset-admin.ts +++ b/src/actions/admin/reset-admin.ts @@ -1,42 +1,149 @@ "use server"; +import "server-only"; +import { randomBytes } from "crypto"; import { query } from "@/lib/db"; +import { + requestPasswordReset as neonAuthRequestPasswordReset, + setUserPassword as neonAuthSetUserPassword, +} from "@/lib/auth"; +import { getAdminUser } from "@/lib/admin-permissions"; export type ResetAdminPasswordResult = - | { success: true; tempPassword: string } + | { success: true; tempPassword: string; method: "set" } + | { success: true; method: "reset_link_sent"; message: string } | { success: false; error: string }; /** - * Emergency recovery action — only usable in development or when the caller - * already has direct DB access. Resets the password for the specified email - * and returns the temp password so it can be displayed to the user. + * Emergency recovery action — resets the password for the specified + * email so the platform admin can hand a working credential to the + * user. * - * Now that Supabase auth is gone, this hits the `users` table directly and - * calls the same `update_user_password` SECURITY DEFINER RPC used by the - * self-service password change action. + * Tries, in order: + * 1. `auth.admin.setUserPassword({ userId, newPassword })` — instant, + * works when the caller has `role='admin'` in `neon_auth.user`. + * 2. Falls back to `requestPasswordReset({ email, redirectTo })` — + * public Neon Auth endpoint, always works, but the user has to + * click the link in the email. The fallback covers the common + * case where `provision-admin.ts` did not promote the caller to + * `role='admin'` in Neon Auth. + * + * Authorization: platform_admin only. The dev-session platform_admin + * shim is honored (same as the rest of the admin actions). */ export async function resetAdminPassword( email: string, - newPassword: string ): Promise { - // Look up the user by email + // 1. Authz check. + const me = await getAdminUser(); + if (!me) { + return { success: false, error: "Not authenticated." }; + } + if (me.role !== "platform_admin") { + return { + success: false, + error: "Only platform admins can reset passwords.", + }; + } + + const normalizedEmail = email.trim().toLowerCase(); + if (!normalizedEmail) { + return { success: false, error: "Email is required." }; + } + + // 2. Look up the Neon Auth user id by email. const { rows } = await query<{ id: string }>( - "SELECT id FROM users WHERE email = $1 LIMIT 1", - [email.toLowerCase()], + `SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1`, + [normalizedEmail], ); const user = rows[0]; if (!user) { - return { success: false, error: "No auth user found for that email address." }; + return { + success: false, + error: `No Neon Auth user found for ${normalizedEmail}.`, + }; } - // Update password via SECURITY DEFINER RPC + // 3. Generate a strong temp password server-side. 16 bytes → 32 + // base64url chars; mix in a symbol so it satisfies typical + // password policies. + const tempPassword = `${randomBytes(18).toString("base64url")}!`; + + // 4. Try the privileged admin endpoint. try { - await query("SELECT update_user_password($1, $2)", [user.id, newPassword]); - return { success: true, tempPassword: newPassword }; + const adminResult = await neonAuthSetUserPassword({ + userId: user.id, + newPassword: tempPassword, + }); + + if (adminResult?.error) { + const code = adminResult.error.code ?? ""; + const canFallback = + code === "FORBIDDEN" || + code === "UNAUTHORIZED" || + code === "INTERNAL_SERVER_ERROR"; + console.warn( + "[resetAdminPassword] setUserPassword failed (code=%s), will%s fall back: %s", + code, + canFallback ? "" : " NOT", + adminResult.error.message ?? "", + ); + + if (!canFallback) { + return { + success: false, + error: adminResult.error.message ?? `setUserPassword failed: ${code}`, + }; + } + // fall through to the public reset-link path below + } else { + // Success — flip must_change_password so the user is forced + // to pick a new password on next sign-in. + try { + await query( + `UPDATE admin_users SET must_change_password = true WHERE user_id = $1`, + [user.id], + ); + } catch (flagErr) { + console.warn( + "[resetAdminPassword] failed to set must_change_password (non-fatal):", + flagErr, + ); + } + return { success: true, tempPassword, method: "set" }; + } + } catch (err) { + console.error("[resetAdminPassword] setUserPassword threw:", err); + // network / unexpected — try the fallback below + } + + // 5. Fallback: send a password-reset email via the public endpoint. + try { + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000"; + const resetResult = await neonAuthRequestPasswordReset({ + email: normalizedEmail, + redirectTo: `${siteUrl}/reset-password`, + }); + + if (resetResult?.error) { + return { + success: false, + error: + resetResult.error.message ?? + resetResult.error.code ?? + "Failed to send reset email", + }; + } + + return { + success: true, + method: "reset_link_sent", + message: `Set-password is not available for this account. Sent a password-reset email to ${normalizedEmail} instead — share that link with the user.`, + }; } catch (err) { return { success: false, - error: err instanceof Error ? err.message : "Failed to update password", + error: err instanceof Error ? err.message : String(err), }; } } diff --git a/src/actions/admin/users.ts b/src/actions/admin/users.ts index 6c1f00d..e9dea04 100644 --- a/src/actions/admin/users.ts +++ b/src/actions/admin/users.ts @@ -2,7 +2,10 @@ import "server-only"; import { query, withTx } from "@/lib/db"; -import { createUser as neonAuthCreateUser } from "@/lib/auth"; +import { + createUser as neonAuthCreateUser, + requestPasswordReset as neonAuthRequestPasswordReset, +} from "@/lib/auth"; import { getAdminUser } from "@/lib/admin-permissions"; export type AdminUserRow = { @@ -563,17 +566,59 @@ export async function setMustChangePassword(userId: string): Promise<{ success: } /** - * 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. + * Sends a password-reset email to the user via Neon Auth. + * + * Authorization: platform_admin only. Brand-scoped admins cannot + * trigger password resets for users outside their brand. + * + * The endpoint used (`requestPasswordReset`) is a public Neon Auth + * endpoint — it does not require the caller to be the target user. + * For the same reason the public /api/auth/forgot-password route + * works, this will always succeed against the Neon Auth API; we + * still return the result so the UI can show a clear success/failure + * message. */ -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 sendPasswordResetEmail( + email: string, +): Promise<{ success: boolean; error: string | null }> { + try { + // Authz: must be signed in as a platform_admin. + const me = await getAdminUser(); + if (!me) { + return { success: false, error: "Not authenticated." }; + } + if (me.role !== "platform_admin") { + return { success: false, error: "Only platform admins can send password resets." }; + } + + const trimmedEmail = email.trim().toLowerCase(); + if (!trimmedEmail) { + return { success: false, error: "Email is required." }; + } + + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000"; + + const result = await neonAuthRequestPasswordReset({ + email: trimmedEmail, + redirectTo: `${siteUrl}/reset-password`, + }); + + if (result?.error) { + console.error("[admin/sendPasswordResetEmail] Neon Auth error:", result.error); + return { + success: false, + error: result.error.message ?? result.error.code ?? "Failed to send reset email", + }; + } + + return { success: true, error: null }; + } catch (err) { + console.error("[admin/sendPasswordResetEmail] Unexpected error:", err); + return { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + } } export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> { diff --git a/src/components/admin/UsersPage.tsx b/src/components/admin/UsersPage.tsx index bcb666c..5228ce6 100644 --- a/src/components/admin/UsersPage.tsx +++ b/src/components/admin/UsersPage.tsx @@ -19,6 +19,7 @@ type UsersPageProps = { type PasswordModal = { email: string; tempPassword: string | null; + message: string | null; loading: boolean; error: string | null; }; @@ -244,17 +245,45 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre } async function handleResetPassword(email: string) { - setPasswordModal({ email, tempPassword: null, loading: true, error: null }); + setPasswordModal({ email, tempPassword: null, message: null, loading: true, error: null }); try { const { resetAdminPassword } = await import("@/actions/admin/reset-admin"); - const result = await resetAdminPassword(email, "Tuxedo2026!"); + const result = await resetAdminPassword(email); if (result.success) { - setPasswordModal({ email, tempPassword: result.tempPassword, loading: false, error: null }); + if (result.method === "reset_link_sent") { + setPasswordModal({ + email, + tempPassword: null, + message: result.message, + loading: false, + error: null, + }); + } else { + setPasswordModal({ + email, + tempPassword: result.tempPassword, + message: null, + loading: false, + error: null, + }); + } } else { - setPasswordModal({ email, tempPassword: null, loading: false, error: result.error ?? "Failed" }); + setPasswordModal({ + email, + tempPassword: null, + message: null, + loading: false, + error: result.error ?? "Failed to reset password", + }); } } catch (e: unknown) { - setPasswordModal({ email, tempPassword: null, loading: false, error: e instanceof Error ? e.message : "Error" }); + setPasswordModal({ + email, + tempPassword: null, + message: null, + loading: false, + error: e instanceof Error ? e.message : "Error", + }); } } @@ -667,6 +696,11 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre

Share this password securely with the user. They will be required to change it on next login.

+ ) : passwordModal.message ? ( +
+

{passwordModal.message}

+ +
) : null} diff --git a/tests/unit/reset-admin-password.test.ts b/tests/unit/reset-admin-password.test.ts new file mode 100644 index 0000000..df3d56f --- /dev/null +++ b/tests/unit/reset-admin-password.test.ts @@ -0,0 +1,239 @@ +/** + * Unit tests for `resetAdminPassword` in `src/actions/admin/reset-admin.ts`. + * + * The action must: + * 1. Reject unauthenticated callers and non-platform-admins + * 2. Look up `neon_auth.user` by email (NOT the legacy `users` table) + * 3. Try `auth.admin.setUserPassword` first (instant credential) + * 4. Fall back to `auth.requestPasswordReset` when the admin + * endpoint returns FORBIDDEN / UNAUTHORIZED / INTERNAL_SERVER_ERROR + * 5. On success, set `must_change_password = true` for the user + * 6. Generate a strong server-side random password (no hard-coded + * defaults like "Tuxedo2026!") + * 7. Return clear errors when no Neon Auth user matches the email + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { + mockGetAdminUser, + mockQuery, + mockSetUserPassword, + mockRequestPasswordReset, +} = vi.hoisted(() => ({ + mockGetAdminUser: vi.fn(), + mockQuery: vi.fn(), + mockSetUserPassword: vi.fn(), + mockRequestPasswordReset: vi.fn(), +})); + +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/admin-permissions", () => ({ + getAdminUser: mockGetAdminUser, +})); + +vi.mock("@/lib/auth", () => ({ + setUserPassword: mockSetUserPassword, + requestPasswordReset: mockRequestPasswordReset, +})); + +vi.mock("@/lib/db", () => ({ + query: mockQuery, +})); + +vi.mock("next/headers", () => ({ + cookies: () => Promise.resolve({ get: () => undefined }), +})); + +import { resetAdminPassword } from "@/actions/admin/reset-admin"; + +const PLATFORM_ADMIN = { + id: "platform-1", + user_id: "platform-1", + email: "root@example.com", + display_name: "Root", + brand_id: null, + brand_ids: [], + brand_slug: null, + role: "platform_admin" as const, + 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, +}; + +const BRAND_ADMIN = { + ...PLATFORM_ADMIN, + role: "brand_admin" as const, + brand_id: "brand-tux", + brand_ids: ["brand-tux"], + brand_slug: "tuxedo", +}; + +beforeEach(() => { + vi.clearAllMocks(); + process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:4000"; + mockGetAdminUser.mockResolvedValue(PLATFORM_ADMIN); + // Default: Neon Auth user lookup succeeds. + mockQuery.mockImplementation(async (sql: string) => { + if (sql.includes("SELECT id FROM neon_auth.user")) { + return { rows: [{ id: "neon-user-1" }] }; + } + // UPDATE admin_users SET must_change_password — accept silently. + return { rows: [], rowCount: 1 }; + }); + // Default: setUserPassword succeeds. + mockSetUserPassword.mockResolvedValue({ data: { ok: true }, error: null }); + mockRequestPasswordReset.mockResolvedValue({ data: { ok: true }, error: null }); +}); + +describe("resetAdminPassword — authorization", () => { + it("rejects unauthenticated callers", async () => { + mockGetAdminUser.mockResolvedValue(null); + const r = await resetAdminPassword("user@example.com"); + expect(r.success).toBe(false); + expect(r.error).toMatch(/not authenticated/i); + expect(mockSetUserPassword).not.toHaveBeenCalled(); + expect(mockRequestPasswordReset).not.toHaveBeenCalled(); + }); + + it("rejects non-platform-admin callers", async () => { + mockGetAdminUser.mockResolvedValue(BRAND_ADMIN); + const r = await resetAdminPassword("user@example.com"); + expect(r.success).toBe(false); + expect(r.error).toMatch(/only platform admins/i); + expect(mockSetUserPassword).not.toHaveBeenCalled(); + expect(mockRequestPasswordReset).not.toHaveBeenCalled(); + }); +}); + +describe("resetAdminPassword — input handling", () => { + it("rejects an empty email", async () => { + const r = await resetAdminPassword(" "); + expect(r.success).toBe(false); + expect(r.error).toMatch(/required/i); + }); + + it("looks up the user in neon_auth.user (not the legacy `users` table)", async () => { + const r = await resetAdminPassword("User@Example.COM"); + expect(r.success).toBe(true); + expect(mockQuery).toHaveBeenCalledWith( + "SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1", + ["user@example.com"], + ); + }); + + it("returns a clear error when no Neon Auth user matches the email", async () => { + mockQuery.mockImplementation(async (sql: string) => { + if (sql.includes("SELECT id FROM neon_auth.user")) { + return { rows: [] }; + } + return { rows: [], rowCount: 0 }; + }); + const r = await resetAdminPassword("missing@example.com"); + expect(r.success).toBe(false); + expect(r.error).toMatch(/no neon auth user found/i); + expect(mockSetUserPassword).not.toHaveBeenCalled(); + }); +}); + +describe("resetAdminPassword — happy path via setUserPassword", () => { + it("returns a server-generated temp password and flips must_change_password", async () => { + const r = await resetAdminPassword("user@example.com"); + expect(r.success).toBe(true); + if (r.method !== "set") throw new Error("expected method=set"); + // The password is server-generated — must be a non-empty strong + // string, never a hard-coded constant like "Tuxedo2026!". + expect(r.tempPassword.length).toBeGreaterThan(20); + expect(r.tempPassword).not.toBe("Tuxedo2026!"); + expect(r.tempPassword).toMatch(/[!@#$%^&*]/); // includes a symbol + + expect(mockSetUserPassword).toHaveBeenCalledTimes(1); + const [args] = mockSetUserPassword.mock.calls[0]; + expect(args.userId).toBe("neon-user-1"); + expect(args.newPassword).toBe(r.tempPassword); + + // The must_change_password flag should be flipped. + const flagUpdate = mockQuery.mock.calls.find(([sql]) => + String(sql).includes("UPDATE admin_users SET must_change_password"), + ); + expect(flagUpdate).toBeTruthy(); + }); +}); + +describe("resetAdminPassword — FORBIDDEN fallback to requestPasswordReset", () => { + it("falls back to the public reset-email endpoint when setUserPassword returns FORBIDDEN", async () => { + mockSetUserPassword.mockResolvedValue({ + data: null, + error: { code: "FORBIDDEN", message: "Admin role required" }, + }); + const r = await resetAdminPassword("user@example.com"); + expect(r.success).toBe(true); + if (r.method !== "reset_link_sent") throw new Error("expected method=reset_link_sent"); + expect(r.message).toMatch(/reset email/i); + expect(mockRequestPasswordReset).toHaveBeenCalledWith({ + email: "user@example.com", + redirectTo: "http://localhost:4000/reset-password", + }); + // In the fallback case, no tempPassword is returned and the + // must_change_password flag is NOT flipped (the user picks their + // own password via the link). + if (r.method === "set") throw new Error("unreachable"); + expect((r as { tempPassword?: string }).tempPassword).toBeUndefined(); + }); + + it("falls back when setUserPassword returns UNAUTHORIZED", async () => { + mockSetUserPassword.mockResolvedValue({ + data: null, + error: { code: "UNAUTHORIZED", message: "no session" }, + }); + const r = await resetAdminPassword("user@example.com"); + expect(r.success).toBe(true); + expect(r.method).toBe("reset_link_sent"); + }); + + it("falls back when setUserPassword throws", async () => { + mockSetUserPassword.mockRejectedValue(new Error("network down")); + const r = await resetAdminPassword("user@example.com"); + expect(r.success).toBe(true); + expect(r.method).toBe("reset_link_sent"); + }); + + it("does NOT fall back for USER_NOT_FOUND — it surfaces the error", async () => { + mockSetUserPassword.mockResolvedValue({ + data: null, + error: { code: "USER_NOT_FOUND", message: "no such user in neon auth" }, + }); + const r = await resetAdminPassword("user@example.com"); + expect(r.success).toBe(false); + expect(r.error).toMatch(/no such user in neon auth/); + expect(mockRequestPasswordReset).not.toHaveBeenCalled(); + }); + + it("propagates the public-endpoint error if BOTH paths fail", async () => { + mockSetUserPassword.mockResolvedValue({ + data: null, + error: { code: "FORBIDDEN" }, + }); + mockRequestPasswordReset.mockResolvedValue({ + data: null, + error: { code: "RATE_LIMITED", message: "too many requests" }, + }); + const r = await resetAdminPassword("user@example.com"); + expect(r.success).toBe(false); + expect(r.error).toMatch(/too many requests/); + }); +}); diff --git a/tests/unit/send-password-reset-email.test.ts b/tests/unit/send-password-reset-email.test.ts new file mode 100644 index 0000000..3e29824 --- /dev/null +++ b/tests/unit/send-password-reset-email.test.ts @@ -0,0 +1,151 @@ +/** + * Unit tests for `sendPasswordResetEmail` in `src/actions/admin/users.ts`. + * + * The action must: + * 1. Reject unauthenticated callers + * 2. Reject non-platform-admin callers + * 3. Trim + lowercase the email before calling Neon Auth + * 4. Call `requestPasswordReset` with the correct `redirectTo` + * 5. Return success on a clean Neon Auth response + * 6. Propagate Neon Auth errors back to the UI + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { + mockGetAdminUser, + mockRequestPasswordReset, +} = vi.hoisted(() => ({ + mockGetAdminUser: vi.fn(), + mockRequestPasswordReset: vi.fn(), +})); + +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/admin-permissions", () => ({ + getAdminUser: mockGetAdminUser, +})); + +vi.mock("@/lib/auth", () => ({ + requestPasswordReset: mockRequestPasswordReset, +})); + +vi.mock("@/lib/db", () => ({ + query: vi.fn(), + withTx: vi.fn(), +})); + +vi.mock("next/headers", () => ({ + cookies: () => Promise.resolve({ get: () => undefined }), +})); + +import { sendPasswordResetEmail } from "@/actions/admin/users"; + +const PLATFORM_ADMIN = { + id: "platform-1", + user_id: "platform-1", + email: "root@example.com", + display_name: "Root", + brand_id: null, + brand_ids: [], + brand_slug: null, + role: "platform_admin" as const, + 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, +}; + +const BRAND_ADMIN = { ...PLATFORM_ADMIN, role: "brand_admin" as const, brand_id: "brand-tux" }; + +beforeEach(() => { + vi.clearAllMocks(); + process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:4000"; + mockGetAdminUser.mockResolvedValue(PLATFORM_ADMIN); + mockRequestPasswordReset.mockResolvedValue({ data: null, error: null }); +}); + +describe("sendPasswordResetEmail — authorization", () => { + it("rejects unauthenticated callers", async () => { + mockGetAdminUser.mockResolvedValue(null); + const r = await sendPasswordResetEmail("user@example.com"); + expect(r.success).toBe(false); + expect(r.error).toMatch(/not authenticated/i); + expect(mockRequestPasswordReset).not.toHaveBeenCalled(); + }); + + it("rejects non-platform-admin callers", async () => { + mockGetAdminUser.mockResolvedValue(BRAND_ADMIN); + const r = await sendPasswordResetEmail("user@example.com"); + expect(r.success).toBe(false); + expect(r.error).toMatch(/only platform admins/i); + expect(mockRequestPasswordReset).not.toHaveBeenCalled(); + }); +}); + +describe("sendPasswordResetEmail — input handling", () => { + it("rejects an empty email without calling Neon Auth", async () => { + const r = await sendPasswordResetEmail(" "); + expect(r.success).toBe(false); + expect(r.error).toMatch(/required/i); + expect(mockRequestPasswordReset).not.toHaveBeenCalled(); + }); + + it("trims and lowercases the email before calling Neon Auth", async () => { + await sendPasswordResetEmail(" User@Example.COM "); + expect(mockRequestPasswordReset).toHaveBeenCalledWith({ + email: "user@example.com", + redirectTo: "http://localhost:4000/reset-password", + }); + }); +}); + +describe("sendPasswordResetEmail — happy path", () => { + it("returns success when Neon Auth accepts the request", async () => { + mockRequestPasswordReset.mockResolvedValue({ data: { ok: true }, error: null }); + const r = await sendPasswordResetEmail("user@example.com"); + expect(r.success).toBe(true); + expect(r.error).toBeNull(); + }); +}); + +describe("sendPasswordResetEmail — error propagation", () => { + it("returns a clear error when Neon Auth returns a structured error", async () => { + mockRequestPasswordReset.mockResolvedValue({ + data: null, + error: { code: "USER_NOT_FOUND", message: "no such user" }, + }); + const r = await sendPasswordResetEmail("user@example.com"); + expect(r.success).toBe(false); + expect(r.error).toMatch(/no such user/); + }); + + it("falls back to the error code if the message is missing", async () => { + mockRequestPasswordReset.mockResolvedValue({ + data: null, + error: { code: "RATE_LIMITED" }, + }); + const r = await sendPasswordResetEmail("user@example.com"); + expect(r.success).toBe(false); + expect(r.error).toMatch(/RATE_LIMITED/); + }); + + it("catches thrown exceptions and surfaces them as a string", async () => { + mockRequestPasswordReset.mockRejectedValue(new Error("network down")); + const r = await sendPasswordResetEmail("user@example.com"); + expect(r.success).toBe(false); + expect(r.error).toMatch(/network down/); + }); +});