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
45 changes: 39 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,15 @@ import { Privacy } from '@/features/compliance/compliance';
import { LegalDocumentPage } from '@/features/compliance/legal';
import { Dashboard, Placeholder } from '@/features/dashboard/dashboard';
import { PRIVACY_PAGE_ENABLED } from '@/config/structure';
import { isServedWhileDisabled, norbixBranded } from '@/config/disabledPortal';

type BootState =
| { status: 'loading' }
| { status: 'no-project' }
| { status: 'echo-failed' }
| { status: 'portal-disabled' }
// Turned off, but an e-mail link page was opened: Norbix-branded, those pages only.
| { status: 'link-only'; config: ProjectConfig }
| { status: 'ready'; config: ProjectConfig };

export default function App() {
Expand Down Expand Up @@ -105,6 +108,14 @@ export default function App() {
// app. `adminPortalEnabled` defaults to enabled when absent (older gateway)
// so we never lock out before the flag ships.
if (isManaged && config.adminPortalEnabled === false) {
// The e-mail links (password reset, invitation) still land here, with
// the Norbix look (owner decision 2026-10-15 #4, config/disabledPortal).
if (isServedWhileDisabled(window.location.pathname)) {
const norbix = norbixBranded(config);
dispatch(projectConfigResolved(norbix));
setBoot({ status: 'link-only', config: norbix });
return;
}
setBoot({ status: 'portal-disabled' });
return;
}
Expand Down Expand Up @@ -144,14 +155,36 @@ export default function App() {
);
}

const portalDisabled = (
<div className="flex min-h-screen flex-col items-center justify-center gap-2 px-4 text-center">
<p className="text-lg font-medium text-fg">Portal unavailable</p>
<p className="text-sm text-fg-muted">
This portal is currently turned off. Please contact the site owner.
</p>
</div>
);

if (boot.status === 'portal-disabled') {
return portalDisabled;
}

if (boot.status === 'link-only') {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-2 px-4 text-center">
<p className="text-lg font-medium text-fg">Portal unavailable</p>
<p className="text-sm text-fg-muted">
This portal is currently turned off. Please contact the site owner.
</p>
</div>
<NorbixProvider key={token ?? 'anon'} client={getNorbixClient()}>
<BrowserRouter>
<Routes>
<Route
path={ROUTES.PASSWORD_RESET_CONFIRM}
element={<PasswordResetConfirm />}
/>
<Route
path={ROUTES.INVITATION_FINISH}
element={<InvitationFinish />}
/>
<Route path="*" element={portalDisabled} />
</Routes>
</BrowserRouter>
</NorbixProvider>
);
}

Expand Down
56 changes: 56 additions & 0 deletions src/config/disabledPortal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { isServedWhileDisabled, norbixBranded } from '@/config/disabledPortal';
import type { ProjectConfig } from '@/types/projectConfig';

// Owner decision 2026-10-15 #4: a turned-off managed portal still serves the
// password-reset and invitation link pages, Norbix-branded.
describe('a turned-off portal', () => {
it('serves only the password-reset and invitation link pages', () => {
expect({
resetConfirm: isServedWhileDisabled('/sign-in/reset/confirm'),
resetConfirmSlash: isServedWhileDisabled('/sign-in/reset/confirm/'),
invitation: isServedWhileDisabled('/sign-up/invitation'),
magicLink: isServedWhileDisabled('/oauth/callback'),
signIn: isServedWhileDisabled('/sign-in'),
resetRequest: isServedWhileDisabled('/sign-in/reset'),
home: isServedWhileDisabled('/'),
}).toEqual({
resetConfirm: true,
resetConfirmSlash: true,
invitation: true,
magicLink: false,
signIn: false,
resetRequest: false,
home: false,
});
});

it('drops the project look but keeps its password policy', () => {
const project = {
branding: {
displayName: 'Laimingas pilvukas',
mainColor: '#ff0066',
logoUrl: 'https://cdn.example/logo.png',
},
auth: {
socialProviders: [],
passkey: false,
methods: ['email'],
passwordPolicy: { minLength: 12, minSpecial: 1 },
exposed: true,
},
links: { website: 'https://laimingaspilvukas.lt' },
} as unknown as ProjectConfig;

const branded = norbixBranded(project);
expect({
branding: branded.branding,
links: branded.links,
passwordPolicy: branded.auth.passwordPolicy,
}).toEqual({
branding: { displayName: 'Norbix' },
links: {},
passwordPolicy: { minLength: 12, minSpecial: 1 },
});
});
});
35 changes: 35 additions & 0 deletions src/config/disabledPortal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// The e-mail link pages while a project's Admin Portal is turned off.
//
// The gateway builds the password-reset and invitation links on the project's
// own portal (pr-<hex>.admin.<domain>) even when the project turned its
// portal off — owner decision 2026-10-15 #4 (gateway
// docs/tasks/owner-answers-2-3-0.md): "otherwise ... it shows the
// Norbix-branded portal instead of the personalised one". So a turned-off
// managed portal still serves these pages, with the Norbix look (no project
// name, colours or logo), and nothing else.
//
// Not the magic link (/oauth/callback): it signs the user in to the portal so
// they can add a new passkey there — with the portal off there is nothing to
// do after it, so it shows "Portal unavailable" like every other page.

import { ROUTES } from '@/routes';
import type { ProjectConfig } from '@/types/projectConfig';

/** The pages a turned-off portal still serves. */
export const PAGES_SERVED_WHILE_DISABLED: readonly string[] = [
ROUTES.PASSWORD_RESET_CONFIRM,
ROUTES.INVITATION_FINISH,
];

/** True when `pathname` is one of those pages (a trailing slash is allowed). */
export const isServedWhileDisabled = (pathname: string): boolean => {
const path = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
return PAGES_SERVED_WHILE_DISABLED.includes(path);
};

/** The project's config with the Norbix look: project branding and links dropped. */
export const norbixBranded = (config: ProjectConfig): ProjectConfig => ({
...config,
branding: { displayName: 'Norbix' },
links: {},
});
51 changes: 48 additions & 3 deletions src/features/auth/invitation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,39 @@ describe('InvitationFinish', () => {
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' }));

// No names typed: nothing but the token and the password is sent, so the
// gateway keeps the names the admin entered.
await waitFor(() =>
expect(finish.calls).toEqual([
{ token: 'In+v=', password: 'Happy-Tummy-2026!' },
]),
);
});

// Owner decision 2026-10-15 #3: the e-mail is shown read-only (it cannot
// change); the user may override the full name and the salutation.
it('shows the e-mail read-only and sends the full name and salutation the user typed', async () => {
const replace = vi.spyOn(window.history, 'replaceState');
open('/sign-up/invitation#token=abc&email=ona%40happytummy.lt');

// Both the token and the e-mail leave the address bar.
expect(replace.mock.calls[0][2]).toBe('/sign-up/invitation');

const email = screen.getByLabelText('E-mail') as HTMLInputElement;
expect(email.value).toBe('ona@happytummy.lt');
expect(email.readOnly).toBe(true);
expect(email.disabled).toBe(true);

fireEvent.change(screen.getByLabelText('Full name (optional)'), {
target: { value: ' Ona Petraitė ' },
});
fireEvent.change(screen.getByLabelText('Salutation (optional)'), {
target: { value: ' Ona ' },
});
fireEvent.change(screen.getByLabelText('Password'), {
target: { value: 'Happy-Tummy-2026!' },
Expand All @@ -75,11 +106,25 @@ describe('InvitationFinish', () => {

await waitFor(() =>
expect(finish.calls).toEqual([
{ token: 'In+v=', password: 'Happy-Tummy-2026!', displayName: 'Ona' },
{
token: 'abc',
password: 'Happy-Tummy-2026!',
fullName: 'Ona Petraitė',
salutation: 'Ona',
},
]),
);
});

it('without an e-mail in the link the page still works (older links)', () => {
open('/sign-up/invitation#token=abc');

expect(screen.queryByLabelText('E-mail')).toBeNull();
expect(
screen.getByRole('button', { name: 'Create my account' }),
).toBeTruthy();
});

it('a link without a token shows an error and no form', () => {
open('/sign-up/invitation');

Expand Down
56 changes: 44 additions & 12 deletions src/features/auth/invitation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 { Button, Alert, Spinner, TextField } from '@/components/ui';
import { TextInputField } from '@/components/forms/fields';
import { requiredValidator } from '@/components/forms/validators';
import { useFinishInvitationMutation } from '@/services/publicApi';
Expand All @@ -19,20 +19,31 @@ const errorMessage = (error: unknown): string | undefined => {
return data?.responseStatus?.errors?.[0]?.message;
};

/** Trimmed, inner runs of spaces collapsed; undefined when blank. */
const clean = (value: string): string | undefined => {
const tidy = value.trim().replace(/\s+/g, ' ');
return tidy.length > 0 ? tidy : undefined;
};

/**
* 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.
* Behind the link in the invitation e-mail
* (`/sign-up/invitation#token=…&email=…`). The invited user chooses a
* password and may change the full name and the salutation the admin entered
* (owner decision 2026-10-15 #3, gateway docs/tasks/owner-answers-2-3-0.md):
* both are optional — empty keeps the admin's values. The e-mail is shown
* read-only; it cannot change. The one-time token and the e-mail are read
* once — fragment first, then the query — and taken out of the address bar at
* once (see ./linkToken). The token goes to the API only in the body of the
* finish POST; the e-mail is never sent (the token names it).
*/
export function InvitationFinish() {
const location = useLocation();
// Read once, on the first render (pure: StrictMode may render twice).
const [token] = useState(() => linkParam(location, 'token'));
const [email] = useState(() => linkParam(location, 'email'));

useEffect(() => {
removeFromAddressBar(location, window.history, 'token');
removeFromAddressBar(location, window.history, 'token', 'email');
// Once, on mount: the location read above is the one to clean.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Expand All @@ -42,13 +53,17 @@ export function InvitationFinish() {

const onSubmit = async (values: {
password: string;
displayName: string;
fullName: string;
salutation: string;
}) => {
const fullName = clean(values.fullName);
const salutation = clean(values.salutation);
try {
await finish({
token,
password: values.password,
displayName: values.displayName.trim() || undefined,
...(fullName ? { fullName } : {}),
...(salutation ? { salutation } : {}),
}).unwrap();
} catch {
/* surfaced via isError */
Expand All @@ -75,7 +90,7 @@ export function InvitationFinish() {
) : (
<Form
onSubmit={onSubmit}
initialValues={{ password: '', displayName: '' }}
initialValues={{ password: '', fullName: '', salutation: '' }}
>
{({ handleSubmit, submitting }) => (
<form
Expand All @@ -89,11 +104,28 @@ export function InvitationFinish() {
'This invitation link is not valid or has expired.'}
</Alert>
)}
{email && (
<TextField
id="invitation-email"
label="E-mail"
type="email"
value={email}
readOnly
disabled
/>
)}
<TextInputField
name="displayName"
label="Your name (optional)"
name="fullName"
label="Full name (optional)"
placeholder="Leave empty to keep the name you were invited with"
autoComplete="name"
/>
<TextInputField
name="salutation"
label="Salutation (optional)"
placeholder="How e-mails greet you, e.g. “Hi Ona,”"
autoComplete="nickname"
/>
<TextInputField
name="password"
type="password"
Expand Down
5 changes: 4 additions & 1 deletion src/services/publicApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ export const publicApi = createApi({
export interface FinishInvitationBody {
token: string;
password: string;
displayName?: string;
/** Optional: replaces the full name the admin entered (owner decision 2026-10-15 #3). */
fullName?: string;
/** Optional: how e-mails greet the user; empty keeps the admin's (or the name). */
salutation?: string;
}

export const { useGetLegalDocumentQuery, useFinishInvitationMutation } =
Expand Down
Loading