diff --git a/src/App.tsx b/src/App.tsx
index 36a2e10..f0d822e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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() {
@@ -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;
}
@@ -144,14 +155,36 @@ export default function App() {
);
}
+ const portalDisabled = (
+
+
Portal unavailable
+
+ This portal is currently turned off. Please contact the site owner.
+
+
+ );
+
if (boot.status === 'portal-disabled') {
+ return portalDisabled;
+ }
+
+ if (boot.status === 'link-only') {
return (
-
-
Portal unavailable
-
- This portal is currently turned off. Please contact the site owner.
-
-
+
+
+
+ }
+ />
+ }
+ />
+
+
+
+
);
}
diff --git a/src/config/disabledPortal.test.ts b/src/config/disabledPortal.test.ts
new file mode 100644
index 0000000..4fb87e7
--- /dev/null
+++ b/src/config/disabledPortal.test.ts
@@ -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 },
+ });
+ });
+});
diff --git a/src/config/disabledPortal.ts b/src/config/disabledPortal.ts
new file mode 100644
index 0000000..86e2182
--- /dev/null
+++ b/src/config/disabledPortal.ts
@@ -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-.admin.) 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: {},
+});
diff --git a/src/features/auth/invitation.test.tsx b/src/features/auth/invitation.test.tsx
index dc5185e..133ca65 100644
--- a/src/features/auth/invitation.test.tsx
+++ b/src/features/auth/invitation.test.tsx
@@ -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!' },
@@ -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');
diff --git a/src/features/auth/invitation.tsx b/src/features/auth/invitation.tsx
index 7de5afb..eb348ce 100644
--- a/src/features/auth/invitation.tsx
+++ b/src/features/auth/invitation.tsx
@@ -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';
@@ -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
}, []);
@@ -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 */
@@ -75,7 +90,7 @@ export function InvitationFinish() {
) : (