Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/(main)/(auth)/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { getUsersForAdmin } from '@/prisma/data/users';

import { getCurrentUser } from '@/lib/auth/server';

import { CreateUserDialog } from '@/components/features/create-user-dialog';
import { UsersTable } from '@/components/features/users-table';
import { PageHeader } from '@/components/layouts/page-header';
import { Button } from '@/components/ui/button';

export const metadata: Metadata = { title: 'Users' };

Expand All @@ -18,7 +20,10 @@ export default async function UsersPage() {

return (
<div className="flex flex-col gap-6">
<PageHeader title="Users" />
<PageHeader
title="Users"
actions={<CreateUserDialog trigger={<Button>Create user</Button>} />}
/>

<UsersTable users={users} currentUserId={user.id} />
</div>
Expand Down
103 changes: 103 additions & 0 deletions components/features/create-user-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use client';

import type { ReactNode } from 'react';

import { toast } from 'sonner';
import type { z } from 'zod/v4';

import { createUser } from '@/prisma/actions/users';

import { createUserSchema } from '@/lib/constants';

import { Checkbox } from '@/components/ui/checkbox';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { FormDialog } from '@/components/ui/form-dialog';
import { Input } from '@/components/ui/input';

type CreateUserFormValues = z.infer<typeof createUserSchema>;

const defaultValues: CreateUserFormValues = {
email: '',
name: '',
isAdmin: false,
};

interface CreateUserDialogProps {
trigger: ReactNode;
}

export function CreateUserDialog({ trigger }: CreateUserDialogProps) {
async function onSubmit(data: CreateUserFormValues): Promise<boolean> {
try {
const result = await createUser(data);
if (result?.error) {
toast.error(result.error);
return false;
}
toast.success(data.isAdmin ? 'Admin user created.' : 'User created.');
return true;
} catch {
toast.error('Something went wrong. Please try again.');
return false;
}
}

return (
<FormDialog
trigger={trigger}
title="Create User"
schema={createUserSchema}
defaultValues={defaultValues}
onSubmit={onSubmit}
submitLabel="Create user"
>
<FormField
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="user@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Optional" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
name="isAdmin"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>Admin</FormLabel>
<FormMessage />
</FormItem>
)}
/>
</FormDialog>
);
}
62 changes: 51 additions & 11 deletions lib/auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,68 @@ import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { cache } from 'react';

import { Prisma } from '@/prisma/client';

import { prisma } from '@/lib/prisma';

export const authServer = createAuthServer();

// Provision-on-first-auth: create the app User row when a real Neon session
// has no matching row yet, then return it. Create-only (empty update {}) so an
// existing row is returned without any write.
// Keyed on the unique neonAuthId → race-safe via the DB unique constraint + upsert.
// Provision-on-first-auth: find or create the app User row for the authenticated
// Neon session. Three-step lookup handles two cases:
// 1. Already linked — fast path via unique neonAuthId lookup.
// 2. Pre-invited — admin pre-created a row with email only; link neonAuthId on
// first sign-in so subsequent requests hit the fast path.
// 3. Brand-new — create a fresh row; P2002 catch handles the concurrent sign-in
// race (both requests pass steps 1–2 simultaneously and one wins create).
// name omitted when falsy (OTP identities often supply an empty string → store null).
// Deactivated users (#153) are blocked by the post-upsert guard below.
// Deactivated users are blocked by the post-lookup guard below.
async function resolveRealUser() {
const { data: session } = await authServer.getSession();
if (!session?.user) return null;

const { id: neonAuthId, email, name } = session.user;

const row = await prisma.user.upsert({
where: { neonAuthId },
update: {},
create: { neonAuthId, email, ...(name ? { name } : {}), isAdmin: false },
});
if (row.deletedAt) return null;
// 1. Already linked
let row = await prisma.user.findUnique({ where: { neonAuthId } });

// 2. Pre-invited by email — link neonAuthId on first sign-in
if (!row) {
const pending = await prisma.user.findFirst({
where: { email, neonAuthId: null },
});
if (pending) {
row = await prisma.user.update({
where: { id: pending.id },
data: { neonAuthId, ...(name?.trim() ? { name: name.trim() } : {}) },
});
}
}

// 3. Brand-new user — catch P2002 if two concurrent requests race to create
if (!row) {
try {
row = await prisma.user.create({
data: {
neonAuthId,
email,
...(name?.trim() ? { name: name.trim() } : {}),
isAdmin: false,
},
});
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
// Lost the race — the concurrent request created the row; fetch it
row = await prisma.user.findUnique({ where: { neonAuthId } });
} else {
throw e;
}
}
}

if (!row || row.deletedAt) return null;
return row;
}

Expand Down
7 changes: 7 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ export const AVAILABILITY_VARIANTS: Record<PositionAvailability, BadgeVariant> =
export const PRIVACY_HREF = '/privacy';
export const TERMS_HREF = '/terms';

// Shared between the createUser server action and the CreateUserDialog form.
export const createUserSchema = z.object({
email: z.string().trim().email('Enter a valid email address.'),
name: z.string().trim().optional(),
isAdmin: z.boolean().default(false),
});

// Maps badge variant to a design-token dot color used in stat cards and the
// activity feed. Extracted from pipeline-summary.tsx so both consumers share
// one source of truth (ENGINEERING §1: abstract at 2+).
Expand Down
34 changes: 34 additions & 0 deletions prisma/actions/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { revalidatePath } from 'next/cache';

import { z } from 'zod/v4';

import { Prisma } from '@/prisma/client';

import { getCurrentUser } from '@/lib/auth/server';
import { createUserSchema } from '@/lib/constants';
import { prisma } from '@/lib/prisma';

const toggleAdminSchema = z.object({
Expand Down Expand Up @@ -69,3 +72,34 @@ export async function deactivateUser(

revalidatePath('/users');
}

export async function createUser(input: unknown): Promise<ActionError | void> {
const admin = await getCurrentUser();
if (!admin.isAdmin) return { error: 'Unauthorized' };

const parsed = createUserSchema.safeParse(input);
if (!parsed.success) return { error: 'Invalid input' };

const { email, name, isAdmin } = parsed.data;

// Pre-invite: create an app-side row with email only (no Neon Auth call).
Comment thread
b-at-neu marked this conversation as resolved.
// When the invitee completes OTP sign-up, resolveRealUser() links their neonAuthId.
// Catch P2002 on the email unique constraint instead of a prior findFirst so the
// duplicate check is atomic with the insert (no read-then-write race).
try {
await prisma.user.create({
data: {
email,
...(name?.trim() ? { name: name.trim() } : {}),
isAdmin,
createdById: admin.id,
},
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002')
return { error: 'A user with this email already exists.' };
throw e;
}

revalidatePath('/users');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "neonAuthId" DROP NOT NULL;
2 changes: 1 addition & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ enum QuestionType {

model User {
id String @id @default(uuid(7))
neonAuthId String @unique
neonAuthId String? @unique
email String @unique
name String?
isAdmin Boolean @default(false)
Expand Down
Loading