diff --git a/src/App.tsx b/src/App.tsx index 9f1289a..36a2e10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ import { PasswordResetConfirm, } from '@/features/auth/passwordReset'; import { OAuthCallback } from '@/features/auth/callback'; +import { InvitationFinish } from '@/features/auth/invitation'; import { SecurityOverview } from '@/features/auth/security'; import { Profile } from '@/features/profile/profile'; import { Preferences } from '@/features/preferences/preferences'; @@ -174,6 +175,10 @@ export default function App() { element={} /> } /> + } + /> } diff --git a/src/features/auth/invitation.test.tsx b/src/features/auth/invitation.test.tsx new file mode 100644 index 0000000..dc5185e --- /dev/null +++ b/src/features/auth/invitation.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +// The invitation link (/sign-up/invitation#token=…): the page reads the +// one-time token from the fragment, takes it out of the address bar at once +// and sends it only in the finish POST body (gateway registration-settings). +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import auth from '@/features/auth/slice'; +import config from '@/config/slice'; +import project from '@/features/project/slice'; + +const finish = vi.hoisted(() => ({ + calls: [] as unknown[], + state: { isLoading: false, isSuccess: false, isError: false } as Record< + string, + unknown + >, +})); + +vi.mock('@/services/publicApi', () => ({ + useFinishInvitationMutation: () => [ + (body: unknown) => { + finish.calls.push(body); + return { unwrap: async () => ({}) }; + }, + finish.state, + ], +})); + +const { InvitationFinish } = await import('./invitation'); + +const open = (entry: string) => + render( + + + + + , + ); + +afterEach(() => { + cleanup(); + finish.calls = []; + finish.state = { isLoading: false, isSuccess: false, isError: false }; + vi.restoreAllMocks(); +}); + +describe('InvitationFinish', () => { + it('token from the fragment, address bar cleaned, token only in the POST body', async () => { + const replace = vi.spyOn(window.history, 'replaceState'); + open('/sign-up/invitation#token=In%2Bv%3D'); + + expect(replace).toHaveBeenCalledTimes(1); + expect(replace.mock.calls[0][2]).toBe('/sign-up/invitation'); + + fireEvent.change(screen.getByLabelText('Your name (optional)'), { + target: { value: ' Ona ' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'Happy-Tummy-2026!' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create my account' })); + + await waitFor(() => + expect(finish.calls).toEqual([ + { token: 'In+v=', password: 'Happy-Tummy-2026!', displayName: 'Ona' }, + ]), + ); + }); + + it('a link without a token shows an error and no form', () => { + open('/sign-up/invitation'); + + expect( + screen.getByText(/This invitation link is not complete/), + ).toBeTruthy(); + expect( + screen.queryByRole('button', { name: 'Create my account' }), + ).toBeNull(); + }); + + it("shows the gateway's reason when the link is refused", () => { + finish.state = { + isLoading: false, + isSuccess: false, + isError: true, + error: { + data: { + responseStatus: { + errors: [ + { + message: + 'This invitation link is not valid or has expired. Ask for a new invitation.', + }, + ], + }, + }, + }, + }; + open('/sign-up/invitation#token=used'); + + expect( + screen.getByText( + 'This invitation link is not valid or has expired. Ask for a new invitation.', + ), + ).toBeTruthy(); + }); + + it('after success it offers sign-in', () => { + finish.state = { isLoading: false, isSuccess: true, isError: false }; + open('/sign-up/invitation#token=ok'); + + expect(screen.getByText(/Your account is ready/)).toBeTruthy(); + expect(screen.getByRole('link', { name: 'Sign in' })).toBeTruthy(); + }); +}); diff --git a/src/features/auth/invitation.tsx b/src/features/auth/invitation.tsx new file mode 100644 index 0000000..7de5afb --- /dev/null +++ b/src/features/auth/invitation.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react'; +import { Form } from 'react-final-form'; +import { Link, useLocation } from 'react-router-dom'; +import { AuthLayout } from '@/components/layouts'; +import { useAppSelector } from '@/app/hooks'; +import { selectProjectBranding } from '@/features/project/slice'; +import { Button, Alert, Spinner } from '@/components/ui'; +import { TextInputField } from '@/components/forms/fields'; +import { requiredValidator } from '@/components/forms/validators'; +import { useFinishInvitationMutation } from '@/services/publicApi'; +import { ROUTES } from '@/routes'; +import { linkParam, removeFromAddressBar } from './linkToken'; + +/** The gateway's error message, when the answer carries one. */ +const errorMessage = (error: unknown): string | undefined => { + const data = (error as { data?: unknown } | undefined)?.data as + | { responseStatus?: { errors?: { message?: string }[] } } + | undefined; + return data?.responseStatus?.errors?.[0]?.message; +}; + +/** + * Behind the link in the invitation e-mail (`/sign-up/invitation#token=…`). + * The invited user chooses a password (and optionally a display name) and the + * account becomes active. The one-time token is read once — fragment first, + * then the query — and taken out of the address bar at once (see + * ./linkToken). It goes to the API only in the body of the finish POST. + */ +export function InvitationFinish() { + const location = useLocation(); + // Read once, on the first render (pure: StrictMode may render twice). + const [token] = useState(() => linkParam(location, 'token')); + + useEffect(() => { + removeFromAddressBar(location, window.history, 'token'); + // Once, on mount: the location read above is the one to clean. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const [finish, { isLoading, isSuccess, isError, error }] = + useFinishInvitationMutation(); + const branding = useAppSelector(selectProjectBranding); + + const onSubmit = async (values: { + password: string; + displayName: string; + }) => { + try { + await finish({ + token, + password: values.password, + displayName: values.displayName.trim() || undefined, + }).unwrap(); + } catch { + /* surfaced via isError */ + } + }; + + return ( + +

+ Finish your registration +

+ {!token ? ( + + This invitation link is not complete. Open the link from the + invitation e-mail again. + + ) : isSuccess ? ( + + Your account is ready.{' '} + + Sign in + + + ) : ( +
+ {({ handleSubmit, submitting }) => ( + + {isError && ( + + {errorMessage(error) ?? + 'This invitation link is not valid or has expired.'} + + )} + + + + + )} + + )} +
+ ); +} diff --git a/src/routes.ts b/src/routes.ts index 4250174..124b10a 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -5,6 +5,9 @@ export const ROUTES = { PASSWORD_RESET: '/sign-in/reset', PASSWORD_RESET_CONFIRM: '/sign-in/reset/confirm', OAUTH_CALLBACK: '/oauth/callback', + // An invited user finishes registration (link in the invitation e-mail, + // token in the fragment — gateway MembershipPortalLinks.InvitationPath). + INVITATION_FINISH: '/sign-up/invitation', // Public legal (canonical) + short aliases that redirect to them LEGAL_TERMS: '/legal/terms', LEGAL_PRIVACY: '/legal/privacy', diff --git a/src/services/publicApi.ts b/src/services/publicApi.ts index dcf8f7e..3665591 100644 --- a/src/services/publicApi.ts +++ b/src/services/publicApi.ts @@ -30,7 +30,18 @@ const dynamicBaseQuery: BaseQueryFn< // Endpoints write `{projectId}` in their url; fill it in here. const fill = (u: string) => u.replace('{projectId}', projectId ?? ''); const filled = - typeof args === 'string' ? fill(args) : { ...args, url: fill(args.url) }; + typeof args === 'string' + ? fill(args) + : { + ...args, + url: fill(args.url), + body: + args.body && + typeof args.body === 'object' && + 'projectId' in args.body + ? { ...args.body, projectId: fill(String(args.body.projectId)) } + : args.body, + }; const rawBaseQuery = fetchBaseQuery({ baseUrl: selectApiRoot(state), prepareHeaders: (headers) => { @@ -62,7 +73,29 @@ export const publicApi = createApi({ available: r.available ?? false, }), }), + // An invited user finishes registration (gateway registration-settings): + // POST /membership/auth/invitation/finish — anonymous, the one-time token + // is the proof and goes only in the body. Hand-written until the SDK + // ships `finishInvitation`; then move it to services/norbix.ts. + finishInvitation: builder.mutation< + { responseStatus?: { isSuccess?: boolean } }, + FinishInvitationBody + >({ + query: (body) => ({ + url: '/membership/auth/invitation/finish', + method: 'POST', + body: { ...body, projectId: '{projectId}' }, + }), + }), }), }); -export const { useGetLegalDocumentQuery } = publicApi; +/** What the invited user sends; the project comes from the portal. */ +export interface FinishInvitationBody { + token: string; + password: string; + displayName?: string; +} + +export const { useGetLegalDocumentQuery, useFinishInvitationMutation } = + publicApi;