Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions packages/console/src/pages/AcceptInvitation/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof React>('react');

return {
TenantsContext: createContext({
navigateTenant: jest.fn(),
resetTenants: jest.fn(),
}),
};
});

jest.mock('@/utils/storage', () => ({
saveRedirect: jest.fn(),
}));

jest.mock('@/components/AppLoading', () => ({
__esModule: true,
default: () => <div>loading</div>,
}));

jest.mock('@/components/AppError', () => ({
__esModule: true,
default: ({ errorMessage }: { readonly errorMessage: string }) => <div>{errorMessage}</div>,
}));

jest.mock('./SwitchAccount', () => ({
__esModule: true,
default: () => <button type="button">switch account</button>,
}));

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(
<MemoryRouter
future={{ v7_relativeSplatPath: true, v7_startTransition: true }}
initialEntries={[entry]}
>
<AcceptInvitation />
</MemoryRouter>
);

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<typeof useLogto>);
});

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<typeof useSWR>);

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<typeof useSWR>);

renderAcceptInvitation('/accept/invitation-id');

expect(requestSubmit).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'switch account' })).toBeTruthy();
});
});
37 changes: 28 additions & 9 deletions packages/console/src/pages/AcceptInvitation/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type InvitationResponse } from '@/cloud/types/router';
import AppError from '@/components/AppError';
import AppLoading from '@/components/AppLoading';
import { isDevFeaturesEnabled } from '@/consts/env';
import { TenantsContext } from '@/contexts/TenantsProvider';
import { type RequestError } from '@/hooks/use-api';
import useRedirectUri from '@/hooks/use-redirect-uri';
Expand Down Expand Up @@ -38,21 +37,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 (!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 (!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) {
Expand All @@ -78,7 +88,7 @@ function AcceptInvitation() {
return <AppError errorMessage={t('invitation.invitation_not_found')} />;
}

const invitationAuthForm = isDevFeaturesEnabled && oneTimeToken && (
const invitationAuthForm = oneTimeToken && (
<form
ref={authFormRef}
hidden
Expand All @@ -98,6 +108,15 @@ function AcceptInvitation() {

// No invitation returned, indicating the current signed-in user is not the invitee.
if (error?.status === 403) {
if (oneTimeToken) {
return (
<>
{invitationAuthForm}
<AppLoading />
</>
);
}

return (
<SwitchAccount
onClickSwitch={() => {
Expand Down
Loading