From a552cc0c57c32820397f54b9f0479bb650cbe002 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Thu, 15 Oct 2026 10:26:25 +0300 Subject: [PATCH 1/3] [3] feat(auth:invitation): the finish page shows the e-mail read-only and lets the user change the full name and salutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decision 2026-10-15 #3 (gateway docs/tasks/owner-answers-2-3-0.md on feat/owner-answers-2-3-0). Both names are optional: empty keeps what the admin entered. The e-mail comes from the link fragment (#token=…&email=…), is never sent and leaves the address bar with the token. Co-Authored-By: Claude Opus 5.5 --- src/features/auth/invitation.test.tsx | 51 ++++++++++++++++++++++-- src/features/auth/invitation.tsx | 56 +++++++++++++++++++++------ src/services/publicApi.ts | 5 ++- 3 files changed, 96 insertions(+), 16 deletions(-) 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() { ) : (
{({ handleSubmit, submitting }) => ( )} + {email && ( + + )} + Date: Thu, 15 Oct 2026 10:33:41 +0300 Subject: [PATCH 2/3] [4] feat(portal): a turned-off managed portal still serves the password-reset and invitation link pages, Norbix-branded Owner decision 2026-10-15 #4 (gateway docs/tasks/owner-answers-2-3-0.md on feat/owner-answers-2-3-0): the e-mail links go to the project's own portal; when that portal is off it shows the Norbix-branded pages instead of the personalised ones. Only /sign-in/reset/confirm and /sign-up/invitation; every other page (magic link included) still says 'Portal unavailable'. Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 45 +++++++++++++++++++---- src/config/disabledPortal.test.ts | 59 +++++++++++++++++++++++++++++++ src/config/disabledPortal.ts | 35 ++++++++++++++++++ 3 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 src/config/disabledPortal.test.ts create mode 100644 src/config/disabledPortal.ts 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..4830f3e --- /dev/null +++ b/src/config/disabledPortal.test.ts @@ -0,0 +1,59 @@ +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: {}, +}); From 5966ff795b777e702f57339a3a77e9b024a4b9d0 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Thu, 15 Oct 2026 11:13:27 +0300 Subject: [PATCH 3/3] style(admin): prettier on disabledPortal.test.ts Co-Authored-By: Claude Opus 5.5 --- src/config/disabledPortal.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/config/disabledPortal.test.ts b/src/config/disabledPortal.test.ts index 4830f3e..4fb87e7 100644 --- a/src/config/disabledPortal.test.ts +++ b/src/config/disabledPortal.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - isServedWhileDisabled, - norbixBranded, -} from '@/config/disabledPortal'; +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