You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Admins should be able to create a user account on behalf of someone directly from the Users page, without requiring that person to go through the sign-in flow themselves.
Acceptance Criteria
A "Create user" button appears on the Users page (app/(main)/(auth)/users/page.tsx) for admins
Clicking it opens a dialog with fields: Email (required), Name (optional), Admin (checkbox, default off)
On submit, the server action:
Calls authClient.admin.createUser() (Neon Auth Admin API) to create the Neon Auth account
Upserts the app User row via Prisma (same shape as resolveRealUser() in lib/auth/server.ts)
Returns { error } for user-facing failures (duplicate email, invalid input), throws for unexpected ones
The new user appears in the table immediately (revalidate the users page)
Toast feedback on success and failure
The created user can sign in via the normal email OTP flow
Implementation notes
Neon Auth exposes authClient.admin.createUser() — check @neondatabase/auth for the exact method signature and required params. The server action must be admin-gated.
Add an admin-only "Create user" dialog to the Users page. A new createUser server action calls the server-side Neon Auth admin API (authServer.admin.createUser from lib/auth/server.ts — runs as the current admin's session, so it's auth-scoped without trusting the client), then upserts the app User row keyed on the returned neonAuthId, mirroring resolveRealUser(). The dialog reuses the existing FormDialog + react-hook-form/zod pattern, exactly like global-question-dialog.tsx.
Key constraint: better-auth's admin/create-user body requires email, password, and name. The app is OTP/passwordless, so the action generates a strong random password to satisfy the API — the user never uses it and signs in via the normal email-OTP flow (verified below). name is optional in our UI; pass an empty string when omitted (the admin schema requires the field, and resolveRealUser already maps a falsy name to null).
Changes
lib/auth/server.ts — export a small helper (e.g. upsertAppUser({ neonAuthId, email, name }, createdById)) extracted from resolveRealUser's upsert so the new action and provisioning share one definition; the action passes createdById: admin.id.
lib/constants.ts — add createUserSchema (zod) shared by the action and the dialog: email (trimmed, valid email), name (optional, trimmed), isAdmin (boolean, default false).
prisma/actions/users.ts — add createUser(input: unknown): admin-gate, zod-parse, call authServer.admin.createUser, upsert the app row, set isAdmin, revalidatePath('/users').
components/features/create-user-dialog.tsx — new client component: FormDialog with Email/Name inputs + Admin Checkbox; calls createUser, toasts, closes on success.
app/(main)/(auth)/users/page.tsx — pass the dialog (with its trigger Button) into PageHeader's actions slot.
Implementation
Extract the User upsert from resolveRealUser in lib/auth/server.ts into an exported upsertAppUser helper that accepts { neonAuthId, email, name } and an optional createdById; have resolveRealUser call it (no behavior change — still update: {}, name omitted when falsy, isAdmin: false on create).
Add createUserSchema to lib/constants.ts: email z.string().trim().email(), name z.string().trim().optional(), isAdmin z.boolean().default(false).
Add createUser server action to prisma/actions/users.ts (see Data & contracts for the contract): admin gate → zod parse → authServer.admin.createUser → upsert app row with createdById/updatedById = admin.id → set isAdmin from input → revalidatePath('/users'). Generate a random password (crypto.randomUUID() ×2 or crypto.randomBytes) for the admin API; never return or log it.
Map the Neon Auth result: pull neonAuthId and email/name from its returned user; if the API signals an existing-email/duplicate condition, return { error: 'A user with this email already exists.' }.
Create components/features/create-user-dialog.tsx using FormDialog (schema = createUserSchema, defaultValues { email: '', name: '', isAdmin: false }), modeled on global-question-dialog.tsx: Email Input, Name Input (optional helper text), Admin Checkbox; onSubmit calls createUser, toasts success/error, returns true only on success.
Wire the dialog trigger (<Button>Create user</Button>) into PageHeader actions on the Users page; keep the page server-only and admin-redirect intact.
Run prettier/eslint/tsc; confirm the created user is visible after revalidate and can sign in via OTP.
Data & contracts
No Prisma schema change — User already has neonAuthId, email, name?, isAdmin, and createdById/updatedById.
createUser(input: unknown): Promise<{ error: string } | void> (in prisma/actions/users.ts)
Auth:const admin = await getCurrentUser(); if (!admin.isAdmin) return { error: 'Unauthorized' }; (matches the file's existing toggleUserAdmin/deactivateUser convention).
Validation:createUserSchema.safeParse(input); on failure return { error: 'Invalid input' } (field-level errors surface inline via the resolver client-side).
Neon Auth call:authServer.admin.createUser({ email, name: name ?? '', password: <random> }). The Neon Auth/better-auth client returns { data, error } (does not throw) — inspect at impl time.
Duplicate email (better-auth USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL) → return { error: 'A user with this email already exists.' } (user-actionable).
Any other error / missing data / network failure → throw (not user-actionable; generic toast via the dialog's catch). Never surface the raw provider message.
App row:upsertAppUser({ neonAuthId, email, name }, admin.id) (create-only upsert, race-safe on the neonAuthId unique constraint), then, when input.isAdmin, set isAdmin: true (and updatedById: admin.id) — do this within a prisma.$transaction together with the upsert since it's a read-then-write multi-step (§2). Build the data object explicitly (no mass assignment).
Decision-test summary: duplicate/invalid email → { error } (shows a sentence the admin can act on); failed auth, provider/network failure, unexpected missing data → throw.
UX states
Trigger: "Create user" Button in the page header actions slot (admin-only page, so no extra role gate needed on the button).
Form (in FormDialog): Email (type="email", required), Name (optional — placeholder/help "Optional"), Admin Checkbox with FormLabel "Admin" (default off). Inline zod field errors via FormMessage.
Pending:FormDialog already disables the submit button and shows a spinner via isSubmitting; submit label "Create user".
Success: toast "User created." (or "Admin user created." when admin) → dialog closes, form resets, new row appears via revalidate.
Error: specific toast for returned { error } (e.g. "A user with this email already exists."); generic "Something went wrong. Please try again." on an unexpected throw (matches users-table.tsx copy). Input is preserved on failure (dialog stays open since onSubmit returns false).
Empty: not applicable — the page already renders the table/empty state.
A11y: every field uses FormLabel; dialog focus-trap/restore handled by the shadcn Dialog primitive; checkbox is keyboard-operable.
Testing
As an admin, open /users; confirm a "Create user" button shows in the header.
Submit with a new email + name, Admin off → success toast, dialog closes, the user appears in the table with no Admin badge.
Submit with Admin checked → user appears with the Admin badge.
Submit with an empty email → inline "valid email" error, no request sent.
Submit with an email that already exists → error toast "A user with this email already exists.", dialog stays open, input preserved.
Submit with name omitted → user created, table shows the email as the primary label (name null).
Sign out and sign in as the newly created user via the email OTP flow → succeeds and lands in the app.
As a non-admin (or unauthenticated), confirm /users still redirects and the action is not reachable.
Risks / notes
Password requirement: better-auth's create-user mandates a password even though the app is OTP-only. Confirm at impl time that an OTP sign-in for the new email works regardless of the generated password (it should — OTP is a separate credential path). If Neon Auth instead exposes a password-less create variant, prefer it.
Result shape: confirm authServer.admin.createUser returns { data, error } (better-auth fetch convention) vs. throwing, and read neonAuthId from the correct field (data.user.id / data.id) — adjust the duplicate-detection branch to the actual error code/shape.
Goal
Admins should be able to create a user account on behalf of someone directly from the Users page, without requiring that person to go through the sign-in flow themselves.
Acceptance Criteria
app/(main)/(auth)/users/page.tsx) for adminsauthClient.admin.createUser()(Neon Auth Admin API) to create the Neon Auth accountUserrow via Prisma (same shape asresolveRealUser()inlib/auth/server.ts){ error }for user-facing failures (duplicate email, invalid input), throws for unexpected onesImplementation notes
Neon Auth exposes
authClient.admin.createUser()— check@neondatabase/authfor the exact method signature and required params. The server action must be admin-gated.Sources:
Implementation Plan
Overview
Add an admin-only "Create user" dialog to the Users page. A new
createUserserver action calls the server-side Neon Auth admin API (authServer.admin.createUserfromlib/auth/server.ts— runs as the current admin's session, so it's auth-scoped without trusting the client), then upserts the appUserrow keyed on the returnedneonAuthId, mirroringresolveRealUser(). The dialog reuses the existingFormDialog+ react-hook-form/zod pattern, exactly likeglobal-question-dialog.tsx.Key constraint: better-auth's
admin/create-userbody requiresemail,password, andname. The app is OTP/passwordless, so the action generates a strong random password to satisfy the API — the user never uses it and signs in via the normal email-OTP flow (verified below).nameis optional in our UI; pass an empty string when omitted (the admin schema requires the field, andresolveRealUseralready maps a falsy name tonull).Changes
lib/auth/server.ts— export a small helper (e.g.upsertAppUser({ neonAuthId, email, name }, createdById)) extracted fromresolveRealUser's upsert so the new action and provisioning share one definition; the action passescreatedById: admin.id.lib/constants.ts— addcreateUserSchema(zod) shared by the action and the dialog:email(trimmed, valid email),name(optional, trimmed),isAdmin(boolean, default false).prisma/actions/users.ts— addcreateUser(input: unknown): admin-gate, zod-parse, callauthServer.admin.createUser, upsert the app row, setisAdmin,revalidatePath('/users').components/features/create-user-dialog.tsx— new client component:FormDialogwith Email/Name inputs + AdminCheckbox; callscreateUser, toasts, closes on success.app/(main)/(auth)/users/page.tsx— pass the dialog (with its triggerButton) intoPageHeader'sactionsslot.Implementation
Userupsert fromresolveRealUserinlib/auth/server.tsinto an exportedupsertAppUserhelper that accepts{ neonAuthId, email, name }and an optionalcreatedById; haveresolveRealUsercall it (no behavior change — stillupdate: {}, name omitted when falsy,isAdmin: falseon create).createUserSchematolib/constants.ts:email z.string().trim().email(),name z.string().trim().optional(),isAdmin z.boolean().default(false).createUserserver action toprisma/actions/users.ts(see Data & contracts for the contract): admin gate → zod parse →authServer.admin.createUser→ upsert app row withcreatedById/updatedById = admin.id→ setisAdminfrom input →revalidatePath('/users'). Generate a random password (crypto.randomUUID()×2 orcrypto.randomBytes) for the admin API; never return or log it.neonAuthIdandemail/namefrom its returned user; if the API signals an existing-email/duplicate condition, return{ error: 'A user with this email already exists.' }.components/features/create-user-dialog.tsxusingFormDialog(schema =createUserSchema,defaultValues { email: '', name: '', isAdmin: false }), modeled onglobal-question-dialog.tsx: EmailInput, NameInput(optional helper text), AdminCheckbox;onSubmitcallscreateUser, toasts success/error, returnstrueonly on success.<Button>Create user</Button>) intoPageHeader actionson the Users page; keep the page server-only and admin-redirect intact.Data & contracts
No Prisma schema change —
Useralready hasneonAuthId,email,name?,isAdmin, andcreatedById/updatedById.createUser(input: unknown): Promise<{ error: string } | void>(inprisma/actions/users.ts)const admin = await getCurrentUser(); if (!admin.isAdmin) return { error: 'Unauthorized' };(matches the file's existingtoggleUserAdmin/deactivateUserconvention).createUserSchema.safeParse(input); on failurereturn { error: 'Invalid input' }(field-level errors surface inline via the resolver client-side).authServer.admin.createUser({ email, name: name ?? '', password: <random> }). The Neon Auth/better-auth client returns{ data, error }(does not throw) — inspect at impl time.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL) →return { error: 'A user with this email already exists.' }(user-actionable).return { error: 'Enter a valid email address.' }.error/ missingdata/ network failure → throw (not user-actionable; generic toast via the dialog's catch). Never surface the raw provider message.upsertAppUser({ neonAuthId, email, name }, admin.id)(create-only upsert, race-safe on theneonAuthIdunique constraint), then, wheninput.isAdmin, setisAdmin: true(andupdatedById: admin.id) — do this within aprisma.$transactiontogether with the upsert since it's a read-then-write multi-step (§2). Build thedataobject explicitly (no mass assignment).revalidatePath('/users'); success returnsvoid.Decision-test summary: duplicate/invalid email →
{ error }(shows a sentence the admin can act on); failed auth, provider/network failure, unexpected missing data → throw.UX states
Buttonin the page headeractionsslot (admin-only page, so no extra role gate needed on the button).FormDialog): Email (type="email", required), Name (optional — placeholder/help "Optional"), AdminCheckboxwithFormLabel"Admin" (default off). Inline zod field errors viaFormMessage.FormDialogalready disables the submit button and shows a spinner viaisSubmitting; submit label "Create user".{ error }(e.g. "A user with this email already exists."); generic "Something went wrong. Please try again." on an unexpected throw (matchesusers-table.tsxcopy). Input is preserved on failure (dialog stays open sinceonSubmitreturnsfalse).FormLabel; dialog focus-trap/restore handled by the shadcnDialogprimitive; checkbox is keyboard-operable.Testing
/users; confirm a "Create user" button shows in the header./usersstill redirects and the action is not reachable.Risks / notes
create-usermandates a password even though the app is OTP-only. Confirm at impl time that an OTP sign-in for the new email works regardless of the generated password (it should — OTP is a separate credential path). If Neon Auth instead exposes a password-less create variant, prefer it.authServer.admin.createUserreturns{ data, error }(better-auth fetch convention) vs. throwing, and readneonAuthIdfrom the correct field (data.user.id/data.id) — adjust the duplicate-detection branch to the actual error code/shape.