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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -1095,8 +1095,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"createdAt": {
"type": "string",
Expand DownExpand Up@@ -1140,8 +1139,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"role": {
"type": "string",
Expand Down
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -1095,8 +1095,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"createdAt": {
"type": "string",
Expand DownExpand Up@@ -1140,8 +1139,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"role": {
"type": "string",
Expand Down
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -1095,8 +1095,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"createdAt": {
"type": "string",
Expand DownExpand Up@@ -1140,8 +1139,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"role": {
"type": "string",
Expand Down
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -1095,8 +1095,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"createdAt": {
"type": "string",
Expand DownExpand Up@@ -1140,8 +1139,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"role": {
"type": "string",
Expand Down
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -1095,8 +1095,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"createdAt": {
"type": "string",
Expand DownExpand Up@@ -1140,8 +1139,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"role": {
"type": "string",
Expand Down
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -1095,8 +1095,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"createdAt": {
"type": "string",
Expand DownExpand Up@@ -1140,8 +1139,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"role": {
"type": "string",
Expand Down
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -1095,8 +1095,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"createdAt": {
"type": "string",
Expand DownExpand Up@@ -1140,8 +1139,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"role": {
"type": "string",
Expand Down
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -1095,8 +1095,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"createdAt": {
"type": "string",
Expand DownExpand Up@@ -1140,8 +1139,7 @@
"nullable": true
},
"email": {
"type": "string",
"nullable": true
"type": "string"
},
"role": {
"type": "string",
Expand Down
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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