Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
brendan-kellam marked this conversation as resolved.

## [5.0.2] - 2026-06-11

Expand Down
6 changes: 2 additions & 4 deletions docs/api-reference/sourcebot-public.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1095,8 +1095,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"createdAt": {
"type": "string",
Expand Down Expand Up @@ -1140,8 +1139,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"role": {
"type": "string",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/app/invite/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
};
});
4 changes: 4 additions & 0 deletions packages/web/src/app/login/error/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const ERROR_CONTENT: Record<string, { title: string; description: string }> = {
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.",
Expand Down
8 changes: 7 additions & 1 deletion packages/web/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`,
Expand Down
10 changes: 5 additions & 5 deletions packages/web/src/features/userManagement/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,15 @@ 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,
}));

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,
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/lib/authUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
})
Expand Down
5 changes: 2 additions & 3 deletions packages/web/src/lib/encryptedPrismaAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/openapi/publicApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,15 @@ 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');

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(),
Expand Down
Loading