chore(studio): render Sign in with ChatGPT unconditionally (#49375)

<!-- ccr-slack-attribution -->
_Requested by **Ivan Vasilov** · [Slack
thread](https://supabase.slack.com/archives/C0161K73J1J/p1787296019236949?thread_ts=1787296019.236949&cid=C0161K73J1J)_

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Chore / feature-flag cleanup.

## What is the current behavior?

The "Sign in with ChatGPT" button on `/sign-in` and `/sign-up` sits
behind three gates in `useEnabledIdentityProviders`:

1. the static `dashboard_auth:sign_in_with_chatgpt` feature flag, AND
2. either the `ShowSignInWithChatGptButton` ConfigCat flag, OR
3. the `SIGN_IN_CHATGPT_ENABLED` (`siwc-enabled`) localStorage opt-in,
flipped by a shareable `?siwc-enabled=1` link via
`useSiwcQueryParamOptIn`.

The ConfigCat flag resolves client-side, so on a fresh load the button
is absent for the first render and appears once the flag comes back.
That pushes the rest of the sign-in options down and produces a visible
layout shift on the sign-in page.

## What is the new behavior?

ChatGPT is gated only by its static
`dashboard_auth:sign_in_with_chatgpt` feature flag, which is resolved
synchronously from `enabled-features.json`. The button renders on the
first paint, with no async re-layout.

Removed:

- the `useFlag('ShowSignInWithChatGptButton')` call and the
`chatgptLocalStorageEnabled || chatGptConfigCatFlagEnabled` branch in
`apps/studio/hooks/misc/useEnabledIdentityProviders.ts`
- `LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED` and its
`LOCAL_STORAGE_KEYS_ALLOWLIST` entry in
`packages/common/constants/local-storage.ts`
- `apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts` and its callers in
`pages/sign-in.tsx` / `pages/sign-up.tsx` — its only job was writing
that localStorage flag
- the tests that covered the two removed rollout gates

The static `dashboard_auth:sign_in_with_chatgpt` kill switch is
untouched.

## Additional context

The `ShowSignInWithChatGptButton` ConfigCat flag is reported as 100%
enabled (per Joshen Lim in the linked thread). The repo contains no
default value, allowlist, or env gate for it — the live value lives only
in ConfigCat, so that number is not verifiable from here. Once this
merges the flag is unreferenced and should be **archived in ConfigCat by
a human**; nothing in ConfigCat was changed as part of this PR.

Verification notes: `packages/common` typechecks clean (`tsc --noEmit`)
and all touched files pass the repo's Prettier config. Studio's
`typecheck`, `lint`, and `vitest` could not be run here — `pnpm install`
fails in this environment because `npm.jsr.io` (needed for studio's
`@std/path` dependency) is not reachable through the network allowlist,
so `apps/studio/node_modules` was never installed. CI should be treated
as the first real run of those checks.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01M21SPvwf6FSthomX4Lj3ZC)_

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
claude[bot]andClaude authored and GitHub committed 2026-08-21 11:32:22 +02:00
1 parent 29e47821f5
commit 12a8e31fa6
9 files changed
+7 -347

No files matched your search

@@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { useEnabledIdentityProviders } from '../useEnabledIdentityProviders'
import {
@@ -8,22 +8,11 @@ import {
} from '@/lib/external-identity-providers'
const mockIsFeatureEnabled = vi.hoisted(() => vi.fn())
const mockUseLocalStorageQuery = vi.hoisted(() => vi.fn())
const mockUseFlag = vi.hoisted(() => vi.fn())
vi.mock('../useIsFeatureEnabled', () => ({
useIsFeatureEnabled: mockIsFeatureEnabled,
}))
vi.mock('../useLocalStorage', () => ({
useLocalStorageQuery: mockUseLocalStorageQuery,
}))
vi.mock('common', async (importOriginal) => ({
...(await importOriginal<typeof import('common')>()),
useFlag: mockUseFlag,
}))
function mockFeatures({ github = false, chatgpt = true }: { github?: boolean; chatgpt?: boolean }) {
mockIsFeatureEnabled.mockReturnValue({
dashboardAuthSignInWithGithub: github,
@@ -32,14 +21,8 @@ function mockFeatures({ github = false, chatgpt = true }: { github?: boolean; ch
}
describe('useEnabledIdentityProviders', () => {
beforeEach(() => {
mockUseFlag.mockReset()
})
it('returns every provider when all flags are enabled', () => {
mockFeatures({ github: true, chatgpt: true })
mockUseLocalStorageQuery.mockReturnValue([true])
mockUseFlag.mockReturnValue(true)
const { result } = renderHook(() => useEnabledIdentityProviders())
@@ -48,88 +31,22 @@ describe('useEnabledIdentityProviders', () => {
it('returns no providers when all flags are disabled', () => {
mockFeatures({ github: false, chatgpt: false })
mockUseLocalStorageQuery.mockReturnValue([false])
mockUseFlag.mockReturnValue(false)
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([])
})
it('includes ChatGPT when localStorage is true and configcat is true', () => {
it('includes ChatGPT when its feature flag is enabled', () => {
mockFeatures({ chatgpt: true })
mockUseLocalStorageQuery.mockReturnValue([true])
mockUseFlag.mockReturnValue(true)
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER])
})
it('includes ChatGPT when localStorage is true and configcat is false', () => {
mockFeatures({ chatgpt: true })
mockUseLocalStorageQuery.mockReturnValue([true])
mockUseFlag.mockReturnValue(false)
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER])
})
it('includes ChatGPT when localStorage is false and configcat is true', () => {
mockFeatures({ chatgpt: true })
mockUseLocalStorageQuery.mockReturnValue([false])
mockUseFlag.mockReturnValue(true)
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER])
})
it('excludes ChatGPT when localStorage is false and configcat is false', () => {
mockFeatures({ chatgpt: true })
mockUseLocalStorageQuery.mockReturnValue([false])
mockUseFlag.mockReturnValue(false)
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([])
})
it('excludes ChatGPT when its feature flag is disabled but configcat is true', () => {
mockFeatures({ chatgpt: false })
mockUseLocalStorageQuery.mockReturnValue([false])
mockUseFlag.mockReturnValue(true)
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([])
})
it('excludes ChatGPT when its feature flag is disabled but localStorage is opted in', () => {
mockFeatures({ chatgpt: false })
mockUseLocalStorageQuery.mockReturnValue([true])
mockUseFlag.mockReturnValue(false)
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([])
})
it('excludes ChatGPT when its feature flag is disabled and both rollout gates are on', () => {
it('excludes ChatGPT when its feature flag is disabled', () => {
mockFeatures({ github: true, chatgpt: false })
mockUseLocalStorageQuery.mockReturnValue([true])
mockUseFlag.mockReturnValue(true)
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([GITHUB_IDENTITY_PROVIDER])
})
it('includes GitHub when its feature flag is enabled', () => {
mockFeatures({ github: true, chatgpt: true })
mockUseLocalStorageQuery.mockReturnValue([false])
mockUseFlag.mockReturnValue(false)
const { result } = renderHook(() => useEnabledIdentityProviders())
@@ -138,11 +55,9 @@ describe('useEnabledIdentityProviders', () => {
it('excludes GitHub when its feature flag is disabled', () => {
mockFeatures({ github: false, chatgpt: true })
mockUseLocalStorageQuery.mockReturnValue([false])
mockUseFlag.mockReturnValue(false)
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([])
expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER])
})
})
@@ -1,87 +0,0 @@
import { renderHook } from '@testing-library/react'
import mockRouter from 'next-router-mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSiwcQueryParamOptIn } from '../useSiwcQueryParamOptIn'
vi.mock('next/router', () => import('next-router-mock'))
// tests/vitestSetup.ts globally mocks `common`'s useParams to always return `{ ref: 'default' }`,
// which would make this hook's `siwcEnabled` lookup always undefined. Restore the real
// implementation here so useParams reflects the mocked router's query params.
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal<typeof import('common')>()
return { ...actual }
})
const mockSetValue = vi.hoisted(() => vi.fn())
const mockUseLocalStorageQuery = vi.hoisted(() => vi.fn())
vi.mock('../useLocalStorage', () => ({
useLocalStorageQuery: mockUseLocalStorageQuery,
}))
describe('useSiwcQueryParamOptIn', () => {
beforeEach(() => {
mockRouter.setCurrentUrl('/sign-in')
mockSetValue.mockClear()
mockUseLocalStorageQuery.mockReturnValue([false, mockSetValue])
})
it('enables the flag when siwc-enabled=1 is present', () => {
mockRouter.setCurrentUrl('/sign-in?siwc-enabled=1')
renderHook(() => useSiwcQueryParamOptIn())
expect(mockSetValue).toHaveBeenCalledWith(true)
})
it('does nothing when the param is absent', () => {
renderHook(() => useSiwcQueryParamOptIn())
expect(mockSetValue).not.toHaveBeenCalled()
})
it('does nothing for a non-"1" value', () => {
mockRouter.setCurrentUrl('/sign-in?siwc-enabled=true')
renderHook(() => useSiwcQueryParamOptIn())
expect(mockSetValue).not.toHaveBeenCalled()
})
it('does nothing when siwc-enabled=0', () => {
mockRouter.setCurrentUrl('/sign-in?siwc-enabled=0')
renderHook(() => useSiwcQueryParamOptIn())
expect(mockSetValue).not.toHaveBeenCalled()
})
it('only considers the first value when the param is repeated (array value)', () => {
// useParams (from 'common') flattens repeated query params to their first occurrence, so
// only the first "0" here is seen by the hook, and it does nothing.
mockRouter.setCurrentUrl('/sign-in?siwc-enabled=0&siwc-enabled=1')
renderHook(() => useSiwcQueryParamOptIn())
expect(mockSetValue).not.toHaveBeenCalled()
})
it('still calls the setter when the flag is already true (idempotent no-op is the setter’s job)', () => {
mockUseLocalStorageQuery.mockReturnValue([true, mockSetValue])
mockRouter.setCurrentUrl('/sign-in?siwc-enabled=1')
renderHook(() => useSiwcQueryParamOptIn())
expect(mockSetValue).toHaveBeenCalledWith(true)
})
it('works the same way on the sign-up URL', () => {
mockRouter.setCurrentUrl('/sign-up?siwc-enabled=1')
renderHook(() => useSiwcQueryParamOptIn())
expect(mockSetValue).toHaveBeenCalledWith(true)
})
})
@@ -1,8 +1,6 @@
import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
import { useMemo } from 'react'
import { useIsFeatureEnabled } from './useIsFeatureEnabled'
import { useLocalStorageQuery } from './useLocalStorage'
import {
CHATGPT_IDENTITY_PROVIDER,
GITHUB_IDENTITY_PROVIDER,
@@ -13,37 +11,22 @@ import {
* Returns the statically-declared identity providers whose feature flag is currently enabled.
* To add a provider: declare its config in `lib/external-identity-providers.ts`, add a
* `dashboard_auth:sign_in_with_*` flag, and gate it here.
*
* ChatGPT carries an extra rollout gate on top of its `dashboard_auth:sign_in_with_chatgpt` flag:
* the feature flag must be enabled AND either the `ShowSignInWithChatGptButton` ConfigCat flag or a
* manual, localStorage-only opt-in switch (`LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED`, flippable
* via the `?siwc-enabled=1` query param — see `useSiwcQueryParamOptIn`) must be on. The feature flag
* is the static kill switch; the OR'd pair is the progressive rollout mechanism.
*/
export function useEnabledIdentityProviders(): ExternalIdentityProviderConfig[] {
const {
dashboardAuthSignInWithGithub: githubEnabled,
dashboardAuthSignInWithChatgpt: chatgptFeatureEnabled,
dashboardAuthSignInWithChatgpt: chatgptEnabled,
} = useIsFeatureEnabled([
'dashboard_auth:sign_in_with_github',
'dashboard_auth:sign_in_with_chatgpt',
])
const [chatgptLocalStorageEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED,
false
)
const chatGptConfigCatFlagEnabled = useFlag('ShowSignInWithChatGptButton')
const isChatGptEnabled =
chatgptFeatureEnabled && (chatgptLocalStorageEnabled || chatGptConfigCatFlagEnabled)
return useMemo(
() =>
[
githubEnabled && GITHUB_IDENTITY_PROVIDER,
isChatGptEnabled && CHATGPT_IDENTITY_PROVIDER,
chatgptEnabled && CHATGPT_IDENTITY_PROVIDER,
].filter((p): p is ExternalIdentityProviderConfig => Boolean(p)),
[githubEnabled, isChatGptEnabled]
[githubEnabled, chatgptEnabled]
)
}
@@ -1,27 +0,0 @@
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { useEffect } from 'react'
import { useLocalStorageQuery } from './useLocalStorage'
/**
* Lets a shareable link (e.g. `/sign-in?siwc-enabled=1`) flip on the manual ChatGPT sign-in
* rollout switch (`LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED`, read by
* `useEnabledIdentityProviders`) for this browser, instead of requiring a devtools localStorage
* edit. Only ever meaningful on `/sign-in` and `/sign-up`, where this param would be linked to.
*
* Only the exact string `'1'` opts in; the flag is never cleared based on the param's absence, so
* it persists once set.
*/
export function useSiwcQueryParamOptIn() {
const { siwcEnabled } = useParams()
const [, setChatgptLocalStorageEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED,
false
)
useEffect(() => {
if (siwcEnabled === '1') {
setChatgptLocalStorageEnabled(true)
}
}, [siwcEnabled, setChatgptLocalStorageEnabled])
}
-3
View File
@@ -14,14 +14,11 @@ import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders'
import { useInboundBranding } from '@/hooks/misc/useInboundBranding'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSiwcQueryParamOptIn } from '@/hooks/misc/useSiwcQueryParamOptIn'
import { IS_PLATFORM } from '@/lib/constants'
import type { ExternalIdentityProviderConfig } from '@/lib/external-identity-providers'
import type { NextPageWithLayout } from '@/types'
const SignInPage: NextPageWithLayout = () => {
useSiwcQueryParamOptIn()
const router = useRouter()
const [showOtherOptions, setShowOtherOptions] = useState(false)
-3
View File
@@ -9,13 +9,10 @@ import { UnknownInterface } from '@/components/ui/UnknownInterface'
import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders'
import { useInboundBranding } from '@/hooks/misc/useInboundBranding'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSiwcQueryParamOptIn } from '@/hooks/misc/useSiwcQueryParamOptIn'
import type { ExternalIdentityProviderConfig } from '@/lib/external-identity-providers'
import type { NextPageWithLayout } from '@/types'
const SignUpPage: NextPageWithLayout = () => {
useSiwcQueryParamOptIn()
const [showOtherOptions, setShowOtherOptions] = useState(false)
const { dashboardAuthSignUp: signUpEnabled } = useIsFeatureEnabled(['dashboard_auth:sign_up'])
-70
View File
@@ -1,70 +0,0 @@
import { render } from '@testing-library/react'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SignInPage from '@/pages/sign-in'
const { mockUseSiwcQueryParamOptIn, mockUseIsFeatureEnabled } = vi.hoisted(() => ({
mockUseSiwcQueryParamOptIn: vi.fn(),
mockUseIsFeatureEnabled: vi.fn(),
}))
vi.mock('next/router', () => ({
useRouter: () => ({ query: {}, replace: vi.fn() }),
}))
vi.mock('@/hooks/misc/useSiwcQueryParamOptIn', () => ({
useSiwcQueryParamOptIn: mockUseSiwcQueryParamOptIn,
}))
vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: mockUseIsFeatureEnabled,
}))
vi.mock('@/hooks/custom-content/useCustomContent', () => ({
useCustomContent: () => ({
dashboardAuthCustomProvider: undefined,
dashboardAuthCustomProviders: undefined,
}),
}))
vi.mock('@/hooks/misc/useEnabledIdentityProviders', () => ({
useEnabledIdentityProviders: () => [],
}))
vi.mock('@/hooks/misc/useInboundBranding', () => ({
useInboundBranding: () => ({ focusProvider: undefined }),
}))
vi.mock('@/components/interfaces/SignIn/LastSignInWrapper', () => ({
LastSignInWrapper: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/interfaces/SignIn/SignInForm', () => ({
SignInForm: () => <div>SignInForm</div>,
}))
vi.mock('@/components/interfaces/SignIn/SignInWithCustom', () => ({
SignInWithCustom: () => <div>SignInWithCustom</div>,
}))
vi.mock('@/components/interfaces/SignIn/SignInWithExternalProvider', () => ({
SignInWithExternalProvider: () => <div>SignInWithExternalProvider</div>,
}))
describe('/sign-in', () => {
beforeEach(() => {
mockUseSiwcQueryParamOptIn.mockClear()
mockUseIsFeatureEnabled.mockReturnValue({
dashboardAuthSignInWithSso: false,
dashboardAuthSignInWithEmail: false,
dashboardAuthSignUp: false,
})
})
it('calls useSiwcQueryParamOptIn so a shareable ?siwc-enabled=1 link can opt this browser in', () => {
render(<SignInPage dehydratedState={{}} />)
expect(mockUseSiwcQueryParamOptIn).toHaveBeenCalled()
})
})
-46
View File
@@ -1,46 +0,0 @@
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SignUpPage from '@/pages/sign-up'
const { mockUseSiwcQueryParamOptIn, mockUseIsFeatureEnabled } = vi.hoisted(() => ({
mockUseSiwcQueryParamOptIn: vi.fn(),
mockUseIsFeatureEnabled: vi.fn(),
}))
vi.mock('@/hooks/misc/useSiwcQueryParamOptIn', () => ({
useSiwcQueryParamOptIn: mockUseSiwcQueryParamOptIn,
}))
vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: mockUseIsFeatureEnabled,
}))
vi.mock('@/hooks/misc/useEnabledIdentityProviders', () => ({
useEnabledIdentityProviders: () => [],
}))
vi.mock('@/hooks/misc/useInboundBranding', () => ({
useInboundBranding: () => ({ focusProvider: undefined }),
}))
vi.mock('@/components/interfaces/SignIn/SignInWithExternalProvider', () => ({
SignInWithExternalProvider: () => <div>SignInWithExternalProvider</div>,
}))
vi.mock('@/components/interfaces/SignIn/SignUpForm', () => ({
SignUpForm: () => <div>SignUpForm</div>,
}))
describe('/sign-up', () => {
beforeEach(() => {
mockUseSiwcQueryParamOptIn.mockClear()
mockUseIsFeatureEnabled.mockReturnValue({ dashboardAuthSignUp: true })
})
it('calls useSiwcQueryParamOptIn so a shareable ?siwc-enabled=1 link can opt this browser in', () => {
render(<SignUpPage dehydratedState={{}} />)
expect(mockUseSiwcQueryParamOptIn).toHaveBeenCalled()
})
})
@@ -30,7 +30,6 @@ export const LOCAL_STORAGE_KEYS = {
UI_PREVIEW_DATABASE_CONNECTIONS: 'preview-database-connections',
AI_ASSISTANT_MCP_OPT_IN: 'ai-assistant-mcp-opt-in',
SIGN_IN_CHATGPT_ENABLED: 'siwc-enabled',
DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`,
STORAGE_PREFERENCE: (ref: string) => `storage-explorer-${ref}`,
@@ -173,7 +172,6 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,
LOCAL_STORAGE_KEYS.BLOG_VIEW,
LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN,
LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED,
LOCAL_STORAGE_KEYS.LINTER_SHOW_FOOTER,
LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR,
LOCAL_STORAGE_KEYS.UI_TIMEZONE,