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
+143 -3
View File
@@ -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> = {
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<Record<string, boolean>>({ ...defaultFlags });
const [saving, setSaving] = useState(false);
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";
@@ -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<Role, string> = {
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 (
<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 (
<GlassModal
title="Create User"
+11 -1
View File
@@ -195,7 +195,17 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
}
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 {
const res = await import("@/actions/admin/users").then((m) =>
m.updateAdminUser({