- {device_code ? (
- <>
-
Your Supabase account is being used to login on Supabase CLI
-
Enter this verification code on Supabase CLI to authorize login.
-
-
-
- {Array.from({ length: 8 }, (_, i) => (
-
- ))}
-
-
-
-
-
After authorizing the login attempt, you can close this window.
-
- If you ever want to remove your new token, go to{' '}
-
- Access Tokens
- {' '}
- page.
-
-
- >
- ) : (
-
- )}
+
+
+
+
{
+ event.preventDefault()
+ event.clipboardData.setData('text/plain', status.deviceCode)
+ }}
+ >
+ {Array.from(status.deviceCode.padEnd(8, ' ')).map((character, index) => (
+
+ {character}
+
+ ))}
+
+
+
+
+
+
+
+ After authorizing, you can close this tab or manage tokens like this one in{' '}
+ Access Tokens.
+
-
+
)
}
diff --git a/apps/studio/tests/components/CopyButton.test.tsx b/apps/studio/tests/components/CopyButton.test.tsx
index 4408ab0761e35..ba8bb4eb2ebdc 100644
--- a/apps/studio/tests/components/CopyButton.test.tsx
+++ b/apps/studio/tests/components/CopyButton.test.tsx
@@ -12,3 +12,14 @@ test('shows copied text', async () => {
await screen.findByText('Copied')
expect(callback).toBeCalled()
})
+
+test('does not show a green copied icon for primary buttons', async () => {
+ const { container } = render(
)
+
+ await userEvent.click(await screen.findByText('Copy'))
+ await screen.findByText('Copied')
+
+ const icon = container.querySelector('svg')
+ expect(icon).toHaveClass('text-inherit')
+ expect(icon).not.toHaveClass('text-brand')
+})
diff --git a/apps/studio/tests/components/OrganizationInvite.test.tsx b/apps/studio/tests/components/OrganizationInvite.test.tsx
index 6f05b49c97859..7c0c2ff8fe2eb 100644
--- a/apps/studio/tests/components/OrganizationInvite.test.tsx
+++ b/apps/studio/tests/components/OrganizationInvite.test.tsx
@@ -198,40 +198,6 @@ describe('OrganizationInvite', () => {
expect(mocks.routerReload).toHaveBeenCalled()
})
- test('renders expired and invalid invite states', () => {
- mocks.useInvitationQuery.mockReturnValueOnce({
- data: { ...READY_INVITE, expired_token: true },
- error: null,
- isSuccess: true,
- isError: false,
- isPending: false,
- })
-
- const { rerender } = render(
)
-
- expect(screen.getByText('Invite expired')).toBeInTheDocument()
- expect(
- screen.getByText('Ask the organization owner to send you a new invite.')
- ).toBeInTheDocument()
-
- mocks.useInvitationQuery.mockReturnValueOnce({
- data: { ...READY_INVITE, token_does_not_exist: true },
- error: null,
- isSuccess: true,
- isError: false,
- isPending: false,
- })
-
- rerender(
)
-
- expect(screen.getByText('Invite invalid')).toBeInTheDocument()
- expect(
- screen.getByText(
- 'Open the full invite link again, or ask the organization owner for a new invite.'
- )
- ).toBeInTheDocument()
- })
-
test('renders no-longer-valid, invalid lookup, and generic error states', () => {
mocks.useInvitationQuery.mockReturnValueOnce({
data: undefined,
diff --git a/apps/studio/tests/components/OrganizationInvite.utils.test.ts b/apps/studio/tests/components/OrganizationInvite.utils.test.ts
new file mode 100644
index 0000000000000..c119fb4653170
--- /dev/null
+++ b/apps/studio/tests/components/OrganizationInvite.utils.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, test } from 'vitest'
+
+import {
+ getOrganizationInviteContent,
+ getOrganizationInviteStatus,
+ type OrganizationInviteStatus,
+} from '@/components/interfaces/OrganizationInvite/OrganizationInvite.utils'
+import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query'
+import type { ResponseError } from '@/types'
+
+const READY_INVITE: OrganizationInviteByToken = {
+ authorized_user: true,
+ email_match: true,
+ expired_token: false,
+ invite_id: 42,
+ organization_name: 'Acme Corp',
+ sso_mismatch: false,
+ token_does_not_exist: false,
+}
+
+const responseError = (message: string, code = 500) => ({ message, code }) as ResponseError
+type StatusOverrides = Partial
[0]>
+
+const getStatus = (overrides: StatusOverrides = {}) =>
+ getOrganizationInviteStatus({
+ data: READY_INVITE,
+ error: null,
+ isErrorInvitation: false,
+ isLoadingInvitation: false,
+ isLoadingProfile: false,
+ isLoggedIn: true,
+ isRouterReady: true,
+ isSuccessInvitation: true,
+ profileExists: true,
+ ...overrides,
+ })
+
+describe('OrganizationInvite utils', () => {
+ test.each<[string, OrganizationInviteStatus, StatusOverrides]>([
+ ['signed out when there is no current user', 'signed-out', { isLoggedIn: false }],
+ ['loading while the profile is loading', 'loading', { isLoadingProfile: true }],
+ ['loading while the invite is loading', 'loading', { isLoadingInvitation: true }],
+ [
+ 'no longer valid for accepted or declined invites',
+ 'no-longer-valid',
+ {
+ data: undefined,
+ error: responseError('Failed to retrieve organization', 401),
+ isErrorInvitation: true,
+ isSuccessInvitation: false,
+ },
+ ],
+ [
+ 'invalid when the API returns a missing token response',
+ 'invalid',
+ {
+ data: { ...READY_INVITE, token_does_not_exist: true },
+ },
+ ],
+ [
+ 'invalid when the invite lookup 404s',
+ 'invalid',
+ {
+ data: undefined,
+ error: responseError('Not Found', 404),
+ isErrorInvitation: true,
+ isSuccessInvitation: false,
+ },
+ ],
+ [
+ 'error for other API failures',
+ 'error',
+ {
+ data: undefined,
+ error: responseError('Failed to retrieve token', 500),
+ isErrorInvitation: true,
+ isSuccessInvitation: false,
+ },
+ ],
+ [
+ 'expired when the token has expired',
+ 'expired',
+ {
+ data: { ...READY_INVITE, expired_token: true },
+ },
+ ],
+ [
+ 'wrong account when the invite email does not match',
+ 'wrong-account',
+ {
+ data: { ...READY_INVITE, email_match: false },
+ },
+ ],
+ ['ready when the invite can be accepted', 'ready', {}],
+ ])('returns %s', (_name, expected, overrides) => {
+ expect(getStatus(overrides)).toBe(expected)
+ })
+
+ test.each<[OrganizationInviteStatus, string, string | undefined]>([
+ ['signed-out', 'View invitation', 'Sign in or create an account to view this invitation'],
+ ['ready', 'Join Acme Corp', 'You have been invited to join this Supabase organization'],
+ ['wrong-account', 'Wrong account', undefined],
+ ['expired', 'Invite expired', undefined],
+ ['invalid', 'Invite invalid', undefined],
+ ['no-longer-valid', 'Invite no longer available', undefined],
+ ['error', 'Unable to load invitation', undefined],
+ ])('returns content for %s', (status, title, description) => {
+ expect(
+ getOrganizationInviteContent({
+ data: READY_INVITE,
+ isSignUpEnabled: true,
+ status,
+ })
+ ).toEqual({ title, ...(description ? { description } : {}) })
+ })
+
+ test('omits sign-up copy when sign-up is disabled', () => {
+ expect(
+ getOrganizationInviteContent({
+ data: READY_INVITE,
+ isSignUpEnabled: false,
+ status: 'signed-out',
+ }).description
+ ).toBe('Sign in to view this invitation')
+ })
+})
diff --git a/apps/studio/tests/pages/cli-login.test.tsx b/apps/studio/tests/pages/cli-login.test.tsx
new file mode 100644
index 0000000000000..afecbf3c6420e
--- /dev/null
+++ b/apps/studio/tests/pages/cli-login.test.tsx
@@ -0,0 +1,122 @@
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+
+import type { ProfileContextType } from '@/lib/profile'
+import { CliLoginScreen } from '@/pages/cli/login'
+import { customRender } from '@/tests/lib/custom-render'
+
+const { createCliLoginSessionMock } = vi.hoisted(() => ({
+ createCliLoginSessionMock: vi.fn(),
+}))
+
+vi.mock('@/data/cli/login', () => ({
+ createCliLoginSession: createCliLoginSessionMock,
+}))
+
+const DEFAULT_PROFILE_CONTEXT: ProfileContextType = {
+ profile: {
+ id: 1,
+ auth0_id: 'auth0|test',
+ gotrue_id: 'gotrue-test',
+ username: 'testuser',
+ primary_email: 'test@example.com',
+ first_name: null,
+ last_name: null,
+ mobile: null,
+ is_alpha_user: false,
+ is_sso_user: false,
+ disabled_features: [],
+ free_project_limit: null,
+ },
+ error: null,
+ isLoading: false,
+ isError: false,
+ isSuccess: true,
+}
+
+function renderScreen(props: Partial[0]> = {}) {
+ const navigate = vi.fn()
+ const result = customRender(
+ ,
+ { profileContext: DEFAULT_PROFILE_CONTEXT }
+ )
+ return { ...result, navigate }
+}
+
+describe('CliLoginScreen', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ test('creates a session and routes to the device code', async () => {
+ createCliLoginSessionMock.mockResolvedValue({ nonce: 'ABCDEFGH12345678' })
+ const { navigate } = renderScreen()
+
+ await waitFor(() => {
+ expect(createCliLoginSessionMock).toHaveBeenCalledWith(
+ 'session-test',
+ 'public-key-test',
+ 'local-dev'
+ )
+ })
+ await waitFor(() => {
+ expect(navigate).toHaveBeenCalledWith('/cli/login?device_code=ABCDEFGH')
+ })
+ })
+
+ test('renders ready state with verification code and copy control', async () => {
+ const user = userEvent.setup()
+ const writeText = vi.fn()
+ vi.spyOn(window.document, 'hasFocus').mockReturnValue(true)
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ })
+
+ const { container } = renderScreen({ deviceCode: 'ZXCV9876' })
+
+ expect(container).toHaveTextContent('ZXCV9876')
+ await user.click(screen.getByRole('button', { name: 'Copy code' }))
+ expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument()
+ expect(writeText).toHaveBeenCalledWith('ZXCV9876')
+ })
+
+ test('copies selected verification code as a single string', () => {
+ renderScreen({ deviceCode: 'ZXCV9876' })
+
+ const clipboardData = { setData: vi.fn() }
+ const code = screen.getByLabelText('Verification code ZXCV9876')
+ const copyEvent = new Event('copy', { bubbles: true })
+
+ Object.defineProperty(copyEvent, 'clipboardData', {
+ value: clipboardData,
+ })
+ code.dispatchEvent(copyEvent)
+
+ expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'ZXCV9876')
+ })
+
+ test('renders missing-params state without redirecting away', () => {
+ renderScreen({ sessionId: undefined })
+
+ expect(screen.getByText('Missing sign-in parameters')).toBeInTheDocument()
+ expect(screen.getByText(/session_id/)).toBeInTheDocument()
+ })
+
+ test('renders creation error state in the card', async () => {
+ createCliLoginSessionMock.mockRejectedValue(new Error('Session expired'))
+ renderScreen()
+
+ expect(await screen.findByText('Unable to create CLI sign-in')).toBeInTheDocument()
+ expect(screen.getByText(/Session expired/)).toBeInTheDocument()
+ })
+})