Skip to content

Commit d03dc93

Browse files
b-at-neuclaude
andcommitted
#239 address review feedback
Call authServer.admin.createUser before creating the app row so the Neon Auth account is pre-provisioned and the user can sign in via OTP immediately; revert resolveRealUser to the original atomic upsert (race-safe, no nullable neonAuthId needed); remove the make-neon-auth-id-nullable migration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2174736 commit d03dc93

4 files changed

Lines changed: 46 additions & 39 deletions

File tree

lib/auth/server.ts

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,45 +7,23 @@ import { prisma } from '@/lib/prisma';
77

88
export const authServer = createAuthServer();
99

10-
// Provision-on-first-auth: creates or links the app User row when a real Neon
11-
// session has no matching row yet, then returns it.
12-
// Pre-invited users (created by an admin before they sign up) are matched by
13-
// email and linked to the session's neonAuthId on first login.
14-
// Deactivated users (#153) are blocked by the post-lookup guard below.
10+
// Provision-on-first-auth: create the app User row when a real Neon session
11+
// has no matching row yet, then return it. Create-only (empty update {}) so an
12+
// existing row is returned without any write.
13+
// Keyed on the unique neonAuthId → race-safe via the DB unique constraint + upsert.
14+
// name omitted when falsy (OTP identities often supply an empty string → store null).
15+
// Deactivated users (#153) are blocked by the post-upsert guard below.
1516
async function resolveRealUser() {
1617
const { data: session } = await authServer.getSession();
1718
if (!session?.user) return null;
1819

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

21-
// 1. Existing user already linked by neonAuthId
22-
let row = await prisma.user.findUnique({ where: { neonAuthId } });
23-
24-
// 2. Pre-invited user — match by email, link neonAuthId on first sign-in
25-
if (!row) {
26-
const pending = await prisma.user.findFirst({
27-
where: { email, neonAuthId: null },
28-
});
29-
if (pending) {
30-
row = await prisma.user.update({
31-
where: { id: pending.id },
32-
data: { neonAuthId, ...(name?.trim() ? { name: name.trim() } : {}) },
33-
});
34-
}
35-
}
36-
37-
// 3. Brand-new user — create app row
38-
if (!row) {
39-
row = await prisma.user.create({
40-
data: {
41-
neonAuthId,
42-
email,
43-
...(name?.trim() ? { name: name.trim() } : {}),
44-
isAdmin: false,
45-
},
46-
});
47-
}
48-
22+
const row = await prisma.user.upsert({
23+
where: { neonAuthId },
24+
update: {},
25+
create: { neonAuthId, email, ...(name ? { name } : {}), isAdmin: false },
26+
});
4927
if (row.deletedAt) return null;
5028
return row;
5129
}

prisma/actions/users.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache';
44

55
import { z } from 'zod/v4';
66

7-
import { getCurrentUser } from '@/lib/auth/server';
7+
import { authServer, getCurrentUser } from '@/lib/auth/server';
88
import { createUserSchema } from '@/lib/constants';
99
import { prisma } from '@/lib/prisma';
1010

@@ -71,6 +71,16 @@ export async function deactivateUser(
7171
revalidatePath('/users');
7272
}
7373

74+
// Type guard for Better Auth error responses — avoids unsafe `as` casts.
75+
function hasCode(e: unknown): e is { code: string } {
76+
return (
77+
typeof e === 'object' &&
78+
e !== null &&
79+
'code' in e &&
80+
typeof (e as Record<string, unknown>).code === 'string'
81+
);
82+
}
83+
7484
export async function createUser(input: unknown): Promise<ActionError | void> {
7585
const admin = await getCurrentUser();
7686
if (!admin.isAdmin) return { error: 'Unauthorized' };
@@ -80,11 +90,32 @@ export async function createUser(input: unknown): Promise<ActionError | void> {
8090

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

83-
const existing = await prisma.user.findFirst({ where: { email } });
84-
if (existing) return { error: 'A user with this email already exists.' };
93+
// Pre-create the Neon Auth account so the user can sign in via OTP immediately.
94+
// A random password satisfies the required field; OTP is the actual sign-in method.
95+
const authResult = await authServer.admin.createUser({
96+
email,
97+
name: name?.trim() ?? '',
98+
password: crypto.randomUUID() + crypto.randomUUID(),
99+
});
100+
101+
if (authResult.error) {
102+
if (
103+
hasCode(authResult.error) &&
104+
(authResult.error.code === 'USER_ALREADY_EXISTS' ||
105+
authResult.error.code === 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL')
106+
)
107+
return { error: 'A user with this email already exists.' };
108+
throw new Error(`Neon Auth createUser failed: ${String(authResult.error)}`);
109+
}
110+
111+
if (!authResult.data?.user.id)
112+
throw new Error('Neon Auth createUser returned no user id');
113+
114+
const neonAuthId = authResult.data.user.id;
85115

86116
await prisma.user.create({
87117
data: {
118+
neonAuthId,
88119
email,
89120
...(name?.trim() ? { name: name.trim() } : {}),
90121
isAdmin: isAdmin ?? false,

prisma/migrations/20260627000000_make-neon-auth-id-nullable/migration.sql

Lines changed: 0 additions & 2 deletions
This file was deleted.

prisma/schema.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ enum QuestionType {
3333

3434
model User {
3535
id String @id @default(uuid(7))
36-
neonAuthId String? @unique
36+
neonAuthId String @unique
3737
email String @unique
3838
name String?
3939
isAdmin Boolean @default(false)

0 commit comments

Comments
 (0)