From 8c9d8260f25c294aeb746567ffc594718c1dfe89 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 7 Oct 2026 17:01:45 +0300 Subject: [PATCH 01/10] [3] fix(admin:project): read the managed pr- host label and turn it into the pr_ id Browsers lower-case the host, so the old pr_ subdomain lost its id and the portal showed "Portal unavailable". The label now matches the gateway's ProjectId.HostLabel / TryParseFromHost and is converted to ProjectId.ViewId with the same steps as Base62Converter (tested against .NET output). Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 2 +- src/config/project.test.ts | 51 ++++++++++++++++++++++---- src/config/project.ts | 53 +++++++++++++++++++++++----- src/features/dashboard/dashboard.tsx | 2 +- 4 files changed, 91 insertions(+), 17 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6617db3..d78c2d0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,7 +56,7 @@ export default function App() { let cancelled = false; const resolve = async () => { - // Resolve the project: pin → pr_ subdomain → meta → custom-domain host + // Resolve the project: pin → pr- host label → meta → custom-domain host // lookup against the managed service. Async because the custom-domain // path calls hub.norbix.ai/admin-portal-id. const projectId = await resolveProjectIdAsync(); diff --git a/src/config/project.test.ts b/src/config/project.test.ts index 2a141a2..7c209e7 100644 --- a/src/config/project.test.ts +++ b/src/config/project.test.ts @@ -1,17 +1,54 @@ import { describe, it, expect } from 'vitest'; -import { parseProjectIdFromHost } from './project'; +import { parseProjectIdFromHost, viewIdFromHex } from './project'; + +// Reference pairs produced by .NET with the gateway's own steps +// (Guid.ParseExact(hex, "N") → Base62Converter.ToBase62String), so the TS +// conversion is checked against the real encoder, not against itself. +const DOTNET_PAIRS: [string, string][] = [ + ['4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2', 'pr_5R4dlqJeXx943tOzSDEwbS'], + ['0a1b2c3d4e5f40718293a4b5c6d7e8f9', 'pr_7bZUvoXYZY0zZaCbVwzqcX'], + ['00000000000040008000000000000001', 'pr_1szWVIyZFBV8Hfv8V3Y3c'], + ['ffffffffffff4fffbfffffffffffffff', 'pr_7n42DGM5TfOoPIflrkH0vv'], +]; + +describe('viewIdFromHex', () => { + it.each(DOTNET_PAIRS)('%s → %s (same as the gateway)', (hex, viewId) => { + expect(viewIdFromHex(hex)).toBe(viewId); + }); +}); describe('parseProjectIdFromHost', () => { - it('extracts the base62 id from a pr_ subdomain', () => { - expect(parseProjectIdFromHost('pr_7Hk2.admin.norbix.ai')).toBe('7Hk2'); + it('reads the pr- host label (ProjectId.HostLabel)', () => { + expect( + parseProjectIdFromHost( + 'pr-4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2.admin.norbix.ai', + ), + ).toBe('pr_5R4dlqJeXx943tOzSDEwbS'); }); - it('returns null for a bare admin host (no project)', () => { - expect(parseProjectIdFromHost('admin.norbix.ai')).toBeNull(); + it('accepts any case and a port, like ProjectId.TryParseFromHost', () => { + expect( + parseProjectIdFromHost( + 'PR-4C0E7B1A8F9D4E2AA1B3C5D7E9F0A1B2.admin.localhost:3100', + ), + ).toBe('pr_5R4dlqJeXx943tOzSDEwbS'); }); - it('returns null when the first label is not a pr_ prefix', () => { - expect(parseProjectIdFromHost('cloud.norbix.ai')).toBeNull(); + it('no longer reads the pr_ form (browsers lower-case it)', () => { + expect(parseProjectIdFromHost('pr_7hk2.admin.norbix.ai')).toBeNull(); + }); + + it('rejects a label with the wrong length or non-hex digits', () => { + expect(parseProjectIdFromHost('pr-4c0e7b1a.admin.norbix.ai')).toBeNull(); + expect( + parseProjectIdFromHost( + 'pr-zz0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2.admin.norbix.ai', + ), + ).toBeNull(); + }); + + it('returns null for a bare admin host (no project)', () => { + expect(parseProjectIdFromHost('admin.norbix.ai')).toBeNull(); }); it('handles single-label hosts (localhost) as no project', () => { diff --git a/src/config/project.ts b/src/config/project.ts index 5b2be28..de46f31 100644 --- a/src/config/project.ts +++ b/src/config/project.ts @@ -3,11 +3,12 @@ // Resolution order (first hit wins): // 1. Build-time pin (VITE_ADMIN_PROJECT_ID) — self-hosted / custom-domain // builds. No network call: you set it, the portal knows its project. -// 2. The pr_{base62} subdomain prefix — managed multi-tenant -// (pr_x.admin.norbix.ai). Extracted from the hostname. +// 2. The managed host label — pr-{32 lower-case hex}.admin.norbix.ai, the +// gateway's ProjectId.HostLabel (DNS-safe: no '_', one case). It is turned +// back into the pr_{base62} id (ProjectId.ViewId) the gateway reads. // 3. A tag, if an edge/host injected one. // 4. Custom domain (e.g. admin.laimingaspilvukas.lt): the host carries no -// pr_ id, so ASK the managed service — GET hub.norbix.ai/{v}/admin-portal-id +// pr- label, so ASK the managed service — GET hub.norbix.ai/{v}/admin-portal-id // ?host= → { projectId } or 404. Only the managed-service Hub answers // (it owns the host→project map); self-hosted Hubs do not. This is async, // so it lives in resolveProjectIdAsync. @@ -16,7 +17,11 @@ import { NORBIX_HUB_URL } from '@norbix.ai/ts'; import { PINNED_PROJECT_ID } from './env'; -const PR_PREFIX = /^pr_([0-9A-Za-z]+)$/; +// ProjectId.HostLabel: "pr-" + the Guid as 32 hex digits ("N" format). The +// gateway parses it case-insensitively (ProjectId.TryParseHostLabel); browsers +// lower-case the host anyway. The older pr_{base62} host form cannot work in a +// browser: '_' is not a legal host character and lower-casing breaks base62. +const HOST_LABEL = /^pr-([0-9a-f]{32})$/i; /** * The request headers that carry the project to the gateway. The API host's @@ -42,10 +47,42 @@ export function setProjectHeaders( // /admin-portal-id. Resolution by custom domain is a managed-service feature. const MANAGED_SERVICE_HUB_ROOT = `${NORBIX_HUB_URL}/v3`; +const BASE62 = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + +/** + * Turn the 32 hex digits of a Guid into the gateway's `pr_{base62}` view id — + * the same steps as IdUtility.GenerateId / Base62Converter.ToBase62String: + * .NET Guid.ToByteArray() order (the first three groups little-endian), read + * as one unsigned little-endian number, written in base62. + */ +export function viewIdFromHex(hex: string): string { + const h = hex.toLowerCase(); + const byteAt = (i: number) => h.slice(i * 2, i * 2 + 2); + // Display order → ToByteArray order: reverse bytes 0-3, 4-5 and 6-7. + const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15]; + // Little-endian: the last byte of the array is the most significant. + let value = 0n; + for (let i = order.length - 1; i >= 0; i--) { + value = (value << 8n) | BigInt(parseInt(byteAt(order[i]), 16)); + } + let out = ''; + while (value > 0n) { + out = BASE62[Number(value % 62n)] + out; + value /= 62n; + } + return `pr_${out}`; +} + +/** + * Read the project from a managed host (`pr-.admin.norbix.ai`, any case, + * optional port). Returns the `pr_{base62}` view id, or null when the first + * label is not a host label. + */ export function parseProjectIdFromHost(host: string): string | null { - const firstLabel = host.split('.')[0] ?? ''; - const match = PR_PREFIX.exec(firstLabel); - return match ? match[1] : null; + const firstLabel = host.split(':')[0].split('.')[0] ?? ''; + const match = HOST_LABEL.exec(firstLabel); + return match ? viewIdFromHex(match[1]) : null; } function projectFromMetaTag(): string | null { @@ -55,7 +92,7 @@ function projectFromMetaTag(): string | null { return content && content.length > 0 ? content : null; } -/** Synchronous resolution: pin → subdomain → meta tag. No network. */ +/** Synchronous resolution: pin → host label → meta tag. No network. */ export function resolveProjectId(host?: string): string | null { if (PINNED_PROJECT_ID) return PINNED_PROJECT_ID; diff --git a/src/features/dashboard/dashboard.tsx b/src/features/dashboard/dashboard.tsx index 356e34d..f39e72b 100644 --- a/src/features/dashboard/dashboard.tsx +++ b/src/features/dashboard/dashboard.tsx @@ -42,7 +42,7 @@ export function Placeholder() {

Norbix Admin

No project selected. Open this portal at your project address (for - example pr_xxxx.admin.norbix.ai) to continue. + example pr-<id>.admin.norbix.ai) to continue.

); From 837bbfe9c277e6b5176eb0a646f5c8a5652d30b1 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 7 Oct 2026 17:04:11 +0300 Subject: [PATCH 02/10] [4] fix(admin:auth): use the usr_ login id from the token for Profile, Preferences and Passkeys The /auth response userId is ServiceStack's numeric UserAuthId ("2"), which /membership/auth/{id} does not accept. Read the cm_auth_id claim the gateway writes into the JWT; persisted sessions are re-derived by the selector. ref: F37 Co-Authored-By: Claude Opus 5.5 --- src/features/auth/loginId.test.ts | 45 ++++++++++++++++++++++++++++ src/features/auth/loginId.ts | 50 +++++++++++++++++++++++++++++++ src/features/auth/passkeys.tsx | 5 +++- src/features/auth/slice.ts | 11 +++++-- src/features/profile/profile.tsx | 5 +++- 5 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 src/features/auth/loginId.test.ts create mode 100644 src/features/auth/loginId.ts diff --git a/src/features/auth/loginId.test.ts b/src/features/auth/loginId.test.ts new file mode 100644 index 0000000..872403e --- /dev/null +++ b/src/features/auth/loginId.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { loginIdFromToken, resolveLoginId } from './loginId'; + +// A JWT with the given payload (header and signature do not matter here). +function jwt(payload: Record): string { + const b64url = (o: unknown) => + btoa(JSON.stringify(o)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return `${b64url({ alg: 'HS256', typ: 'JWT' })}.${b64url(payload)}.sig`; +} + +describe('loginIdFromToken', () => { + it('reads the usr_ id from the cm_auth_id claim, not the numeric sub', () => { + const token = jwt({ sub: '2', cm_auth_id: 'usr_5R4dlqJeXx943tOzSDEwbS' }); + expect(loginIdFromToken(token)).toBe('usr_5R4dlqJeXx943tOzSDEwbS'); + }); + + it('returns null when the claim is missing or not a usr_ id', () => { + expect(loginIdFromToken(jwt({ sub: '2' }))).toBeNull(); + expect(loginIdFromToken(jwt({ cm_auth_id: '2' }))).toBeNull(); + }); + + it('returns null for a token that is not a JWT', () => { + expect(loginIdFromToken('not-a-jwt')).toBeNull(); + expect(loginIdFromToken('a.%%%.c')).toBeNull(); + expect(loginIdFromToken(null)).toBeNull(); + }); +}); + +describe('resolveLoginId', () => { + it('prefers the token claim over the /auth response userId', () => { + const token = jwt({ cm_auth_id: 'usr_abc' }); + expect(resolveLoginId(token, '2')).toBe('usr_abc'); + }); + + it('never returns the numeric ServiceStack id', () => { + expect(resolveLoginId(jwt({ sub: '2' }), '2')).toBeNull(); + }); + + it('falls back to a usr_ id the sign-in flow passed', () => { + expect(resolveLoginId('opaque', 'usr_xyz')).toBe('usr_xyz'); + }); +}); diff --git a/src/features/auth/loginId.ts b/src/features/auth/loginId.ts new file mode 100644 index 0000000..7f70847 --- /dev/null +++ b/src/features/auth/loginId.ts @@ -0,0 +1,50 @@ +// The signed-in user's LOGIN id — the `usr_…` Auth id the membership routes take +// (/membership/auth/{id}, /membership/auth/{id}/preferences). +// +// The /auth response's `userId` is ServiceStack's numeric UserAuthId (e.g. "2"), +// which those routes do not accept. The gateway writes the real id into the JWT +// as the `cm_auth_id` claim (GatewayAuth.cs CreatePayloadFilter), so we read it +// from the token. The token is only decoded, never trusted for anything else — +// the gateway still checks every call. + +const LOGIN_ID_PREFIX = 'usr_'; + +export function isLoginId(id: string | null | undefined): id is string { + return typeof id === 'string' && id.startsWith(LOGIN_ID_PREFIX); +} + +function decodeJwtPayload(token: string): Record | null { + const part = token.split('.')[1]; + if (!part) return null; + try { + const b64 = part.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + const json = new TextDecoder().decode( + Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)), + ); + const payload: unknown = JSON.parse(json); + return payload && typeof payload === 'object' + ? (payload as Record) + : null; + } catch { + return null; + } +} + +/** The `cm_auth_id` claim of a gateway JWT, when it is a `usr_` id. */ +export function loginIdFromToken(token: string | null | undefined): string | null { + if (!token) return null; + const claim = decodeJwtPayload(token)?.cm_auth_id; + return typeof claim === 'string' && isLoginId(claim) ? claim : null; +} + +/** + * The login id for a session: the token claim first, then a `usr_` id the + * sign-in flow passed along. A numeric id ("2") is never returned. + */ +export function resolveLoginId( + token: string | null | undefined, + fallback?: string | null, +): string | null { + return loginIdFromToken(token) ?? (isLoginId(fallback) ? fallback : null); +} diff --git a/src/features/auth/passkeys.tsx b/src/features/auth/passkeys.tsx index 10131c7..1b31d50 100644 --- a/src/features/auth/passkeys.tsx +++ b/src/features/auth/passkeys.tsx @@ -1,3 +1,4 @@ +import { skipToken } from '@reduxjs/toolkit/query'; import { useState } from 'react'; import { Form } from 'react-final-form'; import { Card, Button, Alert, Spinner, ConfirmDialog } from '@/components/ui'; @@ -27,7 +28,9 @@ import { createPasskey, isWebAuthnAvailable } from './webauthn'; */ export function PasskeysCard() { const userId = useAppSelector(selectUserId) ?? ''; - const { data: userData } = useGetUserQuery({ id: userId }); + // Wait for the usr_ login id; never query with an empty or numeric id. + const userArg = userId ? { id: userId } : skipToken; + const { data: userData } = useGetUserQuery(userArg); const email = userData?.user?.email ?? userData?.user?.generalInfo?.primaryEmail ?? ''; diff --git a/src/features/auth/slice.ts b/src/features/auth/slice.ts index e9796cc..1b05e80 100644 --- a/src/features/auth/slice.ts +++ b/src/features/auth/slice.ts @@ -1,8 +1,10 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from '@/app/store'; +import { resolveLoginId } from './loginId'; interface AuthState { token: string | null; + /** The `usr_` login id (see loginId.ts) — never the numeric /auth userId. */ userId: string | null; } @@ -16,7 +18,10 @@ const slice = createSlice({ reducers: { signedIn(state, action: PayloadAction<{ token: string; userId: string }>) { state.token = action.payload.token; - state.userId = action.payload.userId; + state.userId = resolveLoginId( + action.payload.token, + action.payload.userId, + ); }, signedOut(state) { state.token = null; @@ -34,7 +39,9 @@ export const { signedIn, signedOut, reset } = slice.actions; export const selectIsAuthenticated = (state: RootState): boolean => Boolean(state.auth.token); +// Re-derived from the token so a session persisted before this fix (userId "2") +// also gets the usr_ id without signing in again. export const selectUserId = (state: RootState): string | null => - state.auth.userId; + resolveLoginId(state.auth.token, state.auth.userId); export default slice.reducer; diff --git a/src/features/profile/profile.tsx b/src/features/profile/profile.tsx index 6919d35..96f492c 100644 --- a/src/features/profile/profile.tsx +++ b/src/features/profile/profile.tsx @@ -1,3 +1,4 @@ +import { skipToken } from '@reduxjs/toolkit/query'; import { Form, FormSpy } from 'react-final-form'; import type { FormApi } from 'final-form'; import { PageHeader, Card, Button, Alert, Spinner } from '@/components/ui'; @@ -16,7 +17,9 @@ interface Editable { export function Profile() { const userId = useAppSelector(selectUserId) ?? ''; - const { data, isLoading, isError } = useGetUserQuery({ id: userId }); + // Wait for the usr_ login id; never query with an empty or numeric id. + const userArg = userId ? { id: userId } : skipToken; + const { data, isLoading, isError } = useGetUserQuery(userArg); const generalInfo = data?.user?.generalInfo; return ( From e9ca0955b85b6f765e2858be70cade4c2c7ca541 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 7 Oct 2026 17:05:17 +0300 Subject: [PATCH 03/10] [2] fix(admin:privacy): hide the Privacy & data page until the compliance endpoints exist; no two-factor on the Security card /me/compliance, /me/compliance/export and /me/compliance/delete do not exist (404). The page, its nav entry and its Home card hang off PRIVACY_PAGE_ENABLED (false) until Cloud defines the privacy flows. 2FA is not built, so the Home Security card now says "Password and passkeys." Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 24 ++++++++++++++---------- src/components/layouts.tsx | 6 +++++- src/config/structure.test.ts | 35 +++++++++++++++++++++++++++++++++++ src/config/structure.ts | 31 ++++++++++++++++++++++++------- 4 files changed, 78 insertions(+), 18 deletions(-) create mode 100644 src/config/structure.test.ts diff --git a/src/App.tsx b/src/App.tsx index d78c2d0..9f1289a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import { Preferences } from '@/features/preferences/preferences'; 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'; type BootState = | { status: 'loading' } @@ -232,16 +233,19 @@ export default function App() { } /> - - - - - - } - /> + {/* Hidden until /me/compliance* exist (PRIVACY_PAGE_ENABLED). */} + {PRIVACY_PAGE_ENABLED && ( + + + + + + } + /> + )} } /> diff --git a/src/components/layouts.tsx b/src/components/layouts.tsx index 3c50948..231f0ff 100644 --- a/src/components/layouts.tsx +++ b/src/components/layouts.tsx @@ -13,6 +13,7 @@ import { } from './icons'; import { DropdownMenu } from './ui'; import { ROUTES } from '@/routes'; +import { PRIVACY_PAGE_ENABLED } from '@/config/structure'; import { useAppDispatch, useAppSelector } from '@/app/hooks'; import { selectProjectBranding } from '@/features/project/slice'; import { projectNameOf } from '@/config/projectConfig'; @@ -52,7 +53,10 @@ const NAV = [ { to: ROUTES.PROFILE, label: 'Profile', icon: UserIcon }, { to: ROUTES.SECURITY, label: 'Security', icon: ShieldIcon }, { to: ROUTES.PREFERENCES, label: 'Preferences', icon: BellIcon }, - { to: ROUTES.PRIVACY, label: 'Privacy', icon: DocCheckIcon }, + // Hidden until the compliance endpoints exist (see PRIVACY_PAGE_ENABLED). + ...(PRIVACY_PAGE_ENABLED + ? [{ to: ROUTES.PRIVACY, label: 'Privacy', icon: DocCheckIcon }] + : []), ]; export function AppLayout({ children }: { children: ReactNode }) { diff --git a/src/config/structure.test.ts b/src/config/structure.test.ts new file mode 100644 index 0000000..3709dce --- /dev/null +++ b/src/config/structure.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { DEFAULT_NAV_CARDS, navCardsFromStructure } from './structure'; +import { ROUTES } from '@/routes'; +import type { AdminPortalStructure } from '@/services/portalApi'; + +const structure = (keys: string[]): AdminPortalStructure => + ({ + projectId: 'pr_x', + adminPortalEnabled: true, + displayName: '', + modules: keys.map((key) => ({ key, displayName: key, enabled: true })), + }) as AdminPortalStructure; + +describe('navCardsFromStructure', () => { + it('hides the Privacy & data card while the compliance API is missing', () => { + const cards = navCardsFromStructure( + structure(['profile', 'security', 'preferences', 'legal']), + ); + expect(cards.map((c) => c.to)).toEqual([ + ROUTES.PROFILE, + ROUTES.SECURITY, + ROUTES.PREFERENCES, + ]); + }); + + it('has no Privacy & data card in the default layout either', () => { + expect(DEFAULT_NAV_CARDS.map((c) => c.to)).not.toContain(ROUTES.PRIVACY); + }); + + it('does not promise two-factor on the Security card (not built yet)', () => { + const security = DEFAULT_NAV_CARDS.find((c) => c.to === ROUTES.SECURITY); + expect(security?.desc).toBe('Password and passkeys.'); + expect(security?.desc.toLowerCase()).not.toContain('two-factor'); + }); +}); diff --git a/src/config/structure.ts b/src/config/structure.ts index 0589ff0..56cd689 100644 --- a/src/config/structure.ts +++ b/src/config/structure.ts @@ -5,6 +5,16 @@ import { ROUTES } from '@/routes'; import type { AdminPortalStructure } from '@/services/portalApi'; +/** + * The Privacy & data page (export / delete my data) calls /me/compliance, + * /me/compliance/export and /me/compliance/delete, which the gateway does not + * have (404). Cloud must define the privacy flows first (Project Settings → + * Access / legal documents, Compliance settings); the portal then renders what + * Cloud defines. Until then the page, its nav entry and its Home card are + * hidden. The public /legal/terms and /legal/privacy pages are not affected. + */ +export const PRIVACY_PAGE_ENABLED = false; + export interface NavCard { to: string; title: string; @@ -17,13 +27,16 @@ const MODULE_ROUTES: Record = { profile: { to: ROUTES.PROFILE, desc: 'Update your contact information.' }, security: { to: ROUTES.SECURITY, - desc: 'Password and two-factor authentication.', + // Two-factor sign-in is not built yet (Membership task) — do not promise it. + desc: 'Password and passkeys.', }, preferences: { to: ROUTES.PREFERENCES, desc: 'Choose which messages you receive.', }, - legal: { to: ROUTES.PRIVACY, desc: 'Export or delete your data.' }, + ...(PRIVACY_PAGE_ENABLED + ? { legal: { to: ROUTES.PRIVACY, desc: 'Export or delete your data.' } } + : {}), }; // The layout the portal falls back to when structure can't be loaded — the @@ -36,11 +49,15 @@ export const DEFAULT_NAV_CARDS: NavCard[] = [ title: 'Preferences', desc: MODULE_ROUTES.preferences.desc, }, - { - to: ROUTES.PRIVACY, - title: 'Privacy & data', - desc: MODULE_ROUTES.legal.desc, - }, + ...(PRIVACY_PAGE_ENABLED + ? [ + { + to: ROUTES.PRIVACY, + title: 'Privacy & data', + desc: 'Export or delete your data.', + }, + ] + : []), ]; /** Turn a structure into the nav cards the dashboard renders. */ From f6a5d8ee4092f64f36aa0fe21d8dd85eefd96e4d Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 7 Oct 2026 17:08:54 +0300 Subject: [PATCH 04/10] [1] feat(admin:preferences): render the project's communication structure from Cloud instead of one marketing switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud defines channels → groups → tags (Project Settings → Communication Preferences); the backend already reads it with the service key in /api/bootstrap. The page now shows Marketing / Service messages tabs, each group with its tags and one switch per delivery channel the tag sends on (defaultDelivery), "Other options" for tags in no group, and keeps the global marketing switch. blockedTags is written per delivery channel as the gateway reads it (Campaign.cs). Co-Authored-By: Claude Opus 5.5 --- .../preferences/communication.test.ts | 122 +++++++++++ src/features/preferences/communication.ts | 172 ++++++++++++++++ src/features/preferences/preferences.tsx | 189 ++++++++++++++---- src/services/portalApi.ts | 25 ++- 4 files changed, 472 insertions(+), 36 deletions(-) create mode 100644 src/features/preferences/communication.test.ts create mode 100644 src/features/preferences/communication.ts diff --git a/src/features/preferences/communication.test.ts b/src/features/preferences/communication.test.ts new file mode 100644 index 0000000..8cb2ce2 --- /dev/null +++ b/src/features/preferences/communication.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { + buildCommunicationView, + isSubscribed, + setSubscribed, + textOf, + type NotificationSettings, +} from './communication'; + +const tr = (language: string, title: string, description?: string) => ({ + language, + content: { title, description }, +}); + +// What Cloud saves for a project: Marketing has a "News" group with two tags, +// Transactional an "Orders" group; "surveys" is in no group (orphaned) and +// "internal" sends nowhere. +const settings = { + channels: [ + { + channel: 'Marketing', + groups: [{ tag: 'news', tags: ['weekly', 'offers', 'internal'] }], + }, + { + channel: 'Transactional', + groups: [{ tag: 'orders', tags: ['shipping'] }], + }, + { channel: 'System', groups: [{ tag: 'sys', tags: ['security'] }] }, + ], + allGroups: [ + { + tag: 'news', + translations: [tr('en', 'News', 'What is new'), tr('lt', 'Naujienos')], + }, + { tag: 'orders', translations: [tr('en', 'Orders')] }, + ], + allTags: [ + { + tag: 'weekly', + translations: [tr('en', 'Weekly digest')], + defaultDelivery: { Email: true, Sms: false }, + }, + { + tag: 'offers', + translations: [], + defaultDelivery: { Email: true, Push: true }, + }, + { tag: 'internal', translations: [], defaultDelivery: { Email: false } }, + { + tag: 'shipping', + translations: [tr('en', 'Shipping updates')], + defaultDelivery: { Sms: true }, + }, + { tag: 'security', translations: [], defaultDelivery: { Email: true } }, + { + tag: 'surveys', + translations: [tr('en', 'Surveys')], + defaultDelivery: { Email: true }, + }, + ], +} as unknown as NotificationSettings; + +describe('buildCommunicationView', () => { + const view = buildCommunicationView(settings, 'en'); + + it('renders Marketing and Transactional, never System', () => { + expect(view.channels.map((c) => c.channel)).toEqual([ + 'Marketing', + 'Transactional', + ]); + }); + + it('keeps Cloud group order and titles, drops tags that send nowhere', () => { + const news = view.channels[0].groups[0]; + expect(news).toMatchObject({ + tag: 'news', + title: 'News', + description: 'What is new', + }); + expect(news.tags.map((t) => [t.tag, t.title, t.deliveries])).toEqual([ + ['weekly', 'Weekly digest', ['Email']], + ['offers', 'offers', ['Email', 'Push']], + ]); + }); + + it('lists tags in no group as "Other options" (Cloud\'s orphaned tags)', () => { + expect(view.otherOptions.map((t) => t.tag)).toEqual(['surveys']); + }); + + it('is empty when Cloud defined no structure', () => { + expect(buildCommunicationView(null, 'en')).toEqual({ + channels: [], + otherOptions: [], + }); + }); +}); + +describe('textOf', () => { + it('uses the user language, then English, then the tag', () => { + const def = settings.allGroups[0]; + expect(textOf(def, 'news', 'lt-LT').title).toBe('Naujienos'); + expect(textOf(def, 'news', 'de').title).toBe('News'); + expect(textOf(undefined, 'news', 'en').title).toBe('news'); + }); +}); + +describe('blockedTags', () => { + it('turning a tag off adds it under that delivery channel only', () => { + const next = setSubscribed({}, 'Email', 'offers', false); + expect(next).toEqual({ Email: ['offers'] }); + expect(isSubscribed(next, 'Email', 'offers')).toBe(false); + expect(isSubscribed(next, 'Push', 'offers')).toBe(true); + }); + + it('turning it back on removes it and drops the empty entry', () => { + const before = { Email: ['offers'], Sms: ['shipping'] }; + expect(setSubscribed(before, 'Email', 'offers', true)).toEqual({ + Sms: ['shipping'], + }); + expect(before.Email).toEqual(['offers']); // input not mutated + }); +}); diff --git a/src/features/preferences/communication.ts b/src/features/preferences/communication.ts new file mode 100644 index 0000000..788a188 --- /dev/null +++ b/src/features/preferences/communication.ts @@ -0,0 +1,172 @@ +// The project's communication structure, as Cloud defines it +// (Cloud → Project Settings → Communication Preferences), turned into what the +// end user switches on and off. +// +// Cloud's shape (NotificationSettingsDto, carried on the project read model): +// channels[] — one per CommunicationChannel (Marketing, Transactional, …) +// groups[] — { tag: group tag, tags: tag names in that group } +// allGroups[] — group definitions (translations = title / description) +// allTags[] — tag definitions (translations + defaultDelivery per delivery +// channel: Email, Sms, Push, …) +// Tags that are in no group are "orphaned"; Cloud shows them to end users as +// "Other options". +// +// The user's choice is UserMarketingPreferencesDto.blockedTags: per DELIVERY +// channel (Email, Sms, …), the tags they turned off. A tag only sends on a +// delivery channel where its defaultDelivery is on (gateway Campaign.cs), so a +// switch is shown only for those channels. Marketing also has the global +// blockAllMarketingMessages switch. + +import { CodeMashHub2 } from '@norbix.ai/ts/types/hub'; + +export type NotificationSettings = CodeMashHub2.NotificationSettingsDto; +type TagDefinitionBase = CodeMashHub2.TagDefinitionBaseDto; + +/** blockedTags as the API takes it: delivery channel → blocked tag names. */ +export type BlockedTags = Record; + +export interface TagRow { + tag: string; + title: string; + description?: string; + /** Delivery channels this tag sends on (defaultDelivery = true). */ + deliveries: string[]; +} + +export interface GroupView { + tag: string; + title: string; + description?: string; + tags: TagRow[]; +} + +export interface ChannelView { + /** CommunicationChannel: Marketing | Transactional. */ + channel: string; + groups: GroupView[]; +} + +export interface CommunicationView { + channels: ChannelView[]; + /** Tags in no group — Cloud's "Other options". */ + otherOptions: TagRow[]; +} + +// End users switch Marketing and Transactional messages — the two channels +// Cloud lets the developer structure. System messages are never optional. +const USER_CHANNELS = ['Marketing', 'Transactional']; + +/** Display text for a tag/group in `language` (then English, then the first). */ +export function textOf( + def: TagDefinitionBase | undefined, + fallbackTag: string, + language: string, +): { title: string; description?: string } { + const list = def?.translations ?? []; + const lang = language.toLowerCase().split('-')[0]; + const pick = + list.find((t) => t.language?.toLowerCase().split('-')[0] === lang) ?? + list.find((t) => t.language?.toLowerCase().startsWith('en')) ?? + list[0]; + const title = pick?.content?.title?.trim() || fallbackTag; + const description = pick?.content?.description?.trim() || undefined; + return { title, description }; +} + +function tagRow( + tag: string, + defs: Map, + language: string, +): TagRow { + const def = defs.get(tag); + const deliveries = Object.entries(def?.defaultDelivery ?? {}) + .filter(([, on]) => on) + .map(([channel]) => channel); + return { tag, ...textOf(def, tag, language), deliveries }; +} + +/** Build the view the page renders. Tags that send nowhere are left out. */ +export function buildCommunicationView( + settings: NotificationSettings | null | undefined, + language: string, +): CommunicationView { + const tagDefs = new Map((settings?.allTags ?? []).map((t) => [t.tag, t])); + const groupDefs = new Map((settings?.allGroups ?? []).map((g) => [g.tag, g])); + const channels: ChannelView[] = []; + for (const name of USER_CHANNELS) { + const ch = settings?.channels?.find((c) => c.channel === name); + if (!ch) continue; + const groups: GroupView[] = []; + for (const g of ch.groups ?? []) { + const tags = (g.tags ?? []) + .map((t) => tagRow(t, tagDefs, language)) + .filter((r) => r.deliveries.length > 0); + if (tags.length === 0) continue; + groups.push({ + tag: g.tag, + ...textOf(groupDefs.get(g.tag), g.tag, language), + tags, + }); + } + if (groups.length > 0) channels.push({ channel: name, groups }); + } + + // Tags in a group of ANY channel (System included) are not "other options". + const grouped = new Set(); + for (const ch of settings?.channels ?? []) { + for (const g of ch.groups ?? []) + (g.tags ?? []).forEach((t) => grouped.add(t)); + } + const otherOptions = (settings?.allTags ?? []) + .filter((t) => !grouped.has(t.tag)) + .map((t) => tagRow(t.tag, tagDefs, language)) + .filter((r) => r.deliveries.length > 0); + + return { channels, otherOptions }; +} + +/** True when the user receives `tag` on `delivery` (it is not blocked). */ +export function isSubscribed( + blocked: BlockedTags | undefined, + delivery: string, + tag: string, +): boolean { + return !(blocked?.[delivery] ?? []).includes(tag); +} + +/** + * The new blockedTags after switching `tag` on `delivery` (full replacement, + * as UpdateUserPreferences expects). Empty delivery entries are dropped. + */ +export function setSubscribed( + blocked: BlockedTags | undefined, + delivery: string, + tag: string, + subscribed: boolean, +): BlockedTags { + const next: BlockedTags = {}; + for (const [k, v] of Object.entries(blocked ?? {})) next[k] = [...v]; + const set = new Set(next[delivery] ?? []); + if (subscribed) set.delete(tag); + else set.add(tag); + if (set.size > 0) next[delivery] = [...set]; + else delete next[delivery]; + return next; +} + +/** Readable name for a delivery channel key. */ +export const DELIVERY_LABEL: Record = { + Email: 'Email', + Sms: 'SMS', + Push: 'Push', + WebPush: 'Browser', + InApp: 'In-app', + ChatBot: 'Chat bot', + ChatPlatform: 'Chat', +}; + +/** Readable name for a communication channel tab. */ +export const CHANNEL_LABEL: Record = { + Marketing: 'Marketing', + Transactional: 'Service messages', +}; diff --git a/src/features/preferences/preferences.tsx b/src/features/preferences/preferences.tsx index 3cbb5fc..f29f1d4 100644 --- a/src/features/preferences/preferences.tsx +++ b/src/features/preferences/preferences.tsx @@ -1,58 +1,179 @@ +import { useMemo, useState } from 'react'; +import { skipToken } from '@reduxjs/toolkit/query'; import { PageHeader, Card, Toggle, Spinner, Alert } from '@/components/ui'; import { useGetUserPreferencesQuery, useUpdateUserPreferencesMutation, } from '@/services/norbix'; +import { useGetBootstrapQuery } from '@/services/portalApi'; import { useAppSelector } from '@/app/hooks'; import { selectUserId } from '@/features/auth/slice'; +import { selectSelectedProjectId } from '@/features/project/slice'; +import { + buildCommunicationView, + isSubscribed, + setSubscribed, + CHANNEL_LABEL, + DELIVERY_LABEL, + type BlockedTags, + type TagRow, +} from './communication'; + +const userLanguage = (): string => + typeof navigator !== 'undefined' ? navigator.language : 'en'; +/** + * Communication preferences. Cloud defines the structure for the whole project + * (channels → groups → tags, Project Settings → Communication Preferences); + * this page renders it so the user turns topics on or off per delivery channel. + * With no structure (none defined, or the backend has no service key) only the + * global marketing switch is shown. + */ export function Preferences() { - const userId = useAppSelector(selectUserId) ?? ''; - const { data, isLoading } = useGetUserPreferencesQuery({ id: userId }); + const userId = useAppSelector(selectUserId); + const projectId = useAppSelector(selectSelectedProjectId); + const { data, isLoading } = useGetUserPreferencesQuery( + userId ? { id: userId } : skipToken, + ); + const { data: bootstrap, isLoading: structureLoading } = useGetBootstrapQuery( + projectId ? { projectId } : skipToken, + ); const [update, { isError }] = useUpdateUserPreferencesMutation(); + const view = useMemo( + () => + buildCommunicationView(bootstrap?.marketingPreferences, userLanguage()), + [bootstrap], + ); + const [selected, setSelected] = useState(undefined); + const channel = + view.channels.find((c) => c.channel === selected) ?? view.channels[0]; + const prefs = data?.preferences; const blockAll = prefs?.blockAllMarketingMessages ?? false; + const blocked = (prefs?.blockedTags ?? {}) as BlockedTags; + + const save = (next: { blockAll?: boolean; blocked?: BlockedTags }) => { + if (!userId) return; + void update({ + id: userId, + blockAllMarketingMessages: next.blockAll ?? blockAll, + blockedTags: next.blocked ?? blocked, + }); + }; + + const tagRows = (rows: TagRow[], disabled: boolean) => + rows.map((row) => ( +
+
+
{row.title}
+ {row.description && ( +
{row.description}
+ )} +
+
+ {row.deliveries.map((d) => ( + + save({ blocked: setSubscribed(blocked, d, row.tag, on) }) + } + /> + ))} +
+
+ )); + + const loading = isLoading || structureLoading || !data; + const isMarketing = channel?.channel === 'Marketing'; return ( <> - - {isLoading || !data ? ( + {loading ? ( + - ) : ( -
- {isError && ( - Could not update preferences. - )} -
-
-
- Marketing messages -
-
- Receive promotional emails and announcements. Transactional - service messages are always sent. + + ) : ( +
+ {isError && Could not update preferences.} + + {view.channels.length > 1 && ( +
+ {view.channels.map((c) => ( + + ))} +
+ )} + + {(isMarketing || view.channels.length === 0) && ( + +
+
+
+ All marketing messages +
+
+ Turn off to stop every promotional message. Service messages + about your account are still sent. +
+ {/* "subscribed" = NOT blocked. */} + save({ blockAll: !subscribed })} + />
- {/* Toggle is "subscribed" = NOT blocked. */} - - update({ - id: userId, - blockAllMarketingMessages: !subscribed, - blockedTags: prefs?.blockedTags, - }) - } - /> -
-
- )} - + + )} + + {channel?.groups.map((group) => ( + +

{group.title}

+ {group.description && ( +

+ {group.description} +

+ )} +
+ {tagRows(group.tags, isMarketing && blockAll)} +
+
+ ))} + + {view.otherOptions.length > 0 && ( + +

Other options

+
+ {tagRows(view.otherOptions, false)} +
+
+ )} +
+ )} ); } diff --git a/src/services/portalApi.ts b/src/services/portalApi.ts index 0229eb2..80f737d 100644 --- a/src/services/portalApi.ts +++ b/src/services/portalApi.ts @@ -14,10 +14,20 @@ import { CodeMashHub2 } from '@norbix.ai/ts/types/hub'; export type AdminPortalModule = CodeMashHub2.AdminPortalModuleDto; export type AdminPortalStructure = CodeMashHub2.AdminPortalStructureDto; +/** + * The part of GET /api/bootstrap the screens use: the communication structure + * Cloud defines (project NotificationSettings), read server-side with the + * service-user key. Null when the backend has no key or the read failed. + */ +export interface PortalBootstrap { + marketingPreferences: CodeMashHub2.NotificationSettingsDto | null; + warnings: string[]; +} + export const portalApi = createApi({ reducerPath: 'portalApi', baseQuery: fetchBaseQuery({ baseUrl: '/api' }), - tagTypes: ['Structure'], + tagTypes: ['Structure', 'Bootstrap'], endpoints: (builder) => ({ // GET /api/structure?projectId=… — the backend reads the real structure // server-side and always returns a usable layout. The backend itself decides @@ -32,7 +42,18 @@ export const portalApi = createApi({ { type: 'Structure', id: arg.projectId }, ], }), + // GET /api/bootstrap?projectId=… — privileged catalogs (communication + // structure) fetched by the backend with the service key, never the browser. + getBootstrap: builder.query({ + query: ({ projectId }) => ({ + url: `/bootstrap?projectId=${encodeURIComponent(projectId)}`, + method: 'GET', + }), + providesTags: (_result, _error, arg) => [ + { type: 'Bootstrap', id: arg.projectId }, + ], + }), }), }); -export const { useGetStructureQuery } = portalApi; +export const { useGetStructureQuery, useGetBootstrapQuery } = portalApi; From 1ef9fdb12185d9dc89808733849fd5b272ea3be3 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 7 Oct 2026 17:13:24 +0300 Subject: [PATCH 05/10] [5] fix(admin:auth): no empty "or continue with", Terms / Privacy links filled, reset says "sent" only on success - The passkey button renders nothing without WebAuthn, so it no longer counts towards the "or continue with" block. - Terms / Privacy link to the portal's /legal/* pages when Cloud published the document; a link in the project config still wins. - The reset request shows "on its way" only after the call succeeded and an error otherwise (it used to say sent in a finally block). - publicApi takes the project resolved at boot, so legal pages work on custom domains too (resolveProjectId alone misses the async host lookup). - Prettier on project.ts / loginId.ts from steps 3 and 4. ref: F38 Co-Authored-By: Claude Opus 5.5 --- src/config/project.ts | 3 +- src/features/auth/login.tsx | 47 ++++++-- src/features/auth/loginId.ts | 4 +- src/features/auth/passwordReset.tsx | 11 +- src/features/auth/signInScreens.test.tsx | 137 +++++++++++++++++++++++ src/services/publicApi.ts | 13 ++- 6 files changed, 199 insertions(+), 16 deletions(-) create mode 100644 src/features/auth/signInScreens.test.tsx diff --git a/src/config/project.ts b/src/config/project.ts index de46f31..c3f219f 100644 --- a/src/config/project.ts +++ b/src/config/project.ts @@ -47,8 +47,7 @@ export function setProjectHeaders( // /admin-portal-id. Resolution by custom domain is a managed-service feature. const MANAGED_SERVICE_HUB_ROOT = `${NORBIX_HUB_URL}/v3`; -const BASE62 = - '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; +const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; /** * Turn the 32 hex digits of a Guid into the gateway's `pr_{base62}` view id — diff --git a/src/features/auth/login.tsx b/src/features/auth/login.tsx index 3bea68c..271c2b4 100644 --- a/src/features/auth/login.tsx +++ b/src/features/auth/login.tsx @@ -13,6 +13,8 @@ import { useLoginMutation } from '@/services/norbix'; import { useAppDispatch, useAppSelector } from '@/app/hooks'; import { signedIn, selectIsAuthenticated } from './slice'; import { PasskeySignInButton } from './passkeySignIn'; +import { isWebAuthnAvailable } from './webauthn'; +import { useGetLegalDocumentQuery } from '@/services/publicApi'; import { ROUTES } from '@/routes'; import type { ProjectConfig, @@ -60,6 +62,10 @@ export function Login({ config }: { config: ProjectConfig }) { const isAuthenticated = useAppSelector(selectIsAuthenticated); const { socialProviders, passkey, methods, passwordPolicy } = config.auth; + // The passkey button renders nothing without WebAuthn, so only count it when + // the browser can use it — otherwise "or continue with" would stand alone. + const showPasskey = passkey && isWebAuthnAvailable(); + const { termsUrl, privacyUrl } = useLegalLinks(config); useEffect(() => { if (isAuthenticated) navigate(ROUTES.HOME, { replace: true }); @@ -149,13 +155,13 @@ export function Login({ config }: { config: ProjectConfig }) { )} - {(socialProviders.length > 0 || passkey) && ( + {(socialProviders.length > 0 || showPasskey) && (
or continue with
- {passkey && } + {showPasskey && } {socialProviders.map((p) => (
- {(config.links.termsUrl || config.links.privacyUrl) && ( + {(termsUrl || privacyUrl) && (
- {config.links.termsUrl && ( - + {termsUrl && ( + Terms )} - {config.links.termsUrl && config.links.privacyUrl && ' · '} - {config.links.privacyUrl && ( - + {termsUrl && privacyUrl && ' · '} + {privacyUrl && ( + Privacy )} @@ -193,3 +199,28 @@ export function Login({ config }: { config: ProjectConfig }) { ); } + +/** + * Terms / Privacy links under the sign-in form. A link set in the project + * config wins; otherwise the portal's own /legal/* page is linked when Cloud + * has published that document (Project Settings → Access, "expose legal"). + */ +function useLegalLinks(config: ProjectConfig): { + termsUrl?: string; + privacyUrl?: string; +} { + const terms = useGetLegalDocumentQuery('terms', { + skip: Boolean(config.links.termsUrl), + }); + const privacy = useGetLegalDocumentQuery('privacy', { + skip: Boolean(config.links.privacyUrl), + }); + return { + termsUrl: + config.links.termsUrl ?? + (terms.data?.available ? ROUTES.LEGAL_TERMS : undefined), + privacyUrl: + config.links.privacyUrl ?? + (privacy.data?.available ? ROUTES.LEGAL_PRIVACY : undefined), + }; +} diff --git a/src/features/auth/loginId.ts b/src/features/auth/loginId.ts index 7f70847..0aa71c3 100644 --- a/src/features/auth/loginId.ts +++ b/src/features/auth/loginId.ts @@ -32,7 +32,9 @@ function decodeJwtPayload(token: string): Record | null { } /** The `cm_auth_id` claim of a gateway JWT, when it is a `usr_` id. */ -export function loginIdFromToken(token: string | null | undefined): string | null { +export function loginIdFromToken( + token: string | null | undefined, +): string | null { if (!token) return null; const claim = decodeJwtPayload(token)?.cm_auth_id; return typeof claim === 'string' && isLoginId(claim) ? claim : null; diff --git a/src/features/auth/passwordReset.tsx b/src/features/auth/passwordReset.tsx index b975f4b..75fb40e 100644 --- a/src/features/auth/passwordReset.tsx +++ b/src/features/auth/passwordReset.tsx @@ -18,15 +18,17 @@ import { import { ROUTES } from '@/routes'; export function PasswordResetRequest() { - const [request, { isLoading }] = useRequestPasswordResetMutation(); + const [request, { isLoading, isError }] = useRequestPasswordResetMutation(); const [sent, setSent] = useState(false); const branding = useAppSelector(selectProjectBranding); const onSubmit = async (values: { email: string }) => { try { await request({ email: values.email }).unwrap(); - } finally { + // Only a successful call says "sent"; a failure keeps the form open. setSent(true); + } catch { + /* surfaced via isError */ } }; @@ -45,6 +47,11 @@ export function PasswordResetRequest() { className="flex flex-col gap-4" noValidate > + {isError && ( + + Could not send the reset link. Please try again. + + )} ({ + available: { terms: false, privacy: false } as Record, +})); +const reset = vi.hoisted(() => ({ fails: false })); + +vi.mock('@/services/publicApi', () => ({ + useGetLegalDocumentQuery: (kind: 'terms' | 'privacy') => ({ + data: { kind, body: '', available: legal.available[kind] }, + }), +})); + +vi.mock('@/services/norbix', async () => { + const { useState } = await import('react'); + return { + useLoginMutation: () => [vi.fn(), { isLoading: false, isError: false }], + usePasskeyAuthenticationOptionsMutation: () => [vi.fn()], + useVerifyPasskeyAuthenticationMutation: () => [vi.fn()], + useConfirmPasswordResetMutation: () => [vi.fn(), {}], + useRequestPasswordResetMutation: () => { + const [isError, setError] = useState(false); + const request = () => ({ + unwrap: async () => { + if (reset.fails) { + setError(true); + throw new Error('500'); + } + }, + }); + return [request, { isLoading: false, isError }]; + }, + }; +}); + +const { Login } = await import('./login'); +const { PasswordResetRequest } = await import('./passwordReset'); + +const CONFIG = { + projectId: 'pr_1', + branding: { displayName: 'Shop' }, + auth: { + socialProviders: [], + passkey: true, + methods: ['email'], + passwordPolicy: { minLength: 3 }, + exposed: false, + }, + links: {}, +} as ProjectConfig; + +const renderWith = (ui: React.ReactElement) => + render( + + {ui} + , + ); + +afterEach(() => { + cleanup(); + legal.available = { terms: false, privacy: false }; + reset.fails = false; +}); + +describe('Login', () => { + it('shows no "or continue with" when passkeys are on but WebAuthn is missing', () => { + renderWith(); + expect(screen.getByRole('button', { name: 'Sign in' })).toBeTruthy(); + expect(screen.queryByText('or continue with')).toBeNull(); + }); + + it('links the published Terms page and leaves out an unpublished Privacy page', () => { + legal.available = { terms: true, privacy: false }; + renderWith(); + const terms = screen.getByRole('link', { name: 'Terms' }); + expect(terms.getAttribute('href')).toBe('/legal/terms'); + expect(screen.queryByRole('link', { name: 'Privacy' })).toBeNull(); + }); + + it('a link set in the project config wins', () => { + legal.available = { terms: true, privacy: true }; + renderWith( + , + ); + expect( + screen.getByRole('link', { name: 'Privacy' }).getAttribute('href'), + ).toBe('https://x.test/p'); + }); +}); + +describe('PasswordResetRequest', () => { + const submit = () => { + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: 'a@b.co' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Send reset link' })); + }; + + it('says the link is on its way only after the call succeeded', async () => { + renderWith(); + submit(); + expect(await screen.findByText(/a reset link is on its way/)).toBeTruthy(); + }); + + it('keeps the form and shows an error when the call fails', async () => { + reset.fails = true; + renderWith(); + submit(); + expect( + await screen.findByText(/Could not send the reset link/), + ).toBeTruthy(); + expect(screen.queryByText(/a reset link is on its way/)).toBeNull(); + expect(screen.getByLabelText('Email')).toBeTruthy(); + }); +}); diff --git a/src/services/publicApi.ts b/src/services/publicApi.ts index f9cb830..dcf8f7e 100644 --- a/src/services/publicApi.ts +++ b/src/services/publicApi.ts @@ -14,6 +14,7 @@ import { } from '@reduxjs/toolkit/query/react'; import { resolveProjectId, setProjectHeaders } from '@/config/project'; import { selectApiRoot } from '@/config/slice'; +import { selectSelectedProjectId } from '@/features/project/slice'; import type { RootState } from '@/app/store'; import type { LegalDocument } from '@/types/user'; @@ -23,7 +24,13 @@ const dynamicBaseQuery: BaseQueryFn< FetchBaseQueryError > = (args, apiArg, extra) => { const state = apiArg.getState() as RootState; - const projectId = resolveProjectId(); + // The project resolved at boot (custom domains included — their id comes + // from the async host lookup, which resolveProjectId() alone cannot see). + const projectId = selectSelectedProjectId(state) ?? resolveProjectId(); + // 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) }; const rawBaseQuery = fetchBaseQuery({ baseUrl: selectApiRoot(state), prepareHeaders: (headers) => { @@ -31,7 +38,7 @@ const dynamicBaseQuery: BaseQueryFn< return headers; }, }); - return rawBaseQuery(args, apiArg, extra); + return rawBaseQuery(filled, apiArg, extra); }; export const publicApi = createApi({ @@ -40,7 +47,7 @@ export const publicApi = createApi({ endpoints: (builder) => ({ getLegalDocument: builder.query({ query: (kind) => ({ - url: `/public/projects/${resolveProjectId()}/legal/${kind}`, + url: `/public/projects/{projectId}/legal/${kind}`, method: 'GET', }), // Normalize: the endpoint returns { kind, title?, body, available }. From 85788b379858585bbe9f9fde9b0ad13f6b76bd6a Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 7 Oct 2026 17:15:30 +0300 Subject: [PATCH 06/10] [7] fix(admin:structure): self-hosted backend calls /v3/account/projects/{projectId}/admin-portal/structure with the service-user key The route called {hub}/v3/admin-portal/structure, which does not exist; the gateway route is GetAdminPortalStructure at /{version}/account/projects/{projectId}/admin-portal/structure. It now goes through the SDK (hub.account.getAdminPortalStructure) with the service-user API key from task P3, so the path, bearer and project headers match the gateway. ref: F40 Co-Authored-By: Claude Opus 5.5 --- app/api/structure/route.test.ts | 58 +++++++++++++++++++++++++++++++++ app/api/structure/route.ts | 52 +++++++++++++++-------------- 2 files changed, 86 insertions(+), 24 deletions(-) create mode 100644 app/api/structure/route.test.ts diff --git a/app/api/structure/route.test.ts b/app/api/structure/route.test.ts new file mode 100644 index 0000000..f2dfe87 --- /dev/null +++ b/app/api/structure/route.test.ts @@ -0,0 +1,58 @@ +// Structure route, self-hosted path (F40): with API_KEY set, the backend calls +// the gateway's GET /{v}/account/projects/{projectId}/admin-portal/structure +// (GetAdminPortalStructure) as the service user. A real local HTTP server +// stands in for the Hub, so the test sees the exact path and headers. +import { createServer, IncomingHttpHeaders, Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('server-only', () => ({})); + +let hub: Server; +let seen: { url?: string; headers?: IncomingHttpHeaders } = {}; +let route: typeof import('./route'); + +beforeAll(async () => { + hub = createServer((req, res) => { + seen = { url: req.url, headers: req.headers }; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + projectId: 'pr_1', + adminPortalEnabled: true, + displayName: 'Shop', + modules: [{ key: 'profile', displayName: 'Profile', enabled: true }], + }), + ); + }); + await new Promise((resolve) => hub.listen(0, '127.0.0.1', resolve)); + const { port } = hub.address() as AddressInfo; + vi.stubEnv('HUB_BASE_URL', `http://127.0.0.1:${port}`); + vi.stubEnv('API_BASE_URL', `http://127.0.0.1:${port}`); + vi.stubEnv('API_KEY', 'svc-key'); + route = await import('./route'); +}); + +afterAll(() => { + hub.close(); + vi.unstubAllEnvs(); +}); + +describe('GET /api/structure (self-hosted)', () => { + it('calls /v3/account/projects/{projectId}/admin-portal/structure with the service key', async () => { + const res = await route.GET( + new NextRequest('http://portal.test/api/structure?projectId=pr_1'), + ); + + expect(seen.url).toBe('/v3/account/projects/pr_1/admin-portal/structure'); + expect(seen.headers?.authorization).toBe('Bearer svc-key'); + expect(seen.headers?.['norbix-project-id']).toBe('pr_1'); + expect(await res.json()).toEqual({ + projectId: 'pr_1', + adminPortalEnabled: true, + displayName: 'Shop', + modules: [{ key: 'profile', displayName: 'Profile', enabled: true }], + }); + }); +}); diff --git a/app/api/structure/route.ts b/app/api/structure/route.ts index 4f86655..729c5bd 100644 --- a/app/api/structure/route.ts +++ b/app/api/structure/route.ts @@ -3,8 +3,9 @@ // own config — the presence of API_KEY — NOT a client hint and NOT a mode flag: // // API_KEY set → self-hosted → call the PUBLIC Hub endpoint -// GET {hub}/{v}/admin-portal/structure -// authenticated as the project's AdminPortalManager service user. +// GET {hub}/{v}/account/projects/{projectId}/admin-portal/structure +// (gateway GetAdminPortalStructure) through the SDK, authenticated as the +// project's AdminPortalManager service user (its API key, task P3). // // API_KEY unset → managed → call the PRIVATE internal endpoint // GET {hub}/internal/admin-portal/structure?projectId=… @@ -17,6 +18,7 @@ import 'server-only'; import { NextRequest, NextResponse } from 'next/server'; import { NORBIX_HUB_URL } from '@norbix.ai/ts'; +import { serviceClientForProject } from '../../lib/serverNorbix'; // Type-only import (erased at build) — the single admin-repo definition of the // structure shapes lives in portalApi. Both will be replaced by the generated // @norbix.ai/ts DTOs after the gateway SDK regen (G7). @@ -32,7 +34,6 @@ const HUB_BASE = (process.env.HUB_BASE_URL ?? NORBIX_HUB_URL).replace( /\/$/, '', ); -const HUB_VERSION = process.env.HUB_VERSION ?? 'v3'; const API_KEY = process.env.API_KEY; const NORBIX_ENV = process.env.ENV && process.env.ENV.length > 0 ? process.env.ENV : undefined; @@ -73,29 +74,32 @@ export async function GET(req: NextRequest): Promise { // per-project key. const isSelfHosted = Boolean(API_KEY); - const headers: Record = { accept: 'application/json' }; - if (NORBIX_ENV) headers['norbix-env'] = NORBIX_ENV; - - let url: string; - if (isSelfHosted) { - // Public endpoint — authenticate as the service user. - headers.authorization = `Bearer ${API_KEY}`; - headers['x-cm-projectid'] = projectId; - url = `${HUB_BASE}/${HUB_VERSION}/admin-portal/structure`; - } else { - // Managed: private internal endpoint — network-trusted, no key. - url = `${HUB_BASE}/internal/admin-portal/structure?projectId=${encodeURIComponent(projectId)}`; - } - try { - const res = await fetch(url, { headers, cache: 'no-store' }); - if (!res.ok) { - return NextResponse.json( - { ...defaultStructure(projectId), warnings: ['structure-unavailable'] }, - { status: 200 }, - ); + let structure: Partial; + if (isSelfHosted) { + // Public endpoint, as the service user. The SDK fills {projectId} into + // /{v}/account/projects/{projectId}/admin-portal/structure and sends the + // key as the bearer token, plus the project and env headers. + structure = await serviceClientForProject( + projectId, + ).hub.account.getAdminPortalStructure({ projectId }); + } else { + // Managed: private internal endpoint — network-trusted, no key. + const headers: Record = { accept: 'application/json' }; + if (NORBIX_ENV) headers['norbix-env'] = NORBIX_ENV; + const url = `${HUB_BASE}/internal/admin-portal/structure?projectId=${encodeURIComponent(projectId)}`; + const res = await fetch(url, { headers, cache: 'no-store' }); + if (!res.ok) { + return NextResponse.json( + { + ...defaultStructure(projectId), + warnings: ['structure-unavailable'], + }, + { status: 200 }, + ); + } + structure = (await res.json()) as Partial; } - const structure = (await res.json()) as Partial; // Backfill a usable shape if the gateway returned a sparse/empty body. return NextResponse.json( { From 178a37b9a0484ac148b972cca0cc4b208b95e12e Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 7 Oct 2026 17:18:39 +0300 Subject: [PATCH 07/10] [8] docs(admin): host label, Cloud-defined communication preferences, hidden privacy page, Next build args Co-Authored-By: Claude Opus 5.5 --- README.md | 26 ++-- docs/README.md | 16 ++- docs/backend.md | 4 +- docs/devops.md | 31 ++--- docs/features.md | 43 ++++-- docs/login-resolution.md | 18 ++- docs/tasks/portal-structure-hostlabel.md | 165 +++++++++++++++++++++++ 7 files changed, 250 insertions(+), 53 deletions(-) create mode 100644 docs/tasks/portal-structure-hostlabel.md diff --git a/README.md b/README.md index a6bf522..25a2714 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ The **Admin Portal** is the out-of-the-box, self-service portal for a Norbix project's **end users**. When a developer doesn't have time to build their own user dashboard, this gives their users a ready-made one: sign in, manage -password & 2FA, edit their profile, control marketing preferences, and handle -data-privacy requests. It also hosts the project's public terms & privacy +password & passkeys, edit their profile, and choose which messages they get +(the communication structure the project defines in Cloud). The Privacy & data +page (export / delete) is hidden until the gateway has its endpoints. It also hosts the project's public terms & privacy policy at a stable URL (usable in app-store listings). It is open source (MIT) and talks **only to the Norbix API gateway** (the @@ -12,17 +13,21 @@ end-user data plane), never to the Hub. ## How a project is selected -One deployment serves many projects. The project id lives in the **subdomain**: +One deployment serves many projects. The project lives in the **host label** +— the gateway's `ProjectId.HostLabel`, `pr-` + the project Guid as 32 +lower-case hex digits (DNS-safe: no `_`, one case): ``` -pr_{base62}.admin.norbix.ai → that project's login screen -admin.norbix.ai → blank placeholder (no project) +pr-4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2.admin.norbix.ai → project pr_5R4dlqJeXx943tOzSDEwbS +admin.norbix.ai → blank placeholder (no project) ``` -Point a custom domain at it with a CNAME: +The portal turns the label back into the `pr_{base62}` id +(`ProjectId.ViewId`) the gateway reads (`src/config/project.ts`). Point a +custom domain at it with a CNAME: ``` -admin.yourapp.com CNAME pr_7Hk2.admin.norbix.ai +admin.yourapp.com CNAME pr-4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2.admin.norbix.ai ``` See [`docs/`](./docs) for the full architecture, login-config resolution, @@ -50,9 +55,10 @@ The portal uses **`@norbix.ai/react-redux`** (RTK Query hooks over the typed Both come from npm. To try an SDK change before it is published, see [`docs/sdk-local-development.md`](./docs/sdk-local-development.md). -For local dev without a `pr_` subdomain, pin a project with -`VITE_ADMIN_PROJECT_ID` in `.env`, or add a static config at -`config/projects/{id}.json` and visit with that id. +For local dev without a `pr-` host, pin a project with +`NEXT_PUBLIC_ADMIN_PROJECT_ID` in `.env` (a Docker image takes it as +`--build-arg NEXT_PUBLIC_ADMIN_PROJECT_ID=pr_…`: Next inlines it at build +time). ## Tech diff --git a/docs/README.md b/docs/README.md index 74752d7..4ce976d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,22 +6,24 @@ out-of-the-box, self-service portal that a Norbix project's **end users** | Doc | What it covers | | --- | --- | -| [`architecture.md`](./architecture.md) | The full picture: the four portals (Hub, API, Cloud, Admin), how Admin fits, the `pr_{base62}` subdomain model, dynamic vs static login config, CNAME mapping, deployment flavors. | +| [`architecture.md`](./architecture.md) | The full picture: the four portals (Hub, API, Cloud, Admin), how Admin fits, the `pr-` host-label model, dynamic vs static login config, CNAME mapping, deployment flavors. | | [`login-resolution.md`](./login-resolution.md) | How the unauthenticated login screen is built — dynamic (Hub) vs static (YAML/JSON) config, the resolution order, and caching. | -| [`features.md`](./features.md) | The four feature areas: Auth management, Profile / contact info, Marketing preferences, Compliance / privacy. Plus the planned "own records" feature. | +| [`features.md`](./features.md) | The four feature areas: Auth management, Profile / contact info, Communication preferences (rendered from Cloud's structure), Compliance / privacy (hidden until the gateway has it). Plus the planned "own records" feature. | | [`implementation-plan.md`](./implementation-plan.md) | Phased build plan. What we reuse from Cloud, what we rewrite (clean-room), and the open-source licensing rules. | -| [`devops.md`](./devops.md) | Docker image, nginx, and Kubernetes manifests (managed-service + self-hosted), wildcard ingress for `pr_{base62}`. | +| [`devops.md`](./devops.md) | Docker image, nginx, and Kubernetes manifests (managed-service + self-hosted), wildcard ingress for `pr-`. | ## One-paragraph summary The Admin Portal is a single React SPA, open-source, served from `admin.norbix.ai` (managed service) or `admin.` (self-hosted / enterprise). It is **not** deployed per project. Instead the **project is -carried in the subdomain** — `pr_{base62}.admin.norbix.ai`. A bare +carried in the host label** — `pr-<32 hex>.admin.norbix.ai` (the gateway's +`ProjectId.HostLabel`). A bare `admin.norbix.ai` with no project prefix renders a blank/placeholder page. The portal resolves the project from the subdomain, asks the **API gateway** for that project's public membership settings, and renders the correct login screen (email/password, social logins, branding). Authenticated users manage -their **auth** (password, 2FA), **profile** (contact info), **marketing -preferences**, and **compliance** (data export, account deletion, public terms -& policies). +their **auth** (password, passkeys), **profile** (contact info) and +**communication preferences**; the public terms & policies pages need no +sign-in. Compliance (data export, account deletion) is hidden until the +gateway has its endpoints. diff --git a/docs/backend.md b/docs/backend.md index 4ac3c8f..e21b7ed 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -86,7 +86,7 @@ same call: authorization** — no API key, no key lookup, no key vending. It returns structure, never a key. The server passes `projectId`, derived from the end-user host/subdomain (never a browser field). The environment is derived - from the host (e.g. `test.pr_x.admin.norbix.ai`). + from the host (e.g. `test.pr-.admin.norbix.ai`). - **Self-hosted** (forker): your backend calls the **public** Hub structure endpoint authenticated as the project's `AdminPortalManager` service-user key (in `API_KEY`), with normal IAM/tenancy clamping. OR you ship a hardcoded @@ -116,7 +116,7 @@ Runtime (read by the Node server — NOT baked into the bundle): > skip-remote config heuristic — it is NOT the Norbix `ENV` above. Browser-facing config still uses the public, non-secret values; the project id -is resolved as before (pin → `pr_` subdomain → meta → managed-host lookup). +is resolved as before (pin → `pr-` host label → meta → managed-host lookup). ## Single Docker image diff --git a/docs/devops.md b/docs/devops.md index 154224b..2c1d66f 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -7,21 +7,22 @@ image is a static SPA served by nginx, exactly like Cloud. `admin/deployments/Dockerfile` (multi-stage, same shape as Cloud): -1. **build** — `node:22-alpine`, `npm ci`, `npm run build` (`tsc && vite - build`). Build-time flags baked via Vite `define`: - - `VITE_ADMIN_RELEASE` = `ManagedService` | `SelfHosted` | `Enterprise` - - `VITE_ADMIN_API_BASE_URL`, `VITE_ADMIN_API_VERSION` - - `VITE_ADMIN_PROJECT_ID` (optional; pins the project for custom-domain - self-hosted builds) -2. **runtime** — `nginx:1.27-alpine`, serves `/dist`, runs as unprivileged - `nginx` user, listens on `8080`, SPA fallback to `index.html`, `/healthz`. +1. **build** — `node:22-alpine`, `npm ci`, `next build` (standalone output). + Browser values are inlined at build time, so they are build args: + - `NEXT_PUBLIC_ADMIN_PROJECT_ID` (optional; pins the project for + self-hosted / custom-domain builds) + - `NEXT_PUBLIC_ADMIN_CONFIG_MODE` (`dynamic` default, or `static`) +2. **runtime** — `node server.js` as an unprivileged user on `3100`. Server + env at `docker run`: `API_KEY` (service-user key, self-hosted), + `HUB_BASE_URL`, `API_BASE_URL`, `ENV`. Managed vs self-hosted comes from + `/echo` and from whether `API_KEY` is set. Build examples: ``` -docker build -t admin:managed --build-arg VITE_ADMIN_RELEASE=ManagedService . -docker build -t admin:selfhosted --build-arg VITE_ADMIN_RELEASE=SelfHosted \ - --build-arg VITE_ADMIN_PROJECT_ID=7Hk2 . +docker build -f deployments/Dockerfile -t admin:managed . +docker build -f deployments/Dockerfile -t admin:selfhosted \ + --build-arg NEXT_PUBLIC_ADMIN_PROJECT_ID=pr_5R4dlqJeXx943tOzSDEwbS . ``` `admin/deployments/nginx/default.conf` matches Cloud's: long cache on hashed @@ -38,7 +39,7 @@ devops/k8s/ deployments/managed-service/admin.yaml # Deployment (managed) deployments/self-hosted/admin.yaml # Deployment (self-hosted) services/managed-service/admin.yaml # ClusterIP Service - ingress/admin.yaml # wildcard ingress for pr_*.admin + ingress/admin.yaml # wildcard ingress for pr-.admin ``` ### ConfigMap (`norbix-admin-config`) @@ -69,12 +70,12 @@ host: "*.admin.norbix.ai" → service: admin ACME DNS-01, since HTTP-01 can't do wildcards). The bare `admin.norbix.ai` also routes to the same service (renders the blank placeholder). - **Custom domains (CNAME):** the customer CNAMEs `admin.` to - `pr_7Hk2.admin.norbix.ai`. For TLS on the custom host we either (a) ask them + `pr-<32 hex>.admin.norbix.ai` (the project's host label). For TLS on the custom host we either (a) ask them to terminate TLS at their edge, or (b) issue a per-host cert via cert-manager for the custom domain and add an ingress rule. Because the CNAME target carries the project id, the SPA still resolves the project; for the custom - host the ingress injects `X-Norbix-Project: 7Hk2` (annotation) so the SPA - can read it when the visible host has no `pr_` prefix. + host the portal asks the managed Hub (`/admin-portal-id?host=`) because the + visible host has no `pr-` label. ### Secrets diff --git a/docs/features.md b/docs/features.md index 3a9cc22..150bce5 100644 --- a/docs/features.md +++ b/docs/features.md @@ -11,12 +11,13 @@ The signed-in user manages their own credentials and account security. - **Change password** — old + new password. - **Reset password** — request a reset email; confirm with the token from the link. Also reachable unauthenticated from the login screen. -- **Two-factor (2FA)** — enroll (TOTP: show secret + QR, verify a code), - view status, disable (re-auth required). +- **Passkeys** — add, rename, revoke (behind email verification). +- **Two-factor (2FA)** — not built yet (Membership task); the portal does not + show or mention it. - **Sessions / sign-out** — sign out of the current session. (Listing/revoking other sessions is a later enhancement if the API exposes it.) -Screens: `/security` (overview), `/security/password`, `/security/2fa`. +Screens: `/security` (password + passkeys). Unauth: `/sign-in`, `/sign-in/reset`, `/sign-in/reset/confirm`. ## 2. Profile / contact info @@ -27,23 +28,39 @@ endpoints. Screen: `/profile`. -## 3. Marketing preferences +## 3. Communication preferences -The user controls what messages they receive: which marketing topics they are -subscribed to, and which transactional vs. marketing channels are enabled. -Backed by the API marketing-state endpoints (per channel / per subscription), -the same domain concept Cloud's contacts/marketing-state uses on the Hub side. +Cloud defines the communication structure for the whole project (Project +Settings → Communication Preferences): per communication channel (Marketing, +Transactional) a list of **groups**, each with **tags**; every tag says on +which delivery channels it sends (`defaultDelivery`: Email, Sms, Push, …). +Tags in no group are Cloud's "orphaned tags". The portal renders exactly that: -- List subscriptions / topics with on-off toggles. -- Per-channel marketing opt-in/opt-out (email, SMS, push, ...). -- Clear distinction between **transactional** (cannot fully opt out of - essential service messages) and **marketing** (freely opt out). +- A tab per channel (Marketing, Service messages); System is never optional. +- Each group as a card (translated title / description), each tag with one + switch per delivery channel it sends on. +- Tags in no group under **Other options**. +- The global **All marketing messages** switch (`blockAllMarketingMessages`); + when off, the marketing tag switches are disabled. + +A switch writes `blockedTags[]` (full replacement) through +`UpdateUserPreferences` — the gateway checks it per delivery channel +(`Campaign.cs`). The structure comes from `/api/bootstrap`, read server-side +with the service-user key; without a key only the global switch shows. Screen: `/preferences`. ## 4. Compliance / privacy -Self-service data rights: +**Hidden for now** (`PRIVACY_PAGE_ENABLED = false` in `src/config/structure.ts`): +the page called `/me/compliance`, `/me/compliance/export` and +`/me/compliance/delete`, which the gateway does not have. Cloud defines the +privacy flows first (Project Settings → Access / legal documents, Compliance +settings); the portal then renders what Cloud defines. The public +`/legal/terms` and `/legal/privacy` pages stay, and the sign-in screen links +them when the project published the document. + +Planned self-service data rights: - **Data export** — "what data do you hold about me" → request an export; show request status / download when ready. diff --git a/docs/login-resolution.md b/docs/login-resolution.md index 54d271b..9d0d8f3 100644 --- a/docs/login-resolution.md +++ b/docs/login-resolution.md @@ -8,19 +8,25 @@ render the unauthenticated login screen. On boot, the portal reads the project id from the **host**: ``` -pr_7Hk2.admin.norbix.ai → projectId = "7Hk2" -admin.laimingaspilvukas.lt (CNAME → pr_7Hk2.admin.norbix.ai) → projectId = "7Hk2" +pr-4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2.admin.norbix.ai → projectId = "pr_5R4dlqJeXx943tOzSDEwbS" +admin.laimingaspilvukas.lt (CNAME → pr-4c0e….admin.norbix.ai) → host lookup → projectId admin.norbix.ai → no projectId → blank placeholder ``` Rules: -- The host's leftmost label must match `^pr_([0-9A-Za-z]+)$`. The captured - group is the base62 project id. +- The host's leftmost label must match `^pr-([0-9a-f]{32})$`, any case — the + gateway's `ProjectId.HostLabel` (`ProjectId.TryParseFromHost` reads it the + same way). The 32 hex digits are the project Guid; the portal converts them + to the `pr_{base62}` view id with the gateway's own steps + (`Guid.ToByteArray` order → little-endian number → base62), tested against + .NET output in `src/config/project.test.ts`. +- The old `pr_{base62}` host form is not read: `_` is not a legal host + character and browsers lower-case the host, which breaks base62. - A CNAME does not change the browser's visible host, so for custom domains the portal needs the id another way. Two supported mechanisms: - 1. **Build-time pin** (self-hosted/enterprise): `VITE_ADMIN_PROJECT_ID` is - baked into the image. The operator builds one image per project. + 1. **Build-time pin** (self-hosted/enterprise): `NEXT_PUBLIC_ADMIN_PROJECT_ID` + is baked into the image (`--build-arg`). One image per project. 2. **Edge header** (managed service custom domains): the ingress injects an `X-Norbix-Project` header / rewrites a known path that the runtime config shim reads. (See `devops.md`.) diff --git a/docs/tasks/portal-structure-hostlabel.md b/docs/tasks/portal-structure-hostlabel.md new file mode 100644 index 0000000..fa1bb4d --- /dev/null +++ b/docs/tasks/portal-structure-hostlabel.md @@ -0,0 +1,165 @@ +# Admin portal — communication structure, privacy page, managed host label, F37–F40 +This file: /Users/djovaisas/Projects/norbix/admin/docs/tasks/portal-structure-hostlabel.md (branch main, after the merge; worked on in branch fix/portal-structure-hostlabel) + +## Goal +Make the end-user portal render what Cloud defines (communication structure, legal docs), stop calling endpoints that do not exist, and resolve the managed `pr-` host. +Not in scope: gateway changes (the canonical admin URL builder, a compliance API, 2FA, end-user `membership:update`) and the public docs repo — listed under Findings / Needs you. + +## Plan +1. [done] feat(admin:preferences): render the project's channels → groups → tags (Cloud's Communication Preferences) with per-delivery switches, not one marketing switch +2. [done] fix(admin:privacy): hide the Privacy & data page and nav until /me/compliance* exist; drop "two-factor" from the Home Security card +3. [done] fix(admin:project): read the managed host label pr-<32 hex> (ProjectId.HostLabel) and turn it into the pr_ id the gateway expects +4. [done] fix(admin:auth): use the usr_ login id (JWT claim cm_auth_id) for Profile, Preferences and Passkeys ref: F37 +5. [done] fix(admin:auth): no empty "or continue with"; Terms / Privacy links filled; reset form says "sent" only on success ref: F38 +6. [done] chore(admin:docker): ARG NEXT_PUBLIC_ADMIN_PROJECT_ID (and CONFIG_MODE) in the build stage, checked with a real docker build ref: F39 +7. [done] fix(admin:structure): self-hosted backend calls /v3/account/projects/{projectId}/admin-portal/structure with the service-user key ref: F40 +8. [done] docs(admin): README + docs/README, login-resolution, features, devops, backend updated for the host label, hidden privacy page, preferences +9. [doing] release(admin): pull request merged with nbx-ship + +## Changes +| file (absolute, branch main after merge) | what changed | step | +|------|--------------|------| +| /Users/djovaisas/Projects/norbix/admin/src/features/preferences/communication.ts | new: Cloud structure → view model; blockedTags helpers | 1 | +| /Users/djovaisas/Projects/norbix/admin/src/features/preferences/communication.test.ts | new: 7 tests | 1 | +| /Users/djovaisas/Projects/norbix/admin/src/features/preferences/preferences.tsx | tabs, groups, tags, per-delivery switches, Other options, global switch | 1 | +| /Users/djovaisas/Projects/norbix/admin/src/services/portalApi.ts | getBootstrap endpoint (/api/bootstrap) | 1 | +| /Users/djovaisas/Projects/norbix/admin/src/config/structure.ts | PRIVACY_PAGE_ENABLED=false; Security card "Password and passkeys." | 2 | +| /Users/djovaisas/Projects/norbix/admin/src/config/structure.test.ts | new: 3 tests | 2 | +| /Users/djovaisas/Projects/norbix/admin/src/components/layouts.tsx | Privacy nav item behind the flag | 2 | +| /Users/djovaisas/Projects/norbix/admin/src/App.tsx | /privacy route behind the flag; comment | 2, 3 | +| /Users/djovaisas/Projects/norbix/admin/src/config/project.ts | pr- host label → pr_ (viewIdFromHex) | 3 | +| /Users/djovaisas/Projects/norbix/admin/src/config/project.test.ts | .NET reference pairs + host cases (10 tests) | 3 | +| /Users/djovaisas/Projects/norbix/admin/src/features/dashboard/dashboard.tsx | placeholder text shows pr- | 3 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/loginId.ts | new: cm_auth_id claim → usr_ id | 4 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/loginId.test.ts | new: 6 tests | 4 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/slice.ts | signedIn / selectUserId use resolveLoginId | 4 | +| /Users/djovaisas/Projects/norbix/admin/src/features/profile/profile.tsx | skip the user query until a usr_ id exists | 4 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/passkeys.tsx | same | 4 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/login.tsx | showPasskey needs WebAuthn; useLegalLinks | 5 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/passwordReset.tsx | "sent" only on success, error alert | 5 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/signInScreens.test.tsx | new: 5 component tests | 5 | +| /Users/djovaisas/Projects/norbix/admin/src/services/publicApi.ts | project id from the store (custom domains) | 5 | +| /Users/djovaisas/Projects/norbix/admin/deployments/Dockerfile | build args NEXT_PUBLIC_ADMIN_PROJECT_ID / CONFIG_MODE | 6 | +| /Users/djovaisas/Projects/norbix/admin/app/api/structure/route.ts | self-hosted path via SDK getAdminPortalStructure | 7 | +| /Users/djovaisas/Projects/norbix/admin/app/api/structure/route.test.ts | new: local Hub server checks path + bearer | 7 | +| /Users/djovaisas/Projects/norbix/admin/README.md, docs/{README,login-resolution,features,devops,backend}.md | host label, preferences, hidden privacy, Next build args | 8 | + +## Findings + +fix(admin:project): the portal read `pr_` from the host; browsers lower-case it, so the id was wrong and the portal showed "Portal unavailable" — done + where: /Users/djovaisas/Projects/norbix/admin/src/config/project.ts (branch main after merge) +```ts +// before — admin/src/config/project.ts:19,44-48 (main before) +const PR_PREFIX = /^pr_([0-9A-Za-z]+)$/; // <-- here: '_' + mixed case, not a DNS label +export function parseProjectIdFromHost(host: string): string | null { + const firstLabel = host.split('.')[0] ?? ''; + const match = PR_PREFIX.exec(firstLabel); + return match ? match[1] : null; +} +``` +```csharp +// the gateway contract — gateway/src/Isidos.CodeMash.Domain/BoundContext/Project/ValueObjects/ProjectId.cs:76-77 (refactoringV2) +public string HostLabel => + $"{HostLabelPrefix}-{Value.ToString("N").ToLowerInvariant()}"; // pr-<32 hex> +``` +```ts +// after — admin/src/config/project.ts (fix/portal-structure-hostlabel) +const HOST_LABEL = /^pr-([0-9a-f]{32})$/i; +export function parseProjectIdFromHost(host: string): string | null { + const firstLabel = host.split(':')[0].split('.')[0] ?? ''; + const match = HOST_LABEL.exec(firstLabel); + return match ? viewIdFromHex(match[1]) : null; // <-- same steps as Base62Converter; tested vs .NET output +} +``` + +fix(admin:auth): Profile, Preferences and Passkeys used the numeric /auth userId ("2"); /membership/auth/{id} needs the usr_ login id — done ref: F37 + where: /Users/djovaisas/Projects/norbix/admin/src/features/auth/loginId.ts (branch main after merge) +```ts +// before — admin/src/features/auth/login.tsx:76-79 (main before) +if (res.bearerToken) { + dispatch( + signedIn({ token: res.bearerToken, userId: res.userId ?? '' }), // <-- here: ServiceStack UserAuthId "2" + ); +} +``` +```csharp +// where the real id is — gateway/src/Isidos.CodeMash.Services.Api/Configure/Plugins/Auth/Gateway/GatewayAuth.cs:85-90 (refactoringV2) +CreatePayloadFilter = (payload, session) => +{ + if (session is CodeMashUserSession { CodeMashUserAuth: { } userAuth }) + { + if (!string.IsNullOrEmpty(userAuth.AuthId)) + payload["cm_auth_id"] = userAuth.AuthId; // <-- usr_… login id in the JWT +``` + +fix(admin:auth): the reset form said "a reset link is on its way" even when the call failed — done ref: F38 + where: /Users/djovaisas/Projects/norbix/admin/src/features/auth/passwordReset.tsx (branch main after merge) +```tsx +// before — admin/src/features/auth/passwordReset.tsx:25-31 (main before) +const onSubmit = async (values: { email: string }) => { + try { + await request({ email: values.email }).unwrap(); + } finally { + setSent(true); // <-- here: runs on failure too + } +}; +``` + +fix(admin:structure): the self-hosted backend called a route that does not exist — done ref: F40 + where: /Users/djovaisas/Projects/norbix/admin/app/api/structure/route.ts (branch main after merge) +```ts +// before — admin/app/api/structure/route.ts:80-84 (main before) +if (isSelfHosted) { + headers.authorization = `Bearer ${API_KEY}`; + headers['x-cm-projectid'] = projectId; + url = `${HUB_BASE}/${HUB_VERSION}/admin-portal/structure`; // <-- here: no such route +``` +```csharp +// gateway/src/Isidos.CodeMash.Gateway.Hub.Account/Project/Settings/AdminPortalStructure.cs:27-29 (refactoringV2) +[Authenticate] +[Route("/{version}/account/projects/{projectId}/admin-portal/structure", "GET", + Summary = "Admin Portal layout/structure for the project's service user")] +``` + +fix(admin:legal): publicApi read the project with resolveProjectId(), which misses custom domains (their id comes from the async host lookup) — done + where: /Users/djovaisas/Projects/norbix/admin/src/services/publicApi.ts (branch main after merge) +```ts +// before — admin/src/services/publicApi.ts:41-44 (main before) +getLegalDocument: builder.query({ + query: (kind) => ({ + url: `/public/projects/${resolveProjectId()}/legal/${kind}`, // <-- here: null on a custom domain +``` + +blocked(gateway:project:admin-url): the gateway still builds the canonical admin URL with the pr_ ViewId, so Cloud, emails (@Model.Project.AdminUrl) and the CORS seed hand out an address a browser cannot use — needs you + where: /Users/djovaisas/Projects/norbix/gateway/src/Isidos.CodeMash.Domain/ValueObjects/DomainUrl.cs:204 (branch refactoringV2) action: use projectId.HostLabel there (and fix the comment in Hub.Account.Persistent/Project.cs:46) +```csharp +// gateway/src/Isidos.CodeMash.Domain/ValueObjects/DomainUrl.cs:203-204 (refactoringV2) +var safeScheme = string.IsNullOrWhiteSpace(scheme) ? Uri.UriSchemeHttps : scheme; +return Create($"{safeScheme}://{projectId.ViewId}.{suffix}"); // <-- here: pr_, should be HostLabel +``` + +blocked(admin:preferences): on the managed service the backend has no API_KEY, so /api/bootstrap returns no structure and the page falls back to the global marketing switch — needs you + where: /Users/djovaisas/Projects/norbix/admin/app/api/bootstrap/route.ts:91 (branch main) action: gateway — carry NotificationSettingsDto in AdminPortalStructureDto / the internal structure endpoint +```ts +// admin/app/api/bootstrap/route.ts:91-97 (main) +try { + // The marketing-preference STRUCTURE rides on the project read model + // (NotificationSettings). We return only the catalog, never any user's + // values. + const projectResp = await client.hub.account.getProject({ projectId }); // <-- only with the service key + out.marketingPreferences = projectResp.item?.notificationSettings ?? null; +``` + +## Rejected / moved out +- decision(admin:privacy): hide the page behind PRIVACY_PAGE_ENABLED instead of deleting compliance.tsx / complianceService.ts — rejected (delete) — reason: the screen comes back once Cloud + gateway define the flows — new ticket: none +- decision(admin:docs): docs/architecture.md and docs/agreements.md still describe the canonical `pr_{id}.admin.{host}` — moved out — reason: they describe the gateway's current URL builder (blocked line above); change them with that gateway fix +- not mine, untouched: /Users/djovaisas/Projects/norbix/admin/node_modules points @norbix.ai/ts at ../sdks/norbix-js and has no @norbix.ai/react-redux, so `npm run typecheck` fails in the main checkout; the worktree used a clean `npm ci` + +## Needs you +- [ ] blocked(gateway:project:admin-url): canonical admin URL must use ProjectId.HostLabel — needs you · action: gateway task (DomainUrl.cs:204) +- [ ] blocked(gateway:admin-portal:structure): add the communication structure to the managed structure endpoint — needs you · action: gateway task +- [ ] blocked(gateway:compliance): /me/compliance, /export, /delete do not exist; Cloud must define the privacy flows first — needs you · action: product decision + gateway task, then set PRIVACY_PAGE_ENABLED = true +- [ ] docs(codemash-docs:admin): the public admin/* pages describe the old behaviour — needs you · action: see the list in the final report + +## Open questions +- none From 16025672da77ff6ee754ccb600512a603869e4d9 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 7 Oct 2026 17:23:11 +0300 Subject: [PATCH 08/10] [6] chore(admin:docker): build args NEXT_PUBLIC_ADMIN_PROJECT_ID and NEXT_PUBLIC_ADMIN_CONFIG_MODE Next inlines NEXT_PUBLIC_* at build time, so runtime env cannot pin the project. Checked: docker build --build-arg NEXT_PUBLIC_ADMIN_PROJECT_ID=pr_dockerPinCheck puts the id into .next/static/chunks/app/[[...slug]]/page-*.js. ref: F39 Co-Authored-By: Claude Opus 5.5 --- deployments/Dockerfile | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/deployments/Dockerfile b/deployments/Dockerfile index ba1de52..53ef52a 100644 --- a/deployments/Dockerfile +++ b/deployments/Dockerfile @@ -12,9 +12,15 @@ # HUB_BASE_URL — e.g. https://hub.norbix.ai # API_BASE_URL — e.g. https://api.norbix.ai # ENV — optional Norbix environment (staging/test); unset = PROD -# NEXT_PUBLIC_* — anything the browser needs (config mode, etc.) # -# docker build -f deployments/Dockerfile -t norbix-admin . +# Browser values (NEXT_PUBLIC_*) are the exception: Next inlines them into the +# bundle at BUILD time, so they are build args, not runtime env: +# NEXT_PUBLIC_ADMIN_PROJECT_ID — pin one project (self-hosted / custom +# domain); skips host-label resolution +# NEXT_PUBLIC_ADMIN_CONFIG_MODE — 'dynamic' (default) or 'static' +# +# docker build -f deployments/Dockerfile -t norbix-admin \ +# --build-arg NEXT_PUBLIC_ADMIN_PROJECT_ID=pr_xxxx . # docker run -p 3100:3100 -e API_KEY=... norbix-admin ARG NODE_VERSION=22 @@ -33,7 +39,13 @@ RUN --mount=type=cache,target=/root/.npm \ # ───────────────────────────────────────────────────────────────── FROM node:${NODE_VERSION}-alpine AS build WORKDIR /app -ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 +# Inlined into the browser bundle by `next build` (see the header). Empty = +# not pinned: the project comes from the host label / custom-domain lookup. +ARG NEXT_PUBLIC_ADMIN_PROJECT_ID="" +ARG NEXT_PUBLIC_ADMIN_CONFIG_MODE="" +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 \ + NEXT_PUBLIC_ADMIN_PROJECT_ID=${NEXT_PUBLIC_ADMIN_PROJECT_ID} \ + NEXT_PUBLIC_ADMIN_CONFIG_MODE=${NEXT_PUBLIC_ADMIN_CONFIG_MODE} COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build From 3aa6bc47a0a88d6c9018a1974eb0c3c423933264 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 7 Oct 2026 17:26:53 +0300 Subject: [PATCH 09/10] =?UTF-8?q?[2]=20test(admin:e2e):=20refresh=20the=20?= =?UTF-8?q?signed-in=20shell=20screenshot=20=E2=80=94=20no=20Privacy=20ite?= =?UTF-8?q?m=20or=20card,=20Security=20card=20says=20passkeys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../branding-shell-chromium-darwin.png | Bin 44290 -> 38319 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/e2e/branding.spec.ts-snapshots/branding-shell-chromium-darwin.png b/tests/e2e/branding.spec.ts-snapshots/branding-shell-chromium-darwin.png index 7e443c035f0fb7f707f8f945f8b01f50fccfd8e3..26f1750e450729c5b8d1033defde4b22a2bf8c9c 100644 GIT binary patch literal 38319 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 literal 44290 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 Date: Thu, 8 Oct 2026 08:12:47 +0300 Subject: [PATCH 10/10] =?UTF-8?q?chore(admin:deps):=20npm=20audit=20fix=20?= =?UTF-8?q?=E2=80=94=20source-map-js=201.2.1=20(GHSA-68fv-2mgg-jv7q,=20hig?= =?UTF-8?q?h)=20and=20in-range=20patch=20bumps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production audit step failed on a new advisory unrelated to this branch. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 156 +++++++++++++++++++++++----------------------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/package-lock.json b/package-lock.json index 420ac7a..ee3438d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3099,16 +3099,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -3213,15 +3213,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", - "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -3230,13 +3230,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -3257,9 +3257,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -3270,13 +3270,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", - "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.6", + "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -3285,13 +3285,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", - "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -3300,9 +3300,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3313,13 +3313,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -3515,9 +3515,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.37", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", - "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3541,9 +3541,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3565,9 +3565,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -3585,11 +3585,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -3629,9 +3629,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", "funding": [ { "type": "opencollective", @@ -3949,9 +3949,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.375", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", - "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "version": "1.5.449", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.449.tgz", + "integrity": "sha512-dKeYk/3SFQz+nXi5whp3OuJzeWABEG4zCtkWJ9+Vby1E1iFM2y0gElRtQvNXRPJTsj8WAF4HMP7hvP790QtJ9g==", "dev": true, "license": "ISC" }, @@ -4705,9 +4705,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -5061,9 +5061,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, "license": "MIT", "engines": { @@ -6017,9 +6017,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6301,9 +6301,9 @@ } }, "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", "dev": true, "license": "MIT", "engines": { @@ -6454,9 +6454,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -6640,20 +6640,20 @@ } }, "node_modules/vitest": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", - "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.6", - "@vitest/mocker": "3.2.6", - "@vitest/pretty-format": "^3.2.6", - "@vitest/runner": "3.2.6", - "@vitest/snapshot": "3.2.6", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -6683,8 +6683,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.6", - "@vitest/ui": "3.2.6", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" },