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/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/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() + }) +})