diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 190523cf6b0..f91aa1f83fc 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -73,6 +73,7 @@ Manage shared web env var additions and rotations with `pnpm web:env set { expect(mockSendMagicLinkEmail).toHaveBeenCalledWith(mockMagicLinkToken, undefined); }); + describe('while email signups are disabled', () => { + beforeEach(() => { + process.env.EMAIL_SIGNUPS_ENABLED = 'false'; + }); + afterEach(() => { + process.env.EMAIL_SIGNUPS_ENABLED = 'true'; + }); + + it('refuses a new user before creating a token', async () => { + const response = await POST(createRequest({ email: 'new@example.com' })); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'EMAIL-SIGNUP-DISABLED', + }); + expect(mockCreateMagicLinkToken).not.toHaveBeenCalled(); + expect(mockSendMagicLinkEmail).not.toHaveBeenCalled(); + }); + + it('still sends a magic link to an existing user', async () => { + mockFindUserByNormalizedEmail.mockResolvedValue({ + id: 'existing-user', + google_user_email: 'user@example.com', + } as Awaited>); + + const response = await POST(createRequest({ email: 'user@example.com' })); + + expect(response.status).toBe(200); + expect(mockCreateMagicLinkToken).toHaveBeenCalledWith('user@example.com'); + expect(mockSendMagicLinkEmail).toHaveBeenCalled(); + }); + }); + it('rejects magic links for an SSO-protected domain before creating a token', async () => { mockResolveSsoAuthorityForDomain.mockResolvedValue({ status: 'required', diff --git a/apps/web/src/app/api/auth/native/otp/route.test.ts b/apps/web/src/app/api/auth/native/otp/route.test.ts index 5ace00a4c88..e18fd703a8c 100644 --- a/apps/web/src/app/api/auth/native/otp/route.test.ts +++ b/apps/web/src/app/api/auth/native/otp/route.test.ts @@ -99,6 +99,24 @@ describe('POST /api/auth/native/otp', () => { expect(mockCreateSignInCode).not.toHaveBeenCalled(); }); + it('delivers a real code to a new address while email signups are disabled', async () => { + mockCheckEmailSignInEligibility.mockResolvedValue({ + ok: false, + status: 403, + errorCode: 'EMAIL-SIGNUP-DISABLED', + body: { success: false, error: 'EMAIL-SIGNUP-DISABLED' }, + }); + + const response = await POST(createRequest({ email: 'new@example.com' })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + success: true, + challengeId: 'c0000000-0000-0000-0000-000000000001', + }); + expect(mockSendSignInCodeEmail).toHaveBeenCalledWith('new@example.com', '123456'); + }); + it('returns opaque 200 with a challengeId for a blocked domain', async () => { mockCheckEmailSignInEligibility.mockResolvedValue({ ok: false, diff --git a/apps/web/src/app/api/auth/native/otp/route.ts b/apps/web/src/app/api/auth/native/otp/route.ts index f9bb2ff63eb..9eaa3afd5db 100644 --- a/apps/web/src/app/api/auth/native/otp/route.ts +++ b/apps/web/src/app/api/auth/native/otp/route.ts @@ -52,7 +52,9 @@ export const POST = withRestTiming('/api/auth/native/otp', async (request: Reque const { email } = validation.data; const eligibility = await checkEmailSignInEligibility(email, request as NextRequest); - if (!eligibility.ok) { + // Deliver the code so the response does not reveal account existence; the + // token exchange refuses the signup with a step-up shipped apps can render. + if (!eligibility.ok && eligibility.errorCode !== 'EMAIL-SIGNUP-DISABLED') { if (eligibility.errorCode === 'INVALID_EMAIL') { await enforceResponseFloor(startTime); return NextResponse.json({ success: true, challengeId: randomUUID() }); diff --git a/apps/web/src/app/api/auth/native/token/route.test.ts b/apps/web/src/app/api/auth/native/token/route.test.ts index 5f4a9ab5c91..d536446f339 100644 --- a/apps/web/src/app/api/auth/native/token/route.test.ts +++ b/apps/web/src/app/api/auth/native/token/route.test.ts @@ -739,6 +739,22 @@ describe('POST /api/auth/native/token', () => { expect(mockGenerateApiToken).not.toHaveBeenCalled(); }); + it('presents a disabled email signup as the step-up shipped apps understand', async () => { + mockCreateOrUpdateUser.mockResolvedValue({ success: false, error: 'EMAIL-SIGNUP-DISABLED' }); + + const response = await POST( + createRequest({ provider: 'email', email: 'emailuser@example.com', code: '123456' }) + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'SIGNUP-STEP-UP', + methods: ['google', 'apple'], + }); + expect(mockReleaseSignInCode).toHaveBeenCalled(); + expect(mockGenerateApiToken).not.toHaveBeenCalled(); + }); + it('releases the code on DIFFERENT-OAUTH settlement failure so retry succeeds', async () => { mockCreateOrUpdateUser.mockResolvedValue({ success: false, diff --git a/apps/web/src/app/api/auth/native/token/route.ts b/apps/web/src/app/api/auth/native/token/route.ts index 48ccdde39c9..6150824426b 100644 --- a/apps/web/src/app/api/auth/native/token/route.ts +++ b/apps/web/src/app/api/auth/native/token/route.ts @@ -13,7 +13,10 @@ import { releaseSignInCode, consumeSignInCode, } from '@kilocode/web-shared/lib/auth/magic-link-tokens'; -import { hosted_domain_specials } from '@kilocode/web-shared/lib/auth/constants'; +import { + hosted_domain_specials, + SIGNUP_STEP_UP_METHODS, +} from '@kilocode/web-shared/lib/auth/constants'; import { consumeSignInTicket } from '@/lib/auth/passkey'; import { findUserById } from '@kilocode/web-shared/lib/user/find-user-by-id'; import { @@ -76,6 +79,13 @@ function settlementFailureResponse(failure: Extract { } ); + describe('while email signups are disabled', () => { + beforeEach(() => { + process.env.EMAIL_SIGNUPS_ENABLED = 'false'; + mockIsNewAccountEligibleForMagicLink.mockResolvedValue(false); + }); + afterEach(() => { + process.env.EMAIL_SIGNUPS_ENABLED = 'true'; + }); + + it('offers a new address only non-email providers and flags the pause', async () => { + const body = await (await POST(request({ email: 'new@example.com' }))).json(); + + expect(body).toEqual({ + kind: 'new', + providers: expect.arrayContaining(['google', 'github']), + emailSignupDisabled: true, + }); + expect(body.providers).not.toContain('email'); + }); + + it('keeps Email for an existing account', async () => { + mockGetAllUserProviders.mockResolvedValue({ + kind: 'found', + user: { kiloUserId: 'user-1', providers: ['email'], primaryEmail: 'user@example.com' }, + }); + + await expect((await POST(request({ email: 'user@example.com' }))).json()).resolves.toEqual({ + kind: 'existing', + providers: ['email'], + }); + }); + }); + it('keeps an unknown Gmail plus alias new and ineligible for Email', async () => { mockIsNewAccountEligibleForMagicLink.mockResolvedValue(false); diff --git a/apps/web/src/app/api/sso/organizations/route.ts b/apps/web/src/app/api/sso/organizations/route.ts index 3e3a933ed62..f788695a822 100644 --- a/apps/web/src/app/api/sso/organizations/route.ts +++ b/apps/web/src/app/api/sso/organizations/route.ts @@ -11,6 +11,7 @@ import { type SignInDiscoveryResponse, } from '@/lib/schemas/sso-organizations'; import { isNewAccountEligibleForMagicLink } from '@/lib/auth/email-signin-eligibility'; +import { isEmailSignupDisabled } from '@/lib/auth/email-signup-switch'; import { ProdNonSSOAuthProviders } from '@/lib/auth/provider-metadata'; const warnInSentry = sentryLogger('sso-organizations', 'warning'); @@ -104,7 +105,11 @@ export async function POST(request: Request): Promise { ? ProdNonSSOAuthProviders : ProdNonSSOAuthProviders.filter(provider => provider !== 'email')), ]; - return discoveryResponse({ kind: 'new', providers }); + return discoveryResponse({ + kind: 'new', + providers, + ...(isEmailSignupDisabled() ? { emailSignupDisabled: true } : {}), + }); } catch (err: unknown) { warnInSentry('sso error'); captureException(err, { diff --git a/apps/web/src/components/auth/AuthErrorNotification.test.ts b/apps/web/src/components/auth/AuthErrorNotification.test.ts index 94bc7703814..92addf7d6b5 100644 --- a/apps/web/src/components/auth/AuthErrorNotification.test.ts +++ b/apps/web/src/components/auth/AuthErrorNotification.test.ts @@ -45,6 +45,18 @@ describe('AuthErrorNotification', () => { expect(html).toContain(message); }); + it('points new email signups at the other sign-in options', () => { + const html = renderToStaticMarkup( + createElement(AuthErrorNotification, { error: 'EMAIL-SIGNUP-DISABLED' }) + ); + + expect(html).toContain('role="alert"'); + expect(html).toContain('Email Sign-up Is Paused'); + expect(html).toContain('be created with email right now'); + expect(html).toContain('choose one of the other'); + expect(html).toContain('You can still sign in with email.'); + }); + it('uses the generic fallback for an unknown callback error', () => { const html = renderToStaticMarkup( createElement(AuthErrorNotification, { error: 'untrusted-query-error' }) diff --git a/apps/web/src/components/auth/AuthErrorNotification.tsx b/apps/web/src/components/auth/AuthErrorNotification.tsx index fea9be45f36..700226898d4 100644 --- a/apps/web/src/components/auth/AuthErrorNotification.tsx +++ b/apps/web/src/components/auth/AuthErrorNotification.tsx @@ -146,6 +146,16 @@ export function AuthErrorNotification({ ); + if (error === 'EMAIL-SIGNUP-DISABLED') + return ( +
+ + New accounts can't be created with email right now. Please choose one of the other + sign-in options. Already have an account? You can still sign in with email. + +
+ ); + if (error === 'EMAIL-INVALID-FORMAT') return (
diff --git a/apps/web/src/hooks/useSignInFlow.test.ts b/apps/web/src/hooks/useSignInFlow.test.ts index 7375c15ae7c..f3c6d3af4da 100644 --- a/apps/web/src/hooks/useSignInFlow.test.ts +++ b/apps/web/src/hooks/useSignInFlow.test.ts @@ -443,6 +443,53 @@ describe('useSignInFlow discovery cancellation', () => { expect(mounted.container.querySelector('#state')?.textContent).toBe('magic-link-sent'); }); + it('returns a refused sign-up Email to the provider buttons with the pause notice', async () => { + fetchMock.mockResolvedValueOnce(response({ success: true })); + mockSendMagicLink.mockResolvedValueOnce({ + success: false, + error: 'EMAIL-SIGNUP-DISABLED', + } as never); + mounted = mountFlow({ searchParams: {}, isSignUp: true }); + + act(() => button(mounted!.container, 'show-email').click()); + act(() => button(mounted!.container, 'new-email').click()); + act(() => button(mounted!.container, 'submit-email').click()); + await act(async () => { + button(mounted!.container, 'first').click(); + await flushAsyncWork(); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(mounted.container.querySelector('#state')?.textContent).toBe('landing'); + expect(mounted.container.querySelector('#email-input')?.textContent).toBe('false'); + expect(mounted.container.querySelector('#error')?.textContent).toBe('EMAIL-SIGNUP-DISABLED'); + }); + + it('shows the pause notice beside new-account providers when discovery flags it', async () => { + fetchMock + .mockResolvedValueOnce(response({ success: true })) + .mockResolvedValueOnce( + response({ kind: 'new', providers: ['google', 'github'], emailSignupDisabled: true }) + ); + mounted = mountFlow(); + + act(() => button(mounted!.container, 'new-email').click()); + act(() => button(mounted!.container, 'submit-email').click()); + await act(async () => { + button(mounted!.container, 'first').click(); + await flushAsyncWork(); + }); + + expect(mounted.container.querySelector('#state')?.textContent).toBe('provider-select'); + expect(mounted.container.querySelector('#error')?.textContent).toBe('EMAIL-SIGNUP-DISABLED'); + }); + + it('accepts the email signup pause as a callback error', () => { + mounted = mountFlow({ searchParams: {}, error: 'EMAIL-SIGNUP-DISABLED' }); + + expect(mounted.container.querySelector('#error')?.textContent).toBe('EMAIL-SIGNUP-DISABLED'); + }); + it('does not auto-verify a prefilled explicit sign-up email', async () => { mounted = mountFlow({ searchParams: { email: 'new@example.com' }, isSignUp: true }); diff --git a/apps/web/src/hooks/useSignInFlow.ts b/apps/web/src/hooks/useSignInFlow.ts index 524ecf373f2..e85b8159234 100644 --- a/apps/web/src/hooks/useSignInFlow.ts +++ b/apps/web/src/hooks/useSignInFlow.ts @@ -24,6 +24,7 @@ const callbackAuthErrors = new Set([ 'SIGNUP-STEP-UP', 'SIGNUP-UNAVAILABLE', 'EMAIL-ALREADY-USED', + 'EMAIL-SIGNUP-DISABLED', 'EMAIL-INVALID-FORMAT', 'EMAIL-MUST-BE-LOWERCASE', 'EMAIL-CANNOT-CONTAIN-PLUS', @@ -306,7 +307,16 @@ export function useSignInFlow({ } setIsVerifying(false); setShowTurnstile(false); - setError('MAGIC_LINK_DELIVERY_FAILED'); + if (result.error === 'EMAIL-SIGNUP-DISABLED') { + setError('EMAIL-SIGNUP-DISABLED'); + // On sign-up, return to the provider buttons the notice points at. + if (isSignUp) { + setPendingSignIn(null); + setShowEmailInput(false); + } + } else { + setError('MAGIC_LINK_DELIVERY_FAILED'); + } setFlowState('landing'); return false; } catch (error) { @@ -323,7 +333,7 @@ export function useSignInFlow({ } } }, - [email, isCurrentDiscoveryRequest, params, saveHint] + [email, isCurrentDiscoveryRequest, isSignUp, params, saveHint] ); useEffect( @@ -486,6 +496,7 @@ export function useSignInFlow({ } if (checkResult.kind === 'new') { + if (checkResult.emailSignupDisabled) setError('EMAIL-SIGNUP-DISABLED'); setIsNewUser(true); setIsVerifying(false); setAvailableProviders(orderNewAccountProviders(checkResult.providers)); diff --git a/apps/web/src/lib/auth/email-signin-eligibility.test.ts b/apps/web/src/lib/auth/email-signin-eligibility.test.ts index 5f2f188bdca..7d22115882e 100644 --- a/apps/web/src/lib/auth/email-signin-eligibility.test.ts +++ b/apps/web/src/lib/auth/email-signin-eligibility.test.ts @@ -84,4 +84,17 @@ describe('isNewAccountEligibleForMagicLink', () => { await expect(isNewAccountEligibleForMagicLink('not-an-email')).resolves.toBe(false); expect(mockIsEmailBlacklistedByDomainAsync).not.toHaveBeenCalled(); }); + + it.each([undefined, '', 'false', 'TRUE'])( + 'rejects every new address unless EMAIL_SIGNUPS_ENABLED is exactly true: %s', + async value => { + if (value === undefined) delete process.env.EMAIL_SIGNUPS_ENABLED; + else process.env.EMAIL_SIGNUPS_ENABLED = value; + try { + await expect(isNewAccountEligibleForMagicLink('new@example.com')).resolves.toBe(false); + } finally { + process.env.EMAIL_SIGNUPS_ENABLED = 'true'; + } + } + ); }); diff --git a/apps/web/src/lib/auth/email-signin-eligibility.ts b/apps/web/src/lib/auth/email-signin-eligibility.ts index c24e1699e6b..41741ab76e2 100644 --- a/apps/web/src/lib/auth/email-signin-eligibility.ts +++ b/apps/web/src/lib/auth/email-signin-eligibility.ts @@ -3,6 +3,7 @@ import { checkRateLimit } from '@vercel/firewall'; import { createHmac } from 'node:crypto'; import { findUserByNormalizedEmail, getWorkOSOrganization } from '@/lib/user'; import { validateMagicLinkSignupEmail } from '@/lib/schemas/email'; +import { isEmailSignupDisabled } from '@/lib/auth/email-signup-switch'; import { isEmailBlacklistedByDomainAsync, isBlockedTLD, @@ -86,7 +87,12 @@ export type EmailSignInEligibility = | { ok: false; status: number; - errorCode: 'BLOCKED' | 'INVALID_EMAIL' | 'SIGNUP-RATE-LIMITED' | 'SSO_ERROR'; + errorCode: + | 'BLOCKED' + | 'EMAIL-SIGNUP-DISABLED' + | 'INVALID_EMAIL' + | 'SIGNUP-RATE-LIMITED' + | 'SSO_ERROR'; body: Record; }; @@ -95,7 +101,7 @@ export type EmailSignInEligibility = * creating a token, sending mail, or consuming a rate-limit allowance. */ export async function isNewAccountEligibleForMagicLink(email: string): Promise { - if (!validateMagicLinkSignupEmail(email).valid) { + if (isEmailSignupDisabled() || !validateMagicLinkSignupEmail(email).valid) { return false; } @@ -112,6 +118,7 @@ export async function isNewAccountEligibleForMagicLink(email: string): Promise; diff --git a/apps/web/src/lib/user/index.test.ts b/apps/web/src/lib/user/index.test.ts index 750e607ef95..08169a85f15 100644 --- a/apps/web/src/lib/user/index.test.ts +++ b/apps/web/src/lib/user/index.test.ts @@ -953,6 +953,138 @@ describe('User', () => { expect(verifyAdmission).not.toHaveBeenCalled(); }); + describe('while email signups are disabled', () => { + const emailArgs = (email: string) => ({ + google_user_email: email, + google_user_name: 'Email User', + google_user_image_url: '', + hosted_domain: null, + provider: 'email' as const, + provider_account_id: email, + }); + const headers = () => new Headers({ 'x-forwarded-for': '203.0.113.90' }); + + afterEach(() => { + process.env.EMAIL_SIGNUPS_ENABLED = 'true'; + }); + + it('refuses a new email account before Bouncer, Stripe, or user creation', async () => { + const email = 'email-signup-disabled-new@example.com'; + process.env.EMAIL_SIGNUPS_ENABLED = 'false'; + + const result = await createOrUpdateUser(emailArgs(email), undefined, true, headers()); + + expect(result).toEqual({ success: false, error: 'EMAIL-SIGNUP-DISABLED' }); + expect(mockSignupDecide).not.toHaveBeenCalled(); + expect(createStripeCustomer).not.toHaveBeenCalled(); + expect( + await db.query.kilocode_users.findFirst({ + where: eq(kilocode_users.google_user_email, email), + }) + ).toBeUndefined(); + }); + + it('signs in an existing email user', async () => { + const email = 'email-signup-disabled-existing@example.com'; + const created = await createOrUpdateUser(emailArgs(email), undefined, true, headers()); + expect(created.success).toBe(true); + process.env.EMAIL_SIGNUPS_ENABLED = 'false'; + + const signedIn = await createOrUpdateUser(emailArgs(email), undefined, true, headers()); + + expect(signedIn.success).toBe(true); + if (!signedIn.success) return; + expect(signedIn.isNew).toBe(false); + }); + + it('links email to an existing OAuth user on their first magic link', async () => { + const email = 'email-signup-disabled-oauth@example.com'; + const created = await createOrUpdateUser( + { + ...emailArgs(email), + provider: 'google', + provider_account_id: 'google-email-signup-disabled', + }, + undefined, + false, + headers() + ); + expect(created.success).toBe(true); + if (!created.success) return; + process.env.EMAIL_SIGNUPS_ENABLED = 'false'; + + const signedIn = await createOrUpdateUser(emailArgs(email), undefined, true, headers()); + + expect(signedIn.success).toBe(true); + if (!signedIn.success) return; + expect(signedIn.user.id).toBe(created.user.id); + expect(signedIn.isNew).toBe(false); + }); + + it('keeps EMAIL-ALREADY-USED for a normalized match of an existing account', async () => { + const created = await createOrUpdateUser( + emailArgs('emailsignupdisabledalias@gmail.com'), + undefined, + true, + headers() + ); + expect(created.success).toBe(true); + process.env.EMAIL_SIGNUPS_ENABLED = 'false'; + + const result = await createOrUpdateUser( + emailArgs('email.signup.disabled.alias@gmail.com'), + undefined, + true, + headers() + ); + + expect(result).toEqual({ success: false, error: 'EMAIL-ALREADY-USED' }); + }); + + it('refuses a case variant of a legacy account without a normalized email', async () => { + await insertTestUser({ + id: 'email-signup-disabled-legacy', + google_user_email: 'Legacy.Case@Example.com', + normalized_email: null, + }); + process.env.EMAIL_SIGNUPS_ENABLED = 'false'; + + const result = await createOrUpdateUser( + emailArgs('legacy.case@example.com'), + undefined, + true, + headers() + ); + + expect(result).toEqual({ success: false, error: 'EMAIL-ALREADY-USED' }); + expect(createStripeCustomer).not.toHaveBeenCalled(); + expect( + await db.query.kilocode_users.findFirst({ + where: eq(kilocode_users.google_user_email, 'legacy.case@example.com'), + }) + ).toBeUndefined(); + }); + + it('still creates a new OAuth account', async () => { + process.env.EMAIL_SIGNUPS_ENABLED = 'false'; + + const result = await createOrUpdateUser( + { + ...emailArgs('email-signup-disabled-google-new@example.com'), + provider: 'google', + provider_account_id: 'google-email-signup-disabled-new', + }, + undefined, + false, + headers() + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.isNew).toBe(true); + }); + }); + it.each([ { name: 'IP rejection', flags: [{ name: 'signup:burst', source: 'ip' }] }, { name: 'JA4 rejection', flags: [{ name: 'signup:ja4', source: 'ja4' }] }, diff --git a/apps/web/src/lib/user/index.ts b/apps/web/src/lib/user/index.ts index 43416ed0b0c..ff0d7aeb0e1 100644 --- a/apps/web/src/lib/user/index.ts +++ b/apps/web/src/lib/user/index.ts @@ -143,6 +143,7 @@ import { type SignupStepUpMethod, } from '@kilocode/web-shared/lib/auth/constants'; import { shouldAutoProvisionPlatformAdmin } from '@/lib/admin/platform-admin'; +import { isEmailSignupDisabled } from '@/lib/auth/email-signup-switch'; import { strict as assert } from 'node:assert'; import type { OptionalError, Result } from '@kilocode/web-shared/lib/maybe-result'; import { failureResult, successResult, trpcFailure } from '@kilocode/web-shared/lib/maybe-result'; @@ -606,12 +607,25 @@ export async function createOrUpdateUser( } } + const normalizedEmail = normalizeEmail(args.google_user_email); + const operationId = signupOperationId(normalizedEmail); + + // Never fall through to the insert: its dedup misses legacy rows with a NULL + // normalized_email that findUserByNormalizedEmail matches case-insensitively. + if (args.provider === 'email' && isEmailSignupDisabled()) { + const matchingUser = await findUserByNormalizedEmail(args.google_user_email); + console.info('[auth] Signup creation outcome', { + operationId, + outcome: 'rejected', + reason: matchingUser ? 'email_already_used' : 'email_signup_disabled', + }); + return failureResult(matchingUser ? 'EMAIL-ALREADY-USED' : 'EMAIL-SIGNUP-DISABLED'); + } + if (turnstile_guid && (await findUserById(turnstile_guid))) throw new Error('Abuser warning: turnstile guid reuse detected ' + turnstile_guid); const signupIp = getSignupIp(requestHeaders); - const normalizedEmail = normalizeEmail(args.google_user_email); - const operationId = signupOperationId(normalizedEmail); if (verifyNewAccountAdmission && !(await verifyNewAccountAdmission())) { console.info('[auth] Signup creation outcome', { operationId, diff --git a/apps/web/src/lib/user/next-auth-options.ts b/apps/web/src/lib/user/next-auth-options.ts index 44e17d81c79..257639adb41 100644 --- a/apps/web/src/lib/user/next-auth-options.ts +++ b/apps/web/src/lib/user/next-auth-options.ts @@ -1204,6 +1204,7 @@ export const authOptions: NextAuthOptions = { 'SIGNUP-RATE-LIMITED', 'SIGNUP-STEP-UP', 'EMAIL-ALREADY-USED', + 'EMAIL-SIGNUP-DISABLED', ]; // Only log unexpected errors to Sentry diff --git a/apps/web/src/tests/setup/globalSetup.ts b/apps/web/src/tests/setup/globalSetup.ts index 29c8fdeeee4..acef895e4aa 100644 --- a/apps/web/src/tests/setup/globalSetup.ts +++ b/apps/web/src/tests/setup/globalSetup.ts @@ -10,6 +10,8 @@ export default function globalSetup() { loadEnvFile({ path: join(cwd, '.env') }); loadEnvFile({ path: join(cwd, '.env.test'), override: true }); loadEnvFile({ path: join(cwd, '.env.test.local'), override: true }); + // Not in tracked .env.test: `pnpm web:env set` rejects tracked values equal to remote ones. + process.env.EMAIL_SIGNUPS_ENABLED ??= 'true'; // Clean up any existing worker setup flags from previous test runs const tmpDir = join(cwd, '.tmp'); diff --git a/packages/web-shared/src/lib/auth/constants.ts b/packages/web-shared/src/lib/auth/constants.ts index 2aac7f8e4cc..ca6b25091f7 100644 --- a/packages/web-shared/src/lib/auth/constants.ts +++ b/packages/web-shared/src/lib/auth/constants.ts @@ -14,6 +14,7 @@ export type AuthErrorType = | 'SIGNUP-STEP-UP' | 'SIGNUP-UNAVAILABLE' | 'EMAIL-ALREADY-USED' + | 'EMAIL-SIGNUP-DISABLED' | 'ADMISSION_REQUIRED' | 'SSO_ERROR';