Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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,
}
};
});
15 changes: 14 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,19 @@ const nextAuthResult = NextAuth(async () => ({
return false;
}

// Reject any sign-in that arrives without an email. `email` is a required
// column, so a null would otherwise fail the `createUser` insert at the
// database; historically these rows also crashed the members list and other
// surfaces that assume an email is present. Returning false surfaces the auth
// error page instead. In practice only OAuth/OIDC profiles can lack an email
// (credentials and email providers always carry one), but the check is left
// unconditional so any future provider or edge case is covered too. `user` is
// always defined in the signIn callback, so it needs no guard.
// @see 20260616000000_make_user_email_required/migration.sql
if (!user.email) {
return false;
}

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