diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index a7f37f4363..1c384f8195 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -98,6 +98,11 @@ Manage shared web env var additions and rotations with `pnpm web:env set ({ + useFeatureFlagEnabled: () => mockFlagEnabled, +})); + +jest.mock('@/components/PageLayout', () => ({ + PageLayout: ({ children }: { children: ReactElement }) => children, +})); + +jest.mock('@/components/organizations/byok/BYOKKeysManager', () => ({ + BYOKKeysManager: () => createElement('div', null, 'BYOKKeysManager'), +})); + +jest.mock('@/components/organizations/byok/OpenAiChatGptCard', () => ({ + OpenAiChatGptCard: () => createElement('div', null, 'card'), + OpenAiChatGptCardView: () => createElement('div', null, 'card-skeleton'), +})); + +const { default: PersonalBYOKPage } = require('./page') as { + default: () => ReactElement; +}; + +describe('PersonalBYOKPage ChatGPT flag gate', () => { + it('hides the ChatGPT card when the flag is off', () => { + mockFlagEnabled = false; + const html = renderToStaticMarkup(createElement(PersonalBYOKPage)); + + expect(html).not.toContain('card'); + expect(html).toContain('BYOKKeysManager'); + }); + + it('holds the card height while the flag is still loading', () => { + mockFlagEnabled = undefined; + const html = renderToStaticMarkup(createElement(PersonalBYOKPage)); + + expect(html).toContain('card-skeleton'); + expect(html).not.toMatch(/>card { + mockFlagEnabled = true; + const html = renderToStaticMarkup(createElement(PersonalBYOKPage)); + + expect(html).toMatch(/>card - +
+ {chatGptEnabled === true ? ( + + ) : chatGptEnabled === undefined ? ( + // Hold the card's reserved height until the flags resolve, so the key + // list does not shift when the card appears. + + ) : null} + +
); } diff --git a/apps/web/src/app/api/openrouter/[...path]/route.test.ts b/apps/web/src/app/api/openrouter/[...path]/route.test.ts index 99335edfa1..4cc74ca2fa 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.test.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.test.ts @@ -1020,6 +1020,54 @@ describe('POST /api/openrouter/v1/chat/completions rules-engine actions', () => expect(mockedUpstreamRequest).not.toHaveBeenCalled(); }); + it('returns the reconnect error instead of serving another billing path when the ChatGPT connection is dead', async () => { + mockedGetProvider.mockResolvedValue({ + kind: 'chatgpt-reconnect', + message: 'Your ChatGPT connection has expired. Reconnect to continue.', + }); + + const { POST } = await import('./route'); + const response = await POST(makeRequest(makeBody()) as never); + const body = (await response.json()) as { error: string; error_type: string }; + + expect(response.status).toBe(400); + expect(body.error_type).toBe('byok_error'); + expect(body.error).toContain('Your ChatGPT connection has expired. Reconnect to continue.'); + expect(body.error).toContain('/byok'); + expect(mockedUpstreamRequest).not.toHaveBeenCalled(); + expect(mockedClassifyAbuse).not.toHaveBeenCalled(); + }); + + it('applies delay before returning the reconnect error when quarantine-3 re-resolution is dead', async () => { + jest.useFakeTimers(); + mockedRedisGet.mockResolvedValue(cachedRulesEngineAction('quarantine-3')); + mockedClassifyAbuse.mockResolvedValue(classifyResult('quarantine-3')); + mockedGetProvider + .mockResolvedValueOnce({ + kind: 'provider', + provider, + userByok: null, + bypassAccessCheck: false, + }) + .mockResolvedValueOnce({ + kind: 'chatgpt-reconnect', + message: 'Your ChatGPT connection has expired. Reconnect to continue.', + }); + + const { POST } = await import('./route'); + const responsePromise = POST(makeRequest(makeBody()) as never); + + await jest.advanceTimersByTimeAsync(5999); + expect(mockedUpstreamRequest).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(1); + const response = await responsePromise; + + expect(response.status).toBe(400); + expect(mockedGetProvider).toHaveBeenCalledTimes(2); + expect(mockedUpstreamRequest).not.toHaveBeenCalled(); + }); + it('applies delay before returning error when quarantine-3 override API kind is unsupported', async () => { jest.useFakeTimers(); mockedRedisGet.mockResolvedValue(cachedRulesEngineAction('quarantine-3')); diff --git a/apps/web/src/app/api/openrouter/[...path]/route.ts b/apps/web/src/app/api/openrouter/[...path]/route.ts index afcdae119a..b5dc337d3c 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.ts @@ -57,6 +57,7 @@ import { apiKindNotSupportedResponse, checkExclusiveModelProviderAllowed, modelDoesNotExistOnOpenRouterResponse, + chatGptReconnectResponse, } from '@/lib/ai-gateway/llm-proxy-helpers'; import { ProxyErrorType } from '@/lib/proxy-error-types'; import { getBalanceAndOrgSettings } from '@/lib/organizations/organization-usage'; @@ -686,6 +687,11 @@ export async function POST(request: NextRequest): Promise 0) { + await sleepForRulesEngineAction(rulesEngineDecision.delayMs); + } + return chatGptReconnectResponse(quarantineProviderResult.message); + } effectiveProviderContext = quarantineProviderResult; diff --git a/apps/web/src/app/auth/openai/callback/route.test.ts b/apps/web/src/app/auth/openai/callback/route.test.ts new file mode 100644 index 0000000000..215b3fa65b --- /dev/null +++ b/apps/web/src/app/auth/openai/callback/route.test.ts @@ -0,0 +1,196 @@ +const mockNextAuthHttpHandler = jest.fn, [NextRequest, unknown]>(); +const mockGetUserFromSession = jest.fn, []>(); + +jest.mock('@/lib/user/server', () => ({ + nextAuthHttpHandler: (...args: [NextRequest, unknown]) => mockNextAuthHttpHandler(...args), + getUserFromSession: () => mockGetUserFromSession(), +})); + +import { NextRequest } from 'next/server'; +import { GET } from './route'; + +const CALLBACK_URL = 'https://app.kilo.ai/auth/openai/callback'; + +beforeEach(() => { + jest.clearAllMocks(); + mockNextAuthHttpHandler.mockResolvedValue(new Response(null, { status: 200 })); +}); + +function forwardedRequest(): NextRequest | undefined { + return mockNextAuthHttpHandler.mock.calls[0]?.[0]; +} + +function forwardedContext(): { params: Promise<{ nextauth: string[] }> } | undefined { + return mockNextAuthHttpHandler.mock.calls[0]?.[1] as + | { params: Promise<{ nextauth: string[] }> } + | undefined; +} + +function redirectResponse(location: string): Response { + return new Response(null, { status: 302, headers: { location } }); +} + +describe('GET /auth/openai/callback', () => { + test('rewrites the registered callback path to the next-auth openai callback', async () => { + mockGetUserFromSession.mockResolvedValue(null); + const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`); + + await GET(request, {}); + + expect(mockNextAuthHttpHandler).toHaveBeenCalledTimes(1); + expect(forwardedRequest()?.nextUrl.pathname).toBe('/api/auth/callback/openai'); + expect(forwardedRequest()?.nextUrl.searchParams.get('code')).toBe('the-code'); + expect(forwardedRequest()?.nextUrl.searchParams.get('state')).toBe('the-state'); + // NextAuth's App Router adapter picks the action and provider from + // `context.params.nextauth`; without them the delegated call takes its + // legacy API-handler path, which cannot read a NextRequest at all. + await expect(forwardedContext()?.params).resolves.toEqual({ nextauth: ['callback', 'openai'] }); + }); + + test('redirects a callback error to BYOK when a session exists', async () => { + mockGetUserFromSession.mockResolvedValue({ id: 'user-1' }); + const request = new NextRequest(`${CALLBACK_URL}?error=access_denied&state=the-state`); + + const response = await GET(request, {}); + + expect(mockNextAuthHttpHandler).not.toHaveBeenCalled(); + const location = new URL(response.headers.get('location') ?? ''); + expect(location.pathname).toBe('/byok'); + expect(location.searchParams.get('openai_error')).toBe('access_denied'); + }); + + test('falls through to next-auth on a callback error without a session', async () => { + mockGetUserFromSession.mockResolvedValue(null); + const request = new NextRequest(`${CALLBACK_URL}?error=access_denied&state=the-state`); + + await GET(request, {}); + + expect(mockNextAuthHttpHandler).toHaveBeenCalledTimes(1); + expect(forwardedRequest()?.nextUrl.pathname).toBe('/api/auth/callback/openai'); + expect(forwardedRequest()?.nextUrl.searchParams.get('error')).toBe('access_denied'); + }); + + test('redirects an internal callback failure to BYOK when a session exists', async () => { + mockGetUserFromSession.mockResolvedValue({ id: 'user-1' }); + mockNextAuthHttpHandler.mockResolvedValue( + redirectResponse('https://app.kilo.ai/api/auth/error?error=OAuthCallback') + ); + const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`); + + const response = await GET(request, {}); + + expect(mockNextAuthHttpHandler).toHaveBeenCalledTimes(1); + const location = new URL(response.headers.get('location') ?? ''); + expect(location.pathname).toBe('/byok'); + expect(location.searchParams.get('openai_error')).toBe('OAuthCallback'); + }); + + test('redirects the bare sign-in hop next-auth emits when the callback has no usable profile', async () => { + mockGetUserFromSession.mockResolvedValue({ id: 'user-1' }); + // `routes/callback.js` returns `${url}/signin` with no error parameter when + // the exchange yields no profile/account: the only sign-in hop the callback + // action itself emits. The error hop above is followed by the browser, not + // by this route, so this shape is what reaches us from the token exchange. + mockNextAuthHttpHandler.mockResolvedValue( + redirectResponse('http://localhost:3000/api/auth/signin') + ); + const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`); + + const response = await GET(request, {}); + + const location = new URL(response.headers.get('location') ?? ''); + expect(location.pathname).toBe('/byok'); + expect(location.searchParams.get('openai_error')).toBe('OAuthCallback'); + }); + + test('keeps the error code a sign-in hop carries', async () => { + mockGetUserFromSession.mockResolvedValue({ id: 'user-1' }); + mockNextAuthHttpHandler.mockResolvedValue( + redirectResponse('https://app.kilo.ai/api/auth/signin?error=OAuthAccountNotLinked') + ); + const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`); + + const response = await GET(request, {}); + + const location = new URL(response.headers.get('location') ?? ''); + expect(location.pathname).toBe('/byok'); + expect(location.searchParams.get('openai_error')).toBe('OAuthAccountNotLinked'); + }); + + test('leaves an error hop without a failure code unchanged', async () => { + mockGetUserFromSession.mockResolvedValue({ id: 'user-1' }); + const nextAuthResponse = redirectResponse('https://app.kilo.ai/api/auth/error'); + mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse); + const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`); + + const response = await GET(request, {}); + + expect(response).toBe(nextAuthResponse); + expect(mockGetUserFromSession).not.toHaveBeenCalled(); + }); + + test('leaves the internal callback failure on the sign-in path without a session', async () => { + mockGetUserFromSession.mockResolvedValue(null); + const nextAuthResponse = redirectResponse( + 'https://app.kilo.ai/api/auth/error?error=OAuthCallback' + ); + mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse); + const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`); + + const response = await GET(request, {}); + + expect(response).toBe(nextAuthResponse); + expect(response.headers.get('location')).toBe( + 'https://app.kilo.ai/api/auth/error?error=OAuthCallback' + ); + }); + + test('leaves the bare sign-in hop on the sign-in path without a session', async () => { + mockGetUserFromSession.mockResolvedValue(null); + const nextAuthResponse = redirectResponse('http://localhost:3000/api/auth/signin'); + mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse); + const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`); + + const response = await GET(request, {}); + + expect(response).toBe(nextAuthResponse); + expect(response.headers.get('location')).toBe('http://localhost:3000/api/auth/signin'); + }); + + test('returns a successful callback redirect unchanged', async () => { + mockGetUserFromSession.mockResolvedValue({ id: 'user-1' }); + const nextAuthResponse = redirectResponse('https://app.kilo.ai/byok'); + mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse); + const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`); + + const response = await GET(request, {}); + + expect(response).toBe(nextAuthResponse); + expect(mockGetUserFromSession).not.toHaveBeenCalled(); + }); + + test('returns a non-redirect callback response unchanged', async () => { + mockGetUserFromSession.mockResolvedValue({ id: 'user-1' }); + const nextAuthResponse = new Response(null, { status: 200 }); + mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse); + const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`); + + const response = await GET(request, {}); + + expect(response).toBe(nextAuthResponse); + }); + + test('does not rewrite an auth error response for a request without a code', async () => { + mockGetUserFromSession.mockResolvedValue({ id: 'user-1' }); + const nextAuthResponse = redirectResponse( + 'https://app.kilo.ai/api/auth/error?error=OAuthCallback' + ); + mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse); + const request = new NextRequest(`${CALLBACK_URL}?state=the-state`); + + const response = await GET(request, {}); + + expect(response).toBe(nextAuthResponse); + expect(mockGetUserFromSession).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/auth/openai/callback/route.ts b/apps/web/src/app/auth/openai/callback/route.ts new file mode 100644 index 0000000000..3058ca1205 --- /dev/null +++ b/apps/web/src/app/auth/openai/callback/route.ts @@ -0,0 +1,131 @@ +import { getUserFromSession, nextAuthHttpHandler } from '@/lib/user/server'; +import { NextRequest, NextResponse } from 'next/server'; + +/** + * The OpenAI OAuth client is registered with the callback path + * `/auth/openai/callback` (see `OPENAI_REDIRECT_PATH`). NextAuth only knows how + * to complete a callback at `/api/auth/callback/`, so this route + * rewrites the request to that path before delegating. + * + * Every NextAuth 4 cookie uses `path: '/'`, so the state, PKCE and nonce + * cookies set when the sign-in started are present here. + * + * Nothing on this path is trusted before the token exchange: no claim is read + * and no account is matched until NextAuth has verified the ID token. + */ +const NEXT_AUTH_CALLBACK_PATH = '/api/auth/callback/openai'; + +/** + * NextAuth's App Router adapter takes the action and provider from + * `context.params.nextauth` (`next-auth/next/index.js`); it never derives them + * from the URL. This static route has no dynamic params of its own, so the + * delegated call must carry the ones the real `/api/auth/[...nextauth]` route + * receives. Without them NextAuth falls back to its legacy API-handler adapter, + * which reads `req.query` and cannot read a `NextRequest`, so the callback + * would never reach the token exchange. + */ +const NEXT_AUTH_CALLBACK_CONTEXT = { + params: Promise.resolve({ + nextauth: NEXT_AUTH_CALLBACK_PATH.split('/').slice(3), + }), +}; + +/** + * The two locations NextAuth's callback action uses to surface an internal + * failure: the `/api/auth/error` hop, which always carries the failure code in + * its `error` query parameter, and the bare `/api/auth/signin` hop it returns + * when the token exchange produced no usable profile or account + * (`next-auth/core/routes/callback.js`). + */ +const NEXT_AUTH_ERROR_PATH = '/api/auth/error'; +const NEXT_AUTH_SIGNIN_PATH = '/api/auth/signin'; + +/** + * The failure code used for the bare sign-in hop, which carries none of its + * own. It is NextAuth's own code for a callback-stage failure + * (`next-auth/core/routes/callback.js`). + */ +const DEFAULT_CALLBACK_FAILURE_CODE = 'OAuthCallback'; + +function redirectToByok(request: NextRequest, errorCode: string): NextResponse { + const redirectUrl = new URL('/byok', request.nextUrl.origin); + redirectUrl.searchParams.set('openai_error', errorCode); + return NextResponse.redirect(redirectUrl); +} + +/** + * NextAuth reports a callback-stage failure (a rejected or expired code, a + * wrong client secret, a network error during the token exchange) with a 3xx to + * `/api/auth/error`, and a callback whose exchange yields no usable profile + * with a 3xx to `/api/auth/signin` carrying no error code. A person who started + * the connect from the BYOK card would land on the sign-in page with no way + * back and no card message, so this turns either redirect into the card's + * readable failure. The browser follows the error hop itself, so the first + * response is the only chance to catch it here. + * + * Returns null - leaving NextAuth's response untouched - unless this is a real + * authorization-code callback (a sign-in start or a providers fetch must not be + * touched), the failure location is one of NextAuth's two hops, a failure code + * is available (the hop's own, or the default for the bare sign-in hop), and + * the request still has a session. Without a session this is a failed sign-up, + * which must keep landing on the sign-in page with NextAuth's existing readable + * error. Nothing here is logged, so no token or secret can leak into a log + * line. + */ +async function redirectCallbackFailureToByok( + response: Response, + request: NextRequest +): Promise { + if (!request.nextUrl.searchParams.get('code')) { + return null; + } + if (response.status < 300 || response.status >= 400) { + return null; + } + const location = response.headers.get('location'); + if (!location) { + return null; + } + const failureUrl = new URL(location, request.url); + const isErrorHop = failureUrl.pathname === NEXT_AUTH_ERROR_PATH; + const isSignInHop = failureUrl.pathname === NEXT_AUTH_SIGNIN_PATH; + if (!isErrorHop && !isSignInHop) { + return null; + } + const carriedCode = failureUrl.searchParams.get('error'); + const errorCode = carriedCode || (isSignInHop ? DEFAULT_CALLBACK_FAILURE_CODE : null); + if (!errorCode) { + return null; + } + const session = await getUserFromSession(); + if (!session) { + return null; + } + return redirectToByok(request, errorCode); +} + +async function handler(request: NextRequest, _context: unknown) { + const errorCode = request.nextUrl.searchParams.get('error'); + + // A declined or failed connect should return a signed-in person to the BYOK + // page rather than to the sign-in page. Only a real session can select that + // destination; without one the request falls through to NextAuth, which + // surfaces the error on its own terms. + if (errorCode) { + const session = await getUserFromSession(); + if (session) { + return redirectToByok(request, errorCode); + } + } + + const rewrittenUrl = request.nextUrl.clone(); + rewrittenUrl.pathname = NEXT_AUTH_CALLBACK_PATH; + const response = await nextAuthHttpHandler( + new NextRequest(rewrittenUrl, request), + NEXT_AUTH_CALLBACK_CONTEXT + ); + const failure = await redirectCallbackFailureToByok(response, request); + return failure ?? response; +} + +export { handler as GET }; diff --git a/apps/web/src/components/auth/OpenAILogo.tsx b/apps/web/src/components/auth/OpenAILogo.tsx new file mode 100644 index 0000000000..e20bc22a98 --- /dev/null +++ b/apps/web/src/components/auth/OpenAILogo.tsx @@ -0,0 +1,15 @@ +export function OpenAILogo() { + return ( + + ); +} diff --git a/apps/web/src/components/auth/SignInForm.signInOptions.test.ts b/apps/web/src/components/auth/SignInForm.signInOptions.test.ts new file mode 100644 index 0000000000..e29fcdccf3 --- /dev/null +++ b/apps/web/src/components/auth/SignInForm.signInOptions.test.ts @@ -0,0 +1,150 @@ +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires -- Jest node-environment mocks must be registered before loading the component. */ +// The sign-in landing keeps the email prompt but must also offer the OAuth +// providers (including 'Sign in with ChatGPT'), the same group the sign-up page +// renders. The ChatGPT option is behind the PostHog flag, which for a +// signed-out visitor is evaluated against the email the visitor typed; the hook +// is stubbed here and the filter is asserted. The provider buttons and the +// email form are stubbed the way `SignInForm.test.ts` stubs them (their CSS +// module cannot load in jest), but the stubs render the real provider labels +// and the form's submit label. +import { jest } from '@jest/globals'; +import { createElement, Fragment } from 'react'; +import type { ReactElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +let mockFlowEmail = ''; +let mockChatGptAllowed = false; +let mockHookEmail: string | null = null; + +jest.mock('@/components/AnimatedLogoMark', () => ({ + AnimatedLogoMark: () => null, +})); + +jest.mock('@/hooks/useChatGptSignInAccess', () => ({ + useChatGptSignInAccess: (email: string | null) => { + mockHookEmail = email; + return mockChatGptAllowed; + }, +})); + +jest.mock('@/hooks/useSignInFlow', () => ({ + useSignInFlow: ({ isSignUp }: { searchParams: Record; isSignUp?: boolean }) => ({ + isHintLoaded: true, + emailValidation: { isValid: true, error: null }, + error: '', + showTurnstile: false, + flowState: 'landing', + tier: 'new', + hint: null, + showEmailInput: !isSignUp, + email: mockFlowEmail, + isVerifying: false, + availableProviders: [], + isNewUser: false, + pendingSignIn: null, + turnstileError: false, + turnstileAttemptId: 0, + inviteOrgId: undefined, + inviteOrgName: undefined, + handleEmailSubmit: jest.fn(), + handleEmailChange: jest.fn(), + handleBack: jest.fn(), + handleProviderSelect: jest.fn(), + handleOAuthClick: jest.fn(), + handleClearHint: jest.fn(), + handleSSOContinue: jest.fn(), + handleClearInvite: jest.fn(), + handleTurnstileSuccess: jest.fn(), + handleTurnstileError: jest.fn(), + handleRetryTurnstile: jest.fn(), + handleShowEmailInput: jest.fn(), + }), +})); + +jest.mock('@/components/auth/sign-in/AuthProviderButtons', () => { + const { getProviderById } = require('@/lib/auth/provider-metadata') as { + getProviderById: (id: string) => { name: string; signInLabel?: string }; + }; + return { + AuthProviderButtons: ({ providers }: { providers: string[] }) => + createElement( + Fragment, + null, + ...providers.map(provider => { + const meta = getProviderById(provider); + return createElement( + 'button', + { key: provider }, + meta.signInLabel ?? `Continue with ${meta.name}` + ); + }) + ), + }; +}); + +jest.mock('@/components/auth/sign-in/EmailInputForm', () => ({ + EmailInputForm: ({ submitLabel }: { submitLabel?: string }) => + createElement('button', null, submitLabel ?? 'Continue'), +})); + +const { SignInForm } = require('./SignInForm') as { + SignInForm: (props: { + searchParams: Record; + isSignUp?: boolean; + title?: string; + }) => ReactElement; +}; + +beforeEach(() => { + mockFlowEmail = ''; + mockChatGptAllowed = false; + mockHookEmail = null; +}); + +describe('SignInForm sign-in options', () => { + it('hides ChatGPT on sign-in when the flag is off for the submitted email', () => { + const html = renderToStaticMarkup( + createElement(SignInForm, { searchParams: {}, title: 'Welcome.' }) + ); + + expect(html).not.toContain('Sign in with ChatGPT'); + expect(html).toContain('Continue with Google'); + expect(html).toContain('Continue with Email'); + // Nothing is submitted in a static render, so the hook gets no address. + expect(mockHookEmail).toBe(null); + }); + + it('offers ChatGPT on sign-in when the flag is on for the submitted email', () => { + mockFlowEmail = 'person@kilo.ai'; + mockChatGptAllowed = true; + const html = renderToStaticMarkup( + createElement(SignInForm, { searchParams: {}, title: 'Welcome.' }) + ); + + expect(html.match(/Sign in with ChatGPT/g)).toHaveLength(1); + expect(html).toContain('Continue with Google'); + expect(html).toContain('Continue with Email'); + // The typed address is not evaluated; the hook waits for the submit. + expect(mockHookEmail).toBe(null); + }); + + it('hides ChatGPT on sign-up when the flag is off for the typed email', () => { + const html = renderToStaticMarkup( + createElement(SignInForm, { searchParams: {}, isSignUp: true, title: 'Create your account' }) + ); + + expect(html).not.toContain('Sign in with ChatGPT'); + expect(html).toContain('Continue with Google'); + expect(html).toContain('Continue with Email'); + }); + + it('offers ChatGPT on sign-up when the flag is on for the typed email', () => { + mockFlowEmail = 'person@openai.com'; + mockChatGptAllowed = true; + const html = renderToStaticMarkup( + createElement(SignInForm, { searchParams: {}, isSignUp: true, title: 'Create your account' }) + ); + + expect(html.match(/Sign in with ChatGPT/g)).toHaveLength(1); + }); +}); diff --git a/apps/web/src/components/auth/SignInForm.tsx b/apps/web/src/components/auth/SignInForm.tsx index a9c82c90b5..771cfb6e79 100644 --- a/apps/web/src/components/auth/SignInForm.tsx +++ b/apps/web/src/components/auth/SignInForm.tsx @@ -7,6 +7,7 @@ import { ProviderSelectView } from '@/components/auth/sign-in/ProviderSelectView import { EmailInputForm } from '@/components/auth/sign-in/EmailInputForm'; import { AuthProviderButtons } from '@/components/auth/sign-in/AuthProviderButtons'; import { SignInButton } from '@/components/auth/SigninButton'; +import { Separator } from '@/components/ui/separator'; import { FakeLoginForm } from '@/components/auth/FakeLoginForm'; import { AuthErrorNotification } from '@/components/auth/AuthErrorNotification'; import { AnimatedLogoMark } from '@/components/AnimatedLogoMark'; @@ -14,9 +15,23 @@ import Link from 'next/link'; import { SquareUserRound } from 'lucide-react'; import React from 'react'; import type { SignInFormInitialState } from '@/hooks/useSignInFlow'; -import { OAuthProviderIds } from '@/lib/auth/provider-metadata'; +import { useChatGptSignInAccess } from '@/hooks/useChatGptSignInAccess'; +import { OAuthProviderIds, type AuthProviderId } from '@/lib/auth/provider-metadata'; import { buildEnterpriseSsoHref, buildNormalSignInHref } from '@/lib/auth/sign-in-navigation'; +/** + * 'Sign in with ChatGPT' is restricted by the PostHog flag's email allow-list. + * A signed-out visitor is not known to PostHog, so the sign-in page evaluates + * the flag against the email the visitor typed and hides the ChatGPT button + * when the flag is off for that email. + */ +function withoutChatGptWhenUnavailable( + providers: readonly AuthProviderId[], + chatGptAllowed: boolean +): AuthProviderId[] { + return chatGptAllowed ? [...providers] : providers.filter(id => id !== 'openai'); +} + type SignInFormProps = { searchParams: Record; error?: string; @@ -47,6 +62,14 @@ export function SignInForm({ isSignUp, storybookInitialState, }); + // The ChatGPT option is decided from the email the visitor submits, before the + // sign-in options render — not from every keystroke in the field. + const [submittedEmail, setSubmittedEmail] = React.useState(null); + const chatGptAllowed = useChatGptSignInAccess(submittedEmail); + const handleEmailSubmit = (event: React.FormEvent) => { + setSubmittedEmail(flow.email); + flow.handleEmailSubmit(event); + }; // Show minimal loading state while checking localStorage for returning user hint // This prevents flash of "new user" UI before switching to "returning user" UI @@ -119,7 +142,7 @@ export function SignInForm({ {errorNotification}