From a72427f38daecd287750c00a4694620feaf1dc04 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 9 Aug 2026 19:28:20 -0700 Subject: [PATCH 1/6] improvement(emails): funnel every sender through the shared render and subject layer (#6482) * improvement(emails): funnel every sender through the shared render and subject layer * fix(emails): cover dynamic imports in the boundary guard and mock the new subject helper * fix(emails): mock the module the limit-notification sender actually imports --- .../api/chat/[identifier]/otp/route.test.ts | 1 + .../app/api/chat/[identifier]/otp/route.ts | 4 +- apps/sim/app/api/contact/route.ts | 4 +- .../files/public/[token]/otp/route.test.ts | 5 +- .../app/api/files/public/[token]/otp/route.ts | 4 +- apps/sim/app/api/help/route.ts | 4 +- .../emails/_styles/base.tokens.test.ts | 56 ++-- apps/sim/components/emails/_styles/base.ts | 23 +- .../emails/agent/inbox-response-email.tsx | 184 +++++++++++++ .../emails/billing/payment-failed-email.tsx | 5 +- apps/sim/components/emails/boundary.test.ts | 98 +++++++ apps/sim/components/emails/render.ts | 24 +- apps/sim/components/emails/subjects.ts | 29 +- .../billing/core/limit-notifications.test.ts | 5 +- .../lib/billing/core/limit-notifications.ts | 2 +- apps/sim/lib/billing/core/subscription.ts | 11 +- .../sim/lib/billing/webhooks/invoices.test.ts | 6 +- apps/sim/lib/billing/webhooks/invoices.ts | 26 +- apps/sim/lib/mothership/inbox/response.ts | 257 +----------------- 19 files changed, 411 insertions(+), 337 deletions(-) create mode 100644 apps/sim/components/emails/agent/inbox-response-email.tsx create mode 100644 apps/sim/components/emails/boundary.test.ts diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index 1c60db08fa3..e49b485cca3 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -89,6 +89,7 @@ vi.mock('@/lib/messaging/email/mailer', () => ({ })) vi.mock('@/components/emails', () => ({ + getOtpSubject: (label: string) => `Verification code for ${label}`, renderOTPEmail: mockRenderOTPEmail, })) diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 9f7fffd741f..aa936877cc5 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -3,7 +3,7 @@ import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { renderOTPEmail } from '@/components/emails' +import { getOtpSubject, renderOTPEmail } from '@/components/emails' import { requestChatEmailOtpContract, verifyChatEmailOtpContract } from '@/lib/api/contracts/chats' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { RateLimiter } from '@/lib/core/rate-limiter' @@ -120,7 +120,7 @@ export const POST = withRouteHandler( const emailResult = await sendEmail({ to: email, - subject: `Verification code for ${deployment.title || 'Chat'}`, + subject: getOtpSubject(deployment.title || 'Chat'), html: emailHtml, }) diff --git a/apps/sim/app/api/contact/route.ts b/apps/sim/app/api/contact/route.ts index 2b610ec2114..5d1c0404eba 100644 --- a/apps/sim/app/api/contact/route.ts +++ b/apps/sim/app/api/contact/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { renderHelpConfirmationEmail } from '@/components/emails' +import { getRequestConfirmationSubject, renderHelpConfirmationEmail } from '@/components/emails' import { getContactTopicLabel, mapContactTopicToHelpType, @@ -168,7 +168,7 @@ ${message} await sendEmail({ to: [email], - subject: `We've received your message: ${subject}`, + subject: getRequestConfirmationSubject(subject), html: confirmationHtml, from: getFromEmailAddress(), replyTo: `help@${helpInboxDomain}`, diff --git a/apps/sim/app/api/files/public/[token]/otp/route.test.ts b/apps/sim/app/api/files/public/[token]/otp/route.test.ts index eb363eb7d5f..bce515c9237 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.test.ts @@ -50,7 +50,10 @@ vi.mock('@/lib/core/security/otp', () => ({ OTP_IP_RATE_LIMIT: { maxTokens: 10, refillRate: 10, refillIntervalMs: 1000 }, OTP_EMAIL_RATE_LIMIT: { maxTokens: 3, refillRate: 3, refillIntervalMs: 1000 }, })) -vi.mock('@/components/emails', () => ({ renderOTPEmail: mockRenderOTPEmail })) +vi.mock('@/components/emails', () => ({ + getOtpSubject: (label: string) => `Verification code for ${label}`, + renderOTPEmail: mockRenderOTPEmail, +})) vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mockSendEmail })) vi.mock('@/lib/core/rate-limiter', () => ({ RateLimiter: class { diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts index 0dd240788fd..c6b556ad41d 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { renderOTPEmail } from '@/components/emails' +import { getOtpSubject, renderOTPEmail } from '@/components/emails' import { requestPublicFileOtpContract, verifyPublicFileOtpContract, @@ -104,7 +104,7 @@ export const POST = withRouteHandler( const emailHtml = await renderOTPEmail(otp, email, 'email-verification', SHARE_EMAIL_LABEL) const emailResult = await sendEmail({ to: email, - subject: `Verification code for ${SHARE_EMAIL_LABEL}`, + subject: getOtpSubject(SHARE_EMAIL_LABEL), html: emailHtml, }) if (!emailResult.success) { diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index 3c10f68f4d4..e87f4a38578 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { renderHelpConfirmationEmail } from '@/components/emails' +import { getRequestConfirmationSubject, renderHelpConfirmationEmail } from '@/components/emails' import { helpFormBodySchema } from '@/lib/api/contracts/common' import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' @@ -130,7 +130,7 @@ ${message} await sendEmail({ to: [email], - subject: `Your ${type} request has been received: ${subject}`, + subject: getRequestConfirmationSubject(subject, type), html: confirmationHtml, from: getFromEmailAddress(), replyTo: getHelpEmailAddress(), diff --git a/apps/sim/components/emails/_styles/base.tokens.test.ts b/apps/sim/components/emails/_styles/base.tokens.test.ts index c1223ebec42..0baac25f776 100644 --- a/apps/sim/components/emails/_styles/base.tokens.test.ts +++ b/apps/sim/components/emails/_styles/base.tokens.test.ts @@ -1,9 +1,7 @@ /** * Email styles cannot use CSS variables — clients strip them — so `base.ts` - * hardcodes hex copies of the platform tokens. Nothing else detects it when - * `globals.css`, `tailwind.config.ts`, or the chip chrome moves and the copies - * go stale, which is exactly how they drifted before. This suite is that - * detector. + * hardcodes hex copies of the platform tokens. This suite fails when those + * copies drift from `globals.css`, `tailwind.config.ts`, or the chip chrome. * * @vitest-environment node */ @@ -11,32 +9,28 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { baseStyles, colors, typography } from '@/components/emails/_styles' +import tailwindConfig from '@/tailwind.config' const APP_ROOT = join(__dirname, '../../..') const globalsCss = readFileSync(join(APP_ROOT, 'app/_styles/globals.css'), 'utf8') -const tailwindConfig = readFileSync(join(APP_ROOT, 'tailwind.config.ts'), 'utf8') const chipChrome = readFileSync( join(APP_ROOT, '../../packages/emcn/src/components/chip/chip-chrome.ts'), 'utf8' ) +const tailwindFontSize = tailwindConfig.theme?.extend?.fontSize as Record + /** - * The light-mode `:root` block. Dark mode redefines the same names later in the - * file, and emails are light-only, so the FIRST definition is the one to read. + * Dark mode redefines the same names later in the file and emails are + * light-only, so the FIRST definition is the one to read. */ function readCssVar(name: string): string { - const match = globalsCss.match(new RegExp(`--${name}:\\s*([^;]+);`)) + const match = globalsCss.match(new RegExp(`(?:^|[^-\\w])--${name}:\\s*([^;]+);`, 'm')) if (!match) throw new Error(`--${name} not found in globals.css`) return match[1].trim() } -function readTailwindFontSize(name: string): string { - const match = tailwindConfig.match(new RegExp(`\\b${name}:\\s*'([^']+)'`)) - if (!match) throw new Error(`fontSize.${name} not found in tailwind.config.ts`) - return match[1] -} - /** Every email color token and the platform variable it copies. */ const COLOR_MIRROR: Record = { bgOuter: 'surface-1', @@ -44,6 +38,7 @@ const COLOR_MIRROR: Record = { surfaceSubtle: 'surface-3', textPrimary: 'text-primary', textBody: 'text-body', + textSecondary: 'text-secondary', textMuted: 'text-muted', textInverse: 'text-inverse', border: 'border', @@ -52,10 +47,7 @@ const COLOR_MIRROR: Record = { footerBg: 'surface-1', } -/** - * Tokens with no single CSS variable behind them. Each needs a stated reason — - * an entry here is a deliberate exception, not an oversight. - */ +/** Tokens with no single CSS variable behind them, and why. */ const UNMIRRORED_COLORS: Record = { brandTertiary: 'Runtime-conditional on getBrandConfig(); neutral default equals --text-primary.', } @@ -69,30 +61,25 @@ describe('email color tokens mirror globals.css', () => { it('every color token is either mirrored or has a written exemption', () => { const accounted = new Set([...Object.keys(COLOR_MIRROR), ...Object.keys(UNMIRRORED_COLORS)]) - const unaccounted = Object.keys(colors).filter((key) => !accounted.has(key)) - expect(unaccounted).toEqual([]) - }) - - it('exemptions state a reason', () => { - for (const reason of Object.values(UNMIRRORED_COLORS)) { - expect(reason.trim().length).toBeGreaterThan(0) - } + expect(Object.keys(colors).filter((key) => !accounted.has(key))).toEqual([]) }) }) describe('email type scale mirrors tailwind.config.ts', () => { - it.each(['caption', 'base', 'md'])('fontSize.%s matches the Tailwind token', (name) => { - expect(typography.fontSize[name as 'caption' | 'base' | 'md']).toBe(readTailwindFontSize(name)) + it.each(['caption', 'small', 'base', 'md'])('fontSize.%s matches the Tailwind token', (name) => { + expect(typography.fontSize[name as keyof typeof typography.fontSize]).toBe( + tailwindFontSize[name] + ) }) it('sm is Tailwind stock 14px — the size text-sm resolves to in chip chrome', () => { expect(typography.fontSize.sm).toBe('14px') + expect(tailwindFontSize.sm).toBeUndefined() expect(chipChrome).toContain('text-sm') }) - it('display is deliberately off-scale (no platform headline-numeral token)', () => { - expect(typography.fontSize.display).toBe('24px') - expect(tailwindConfig).not.toContain("'24px'") + it('display is deliberately off-scale — the platform has no headline numeral', () => { + expect(Object.values(tailwindFontSize)).not.toContain(typography.fontSize.display) }) }) @@ -106,10 +93,9 @@ describe('email geometry mirrors the platform', () => { it('the CTA transcribes chipGeometryClass', () => { const geometry = chipChrome.match(/chipGeometryClass = `([^`]+)`/)?.[1] expect(geometry).toBeDefined() - expect(geometry).toContain('h-[30px]') - expect(geometry).toContain('rounded-lg') - expect(geometry).toContain('px-2') - expect(geometry).toContain('text-sm') + for (const token of ['h-[30px]', 'rounded-lg', 'px-2', 'text-sm']) { + expect(geometry).toContain(token) + } expect(baseStyles.button.lineHeight).toBe('30px') expect(baseStyles.button.borderRadius).toBe('8px') diff --git a/apps/sim/components/emails/_styles/base.ts b/apps/sim/components/emails/_styles/base.ts index c9b51e90de4..6aa4c7d02d0 100644 --- a/apps/sim/components/emails/_styles/base.ts +++ b/apps/sim/components/emails/_styles/base.ts @@ -21,6 +21,8 @@ function buildColors() { textPrimary: '#1a1a1a', /** Body and value text — platform `--text-body` */ textBody: '#434343', + /** De-emphasized text inside a body block — platform `--text-secondary` */ + textSecondary: '#525252', /** Muted text (labels, footer) — platform `--text-muted` */ textMuted: '#7a7a7a', /** Accent for buttons and links — neutral by default, brand color when whitelabeled */ @@ -56,10 +58,13 @@ export const typography = { fontFamily: "'Season Sans', system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif", /** - * Deliberately brand-free, for the plain personal emails — those read as a - * message typed by a person, so they must NOT carry the brand face. + * Deliberately brand-free, for emails that must read as typed by a person + * (the plain founder notes, the agent's thread replies). Carries the same + * non-brand fallbacks as {@link fontFamily} so Android and Linux clients land + * on Roboto rather than a generic sans. */ - systemFontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + systemFontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', /** * `caption`/`base`/`md` are Sim's own scale from `tailwind.config.ts`. `sm` is * Tailwind's stock 14px — not a Sim token, but what `text-sm` resolves to in @@ -67,6 +72,7 @@ export const typography = { */ fontSize: { caption: '12px', + small: '13px', sm: '14px', base: '15px', /** Email body copy. Larger than the app's 15px `base` — the client default. */ @@ -104,7 +110,7 @@ export const spacing = { paragraphGap: 12, } -/** Shared body-copy ramp. {@link baseStyles.paragraph} and `greeting` differ only in margin. */ +/** Shared body-copy ramp. */ const bodyText = { fontSize: typography.fontSize.md, lineHeight: typography.lineHeight.body, @@ -113,7 +119,7 @@ const bodyText = { fontFamily: typography.fontFamily, } -/** Shared box geometry. {@link baseStyles.infoBox} and `errorBox` differ only in fill. */ +/** Shared box geometry. */ const boxGeometry = { padding: '16px 18px', borderRadius: RADIUS, @@ -218,10 +224,9 @@ export const baseStyles = { }, /** - * The closing fine-print line inside the card (who this was sent to, when it - * fires again). Same ramp as {@link footerText}, but left-aligned — the card - * is left-aligned while the footer's cells are not. Every template spelled - * this out as a spread override; use the token. + * The closing fine-print line inside the card. Same ramp as + * {@link footerText}, but left-aligned — the card is left-aligned while the + * footer's own cells are not. */ footnote: { fontSize: typography.fontSize.caption, diff --git a/apps/sim/components/emails/agent/inbox-response-email.tsx b/apps/sim/components/emails/agent/inbox-response-email.tsx new file mode 100644 index 00000000000..05322657c83 --- /dev/null +++ b/apps/sim/components/emails/agent/inbox-response-email.tsx @@ -0,0 +1,184 @@ +import { type ComponentType, type CSSProperties, createElement, type ReactNode } from 'react' +import { Body, Head, Html, Link, Markdown, Section, Text } from '@react-email/components' +import { colors, fontWeight, typography } from '@/components/emails/_styles' +import { getBrandConfig } from '@/ee/whitelabeling' + +const CODE_FONT_FAMILY = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace" + +const BODY_TEXT = { + fontSize: typography.fontSize.base, + lineHeight: '25px', + color: colors.textPrimary, + fontFamily: typography.systemFontFamily, + fontWeight: fontWeight.normal, +} + +const HEADING = { + fontWeight: fontWeight.semibold, + color: colors.textPrimary, + margin: '24px 0 12px 0', + fontFamily: typography.systemFontFamily, +} + +const CODE_SURFACE = { + backgroundColor: colors.surfaceSubtle, + fontFamily: CODE_FONT_FAMILY, + fontSize: typography.fontSize.small, + color: colors.textPrimary, +} + +const emailStyles = { + body: BODY_TEXT, + content: { margin: 0 }, + markdownContainer: { margin: 0 }, + signature: { color: colors.textSecondary, marginTop: '32px', fontSize: typography.fontSize.sm }, + signatureText: { + color: colors.textSecondary, + margin: '0 0 16px 0', + fontSize: typography.fontSize.sm, + lineHeight: '25px', + fontFamily: typography.systemFontFamily, + }, + signatureLink: { + color: colors.textPrimary, + textDecoration: 'underline', + textDecorationStyle: 'dashed', + textUnderlineOffset: '2px', + }, +} satisfies Record + +const markdownStyles = { + p: { ...BODY_TEXT, margin: '0 0 16px 0' }, + h1: { ...HEADING, fontSize: typography.fontSize.display, lineHeight: '32px' }, + h2: { ...HEADING, fontSize: '20px', lineHeight: '28px' }, + h3: { ...HEADING, fontSize: typography.fontSize.md, lineHeight: '24px' }, + h4: { ...HEADING, fontSize: typography.fontSize.base, lineHeight: '25px' }, + /** + * `bold`, not `strong` — that is the key `@react-email/markdown` looks up. A + * `strong` key silently falls through to its default of 700, off the + * platform's 400/500/600 scale. + */ + bold: { fontWeight: fontWeight.semibold, color: colors.textPrimary }, + codeInline: { ...CODE_SURFACE, padding: '2px 6px', borderRadius: '4px' }, + codeBlock: { + ...CODE_SURFACE, + padding: '16px', + borderRadius: '8px', + border: `1px solid ${colors.border}`, + overflowX: 'auto', + margin: '24px 0', + lineHeight: '21px', + }, + table: { borderCollapse: 'collapse', margin: '16px 0' }, + th: { + border: `1px solid ${colors.border}`, + padding: '8px 12px', + textAlign: 'left', + fontSize: typography.fontSize.sm, + backgroundColor: colors.surfaceSubtle, + fontWeight: fontWeight.semibold, + }, + td: { + border: `1px solid ${colors.border}`, + padding: '8px 12px', + textAlign: 'left', + fontSize: typography.fontSize.sm, + }, + blockQuote: { + borderLeft: `4px solid ${colors.border}`, + margin: '16px 0', + padding: '4px 16px', + color: colors.textSecondary, + fontStyle: 'italic', + }, + a: { + color: colors.textPrimary, + textDecoration: 'underline', + textDecorationStyle: 'dashed', + textUnderlineOffset: '2px', + }, + ul: { margin: '16px 0', paddingLeft: '24px' }, + ol: { margin: '16px 0', paddingLeft: '24px' }, + li: { margin: '4px 0' }, + hr: { border: 'none', borderTop: `1px solid ${colors.border}`, margin: '24px 0' }, +} satisfies Record + +interface EmailMarkdownProps { + children?: string + markdownContainerStyles?: CSSProperties + markdownCustomStyles?: Record +} + +const EmailMarkdown = Markdown as ComponentType + +/** + * Shell for the agent's reply. Deliberately not {@link EmailLayout}: this lands + * inside an existing mail thread, where a logo header and unsubscribe footer + * would be wrong — the same carve-out as `plainEmailStyles`. + */ +function InboxShell({ + children, + chatUrl, + linkLabel, +}: { + children?: ReactNode + chatUrl: string + linkLabel: string +}) { + return createElement( + Html, + { lang: 'en', dir: 'ltr' }, + createElement(Head), + createElement( + Body, + { style: emailStyles.body }, + createElement(Section, { style: emailStyles.content }, children), + createElement( + Section, + { style: emailStyles.signature }, + createElement( + Text, + { style: emailStyles.signatureText }, + createElement(Link, { href: chatUrl, style: emailStyles.signatureLink }, linkLabel) + ), + createElement( + Text, + { style: emailStyles.signatureText }, + 'Best,', + createElement('br'), + getBrandConfig().name + ) + ) + ) + ) +} + +/** The agent's reply carrying its markdown answer. */ +export function InboxResponseEmail({ markdown, chatUrl }: { markdown: string; chatUrl: string }) { + return createElement( + InboxShell, + { chatUrl, linkLabel: 'View full conversation' }, + createElement( + EmailMarkdown, + { + markdownContainerStyles: emailStyles.markdownContainer, + markdownCustomStyles: markdownStyles, + }, + markdown + ) + ) +} + +/** The agent's reply when it could not complete the task. */ +export function InboxErrorEmail({ error, chatUrl }: { error: string; chatUrl: string }) { + return createElement( + InboxShell, + { chatUrl, linkLabel: 'View details' }, + createElement(Text, { style: markdownStyles.p }, "I wasn't able to complete this task."), + createElement( + Text, + { style: { ...markdownStyles.p, color: colors.textSecondary } }, + `Error: ${error}` + ) + ) +} diff --git a/apps/sim/components/emails/billing/payment-failed-email.tsx b/apps/sim/components/emails/billing/payment-failed-email.tsx index ace471b9747..e531ae4c376 100644 --- a/apps/sim/components/emails/billing/payment-failed-email.tsx +++ b/apps/sim/components/emails/billing/payment-failed-email.tsx @@ -1,6 +1,7 @@ import { Link, Section, Text } from '@react-email/components' import { baseStyles, colors, fontWeight } from '@/components/emails/_styles' import { EmailButton, EmailLayout } from '@/components/emails/components' +import { getEmailSubject } from '@/components/emails/subjects' import { getBrandConfig } from '@/ee/whitelabeling' interface PaymentFailedEmailProps { @@ -9,7 +10,6 @@ interface PaymentFailedEmailProps { lastFourDigits?: string billingPortalUrl: string failureReason?: string - sentDate?: Date } export function PaymentFailedEmail({ @@ -18,11 +18,10 @@ export function PaymentFailedEmail({ lastFourDigits, billingPortalUrl, failureReason, - sentDate = new Date(), }: PaymentFailedEmailProps) { const brand = getBrandConfig() - const previewText = `${brand.name}: Payment Failed - Action Required` + const previewText = getEmailSubject('payment-failed') return ( diff --git a/apps/sim/components/emails/boundary.test.ts b/apps/sim/components/emails/boundary.test.ts new file mode 100644 index 00000000000..cfe6956c8d6 --- /dev/null +++ b/apps/sim/components/emails/boundary.test.ts @@ -0,0 +1,98 @@ +/** + * Enforces that every email leaves the app through the shared layer: + * `render.ts` for the body, `subjects.ts` for the subject. + * + * @vitest-environment node + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { renderInboxResponseEmail } from '@/components/emails/render' + +const APP_ROOT = join(__dirname, '../..') +const EMAILS_DIR = join(__dirname) + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === '.next') continue + const full = join(dir, entry) + if (statSync(full).isDirectory()) walk(full, out) + else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full) + } + return out +} + +/** + * Only files that actually send mail. Scanning every module under `lib` would + * flag unrelated `subject:` keys (mail-tool params, schema fixtures). + * `app/api/tools/ses` is excluded because it sends the end user's own mail + * through their credentials — it is not a Sim-branded email. + */ +const senderFiles = ['lib', 'app/api', 'background'] + .flatMap((root) => walk(join(APP_ROOT, root))) + .filter((f) => !f.includes(join(APP_ROOT, 'app/api/tools/ses'))) + .filter((f) => readFileSync(f, 'utf8').includes('sendEmail')) + +const rel = (f: string) => f.slice(APP_ROOT.length + 1) + +/** Template components, read from `render.ts`'s imports so new ones are covered. */ +const renderSource = readFileSync(join(EMAILS_DIR, 'render.ts'), 'utf8') +const templateComponents = [ + ...new Set( + [...renderSource.slice(0, renderSource.indexOf('export ')).matchAll(/\b(\w*Email)\b/g)].map( + (m) => m[1] + ) + ), +] + +describe('every email goes through the shared layer', () => { + it('finds the senders and the templates', () => { + expect(senderFiles.length).toBeGreaterThan(5) + expect(templateComponents).toContain('WelcomeEmail') + }) + + it('no sender reaches for @react-email/render', () => { + // Substring, not an import-statement match — senders here use `await import()` too. + const offenders = senderFiles.filter((f) => + readFileSync(f, 'utf8').includes('@react-email/render') + ) + expect(offenders.map(rel)).toEqual([]) + }) + + it('no sender names a template component instead of its render wrapper', () => { + const offenders: string[] = [] + for (const file of senderFiles) { + const src = readFileSync(file, 'utf8') + for (const component of templateComponents) { + // Whole-file scan so static and dynamic imports are both covered. The + // word boundary keeps `PaymentFailedEmail` from matching inside + // `renderPaymentFailedEmail`. + if (new RegExp(`\\b${component}\\b`).test(src)) { + offenders.push(`${rel(file)} -> ${component}`) + } + } + } + expect(offenders).toEqual([]) + }) + + it('no sender builds its own subject line', () => { + const offenders: string[] = [] + for (const file of senderFiles) { + for (const match of readFileSync(file, 'utf8').matchAll(/subject:\s*(['"`])(.*?)\1/g)) { + // Bracket-tagged subjects are internal team-inbox alerts, not product email. + if (match[2].startsWith('[')) continue + offenders.push(`${rel(file)}: ${match[2]}`) + } + } + expect(offenders).toEqual([]) + }) + + it('the agent reply keeps markdown emphasis on the platform weight scale', async () => { + const html = await renderInboxResponseEmail({ + markdown: 'Some **emphasis** here.', + chatUrl: 'https://example.test/chat', + }) + expect(html).not.toMatch(/font-weight:(700|bold)/) + expect(html).toContain('font-weight:600') + }) +}) diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 3b8f7e694f2..83d285fe17f 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -1,4 +1,5 @@ import { render } from '@react-email/render' +import { InboxErrorEmail, InboxResponseEmail } from '@/components/emails/agent/inbox-response-email' import { ExistingAccountEmail, OnboardingFollowupEmail, @@ -30,8 +31,6 @@ import type { UpgradeReason } from '@/lib/billing/upgrade-reasons' import { getBaseUrl } from '@/lib/core/utils/urls' import type { ScheduleDisableReason } from '@/lib/workflows/schedules/disable-reasons' -export { getEmailSubject, getLimitEmailSubject } from './subjects' - interface WorkspaceInvitation { workspaceId: string workspaceName: string @@ -284,3 +283,24 @@ export async function renderPaymentFailedEmail(params: { }) ) } + +/** Neutralize `javascript:`/`data:` hrefs that agent-authored markdown could emit. */ +function stripUnsafeUrls(html: string): string { + return html.replace(/href\s*=\s*(['"])(?:javascript|vbscript|data):.*?\1/gi, 'href="#"') +} + +/** The agent's reply to an inbound email. */ +export async function renderInboxResponseEmail(params: { + markdown: string + chatUrl: string +}): Promise { + return stripUnsafeUrls(await render(InboxResponseEmail(params))) +} + +/** The agent's reply when the task could not be completed. */ +export async function renderInboxErrorEmail(params: { + error: string + chatUrl: string +}): Promise { + return stripUnsafeUrls(await render(InboxErrorEmail(params))) +} diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index b5a4dc7ac71..b8df1a5f27f 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -12,12 +12,10 @@ export type EmailSubjectType = | 'invitation' | 'batch-invitation' | 'workspace-added' - | 'help-confirmation' | 'enterprise-subscription' | 'usage-threshold' | 'free-tier-upgrade' - | 'plan-welcome-pro' - | 'plan-welcome-team' + | 'payment-failed' | 'credit-purchase' | 'abandoned-checkout' | 'free-tier-exhausted' @@ -52,18 +50,14 @@ export function getEmailSubject(type: EmailSubjectType): string { return `You've been invited to join a team and workspaces on ${brandName}` case 'workspace-added': return `You've been added to a workspace on ${brandName}` - case 'help-confirmation': - return 'Your request has been received' case 'enterprise-subscription': return `Your Enterprise Plan is now active on ${brandName}` case 'usage-threshold': return `You're nearing your monthly budget on ${brandName}` case 'free-tier-upgrade': return `You're at 80% of your free credits on ${brandName}` - case 'plan-welcome-pro': - return `Your Pro plan is now active on ${brandName}` - case 'plan-welcome-team': - return `Your Team plan is now active on ${brandName}` + case 'payment-failed': + return `Payment failed on ${brandName} — action required` case 'credit-purchase': return `Credits added to your ${brandName} account` case 'abandoned-checkout': @@ -92,3 +86,20 @@ export function getLimitEmailSubject(reason: UpgradeReason, kind: 'warning' | 'r const subject = kind === 'reached' ? copy.reachedSubject : copy.warningSubject return `${subject} on ${brandName}` } + +/** The plan's display name is resolved at send time; it carries tier qualifiers. */ +export function getPlanWelcomeSubject(planDisplayName: string): string { + return `Your ${planDisplayName} plan is now active on ${getBrandConfig().name}` +} + +/** Echoes the sender's own subject line so the reply threads correctly. */ +export function getRequestConfirmationSubject(userSubject: string, requestType?: string): string { + return requestType + ? `Your ${requestType} request has been received: ${userSubject}` + : `We've received your message: ${userSubject}` +} + +/** Names the resource being unlocked rather than the brand — that is what the recipient opened. */ +export function getOtpSubject(resourceLabel: string): string { + return `Verification code for ${resourceLabel}` +} diff --git a/apps/sim/lib/billing/core/limit-notifications.test.ts b/apps/sim/lib/billing/core/limit-notifications.test.ts index 1d00608d855..dca72db39b7 100644 --- a/apps/sim/lib/billing/core/limit-notifications.test.ts +++ b/apps/sim/lib/billing/core/limit-notifications.test.ts @@ -26,7 +26,7 @@ vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: sendEmailSpy })) vi.mock('@/lib/messaging/email/unsubscribe', () => ({ getEmailPreferences: getEmailPreferencesMock, })) -vi.mock('@/components/emails/render', () => ({ +vi.mock('@/components/emails', () => ({ renderLimitThresholdEmail: renderMock, getLimitEmailSubject: subjectMock, })) @@ -72,6 +72,9 @@ describe('maybeSendLimitThresholdEmail', () => { expect(sendEmailSpy).toHaveBeenCalledTimes(1) expect(renderMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'warning' })) expect(subjectMock).toHaveBeenCalledWith('storage', 'warning') + // Pins the subject to the shared helper's return, so a sender that builds + // its own string — or a mock aimed at the wrong module path — fails here. + expect(sendEmailSpy).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Subject' })) }) it('sends a reached email at/over 100%', async () => { diff --git a/apps/sim/lib/billing/core/limit-notifications.ts b/apps/sim/lib/billing/core/limit-notifications.ts index 3b0b7f753a3..2df581f402a 100644 --- a/apps/sim/lib/billing/core/limit-notifications.ts +++ b/apps/sim/lib/billing/core/limit-notifications.ts @@ -3,7 +3,7 @@ import { member, organization, settings, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, sql } from 'drizzle-orm' -import { getLimitEmailSubject, renderLimitThresholdEmail } from '@/components/emails/render' +import { getLimitEmailSubject, renderLimitThresholdEmail } from '@/components/emails' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import type { BillingEntity } from '@/lib/billing/core/usage-log' diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index e4b9fa010d2..44345553e4d 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -731,21 +731,24 @@ export async function sendPlanWelcomeEmail(subscription: any): Promise { .limit(1) if (users.length > 0 && users[0].email) { - const { getEmailSubject, renderPlanWelcomeEmail } = await import('@/components/emails') + const { getPlanWelcomeSubject, renderPlanWelcomeEmail } = await import( + '@/components/emails' + ) const { sendEmail } = await import('@/lib/messaging/email/mailer') const baseUrl = getBaseUrl() const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers') + const displayName = getDisplayPlanName(subPlan) + const html = await renderPlanWelcomeEmail({ - planName: getDisplayPlanName(subPlan), + planName: displayName, userName: users[0].name || undefined, loginLink: `${baseUrl}/login`, }) - const displayName = getDisplayPlanName(subPlan) await sendEmail({ to: users[0].email, - subject: `Your ${displayName} plan is now active on ${(await import('@/ee/whitelabeling')).getBrandConfig().name}`, + subject: getPlanWelcomeSubject(displayName), html, emailType: 'updates', }) diff --git a/apps/sim/lib/billing/webhooks/invoices.test.ts b/apps/sim/lib/billing/webhooks/invoices.test.ts index cdd2d96088c..61368c8050a 100644 --- a/apps/sim/lib/billing/webhooks/invoices.test.ts +++ b/apps/sim/lib/billing/webhooks/invoices.test.ts @@ -18,9 +18,9 @@ const { mockBlockOrgMembers, mockUnblockOrgMembers } = vi.hoisted(() => ({ })) vi.mock('@/components/emails', () => ({ - PaymentFailedEmail: vi.fn(), getEmailSubject: vi.fn(), renderCreditPurchaseEmail: vi.fn(), + renderPaymentFailedEmail: vi.fn(), })) vi.mock('@/lib/billing/core/billing', () => ({ @@ -93,10 +93,6 @@ vi.mock('@/lib/messaging/email/validation', () => ({ quickValidateEmail: vi.fn(() => ({ isValid: true })), })) -vi.mock('@react-email/render', () => ({ - render: vi.fn(), -})) - import { handleInvoicePaymentFailed, handleInvoicePaymentSucceeded, diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index 52e8b423a3b..a89383ccab0 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -1,4 +1,3 @@ -import { render } from '@react-email/render' import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { @@ -12,7 +11,11 @@ import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' import type Stripe from 'stripe' -import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails' +import { + getEmailSubject, + renderCreditPurchaseEmail, + renderPaymentFailedEmail, +} from '@/components/emails' import { BILLING_LOCK_TIMEOUT_MS } from '@/lib/billing/constants' import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' import { @@ -353,22 +356,19 @@ async function sendPaymentFailureEmails( // Send emails to all affected users for (const userToNotify of usersToNotify) { try { - const emailHtml = await render( - PaymentFailedEmail({ - userName: userToNotify.name || undefined, - amountDue, - lastFourDigits, - billingPortalUrl, - failureReason, - sentDate: new Date(), - }) - ) + const emailHtml = await renderPaymentFailedEmail({ + userName: userToNotify.name || undefined, + amountDue, + lastFourDigits, + billingPortalUrl, + failureReason, + }) const { from } = getPersonalEmailFrom() const replyTo = getHelpEmailAddress() await sendEmail({ to: userToNotify.email, - subject: 'Payment Failed - Action Required', + subject: getEmailSubject('payment-failed'), html: emailHtml, from, replyTo, diff --git a/apps/sim/lib/mothership/inbox/response.ts b/apps/sim/lib/mothership/inbox/response.ts index 9f76951cf8c..e4c0561c8ea 100644 --- a/apps/sim/lib/mothership/inbox/response.ts +++ b/apps/sim/lib/mothership/inbox/response.ts @@ -1,12 +1,11 @@ -import { type ComponentType, type CSSProperties, createElement, type ReactNode } from 'react' -import { Body, Head, Html, Link, Markdown, Section, Text } from '@react-email/components' -import { render } from '@react-email/render' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { renderInboxErrorEmail, renderInboxResponseEmail } from '@/components/emails' import { getBaseUrl } from '@/lib/core/utils/urls' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { replaceUntilStable } from '@/lib/mothership/inbox/format' import type { InboxTask } from '@/lib/mothership/inbox/types' +import { getBrandConfig } from '@/ee/whitelabeling' const logger = createLogger('InboxResponse') @@ -35,13 +34,18 @@ export async function sendInboxResponse( ? `${getBaseUrl()}/workspace/${ctx.workspaceId}/chat/${inboxTask.chatId}` : `${getBaseUrl()}/workspace/${ctx.workspaceId}/home` + const brandName = getBrandConfig().name + const text = result.success - ? `${result.content}\n\n[View full conversation](${chatUrl})\n\nBest,\nMothership` - : `I wasn't able to complete this task.\n\nError: ${result.error || 'Unknown error'}\n\n[View details](${chatUrl})\n\nBest,\nMothership` + ? `${result.content}\n\n[View full conversation](${chatUrl})\n\nBest,\n${brandName}` + : `I wasn't able to complete this task.\n\nError: ${result.error || 'Unknown error'}\n\n[View details](${chatUrl})\n\nBest,\n${brandName}` const html = result.success - ? await renderEmailHtml(result.content, chatUrl) - : await renderErrorHtml(result.error || 'Unknown error', chatUrl) + ? await renderInboxResponseEmail({ + markdown: preserveSoftBreaks(stripRawHtml(result.content)), + chatUrl, + }) + : await renderInboxErrorEmail({ error: result.error || 'Unknown error', chatUrl }) try { const response = await agentmail.replyToMessage( @@ -61,200 +65,6 @@ export async function sendInboxResponse( } } -const FONT_FAMILY = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif" -const CODE_FONT_FAMILY = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace" - -const emailStyles = { - body: { - fontFamily: FONT_FAMILY, - fontSize: '15px', - lineHeight: '25px', - color: '#1a1a1a', - fontWeight: 400, - }, - content: { - margin: 0, - }, - markdownContainer: { - margin: 0, - }, - signature: { - color: '#525252', - marginTop: '32px', - fontSize: '14px', - }, - signatureText: { - color: '#525252', - margin: '0 0 16px 0', - fontSize: '14px', - lineHeight: '25px', - fontFamily: FONT_FAMILY, - }, - signatureLink: { - color: '#1a1a1a', - textDecoration: 'underline', - textDecorationStyle: 'dashed', - textUnderlineOffset: '2px', - }, -} satisfies Record - -const markdownStyles = { - p: { - margin: '0 0 16px 0', - fontSize: '15px', - lineHeight: '25px', - color: '#1a1a1a', - fontFamily: FONT_FAMILY, - fontWeight: 400, - }, - h1: { - fontWeight: 600, - color: '#1a1a1a', - margin: '24px 0 12px 0', - fontSize: '24px', - lineHeight: '32px', - fontFamily: FONT_FAMILY, - }, - h2: { - fontWeight: 600, - color: '#1a1a1a', - margin: '24px 0 12px 0', - fontSize: '20px', - lineHeight: '28px', - fontFamily: FONT_FAMILY, - }, - h3: { - fontWeight: 600, - color: '#1a1a1a', - margin: '24px 0 12px 0', - fontSize: '16px', - lineHeight: '24px', - fontFamily: FONT_FAMILY, - }, - h4: { - fontWeight: 600, - color: '#1a1a1a', - margin: '24px 0 12px 0', - fontSize: '15px', - lineHeight: '25px', - fontFamily: FONT_FAMILY, - }, - strong: { - fontWeight: 600, - color: '#1a1a1a', - }, - codeInline: { - backgroundColor: '#f3f3f3', - padding: '2px 6px', - borderRadius: '4px', - fontFamily: CODE_FONT_FAMILY, - fontSize: '13px', - color: '#1a1a1a', - }, - codeBlock: { - backgroundColor: '#f3f3f3', - padding: '16px', - borderRadius: '8px', - border: '1px solid #ededed', - overflowX: 'auto', - margin: '24px 0', - fontFamily: CODE_FONT_FAMILY, - fontSize: '13px', - lineHeight: '21px', - color: '#1a1a1a', - }, - table: { - borderCollapse: 'collapse', - margin: '16px 0', - }, - th: { - border: '1px solid #ededed', - padding: '8px 12px', - textAlign: 'left', - fontSize: '14px', - backgroundColor: '#f5f5f5', - fontWeight: 600, - }, - td: { - border: '1px solid #ededed', - padding: '8px 12px', - textAlign: 'left', - fontSize: '14px', - }, - blockQuote: { - borderLeft: '4px solid #e0e0e0', - margin: '16px 0', - padding: '4px 16px', - color: '#525252', - fontStyle: 'italic', - }, - a: { - color: '#2563eb', - textDecoration: 'underline', - textDecorationStyle: 'dashed', - textUnderlineOffset: '2px', - }, - ul: { - margin: '16px 0', - paddingLeft: '24px', - }, - ol: { - margin: '16px 0', - paddingLeft: '24px', - }, - li: { - margin: '4px 0', - }, - hr: { - border: 'none', - borderTop: '1px solid #ededed', - margin: '24px 0', - }, -} satisfies Record - -interface InboxResponseEmailProps { - children?: ReactNode - chatUrl: string - linkLabel: string -} - -interface EmailMarkdownProps { - children?: string - markdownContainerStyles?: CSSProperties - markdownCustomStyles?: Record -} - -const EmailMarkdown = Markdown as ComponentType - -function InboxResponseEmail({ children, chatUrl, linkLabel }: InboxResponseEmailProps) { - return createElement( - Html, - { lang: 'en', dir: 'ltr' }, - createElement(Head), - createElement( - Body, - { style: emailStyles.body }, - createElement(Section, { style: emailStyles.content }, children), - createElement( - Section, - { style: emailStyles.signature }, - createElement( - Text, - { style: emailStyles.signatureText }, - createElement(Link, { href: chatUrl, style: emailStyles.signatureLink }, linkLabel) - ), - createElement( - Text, - { style: emailStyles.signatureText }, - 'Best,', - createElement('br'), - 'Sim' - ) - ) - ) - ) -} - function stripRawHtml(text: string): string { return text .split(/(```[\s\S]*?```)/g) @@ -270,48 +80,3 @@ function preserveSoftBreaks(text: string): string { .map((segment, i) => (i % 2 === 0 ? segment.replace(/([^\n])\n(?=[^\n])/g, '$1 \n') : segment)) .join('') } - -function stripUnsafeUrls(html: string): string { - return html.replace(/href\s*=\s*(['"])(?:javascript|vbscript|data):.*?\1/gi, 'href="#"') -} - -async function renderEmailHtml(markdown: string, chatUrl: string): Promise { - const safeMarkdown = preserveSoftBreaks(stripRawHtml(markdown)) - const html = await render( - createElement( - InboxResponseEmail, - { chatUrl, linkLabel: 'View full conversation' }, - createElement( - EmailMarkdown, - { - markdownContainerStyles: emailStyles.markdownContainer, - markdownCustomStyles: markdownStyles, - }, - safeMarkdown - ) - ) - ) - - return stripUnsafeUrls(html) -} - -async function renderErrorHtml(error: string, chatUrl: string): Promise { - const html = await render( - createElement( - InboxResponseEmail, - { chatUrl, linkLabel: 'View details' }, - createElement( - Text, - { key: 'message', style: markdownStyles.p }, - "I wasn't able to complete this task." - ), - createElement( - Text, - { key: 'error', style: { ...markdownStyles.p, color: '#6b7280' } }, - `Error: ${error}` - ) - ) - ) - - return stripUnsafeUrls(html) -} From 4b2412b7525137d6ed89f3b02f761d2684969a66 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 9 Aug 2026 22:02:42 -0700 Subject: [PATCH 2/6] fix(auth): stop offering account creation when registration is disabled (#6484) DISABLE_REGISTRATION blocks /signup server-side, but the invite flow, the login form, the SSO form, and the CLI handoff all kept routing people there, stranding invited users on a dead end. The flag also never covered OAuth account creation, so social sign-in still minted accounts for unknown identities. --- .../platform/self-hosting/authentication.mdx | 6 +- .../self-hosting/environment-variables.mdx | 2 +- apps/sim/app/(auth)/auth-redirect.test.ts | 52 ++++++- apps/sim/app/(auth)/auth-redirect.ts | 36 ++++- apps/sim/app/(auth)/login/login-form.tsx | 5 +- apps/sim/app/(auth)/login/page.tsx | 2 + apps/sim/app/(auth)/signup/page.tsx | 25 ++- .../(auth)/signup/registration-disabled.tsx | 31 ++++ apps/sim/app/(auth)/signup/search-params.ts | 23 +++ apps/sim/app/(auth)/signup/signup-form.tsx | 13 +- apps/sim/app/(auth)/sso/page.tsx | 4 +- apps/sim/app/cli/auth/page.test.tsx | 71 +++++++++ apps/sim/app/cli/auth/page.tsx | 13 +- apps/sim/app/invite/[id]/invite.test.tsx | 75 +++++++-- apps/sim/app/invite/[id]/invite.tsx | 145 ++++++++++++------ apps/sim/app/invite/[id]/page.tsx | 3 +- apps/sim/app/oauth-error/page.tsx | 8 + apps/sim/ee/sso/components/sso-form.test.tsx | 25 ++- apps/sim/ee/sso/components/sso-form.tsx | 9 +- apps/sim/lib/auth/auth.ts | 66 ++++---- apps/sim/lib/auth/constants.test.ts | 50 ++++++ apps/sim/lib/auth/constants.ts | 54 +++++++ 22 files changed, 616 insertions(+), 102 deletions(-) create mode 100644 apps/sim/app/(auth)/signup/registration-disabled.tsx create mode 100644 apps/sim/app/(auth)/signup/search-params.ts create mode 100644 apps/sim/app/cli/auth/page.test.tsx diff --git a/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx b/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx index 15bcd34c7f7..d997438d09b 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx @@ -84,7 +84,7 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th | Variable | Effect | |---|---| -| `DISABLE_REGISTRATION=true` | Blocks email/password registration | +| `DISABLE_REGISTRATION=true` | Blocks all new accounts — email/password, email OTP, and social sign-in. Only existing accounts can sign in, including to accept a workspace invitation. SSO is unaffected | | `DISABLE_EMAIL_SIGNUP=true` | Blocks new email/password registrations; existing email login keeps working | | `ALLOWED_LOGIN_DOMAINS` | Comma-separated domain allowlist, e.g. `acme.com,acme.co.uk`. Gates email sign-**in** as well as signup | | `ALLOWED_LOGIN_EMAILS` | Comma-separated address allowlist, applied the same way | @@ -93,7 +93,9 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th | `BLOCKED_EMAIL_MX_HOSTS` | MX-host substrings to block; used only with the above | - These controls gate the **email/password** path. A first-time sign-in through Google, GitHub, or Microsoft creates an account through the social provider and is not filtered by them. If you need a hard boundary, disable the social providers you have not vetted (`DISABLE_GOOGLE_AUTH`, `DISABLE_GITHUB_AUTH`, `DISABLE_MICROSOFT_AUTH`) or restrict membership at the identity provider and use SSO. + `ALLOWED_LOGIN_DOMAINS`, `ALLOWED_LOGIN_EMAILS`, and `SIGNUP_MX_VALIDATION_ENABLED` gate the **email/password** path only. A first-time sign-in through Google, GitHub, or Microsoft creates an account through the social provider and is not filtered by them. To restrict who may sign in through a social provider, disable the ones you have not vetted (`DISABLE_GOOGLE_AUTH`, `DISABLE_GITHUB_AUTH`, `DISABLE_MICROSOFT_AUTH`) or restrict membership at the identity provider and use SSO. + + `DISABLE_REGISTRATION` and `BLOCKED_SIGNUP_DOMAINS` apply to every path, social included. For a company deployment, the usual pairing is domain-restricted signup plus SSO: diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 8a2f01bf27f..3835925ca00 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -107,7 +107,7 @@ See [Authentication](/platform/self-hosting/authentication). | Variable | Description | |----------|-------------| -| `DISABLE_REGISTRATION` | Set `true` to disable new user signups entirely | +| `DISABLE_REGISTRATION` | Set `true` to block all new accounts, including social sign-in. Invitations still work for people who already have an account. SSO is unaffected | | `DISABLE_EMAIL_SIGNUP` | Block new email/password registrations; existing email login keeps working | | `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) | | `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) | diff --git a/apps/sim/app/(auth)/auth-redirect.test.ts b/apps/sim/app/(auth)/auth-redirect.test.ts index e4b9e25b3df..93a94dd6eae 100644 --- a/apps/sim/app/(auth)/auth-redirect.test.ts +++ b/apps/sim/app/(auth)/auth-redirect.test.ts @@ -2,7 +2,11 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { buildAuthCrossLink, resolvePostSignupDestination } from '@/app/(auth)/auth-redirect' +import { + buildAuthCrossLink, + resolveAuthRedirect, + resolvePostSignupDestination, +} from '@/app/(auth)/auth-redirect' describe('resolvePostSignupDestination', () => { it('routes to the verify hop when verification is enforceable', () => { @@ -56,4 +60,50 @@ describe('buildAuthCrossLink', () => { '/signup' ) }) + + it('marks a new user so the invite page leads with account creation', () => { + expect( + buildAuthCrossLink('/signup', { + callbackUrl: '/invite/abc', + isInviteFlow: true, + isNewUser: true, + }) + ).toBe('/signup?invite_flow=true&callbackUrl=%2Finvite%2Fabc&new=true') + }) + + it('omits the new-user marker by default', () => { + expect(buildAuthCrossLink('/signup', { callbackUrl: null, isInviteFlow: true })).not.toContain( + 'new=true' + ) + }) +}) + +describe('resolveAuthRedirect', () => { + const NONE = { redirect: null, callbackUrl: null, inviteFlow: null } + + it('prefers redirect over callbackUrl', () => { + expect(resolveAuthRedirect({ ...NONE, redirect: '/a', callbackUrl: '/b' }).rawCallbackUrl).toBe( + '/a' + ) + }) + + it('falls through an empty redirect to callbackUrl', () => { + expect( + resolveAuthRedirect({ ...NONE, redirect: '', callbackUrl: '/invite/abc' }).rawCallbackUrl + ).toBe('/invite/abc') + }) + + it('reports no destination when nothing was carried', () => { + expect(resolveAuthRedirect(NONE)).toEqual({ rawCallbackUrl: '', isInviteFlow: false }) + }) + + it('treats an invitation destination as an invite flow without the flag', () => { + expect(resolveAuthRedirect({ ...NONE, callbackUrl: '/invite/abc' }).isInviteFlow).toBe(true) + }) + + it('honors the explicit flag when the destination is unrelated', () => { + expect( + resolveAuthRedirect({ ...NONE, callbackUrl: '/workspace', inviteFlow: 'true' }).isInviteFlow + ).toBe(true) + }) }) diff --git a/apps/sim/app/(auth)/auth-redirect.ts b/apps/sim/app/(auth)/auth-redirect.ts index 0cfd1310b22..269528c0bb1 100644 --- a/apps/sim/app/(auth)/auth-redirect.ts +++ b/apps/sim/app/(auth)/auth-redirect.ts @@ -43,10 +43,43 @@ export function resolvePostSignupDestination({ return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' } } +/** The raw redirect-carrying params, as read from a URL on client or server. */ +interface AuthRedirectParams { + redirect: string | null + callbackUrl: string | null + inviteFlow: string | null +} + +/** + * The post-auth destination a visitor arrived with, and whether they are mid + * invitation. + * + * `redirect` wins over `callbackUrl` — both spellings are in circulation. The + * invite flow is inferred from the destination as well as the explicit flag, so + * a link that lost `invite_flow` still reads as an invitation. + * + * Shared so the signup form and the registration-disabled page cannot drift on + * which param wins; both feed the result to {@link buildAuthCrossLink}. The + * caller validates — this function does not, so that a client can log the + * rejection it already reports. + */ +export function resolveAuthRedirect({ redirect, callbackUrl, inviteFlow }: AuthRedirectParams): { + rawCallbackUrl: string + isInviteFlow: boolean +} { + const rawCallbackUrl = redirect || callbackUrl || '' + return { + rawCallbackUrl, + isInviteFlow: inviteFlow === 'true' || rawCallbackUrl.startsWith('/invite/'), + } +} + interface AuthCrossLinkParams { /** Validated post-auth destination to carry over, or null to drop it. */ callbackUrl: string | null isInviteFlow: boolean + /** Marks the visitor as new so the invite page leads with account creation. */ + isNewUser?: boolean } /** @@ -57,11 +90,12 @@ interface AuthCrossLinkParams { */ export function buildAuthCrossLink( path: '/login' | '/signup', - { callbackUrl, isInviteFlow }: AuthCrossLinkParams + { callbackUrl, isInviteFlow, isNewUser = false }: AuthCrossLinkParams ): string { const params = new URLSearchParams() if (isInviteFlow) params.set('invite_flow', 'true') if (callbackUrl) params.set('callbackUrl', callbackUrl) + if (isNewUser) params.set('new', 'true') const query = params.toString() return query ? `${path}?${query}` : path diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index a2349c428eb..380aeb89a42 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -88,11 +88,14 @@ export default function LoginPage({ googleAvailable, microsoftAvailable, isProduction, + registrationDisabled, }: { githubAvailable: boolean googleAvailable: boolean microsoftAvailable: boolean isProduction: boolean + /** DISABLE_REGISTRATION. Hides the signup cross-link, which `/signup` blocks. */ + registrationDisabled: boolean }) { const router = useRouter() const searchParams = useSearchParams() @@ -436,7 +439,7 @@ export default function LoginPage({ )} - {emailEnabled && ( + {emailEnabled && !registrationDisabled && ( )} diff --git a/apps/sim/app/(auth)/login/page.tsx b/apps/sim/app/(auth)/login/page.tsx index ecec5ceb41a..1490ac85e44 100644 --- a/apps/sim/app/(auth)/login/page.tsx +++ b/apps/sim/app/(auth)/login/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { isRegistrationDisabled } from '@/lib/core/config/env-flags' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import LoginForm from '@/app/(auth)/login/login-form' @@ -20,6 +21,7 @@ export default async function LoginPage() { googleAvailable={googleAvailable} microsoftAvailable={microsoftAvailable} isProduction={isProduction} + registrationDisabled={isRegistrationDisabled} /> ) diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index 3d5a8933cd6..d43dc7c0475 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -1,7 +1,12 @@ import type { Metadata } from 'next' +import type { SearchParams } from 'nuqs/server' import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags' +import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification' +import { resolveAuthRedirect } from '@/app/(auth)/auth-redirect' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' +import { RegistrationDisabled } from '@/app/(auth)/signup/registration-disabled' +import { signupSearchParamsCache } from '@/app/(auth)/signup/search-params' import SignupForm from '@/app/(auth)/signup/signup-form' export const metadata: Metadata = { @@ -10,9 +15,25 @@ export const metadata: Metadata = { export const dynamic = 'force-dynamic' -export default async function SignupPage() { +export default async function SignupPage({ + searchParams, +}: { + searchParams: Promise +}) { if (isRegistrationDisabled) { - return
Registration is disabled, please contact your admin.
+ const { redirect, callbackUrl, inviteFlow } = await signupSearchParamsCache.parse(searchParams) + const { rawCallbackUrl, isInviteFlow } = resolveAuthRedirect({ + redirect, + callbackUrl, + inviteFlow, + }) + + return ( + + ) } const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } = diff --git a/apps/sim/app/(auth)/signup/registration-disabled.tsx b/apps/sim/app/(auth)/signup/registration-disabled.tsx new file mode 100644 index 00000000000..2096c056506 --- /dev/null +++ b/apps/sim/app/(auth)/signup/registration-disabled.tsx @@ -0,0 +1,31 @@ +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' +import { AuthHeader, AuthNavPrompt } from '@/app/(auth)/components' + +interface RegistrationDisabledProps { + /** Post-auth destination the visitor arrived with, already validated. */ + callbackUrl: string | null + isInviteFlow: boolean +} + +/** + * The signup page under DISABLE_REGISTRATION. Visitors reach it from a stale + * link, a bookmark, or an invitation, so it wears the same shell as the form it + * replaces and carries the post-auth destination over to login — an invited + * visitor who lands here can still sign in and end up back on their invitation + * rather than losing it. + */ +export function RegistrationDisabled({ callbackUrl, isInviteFlow }: RegistrationDisabledProps) { + return ( +
+ + +
+ ) +} diff --git a/apps/sim/app/(auth)/signup/search-params.ts b/apps/sim/app/(auth)/signup/search-params.ts new file mode 100644 index 00000000000..54c247dc2a9 --- /dev/null +++ b/apps/sim/app/(auth)/signup/search-params.ts @@ -0,0 +1,23 @@ +import { createSearchParamsCache, parseAsString } from 'nuqs/server' + +/** + * The redirect signals the signup page carries. Read once to decide where a + * visitor goes after authenticating, never written, so every parser is nullable + * with no default — absent means "no destination", which is a real state rather + * than something to fall back from. + */ +const signupParsers = { + redirect: parseAsString, + callbackUrl: parseAsString, + inviteFlow: parseAsString, +} as const + +/** `invite_flow` on the wire; camelCase for destructuring. */ +const signupUrlKeys = { urlKeys: { inviteFlow: 'invite_flow' } } as const + +/** + * Server-side reader for the signup page. The client form reads these same keys + * through `useSearchParams` (the read-once auth-signal carve-out), so the wire + * keys here and in `signup-form.tsx` must stay in step. + */ +export const signupSearchParamsCache = createSearchParamsCache(signupParsers, signupUrlKeys) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 4915da788b3..ad0d5213b97 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -1,6 +1,6 @@ 'use client' -import { Suspense, useEffect, useMemo, useRef, useState } from 'react' +import { Suspense, useEffect, useRef, useState } from 'react' import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { createLogger } from '@sim/logger' import { useRouter, useSearchParams } from 'next/navigation' @@ -15,6 +15,7 @@ import { buildAuthCrossLink, DEFAULT_POST_AUTH_ROUTE, POST_AUTH_REDIRECT_STORAGE_KEY, + resolveAuthRedirect, resolvePostSignupDestination, VERIFY_FROM_SIGNUP_ROUTE, } from '@/app/(auth)/auth-redirect' @@ -123,7 +124,11 @@ function SignupFormContent({ const [formError, setFormError] = useState(null) const turnstileRef = useRef(null) const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) - const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || '' + const { rawCallbackUrl: rawRedirectUrl, isInviteFlow } = resolveAuthRedirect({ + redirect: searchParams.get('redirect'), + callbackUrl: searchParams.get('callbackUrl'), + inviteFlow: searchParams.get('invite_flow'), + }) const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false const invalidCallbackRef = useRef(false) if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) { @@ -131,10 +136,6 @@ function SignupFormContent({ logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl }) } const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : '' - const isInviteFlow = useMemo( - () => searchParams.get('invite_flow') === 'true' || redirectUrl.startsWith('/invite/'), - [searchParams, redirectUrl] - ) const [name, setName] = useState('') const [nameErrors, setNameErrors] = useState([]) diff --git a/apps/sim/app/(auth)/sso/page.tsx b/apps/sim/app/(auth)/sso/page.tsx index b1e27f22608..14463bf4abb 100644 --- a/apps/sim/app/(auth)/sso/page.tsx +++ b/apps/sim/app/(auth)/sso/page.tsx @@ -1,7 +1,7 @@ import { Suspense } from 'react' import type { Metadata } from 'next' import { redirect } from 'next/navigation' -import { isSsoEnabled } from '@/lib/core/config/env-flags' +import { isRegistrationDisabled, isSsoEnabled } from '@/lib/core/config/env-flags' import SSOForm from '@/ee/sso/components/sso-form' export const metadata: Metadata = { @@ -17,7 +17,7 @@ export default async function SSOPage() { return ( - + ) } diff --git a/apps/sim/app/cli/auth/page.test.tsx b/apps/sim/app/cli/auth/page.test.tsx new file mode 100644 index 00000000000..ce1cbcd7d86 --- /dev/null +++ b/apps/sim/app/cli/auth/page.test.tsx @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ +import { envFlagsMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockRedirect } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockRedirect: vi.fn((url: string) => { + throw new Error(`NEXT_REDIRECT:${url}`) + }), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('next/navigation', () => ({ + redirect: mockRedirect, +})) + +import CliAuthPage from '@/app/cli/auth/page' + +/** BASE64URL, 43 chars; pairing is `XXXX-XXXX` over the no-look-alike alphabet. */ +const REQUEST = 'r'.repeat(43) +const CHALLENGE = 'c'.repeat(43) +const PAIRING = 'ABCD-2345' + +const EXPECTED_CALLBACK = encodeURIComponent( + `/cli/auth?request=${REQUEST}&challenge=${CHALLENGE}&pairing=${PAIRING}` +) + +function pageProps() { + return { + searchParams: Promise.resolve({ + request: REQUEST, + challenge: CHALLENGE, + pairing: PAIRING, + }), + } +} + +describe('CliAuthPage signed-out bounce', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue(null) + }) + + afterEach(() => { + envFlagsMock.isRegistrationDisabled = false + }) + + it('sends a signed-out visitor to signup, carrying the handoff as callbackUrl', async () => { + await expect(CliAuthPage(pageProps())).rejects.toThrow( + `NEXT_REDIRECT:/signup?callbackUrl=${EXPECTED_CALLBACK}` + ) + }) + + /** + * Nobody can create an account under the flag, so the pairing visitor is + * necessarily an existing user and signup would be a guaranteed dead end. + */ + it('sends them to login instead when registration is disabled', async () => { + envFlagsMock.isRegistrationDisabled = true + + await expect(CliAuthPage(pageProps())).rejects.toThrow( + `NEXT_REDIRECT:/login?callbackUrl=${EXPECTED_CALLBACK}` + ) + }) +}) diff --git a/apps/sim/app/cli/auth/page.tsx b/apps/sim/app/cli/auth/page.tsx index 2bc0d86370a..e49a47be4cc 100644 --- a/apps/sim/app/cli/auth/page.tsx +++ b/apps/sim/app/cli/auth/page.tsx @@ -3,6 +3,8 @@ import type { Metadata } from 'next' import { redirect } from 'next/navigation' import type { SearchParams } from 'nuqs/server' import { getSession } from '@/lib/auth' +import { isRegistrationDisabled } from '@/lib/core/config/env-flags' +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { AuthShell } from '@/app/(auth)/components' import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request' import { CliAuthView } from '@/app/cli/auth/cli-auth-view' @@ -30,6 +32,10 @@ export const dynamic = 'force-dynamic' * configuring Sim for the first time has no account yet. Both auth pages * cross-link carrying the same `callbackUrl`, so a returning user is one click * from login with their destination intact. + * + * That reasoning inverts under DISABLE_REGISTRATION: nobody can create an + * account, so the pairing visitor is necessarily an existing user and signup is + * guaranteed to be the wrong hop. Go straight to login there. */ export default async function CliAuthPage({ searchParams, @@ -49,7 +55,12 @@ export default async function CliAuthPage({ challenge: resolution.request.challenge, pairing: resolution.request.pairing, }) - redirect(`/signup?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`) + redirect( + buildAuthCrossLink(isRegistrationDisabled ? '/login' : '/signup', { + callbackUrl: `/cli/auth?${query}`, + isInviteFlow: false, + }) + ) } return ( diff --git a/apps/sim/app/invite/[id]/invite.test.tsx b/apps/sim/app/invite/[id]/invite.test.tsx index 83fba300438..24363971dc4 100644 --- a/apps/sim/app/invite/[id]/invite.test.tsx +++ b/apps/sim/app/invite/[id]/invite.test.tsx @@ -27,9 +27,7 @@ const { }, mockPush: vi.fn(), mockRequestJson: vi.fn(), - mockSearchParams: { - get: (key: string) => (key === 'token' ? 'token-1' : null), - }, + mockSearchParams: { current: new URLSearchParams('token=token-1') }, mockSetActive: vi.fn(), mockSetQueryData: vi.fn(), mockSignOut: vi.fn(), @@ -43,7 +41,7 @@ vi.mock('@sim/logger', () => ({ vi.mock('next/navigation', () => ({ useParams: () => ({ id: 'invitation-1' }), useRouter: () => ({ push: mockPush }), - useSearchParams: () => mockSearchParams, + useSearchParams: () => mockSearchParams.current, })) vi.mock('@tanstack/react-query', async () => { @@ -106,15 +104,18 @@ vi.mock('@/app/invite/components', () => ({ InviteLayout: ({ children }: { children: ReactNode }) => children, InviteStatusCard: ({ actions = [], + description, title, type, }: { actions?: Array<{ label: string; onClick: () => void }> + description?: ReactNode title: string type: string }) => ( <>
{title}
+
{description}
{actions.map((action) => ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts index 0c97956e2ce..80865a985e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts @@ -14,24 +14,11 @@ export interface ContextMenuPosition { y: number } -/** - * Sort field options for terminal entries - */ -export type SortField = 'timestamp' - /** * Sort direction options */ export type SortDirection = 'asc' | 'desc' -/** - * Sort configuration for terminal entries - */ -export interface SortConfig { - field: SortField - direction: SortDirection -} - /** * Status type for console entries */ From 90a76dd68dde63dffb0a5b33080c5b7958033dd7 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 9 Aug 2026 23:44:14 -0700 Subject: [PATCH 6/6] fix(invite): hold the loading state until the stored token resolves (#6488) Two gaps left by #6486. The query was gated on isTokenResolved but the loading state was not. With enabled: false React Query still reports success when the key already holds data, so a cached null-token entry made isPending false and rendered the accept UI for one frame before the effect applied the stored token. Reachable only on a client-side remount after a tokenless fetch already succeeded. An empty ?token= also stopped falling back to storage: searchParams.get returns '' which is not null, so token became '' where the pre-#6486 truthiness check had read sessionStorage. Normalize to null at the source. --- apps/sim/app/invite/[id]/invite.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index d1ce0067047..232dfbdb884 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -286,7 +286,8 @@ export default function Invite({ registrationDisabled }: InviteProps) { const isNewUser = searchParams.get('new') === 'true' const errorReason = searchParams.get('error') const urlError = errorReason ? getInviteError(errorReason) : null - const tokenFromQuery = searchParams.get('token') + /** `|| null` so an empty `?token=` falls back to storage rather than querying with ''. */ + const tokenFromQuery = searchParams.get('token') || null /** * Derived during render so the invitation query key is correct on the first * commit; an effect-set token refetches under a second key whenever the @@ -308,7 +309,7 @@ export default function Invite({ registrationDisabled }: InviteProps) { }) const invitation = invitationQuery.data?.invitation ?? null const joinPreview = invitationQuery.data?.joinPreview ?? null - const isLoading = Boolean(session?.user) && invitationQuery.isPending + const isLoading = Boolean(session?.user) && (!isTokenResolved || invitationQuery.isPending) const fetchError = invitationQuery.error ? getInviteError(