From d27a1532df3ea2ecdb128357fd8a67a171d497d6 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Wed, 24 Jun 2026 12:04:17 +0800 Subject: [PATCH] fix(console): handle mismatched invitation token links --- .../src/pages/AcceptInvitation/index.test.tsx | 127 ++++++++++++++++++ .../src/pages/AcceptInvitation/index.tsx | 34 ++++- 2 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 packages/console/src/pages/AcceptInvitation/index.test.tsx diff --git a/packages/console/src/pages/AcceptInvitation/index.test.tsx b/packages/console/src/pages/AcceptInvitation/index.test.tsx new file mode 100644 index 00000000000..8cc58a88e74 --- /dev/null +++ b/packages/console/src/pages/AcceptInvitation/index.test.tsx @@ -0,0 +1,127 @@ +import { useLogto } from '@logto/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import type * as React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import useSWR from 'swr'; + +import AcceptInvitation from '.'; + +jest.mock('swr', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@logto/react', () => ({ + useLogto: jest.fn(), +})); + +jest.mock('@/consts/env', () => ({ + isDevFeaturesEnabled: true, +})); + +jest.mock('@/cloud/hooks/use-cloud-api', () => ({ + useCloudApi: jest.fn(() => ({ + get: jest.fn(), + patch: jest.fn(), + })), +})); + +jest.mock('@/hooks/use-redirect-uri', () => ({ + __esModule: true, + default: jest.fn(() => new URL('/callback', window.location.origin)), +})); + +jest.mock('@/contexts/TenantsProvider', () => { + const { createContext } = jest.requireActual('react'); + + return { + TenantsContext: createContext({ + navigateTenant: jest.fn(), + resetTenants: jest.fn(), + }), + }; +}); + +jest.mock('@/utils/storage', () => ({ + saveRedirect: jest.fn(), +})); + +jest.mock('@/components/AppLoading', () => ({ + __esModule: true, + default: () =>
loading
, +})); + +jest.mock('@/components/AppError', () => ({ + __esModule: true, + default: ({ errorMessage }: { readonly errorMessage: string }) =>
{errorMessage}
, +})); + +jest.mock('./SwitchAccount', () => ({ + __esModule: true, + default: () => , +})); + +const mockedUseLogto = jest.mocked(useLogto); +const mockedUseSWR = jest.mocked(useSWR); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ invitationId: 'invitation-id' }), +})); + +const renderAcceptInvitation = (entry: string) => + render( + + + + ); + +describe('AcceptInvitation', () => { + const requestSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(HTMLFormElement.prototype, 'requestSubmit').mockImplementation(requestSubmit); + mockedUseLogto.mockReturnValue({ + isLoading: false, + isAuthenticated: true, + signIn: jest.fn(), + } as unknown as ReturnType); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('starts invitation one-time-token auth for a signed-in mismatched user', async () => { + mockedUseSWR.mockReturnValue({ + error: { status: 403 }, + } as unknown as ReturnType); + + renderAcceptInvitation('/accept/invitation-id?one_time_token=one-time-token'); + + await waitFor(() => { + expect(requestSubmit).toHaveBeenCalledTimes(1); + }); + + expect(screen.queryByRole('button', { name: 'switch account' })).toBeNull(); + expect(document.querySelector('form')?.getAttribute('method')).toBe('post'); + expect(document.querySelector('form')?.getAttribute('action')).toBe( + '/api/invitations/invitation-id/auth?one_time_token=one-time-token' + ); + }); + + it('shows the manual switch-account page for a signed-in mismatched user without one-time token', () => { + mockedUseSWR.mockReturnValue({ + error: { status: 403 }, + } as unknown as ReturnType); + + renderAcceptInvitation('/accept/invitation-id'); + + expect(requestSubmit).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'switch account' })).toBeTruthy(); + }); +}); diff --git a/packages/console/src/pages/AcceptInvitation/index.tsx b/packages/console/src/pages/AcceptInvitation/index.tsx index 734e923fe66..5a8b2471c4a 100644 --- a/packages/console/src/pages/AcceptInvitation/index.tsx +++ b/packages/console/src/pages/AcceptInvitation/index.tsx @@ -38,21 +38,32 @@ function AcceptInvitation() { ); useEffect(() => { - if (isLoading || isAuthenticated || !invitationId || hasStartedSignIn.current) { + if (isLoading || !invitationId || hasStartedSignIn.current) { return; } - // eslint-disable-next-line @silverhand/fp/no-mutation -- React ref guards against duplicate sign-in redirects - hasStartedSignIn.current = true; + if (!isAuthenticated) { + // eslint-disable-next-line @silverhand/fp/no-mutation -- React ref guards against duplicate sign-in redirects + hasStartedSignIn.current = true; + + if (!isDevFeaturesEnabled || !oneTimeToken) { + saveRedirect(buildInvitationAcceptUrl(invitationId)); + void signIn(redirectUri.href, 'signUp'); + return; + } + + authFormRef.current?.requestSubmit(); + return; + } - if (!isDevFeaturesEnabled || !oneTimeToken) { - saveRedirect(buildInvitationAcceptUrl(invitationId)); - void signIn(redirectUri.href, 'signUp'); + if (!isDevFeaturesEnabled || !oneTimeToken || error?.status !== 403) { return; } + // eslint-disable-next-line @silverhand/fp/no-mutation -- React ref guards against duplicate sign-in redirects + hasStartedSignIn.current = true; authFormRef.current?.requestSubmit(); - }, [invitationId, isAuthenticated, isLoading, oneTimeToken, redirectUri, signIn]); + }, [error?.status, invitationId, isAuthenticated, isLoading, oneTimeToken, redirectUri, signIn]); useEffect(() => { if (!invitation || invitation.status !== OrganizationInvitationStatus.Pending) { @@ -98,6 +109,15 @@ function AcceptInvitation() { // No invitation returned, indicating the current signed-in user is not the invitee. if (error?.status === 403) { + if (isDevFeaturesEnabled && oneTimeToken) { + return ( + <> + {invitationAuthForm} + + + ); + } + return ( {