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