Create-user flow now provisions the Neon Auth account
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:
Tyler
2026-06-17 11:46:39 -06:00
parent 11cd2fd01a
commit d75380eb9a
4 changed files with 936 additions and 77 deletions
+329 -73
View File
@@ -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<void> {
}): 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<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;
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<Record<string, unknown>>(
`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<string, unknown> | null = null;
try {
const result = await withTx(async (client) => {
const ins = await client.query<Record<string, unknown>>(
`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;