diff --git a/CHANGELOG.md b/CHANGELOG.md index bc8dcd9a1..84d4ccb25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Validated that `SOURCEBOT_ENCRYPTION_KEY` is exactly 32 characters at startup, failing fast with an actionable message instead of a runtime encryption error. [#1305](https://github.com/sourcebot-dev/sourcebot/pull/1305) - Fixed the web UI crashing when anonymous access is enabled and a request omits the `User-Agent` header (e.g. proxy or health-check probes). [#1309](https://github.com/sourcebot-dev/sourcebot/pull/1309) +- Fixed the Members page crashing when a `User` had a null email. `User.email` is now required (with a backfilling migration), and SSO sign-ins without an email are rejected. [#1310](https://github.com/sourcebot-dev/sourcebot/pull/1310) ## [5.0.2] - 2026-06-11 diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 745d22233..108ac097c 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -1095,8 +1095,7 @@ "nullable": true }, "email": { - "type": "string", - "nullable": true + "type": "string" }, "createdAt": { "type": "string", @@ -1140,8 +1139,7 @@ "nullable": true }, "email": { - "type": "string", - "nullable": true + "type": "string" }, "role": { "type": "string", diff --git a/packages/db/prisma/migrations/20260616000000_make_user_email_required/migration.sql b/packages/db/prisma/migrations/20260616000000_make_user_email_required/migration.sql new file mode 100644 index 000000000..87d5a44f4 --- /dev/null +++ b/packages/db/prisma/migrations/20260616000000_make_user_email_required/migration.sql @@ -0,0 +1,20 @@ +-- Make `User.email` required (NOT NULL). +-- +-- This migration runs automatically on startup (`prisma migrate deploy`), so it must +-- never fail on existing data. Some instances have legacy `User` rows with a NULL +-- email — most commonly OAuth/OIDC accounts created from an identity-provider profile +-- that returned no email. A bare `SET NOT NULL` would error on those rows and brick the +-- upgrade, so we first backfill any NULL email with a deterministic, unique, obviously +-- synthetic placeholder (the `.invalid` TLD is reserved and can never be a real address; +-- keying off the row `id` guarantees uniqueness under the existing unique constraint). +-- +-- Going forward, the `signIn` callback in `packages/web/src/auth.ts` rejects OAuth/OIDC +-- sign-ins whose profile has no email, so no new NULL/placeholder rows are created. +-- Operators can identify backfilled accounts with: +-- SELECT id, email FROM "User" WHERE email LIKE 'placeholder-%@no-email.invalid'; +UPDATE "User" +SET "email" = 'placeholder-' || "id" || '@no-email.invalid' +WHERE "email" IS NULL; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "email" SET NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ae0a53840..415b0d3d1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -430,7 +430,7 @@ model Audit { model User { id String @id @default(cuid()) name String? - email String? @unique + email String @unique hashedPassword String? emailVerified DateTime? image String? diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 9dfc71a9c..efd996c1b 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -443,7 +443,7 @@ export const createAccountRequest = async () => sew(async () => { baseUrl: deploymentUrl, requestor: { name: user.name ?? undefined, - email: user.email!, + email: user.email, avatarUrl: user.image ?? undefined, }, orgName: org.name, diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts index b05cde1ac..2803c34b9 100644 --- a/packages/web/src/app/invite/actions.ts +++ b/packages/web/src/app/invite/actions.ts @@ -195,12 +195,12 @@ export const getInviteInfo = async (inviteId: string) => sew(async () => { orgImageUrl: invite.org.imageUrl ?? undefined, host: { name: invite.host.name ?? undefined, - email: invite.host.email!, + email: invite.host.email, avatarUrl: invite.host.image ?? undefined, }, recipient: { name: user.name ?? undefined, - email: user.email!, + email: user.email, } }; }); diff --git a/packages/web/src/app/login/error/page.tsx b/packages/web/src/app/login/error/page.tsx index 2694feafb..333c0c025 100644 --- a/packages/web/src/app/login/error/page.tsx +++ b/packages/web/src/app/login/error/page.tsx @@ -20,6 +20,10 @@ const ERROR_CONTENT: Record = { title: "Access denied", description: "You do not have permission to sign in.", }, + EmailRequired: { + title: "No email on your account", + description: "Your identity provider didn't share an email address, which Sourcebot requires to sign you in. Add or verify an email on your upstream account, then try again.", + }, Verification: { title: "This sign-in link has expired", description: "The code or link you used is no longer valid - it may have expired or already been used. Request a new one and try again.", diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 3240824e5..00fa426df 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -288,7 +288,7 @@ const nextAuthResult = NextAuth(async () => ({ } }, callbacks: { - async signIn({ account }) { + async signIn({ account, user }) { const matchingProvider = account ? (await getProviders()).find((p) => p.id === account.provider) : undefined; @@ -318,6 +318,12 @@ const nextAuthResult = NextAuth(async () => ({ return false; } + // Reject any sign-in that arrives without an email. + // @see 20260616000000_make_user_email_required/migration.sql + if (!user.email) { + return '/login/error?error=EmailRequired'; + } + return true; }, // Restrict post-auth redirects (sign-in / sign-out, `callbackUrl`, diff --git a/packages/web/src/features/userManagement/actions.ts b/packages/web/src/features/userManagement/actions.ts index 859617f91..8a6bc334f 100644 --- a/packages/web/src/features/userManagement/actions.ts +++ b/packages/web/src/features/userManagement/actions.ts @@ -233,7 +233,7 @@ export const approveAccountRequest = async (requestId: string) => sew(async () = baseUrl: env.AUTH_URL, user: { name: request.requestedBy.name ?? undefined, - email: request.requestedBy.email!, + email: request.requestedBy.email, avatarUrl: request.requestedBy.image ?? undefined, }, orgName: org.name, @@ -241,7 +241,7 @@ export const approveAccountRequest = async (requestId: string) => sew(async () = const transport = createTransport(smtpConnectionUrl); const result = await transport.sendMail({ - to: request.requestedBy.email!, + to: request.requestedBy.email, from: env.EMAIL_FROM_ADDRESS, subject: `Your request to join ${org.name} has been approved`, html, @@ -391,7 +391,7 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea baseUrl: env.AUTH_URL, host: { name: user.name ?? undefined, - email: user.email!, + email: user.email, avatarUrl: user.image ?? undefined, }, recipient: { @@ -481,7 +481,7 @@ export const getOrgMembers = async () => sew(() => return members.map((member) => ({ id: member.userId, - email: member.user.email!, + email: member.user.email, name: member.user.name ?? undefined, avatarUrl: member.user.image ?? undefined, role: member.role, @@ -520,7 +520,7 @@ export const getOrgAccountRequests = async () => sew(() => return requests.map((request) => ({ id: request.id, - email: request.requestedBy.email!, + email: request.requestedBy.email, createdAt: request.createdAt, name: request.requestedBy.name ?? undefined, image: request.requestedBy.image ?? undefined, diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts index 9215cabb7..ef0dd6551 100644 --- a/packages/web/src/lib/authUtils.ts +++ b/packages/web/src/lib/authUtils.ts @@ -260,7 +260,7 @@ export const addUserToOrganization = async (userId: string, orgId: number): Prom // Delete any invites that may exist for this user since we've added them to the org const invites = await tx.invite.findMany({ where: { - recipientEmail: user.email!, + recipientEmail: user.email, orgId: org.id, }, }) diff --git a/packages/web/src/lib/encryptedPrismaAdapter.ts b/packages/web/src/lib/encryptedPrismaAdapter.ts index 90abb1db4..70e145032 100644 --- a/packages/web/src/lib/encryptedPrismaAdapter.ts +++ b/packages/web/src/lib/encryptedPrismaAdapter.ts @@ -53,9 +53,8 @@ export function EncryptedPrismaAdapter(prisma: PrismaClient): Adapter { }, include: { user: true }, }); - // Cast: Prisma's User.email is nullable but AdapterUser.email is - // typed as `string`. The base PrismaAdapter performs the same - // implicit widening; we mirror it here. + // Cast to AdapterUser to satisfy next-auth's adapter return type; + // the base PrismaAdapter returns the user row directly, and we mirror it. return (account?.user ?? null) as AdapterUser | null; }, async unlinkAccount({ provider, providerAccountId }) { diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts index 45b9e8f7f..3b7efba31 100644 --- a/packages/web/src/openapi/publicApiSchemas.ts +++ b/packages/web/src/openapi/publicApiSchemas.ts @@ -67,7 +67,7 @@ export const publicHealthResponseSchema = z.object({ // EE: User Management export const publicEeUserSchema = z.object({ name: z.string().nullable(), - email: z.string().nullable(), + email: z.string(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }).openapi('PublicEeUser'); @@ -75,7 +75,7 @@ export const publicEeUserSchema = z.object({ export const publicEeUserListItemSchema = z.object({ id: z.string(), name: z.string().nullable(), - email: z.string().nullable(), + email: z.string(), role: z.enum(['OWNER', 'MEMBER']), createdAt: z.string().datetime(), lastActivityAt: z.string().datetime().nullable(),