Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -174,6 +175,10 @@ export default function App() {
element={<PasswordResetConfirm />}
/>
<Route path={ROUTES.OAUTH_CALLBACK} element={<OAuthCallback />} />
<Route
path={ROUTES.INVITATION_FINISH}
element={<InvitationFinish />}
/>
<Route
path={ROUTES.LEGAL_TERMS}
element={<LegalDocumentPage kind="terms" />}
Expand Down
128 changes: 128 additions & 0 deletions src/features/auth/invitation.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Provider
store={configureStore({
reducer: combineReducers({ auth, config, project }),
})}
>
<MemoryRouter initialEntries={[entry]}>
<InvitationFinish />
</MemoryRouter>
</Provider>,
);

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();
});
});
113 changes: 113 additions & 0 deletions src/features/auth/invitation.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AuthLayout brandName={branding?.displayName} logoUrl={branding?.logoUrl}>
<h2 className="mb-4 text-center text-xl font-semibold">
Finish your registration
</h2>
{!token ? (
<Alert kind="error">
This invitation link is not complete. Open the link from the
invitation e-mail again.
</Alert>
) : isSuccess ? (
<Alert kind="success">
Your account is ready.{' '}
<Link to={ROUTES.SIGN_IN} className="underline">
Sign in
</Link>
</Alert>
) : (
<Form
onSubmit={onSubmit}
initialValues={{ password: '', displayName: '' }}
>
{({ handleSubmit, submitting }) => (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4"
noValidate
>
{isError && (
<Alert kind="error">
{errorMessage(error) ??
'This invitation link is not valid or has expired.'}
</Alert>
)}
<TextInputField
name="displayName"
label="Your name (optional)"
autoComplete="name"
/>
<TextInputField
name="password"
type="password"
label="Password"
autoComplete="new-password"
validate={requiredValidator}
/>
<Button type="submit" block disabled={submitting || isLoading}>
{submitting || isLoading ? <Spinner /> : 'Create my account'}
</Button>
</form>
)}
</Form>
)}
</AuthLayout>
);
}
3 changes: 3 additions & 0 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
37 changes: 35 additions & 2 deletions src/services/publicApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
Loading