From 9660b0075c877e3bd8eaa4aed152a504c759e936 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 13 May 2026 10:40:41 +1000 Subject: [PATCH 1/2] refine organisation invite state helpers (#45813) ## What kind of change does this PR introduce? Code cleanup. Follow-up to #45774. ## What is the current behavior? The organisation invite interstitial derives invite states, titles, and descriptions from nested conditional logic in the component. That makes the component harder to scan and pushes too much state coverage into render tests. ## What is the new behavior? See #45774 for screenshots of the general UI before-and-after (which this one builds upon). That PR also contains testing instructions. Extracts the invite status and content decisions into small pure helpers, then covers those helpers with focused unit tests. The component keeps the user-facing render and interaction coverage, including the invalid lookup regression where a 404 should render the invalid invite state instead of raw backend copy. ## Summary by CodeRabbit * **Refactor** * Improved organization invite flow with enhanced error state handling for expired, invalid, and wrong-account scenarios. * Better consistency in error messages and user guidance throughout the invite process. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45813) --- .../OrganizationInvite/OrganizationInvite.tsx | 77 ++++------- .../OrganizationInvite.utils.ts | 96 +++++++++++++ .../components/OrganizationInvite.test.tsx | 34 ----- .../OrganizationInvite.utils.test.ts | 126 ++++++++++++++++++ 4 files changed, 251 insertions(+), 82 deletions(-) create mode 100644 apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts create mode 100644 apps/studio/tests/components/OrganizationInvite.utils.test.ts diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx index cb8fcc1499508..214702fd3f750 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx @@ -6,6 +6,10 @@ import { toast } from 'sonner' import { Button, Card, CardContent } from 'ui' import { Admonition, ShimmeringLoader } from 'ui-patterns' +import { + getOrganizationInviteContent, + getOrganizationInviteStatus, +} from './OrganizationInvite.utils' import { OrganizationInviteError } from './OrganizationInviteError' import { InterstitialAccountRow, @@ -40,50 +44,27 @@ export const OrganizationInvite = () => { enabled: !!profile && !!slug && !!token, } ) - const inviteIsNoLongerValid = - error?.code === 401 && error?.message.includes('Failed to retrieve organization') - const inviteIsInvalid = - (isSuccessInvitation && !!data?.token_does_not_exist) || - (isErrorInvitation && error?.code === 404) - const hasError = - isErrorInvitation || - (isSuccessInvitation && (data.token_does_not_exist || data.expired_token || !data.email_match)) - - const isWrongAccount = isSuccessInvitation && !!data && !data.email_match - const showOrganizationHeader = - isSuccessInvitation && - !!data && - !data.token_does_not_exist && - !data.expired_token && - !isWrongAccount - const organizationName = data?.organization_name ?? 'an organization' - const isSignedOut = !isLoggedIn || (!profile && !isLoadingProfile) - const isInvitationLoading = - !isSignedOut && (isLoadingProfile || isLoadingInvitation || !router.isReady) + const inviteStatus = getOrganizationInviteStatus({ + data, + error, + isErrorInvitation, + isLoadingInvitation, + isLoadingProfile, + isLoggedIn, + isRouterReady: router.isReady, + isSuccessInvitation, + profileExists: !!profile, + }) + const isSignedOut = inviteStatus === 'signed-out' + const isInvitationLoading = inviteStatus === 'loading' + const inviteContent = getOrganizationInviteContent({ + data, + isSignUpEnabled, + status: inviteStatus, + }) + const hasError = ['wrong-account', 'expired', 'invalid', 'error'].includes(inviteStatus) const loginRedirectLink = `/sign-in?returnTo=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}` const signupRedirectLink = `/sign-up?returnTo=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}` - const interstitialTitle = inviteIsNoLongerValid - ? 'Invite no longer available' - : isSignedOut - ? 'View invitation' - : isWrongAccount - ? 'Wrong account' - : inviteIsInvalid - ? 'Invite invalid' - : isErrorInvitation - ? 'Unable to load invitation' - : data?.expired_token - ? 'Invite expired' - : showOrganizationHeader - ? `Join ${organizationName}` - : undefined - const interstitialDescription = showOrganizationHeader - ? isSignedOut - ? `Sign in${isSignUpEnabled ? ' or create an account' : ''} to view this invitation` - : 'You have been invited to join this Supabase organization' - : isSignedOut - ? `Sign in${isSignUpEnabled ? ' or create an account' : ''} to view this invitation` - : undefined const { mutate: joinOrganization, isPending: isJoining } = useOrganizationAcceptInvitationMutation({ @@ -107,15 +88,15 @@ export const OrganizationInvite = () => { title={ isInvitationLoading ? ( - ) : interstitialTitle ? ( - interstitialTitle + ) : inviteContent.title ? ( + inviteContent.title ) : undefined } description={ isInvitationLoading ? ( - ) : interstitialDescription ? ( - interstitialDescription + ) : inviteContent.description ? ( + inviteContent.description ) : undefined } titleClassName="text-xl" @@ -159,7 +140,7 @@ export const OrganizationInvite = () => { ) } - if (inviteIsNoLongerValid) { + if (inviteStatus === 'no-longer-valid') { return withLayout(
{ data={data} error={error} isError={isErrorInvitation} - isInvalidInvite={inviteIsInvalid} + isInvalidInvite={inviteStatus === 'invalid'} /> ) } diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts new file mode 100644 index 0000000000000..308bc1e702a9f --- /dev/null +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts @@ -0,0 +1,96 @@ +import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query' +import type { ResponseError } from '@/types' + +type OrganizationInviteStatusVariables = { + data?: OrganizationInviteByToken + error?: ResponseError | null + isErrorInvitation: boolean + isLoadingInvitation: boolean + isLoadingProfile: boolean + isLoggedIn: boolean + isRouterReady: boolean + isSuccessInvitation: boolean + profileExists: boolean +} + +type OrganizationInviteContentVariables = { + data?: OrganizationInviteByToken + isSignUpEnabled: boolean + status: OrganizationInviteStatus +} + +export type OrganizationInviteStatus = + | 'signed-out' + | 'loading' + | 'ready' + | 'wrong-account' + | 'expired' + | 'invalid' + | 'no-longer-valid' + | 'error' + +export function getOrganizationInviteStatus({ + data, + error, + isErrorInvitation, + isLoadingInvitation, + isLoadingProfile, + isLoggedIn, + isRouterReady, + isSuccessInvitation, + profileExists, +}: OrganizationInviteStatusVariables): OrganizationInviteStatus { + const isSignedOut = !isLoggedIn || (!profileExists && !isLoadingProfile) + + if (isSignedOut) return 'signed-out' + if (isLoadingProfile || isLoadingInvitation || !isRouterReady) return 'loading' + + if (error?.code === 401 && error?.message.includes('Failed to retrieve organization')) { + return 'no-longer-valid' + } + + if ( + (isSuccessInvitation && !!data?.token_does_not_exist) || + (isErrorInvitation && error?.code === 404) + ) { + return 'invalid' + } + + if (isErrorInvitation) return 'error' + if (isSuccessInvitation && !!data?.expired_token) return 'expired' + if (isSuccessInvitation && !!data && !data.email_match) return 'wrong-account' + + return 'ready' +} + +export function getOrganizationInviteContent({ + data, + isSignUpEnabled, + status, +}: OrganizationInviteContentVariables) { + const signedOutDescription = `Sign in${ + isSignUpEnabled ? ' or create an account' : '' + } to view this invitation` + + if (status === 'signed-out') { + return { + title: 'View invitation', + description: signedOutDescription, + } + } + + if (status === 'ready') { + return { + title: `Join ${data?.organization_name ?? 'an organization'}`, + description: 'You have been invited to join this Supabase organization', + } + } + + if (status === 'wrong-account') return { title: 'Wrong account' } + if (status === 'expired') return { title: 'Invite expired' } + if (status === 'invalid') return { title: 'Invite invalid' } + if (status === 'no-longer-valid') return { title: 'Invite no longer available' } + if (status === 'error') return { title: 'Unable to load invitation' } + + return {} +} 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') + }) +}) From 205ab69061ac310704b498c3c2d3e2d7be6ba74b Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 13 May 2026 11:14:38 +1000 Subject: [PATCH 2/2] feat(studio): move CLI login to connect interstitial (#45814) ## What kind of change does this PR introduce? Feature / UI refactor ## What is the current behaviour? The CLI browser login route still uses the older API authorisation layout and redirects missing or failed sign-in session states to generic 404/500 pages. ## What is the new behaviour? Moves `/cli/login` onto the shared connect interstitial layout as the next small stacked slice after the organisation invite work. This keeps the real CLI login contract intact while updating the surface: - creates the CLI login session from `session_id`, `public_key`, and optional `token_name` - redirects to the generated `device_code` - renders missing-parameter and session-creation failures in-card instead of redirecting away - keeps the 8-character verification code selectable and copyable as a single string - uses a full-width primary `Copy code` action This also adds the small shared interstitial helpers needed by this surface and adjusts `CopyButton` so the copied check icon inherits the primary button colour instead of turning green. This also removes the CLI version admonition: > Browser login flow requires Supabase CLI version 1.219.0 and above. I checked with our stats and the CLI team. The vast majority of users are on a newer version. | Before | After | | --- | --- | | Authorize API access
Supabase-D1E3CF26-BD59-4BB2-B457-B552EE47E3DA | Authorize CLI
Supabase-C9977F21-88B8-441B-8A2C-09A9515935B0 | | Supabase-F42FBEAF-F74D-4920-8A51-7C25004F66D5 | Authorize CLI
Supabase-8159A1B1-2594-4183-AC35-FEF1EFD4EA37 | Supabase-2506E468-9F42-44B9-A5B7-BC4D3777F552 | Authorize CLI
Supabase-A0EE1239-A345-427C-9CF7-997037A8FC0E | | Authorize API access
Supabase-A7B84CA6-D230-4C3E-9227-DE21CE35375C | Authorize CLI
Supabase-F55E26B2-609B-449C-9C64-08AA90AE3D1E | ## Testing instructions Use the Vercel preview URL for this PR once it is available. The examples below use `` as a placeholder, for example `https://studio-git-dnywh-feat-cli-login-interstitial-supabase.vercel.app`. You need to be signed in to Studio to see these states because `/cli/login` is still behind `withAuth`. Ready state: - Open `/cli/login?device_code=ABCD1234` - Check the page title is `Authorize CLI | Supabase` - Check the card title is `Authorize Supabase CLI` - Check the code fills the width, uses the normal sans font, and can be selected - Drag-select the code and copy it; the clipboard should contain `ABCD1234`, not one character per line - Click `Copy code`; the button should show the usual copied success state without a green check icon on the primary button Missing parameters state: - Open `/cli/login` - Check the card says `Missing sign-in parameters` and names the missing `session_id` and `public_key` parameters - Open `/cli/login?session_id=session-test` - Check it still stays in-card and names the missing `public_key` parameter instead of redirecting to `/404` Creation error state: - Open `/cli/login?session_id=not-real&public_key=not-real&token_name=local-dev` - Check it stays in-card with `Unable to create CLI sign-in` instead of redirecting to `/500` - The exact error detail can vary by environment; the important bit is that the failure is shown inside the interstitial card Loading state: - This is transient because there are no production mocks in this slice - To inspect it manually, throttle the browser network before opening a session-creation URL such as `/cli/login?session_id=not-real&public_key=not-real` Real CLI flow: - Run the browser login flow from Supabase CLI as usual - When the CLI opens a Studio URL, keep the path and query string but replace the origin with the PR preview origin - The page should create the login session and then route to `/cli/login?device_code=<8 character code>` - Enter that 8-character code back in the CLI prompt ## Summary by CodeRabbit * **New Features** * Redesigned CLI login flow with clearer state-driven screens and improved verification UI. * Added a small paired-logo component for centered logo pairs with a connector icon. * **Improvements** * Copy button behavior and styling refined for consistent visual feedback across variants. * **Tests** * New unit tests covering copy-button behavior and multiple CLI login UI flows. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45814) --- .../components/layouts/InterstitialLayout.tsx | 9 + apps/studio/components/ui/CopyButton.tsx | 15 +- apps/studio/pages/cli/login.tsx | 284 ++++++++++++++---- .../tests/components/CopyButton.test.tsx | 11 + apps/studio/tests/pages/cli-login.test.tsx | 122 ++++++++ 5 files changed, 387 insertions(+), 54 deletions(-) create mode 100644 apps/studio/tests/pages/cli-login.test.tsx diff --git a/apps/studio/components/layouts/InterstitialLayout.tsx b/apps/studio/components/layouts/InterstitialLayout.tsx index cd25002cfa090..ba7981cca87e6 100644 --- a/apps/studio/components/layouts/InterstitialLayout.tsx +++ b/apps/studio/components/layouts/InterstitialLayout.tsx @@ -1,4 +1,5 @@ import { motion } from 'framer-motion' +import { ArrowRightLeft } from 'lucide-react' import type { PropsWithChildren, ReactNode } from 'react' import { Card, CardContent, CardHeader, cn } from 'ui' @@ -88,6 +89,14 @@ export const LogoBox = ({ children, className }: { children: ReactNode; classNam
) +export const LogoPair = ({ left, right }: { left: ReactNode; right: ReactNode }) => ( +
+ {left} + + {right} +
+) + export const SupabaseLogo = () => ( Supabase diff --git a/apps/studio/components/ui/CopyButton.tsx b/apps/studio/components/ui/CopyButton.tsx index 425a168775ffd..334ee62445dba 100644 --- a/apps/studio/components/ui/CopyButton.tsx +++ b/apps/studio/components/ui/CopyButton.tsx @@ -31,6 +31,9 @@ const CopyButton = forwardRef( onClick, copyLabel = 'Copy', copiedLabel = 'Copied', + type = 'primary', + icon, + className, ...props }, ref @@ -53,9 +56,17 @@ const CopyButton = forwardRef( onClick?.(e) }} {...props} - className={cn({ 'px-1': iconOnly }, props.className)} + type={type} + className={cn({ 'px-1': iconOnly }, className)} icon={ - showCopied ? : (props.icon ?? ) + showCopied ? ( + + ) : ( + (icon ?? ) + ) } > {!iconOnly && <>{children ?? (showCopied ? copiedLabel : copyLabel)}} diff --git a/apps/studio/pages/cli/login.tsx b/apps/studio/pages/cli/login.tsx index 912c1c5a94196..280ef6793b2a3 100644 --- a/apps/studio/pages/cli/login.tsx +++ b/apps/studio/pages/cli/login.tsx @@ -1,87 +1,267 @@ import { useIsLoggedIn, useParams } from 'common' +import { Terminal } from 'lucide-react' import Head from 'next/head' import Link from 'next/link' import { useRouter } from 'next/router' -import { useEffect } from 'react' -import { toast } from 'sonner' -import { InputOTP, InputOTPGroup, InputOTPSlot, LogoLoader } from 'ui' -import { Admonition } from 'ui-patterns' +import { useEffect, useState, type ReactNode } from 'react' +import { Button, Card, CardContent } from 'ui' +import { Admonition, ShimmeringLoader } from 'ui-patterns' -import { APIAuthorizationLayout } from '@/components/layouts/APIAuthorizationLayout' +import { + InterstitialAccountRow, + InterstitialLayout, + LogoBox, + LogoPair, + SupabaseLogo, +} from '@/components/layouts/InterstitialLayout' import CopyButton from '@/components/ui/CopyButton' +import { InlineLink } from '@/components/ui/InlineLink' import { createCliLoginSession } from '@/data/cli/login' import { withAuth } from '@/hooks/misc/withAuth' +import { buildStudioPageTitle } from '@/lib/page-title' +import { useProfile } from '@/lib/profile' import type { NextPageWithLayout } from '@/types' +const PAGE_TITLE = buildStudioPageTitle({ section: 'Authorize CLI', brand: 'Supabase' }) + +const CliLogo = () => ( + + + +) + +const CliLoginInterstitial = ({ + title, + description, + children, +}: { + title: ReactNode + description?: ReactNode + children: ReactNode +}) => ( + } right={} />} + title={title} + description={description} + > +
{children}
+
+) + const CliLoginPage: NextPageWithLayout = () => { const router = useRouter() const { session_id, public_key, token_name, device_code } = useParams() const isLoggedIn = useIsLoggedIn() + if (!router.isReady) return null + + return ( + <> + + {PAGE_TITLE} + + router.push(destination)} + /> + + ) +} + +type CliLoginStatus = + | { _tag: 'loading' } + | { _tag: 'ready'; deviceCode: string } + | { _tag: 'missing-params'; missingParameters: string[] } + | { _tag: 'error'; message?: string } + +export const CliLoginScreen = ({ + isLoggedIn, + routerReady, + sessionId, + publicKey, + tokenName, + deviceCode, + navigate, +}: { + isLoggedIn: boolean + routerReady: boolean + sessionId?: string + publicKey?: string + tokenName?: string + deviceCode?: string + navigate: (destination: string) => void +}) => { + const { profile } = useProfile() + const [status, setStatus] = useState({ _tag: 'loading' }) + const displayName = profile?.primary_email ?? profile?.username + useEffect(() => { - if (!isLoggedIn || !router.isReady || device_code) { + if (!isLoggedIn || !routerReady) return + if (deviceCode) { + setStatus({ _tag: 'ready', deviceCode }) return } - async function createSession() { - if (!session_id || !public_key) { - router.push('/404') - return - } + const missingParameters = [ + !sessionId ? 'session_id' : undefined, + !publicKey ? 'public_key' : undefined, + ].filter(Boolean) as string[] + + if (missingParameters.length > 0) { + setStatus({ _tag: 'missing-params', missingParameters }) + return + } + let isActive = true + setStatus({ _tag: 'loading' }) + + async function createSession() { try { - const { nonce } = await createCliLoginSession(session_id, public_key, token_name) + const { nonce } = await createCliLoginSession(sessionId!, publicKey!, tokenName) + + if (!isActive) return if (nonce) { - router.push(`/cli/login?device_code=${nonce.substring(0, 8)}`) + navigate(`/cli/login?device_code=${nonce.substring(0, 8)}`) } else { - router.push(`/404`) + setStatus({ _tag: 'error', message: 'The CLI sign-in session did not return a code.' }) } - } catch (error: any) { - toast.error(`Failed to create login session: ${error.message}`) - router.push(`/500`) + } catch (error: unknown) { + if (!isActive) return + setStatus({ + _tag: 'error', + message: error instanceof Error ? error.message : 'Unknown error', + }) } } createSession() - }, [isLoggedIn, router, router.isReady, session_id, public_key, token_name, device_code]) + + return () => { + isActive = false + } + }, [deviceCode, isLoggedIn, navigate, publicKey, routerReady, sessionId, tokenName]) + + if (status._tag === 'loading') { + return ( + } + description={} + > +
+ + + +
+ + +
+
+
+ +
+
+ ) + } + + if (status._tag === 'missing-params') { + const isPlural = status.missingParameters.length > 1 + + return ( + +
+ + +
+
+ ) + } + + if (status._tag === 'error') { + return ( + +
+ + Supabase could not create the CLI sign-in session. + {status.message && ( + + Error: {status.message} + + )} + + } + /> + +
+
+ ) + } return ( - -
- {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/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() + }) +})