Create-user flow now provisions the Neon Auth account
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
Previously createAdminUser only inserted a local admin_users row and
emailed the password as plaintext — the user could not sign in because
the password was never set on a Neon Auth account.
This change makes the create flow:
1. Authorize: only platform_admin can mint new admin users.
2. Create the Neon Auth user via auth.admin.createUser, falling back
to the public /sign-up/email + emailVerified=true pattern when
the caller's Neon Auth session isn't an admin (the common case
in dev — provision-admin.ts does not set neon_auth.user.role).
3. Wrap the admin_users INSERT + admin_user_brands link in a
transaction, returning an error if the local insert fails (the
Neon Auth user is left orphaned and surfaced in the message).
4. Send the welcome email best-effort, returning success/failure
info to the UI.
The CreateUserModal now shows a success state with the temp password
(copy-to-clipboard), the welcome email status, and the auth path
used. The slide-in edit panel surfaces the password via window.alert
as a defense against the dead-code new-user path.
10 new unit tests cover authorization, the admin + signup paths, the
DB-failure orphan case, and the email best-effort behavior.
This commit is contained in:
+295
-39
@@ -1,8 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import "server-only";
|
import "server-only";
|
||||||
import { cookies } from "next/headers";
|
import { query, withTx } from "@/lib/db";
|
||||||
import { pool, query } from "@/lib/db";
|
import { createUser as neonAuthCreateUser } from "@/lib/auth";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
|
||||||
export type AdminUserRow = {
|
export type AdminUserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -110,7 +111,7 @@ async function sendWelcomeEmailSafe(input: {
|
|||||||
role: "platform_admin" | "brand_admin" | "store_employee";
|
role: "platform_admin" | "brand_admin" | "store_employee";
|
||||||
password: string;
|
password: string;
|
||||||
brandId?: string;
|
brandId?: string;
|
||||||
}): Promise<void> {
|
}): Promise<{ sent: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||||
const { getBrandSettings } = await import("@/actions/brand-settings");
|
const { getBrandSettings } = await import("@/actions/brand-settings");
|
||||||
@@ -119,14 +120,21 @@ async function sendWelcomeEmailSafe(input: {
|
|||||||
let logoUrl: string | null = null;
|
let logoUrl: string | null = null;
|
||||||
let brandName = "Route Commerce";
|
let brandName = "Route Commerce";
|
||||||
if (input.brandId) {
|
if (input.brandId) {
|
||||||
|
try {
|
||||||
const settings = await getBrandSettings(input.brandId);
|
const settings = await getBrandSettings(input.brandId);
|
||||||
if (settings.success && settings.settings) {
|
if (settings.success && settings.settings) {
|
||||||
logoUrl = settings.settings.logo_url ?? null;
|
logoUrl = settings.settings.logo_url ?? null;
|
||||||
brandName = settings.settings.brand_name ?? brandName;
|
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,
|
to: input.to,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||||
@@ -134,8 +142,11 @@ async function sendWelcomeEmailSafe(input: {
|
|||||||
tempPassword: input.password,
|
tempPassword: input.password,
|
||||||
logoUrl: logoUrl ?? undefined,
|
logoUrl: logoUrl ?? undefined,
|
||||||
});
|
});
|
||||||
} catch {
|
return ok ? { sent: true } : { sent: false, error: "sendWelcomeEmail returned false" };
|
||||||
// welcome email is best-effort; never block user creation
|
} 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,29 +181,181 @@ 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 = {
|
||||||
try {
|
user: AdminUserRow | null;
|
||||||
// No Supabase Auth — `user_id` stays NULL until the user signs in
|
error: string | null;
|
||||||
// via Auth.js and `get_admin_user_for_session` matches them by
|
/**
|
||||||
// `auth_subject` / `email`. We just insert the row.
|
* 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<CreateAdminUserResult> {
|
||||||
|
// 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;
|
const f = input.flags;
|
||||||
const { rows } = await query<Record<string, unknown>>(
|
|
||||||
|
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<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
const result = await withTx(async (client) => {
|
||||||
|
const ins = await client.query<Record<string, unknown>>(
|
||||||
`INSERT INTO admin_users
|
`INSERT INTO admin_users
|
||||||
(user_id, display_name, email, phone_number, role, brand_id,
|
(user_id, display_name, email, phone_number, role, brand_id,
|
||||||
can_manage_products, can_manage_stops, can_manage_orders,
|
can_manage_products, can_manage_stops, can_manage_orders,
|
||||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||||
can_manage_users, can_manage_water_log, can_manage_reports,
|
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||||
active, must_change_password, auth_provider, auth_subject)
|
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)
|
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,
|
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
|
||||||
can_manage_products, can_manage_stops, can_manage_orders,
|
can_manage_products, can_manage_stops, can_manage_orders,
|
||||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||||
can_manage_users, can_manage_water_log, can_manage_reports,
|
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||||
active, must_change_password, created_at, last_login`,
|
active, must_change_password, created_at, last_login`,
|
||||||
[
|
[
|
||||||
null,
|
neonAuthUserId,
|
||||||
input.display_name ?? input.email.split("@")[0],
|
displayName,
|
||||||
input.email.toLowerCase(),
|
email,
|
||||||
input.phone_number ?? null,
|
input.phone_number ?? null,
|
||||||
input.role,
|
input.role,
|
||||||
input.brand_id,
|
input.brand_id,
|
||||||
@@ -206,41 +369,138 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
|
|||||||
f.can_manage_water_log ?? false,
|
f.can_manage_water_log ?? false,
|
||||||
f.can_manage_reports ?? false,
|
f.can_manage_reports ?? false,
|
||||||
input.mustChangePassword ?? true,
|
input.mustChangePassword ?? true,
|
||||||
input.email.toLowerCase(),
|
neonAuthUserId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
if (!rows[0]) return { user: null, error: "Insert returned no row" };
|
if (!ins.rows[0]) throw new Error("admin_users insert returned no row");
|
||||||
|
|
||||||
const newAdminId = String(rows[0].id);
|
|
||||||
|
|
||||||
// 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) {
|
if (input.brand_id) {
|
||||||
try {
|
await client.query(
|
||||||
await query(
|
|
||||||
`INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
`INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
ON CONFLICT (admin_user_id, brand_id) DO NOTHING`,
|
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.`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendWelcomeEmailSafe({
|
// 4. Welcome email (best-effort).
|
||||||
to: input.email,
|
const emailResult = await sendWelcomeEmailSafe({
|
||||||
name: input.display_name ?? input.email.split("@")[0],
|
to: email,
|
||||||
|
name: displayName,
|
||||||
role: input.role,
|
role: input.role,
|
||||||
password: input.password,
|
password,
|
||||||
brandId: input.brand_id ?? undefined,
|
brandId: input.brand_id ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { user: mapUserRow(rows[0]), error: null };
|
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) {
|
} 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) };
|
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;
|
|
||||||
|
|||||||
@@ -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<string, string> = {
|
const FLAG_LABELS: Record<string, string> = {
|
||||||
can_manage_products: "Products",
|
can_manage_products: "Products",
|
||||||
can_manage_stops: "Stops",
|
can_manage_stops: "Stops",
|
||||||
@@ -61,6 +69,8 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
|
|||||||
const [flags, setFlags] = useState<Record<string, boolean>>({ ...defaultFlags });
|
const [flags, setFlags] = useState<Record<string, boolean>>({ ...defaultFlags });
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<SuccessState | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const showBrandSelect = role === "brand_admin" || role === "store_employee";
|
const showBrandSelect = role === "brand_admin" || role === "store_employee";
|
||||||
|
|
||||||
@@ -82,6 +92,8 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
|
|||||||
setBrandId(null);
|
setBrandId(null);
|
||||||
setFlags({ ...defaultFlags });
|
setFlags({ ...defaultFlags });
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
setCopied(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
@@ -111,10 +123,16 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.user) {
|
if (result.user && result.tempPassword) {
|
||||||
onSuccess(result.user);
|
onSuccess(result.user);
|
||||||
resetForm();
|
setSuccess({
|
||||||
onClose();
|
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) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : "An unexpected error occurred.");
|
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<Role, string> = {
|
const roleDescriptions: Record<Role, string> = {
|
||||||
platform_admin: "Full platform access",
|
platform_admin: "Full platform access",
|
||||||
brand_admin: "Brand-level admin",
|
brand_admin: "Brand-level admin",
|
||||||
@@ -138,6 +167,117 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Success state — show temp password + email status, then "Done" closes.
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<GlassModal
|
||||||
|
title="User Created"
|
||||||
|
titleIcon={
|
||||||
|
<svg className="h-5 w-5 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 12l2 2 4-4" />
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
subtitle={`${success.user.display_name ?? success.user.email} is ready to sign in`}
|
||||||
|
onClose={handleClose}
|
||||||
|
maxWidth="max-w-lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4 sm:space-y-5">
|
||||||
|
<p className="text-sm text-[var(--admin-text-secondary)]">
|
||||||
|
The account has been created in Neon Auth and linked to the local admin record.
|
||||||
|
Share the temporary password below with the new user — it will not be shown again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-3 sm:p-4">
|
||||||
|
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
|
||||||
|
Sign-in email
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-[var(--admin-text-primary)] break-all">
|
||||||
|
{success.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Temp password */}
|
||||||
|
<div className="rounded-xl border border-amber-300/80 bg-amber-50/80 p-3 sm:p-4">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-800">
|
||||||
|
Temporary password
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyPassword}
|
||||||
|
className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-900 hover:text-amber-700 transition-colors inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M20 6L9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||||
|
</svg>
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-sm text-amber-900 break-all select-all">
|
||||||
|
{success.tempPassword}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-amber-700 mt-2">
|
||||||
|
The user should change this on first sign-in (must_change_password is set).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email delivery status */}
|
||||||
|
<div
|
||||||
|
className={`rounded-xl border p-3 sm:p-4 ${
|
||||||
|
success.emailSent
|
||||||
|
? "border-emerald-200/80 bg-emerald-50/60"
|
||||||
|
: "border-rose-200/80 bg-rose-50/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
|
||||||
|
Welcome email
|
||||||
|
</p>
|
||||||
|
{success.emailSent ? (
|
||||||
|
<p className="text-sm text-emerald-800">
|
||||||
|
Sent to {success.user.email}. The user has the password in their inbox.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-rose-800">
|
||||||
|
Could not be sent automatically
|
||||||
|
{success.emailError ? `: ${success.emailError}` : "."} Share the password above out-of-band.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success.authPath === "signup" && (
|
||||||
|
<p className="text-[11px] text-[var(--admin-text-muted)]">
|
||||||
|
Note: the admin sign-up endpoint was unavailable, so the account was created via
|
||||||
|
the public sign-up path. This is fine — the user can sign in normally.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</GlassModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassModal
|
<GlassModal
|
||||||
title="Create User"
|
title="Create User"
|
||||||
|
|||||||
@@ -195,7 +195,17 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (res.user) setUsers((prev) => [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 {
|
} else {
|
||||||
const res = await import("@/actions/admin/users").then((m) =>
|
const res = await import("@/actions/admin/users").then((m) =>
|
||||||
m.updateAdminUser({
|
m.updateAdminUser({
|
||||||
|
|||||||
@@ -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<typeof vi.fn>).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user