Skip to content

Allow Admins To Create User Accounts #239

Description

@b-at-neu

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

  • 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:
    1. Calls authClient.admin.createUser() (Neon Auth Admin API) to create the Neon Auth account
    2. Upserts the app User row via Prisma (same shape as resolveRealUser() in lib/auth/server.ts)
    3. 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.

Sources:


Implementation Plan

Overview

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).
    • Invalid email rejected server-side → return { error: 'Enter a valid email address.' }.
    • 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).
  • Post-write: revalidatePath('/users'); success returns void.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    claudeWill be worked on by Claudepr openedPull request has been opened

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions