diff --git a/frontend/src/__tests__/MasterAdminPortalsPage.test.tsx b/frontend/src/__tests__/MasterAdminPortalsPage.test.tsx new file mode 100644 index 000000000..99b8af7af --- /dev/null +++ b/frontend/src/__tests__/MasterAdminPortalsPage.test.tsx @@ -0,0 +1,133 @@ +/** + * MasterAdminPortalsPage.test.tsx — FF-EPIC-17-S7 master-admin portal fleet + * console, gated behind `fuzefront.platform.multi-tenant-portals` (release + * flag, default OFF) and wired to the REAL, merged org-tree portal contract + * (`@fuzefront/security-client` 0.7.0, PR #704: + * `GET/POST /api/v1/security/portals`, + * `POST /api/v1/security/portals/{portalOrgId}/(suspend|resume)`). + * + * Exercises the real wiring (fetch -> @fuzefront/portal-admin-ui's + * `createAdminPortalsClient`, resolved from source via the vite/vitest alias) + * — mirrors `EmployeeConsolePage.test.tsx`'s pattern: stub global `fetch`, + * let the real HttpClient run. Covers BOTH flag states (baseline §10 / + * `feature-flags` skill) plus the frame states this flow renders: loading, + * empty, populated, error+retry, and the fail-closed 403 FORBIDDEN. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import MasterAdminPortalsPage from '../pages/MasterAdminPortalsPage' + +const PORTALS_URL = '/api/v1/security/portals' + +let flagValue = false +vi.mock('../platform/featureFlags', () => ({ + useFlag: (_key: string, _fallback: boolean) => flagValue, +})) + +vi.mock('../lib/accounts', () => ({ + getActiveAuthToken: () => 'tok-123', +})) + +function mockResponse(body: unknown, status = 200): Response { + return { + ok: status < 300, + status, + statusText: status < 300 ? 'OK' : 'Error', + text: async () => JSON.stringify(body), + } as Response +} + +function renderPage() { + return render( + + + } /> + + + ) +} + +const PORTAL = { + orgId: 'org_acme', + parentOrgId: 'org_root', + name: 'Acme Co', + slug: 'acme', + kind: 'portal', + status: 'active', + isPortalRoot: true, + ownerEmail: 'owner@acme.example', + customDomain: null, + branding: { name: 'Acme Co' }, + billingMode: 'platform', + appCatalogMode: 'inherit', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +} + +describe('', () => { + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + flagValue = false + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('flag gate — fuzefront.platform.multi-tenant-portals', () => { + it('flag OFF (default): renders no console chrome and never fetches the fleet', () => { + flagValue = false + renderPage() + expect(screen.getByText(/isn.t available yet/i)).toBeInTheDocument() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('flag ON: fetches the fleet from the real org-tree contract and renders it', async () => { + flagValue = true + fetchMock.mockImplementation((url: string) => { + if (url.startsWith(PORTALS_URL)) { + return Promise.resolve(mockResponse({ items: [PORTAL], page: { nextCursor: null, hasMore: false } })) + } + throw new Error(`unexpected fetch ${url}`) + }) + renderPage() + await waitFor(() => expect(screen.getByText('Acme Co')).toBeInTheDocument()) + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining(PORTALS_URL), + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer tok-123' }) }) + ) + }) + }) + + describe('flag ON — frame states', () => { + beforeEach(() => { + flagValue = true + }) + + it('renders the real empty state when zero portals exist (the platform root is never listed)', async () => { + fetchMock.mockResolvedValue(mockResponse({ items: [], page: { nextCursor: null, hasMore: false } })) + renderPage() + await waitFor(() => expect(document.querySelector('[data-state="empty"]')).toBeInTheDocument()) + expect(screen.getByText('No tenant portals yet')).toBeInTheDocument() + }) + + it('renders an error with retry on a load failure', async () => { + fetchMock.mockResolvedValue(mockResponse({ error: 'boom' }, 500)) + renderPage() + await waitFor(() => expect(document.querySelector('[data-state="error"]')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument() + }) + + it('a non-platform-admin gets the fail-closed 403 FORBIDDEN state rendered in place, never a redirect', async () => { + fetchMock.mockResolvedValue(mockResponse({ error: 'Forbidden', code: 'FORBIDDEN' }, 403)) + renderPage() + await waitFor(() => expect(document.querySelector('[data-state="forbidden"]')).toBeInTheDocument()) + expect(document.querySelector('[data-error-code="FORBIDDEN"][data-http="403"]')).toBeInTheDocument() + expect(document.querySelector('[data-portal]')).not.toBeInTheDocument() + }) + }) +}) diff --git a/package-lock.json b/package-lock.json index 1216053bd..7fa576a7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -670,6 +670,7 @@ "version": "1.0.0", "dependencies": { "@fuzefront/core": "1.0.0", + "@fuzefront/feature-flags": "1.0.0", "@fuzefront/shared": "1.0.0", "@izzywdev/fuzefront-identity": "1.0.0", "bcrypt": "^6.0.0", diff --git a/packages/feature-flags/flag-registry.yaml b/packages/feature-flags/flag-registry.yaml index 561584822..2395c5c7d 100644 --- a/packages/feature-flags/flag-registry.yaml +++ b/packages/feature-flags/flag-registry.yaml @@ -71,7 +71,12 @@ flags: description: > Master switch for the multi-tenant portals feature (FF-EPIC-09-S4). Gates the portal bootstrap surface. Custom domains (see - fuzefront.platform.portal-domains) require BOTH flags. + fuzefront.platform.portal-domains) require BOTH flags. FF-EPIC-17-S7 + additionally reuses it to gate the master-admin portal fleet console + (`/admin/portals`, `MasterAdminPortalsFlow`, + `@fuzefront/security-client` 0.7.0) rather than minting a new flag — + the fleet console has nothing to manage while the platform-wide + capability itself is off. owner: platform team removal_criterion: > When multi-tenant portals are GA and enabled for 100% of orgs. @@ -79,7 +84,8 @@ flags: gates: - Portal shell and PortalLoginFlow boot surface - Custom-domain feature (conjunction with portal-domains) - web_exposed: false + - Master-admin portal fleet console (FF-EPIC-17-S7, UI-side) + web_exposed: true jira: ~ - key: fuzefront.platform.portal-domains diff --git a/packages/feature-flags/src/catalog.ts b/packages/feature-flags/src/catalog.ts index c61485578..dac2e32e5 100644 --- a/packages/feature-flags/src/catalog.ts +++ b/packages/feature-flags/src/catalog.ts @@ -82,6 +82,25 @@ export const FLAG_KEYS = { * Removal criterion: 100% rollout; flag-OFF path unexercised. */ IDENTITY_EMPLOYEE_CONSOLE: 'fuzefront.identity.employee-console', + /** + * FF-EPIC-17-S7 (#704 backend, master-admin portal console UI). Master + * switch for the multi-tenant portals feature; this UI reuses it to gate + * the master-admin portal fleet console (`/admin/portals`, + * `MasterAdminPortalsFlow`) per `design/frames/portal-admin-consoles/ + * manifest.json`'s declared `featureFlag` for the `master-admin-portals` + * build flow. Read in the browser via `useFlag()` in + * `frontend/src/pages/MasterAdminPortalsPage.tsx`. Default OFF. Release + * flag. Owner: platform team. + * Removal criterion: when multi-tenant portals are GA and enabled for + * 100% of orgs (see `flag-registry.yaml`). + * + * Was declared `web_exposed: false` in `flag-registry.yaml` (server-only, + * gating the portal-shell/PortalLoginFlow boot surface) — adding this + * entry is what actually discloses it to `GET /api/flags` for the master- + * admin console's `useFlag()` read. Without it the flow's flag check + * always falls back to its in-code default (OFF), same class of gap as #697. + */ + MULTI_TENANT_PORTALS: 'fuzefront.platform.multi-tenant-portals', } as const; export const WEB_EXPOSED_FLAGS: readonly FlagDescriptor[] = [ @@ -101,4 +120,8 @@ export const WEB_EXPOSED_FLAGS: readonly FlagDescriptor[] = [ { key: FLAG_KEYS.IDENTITY_PERSONAL_CONTEXT, type: 'release', default: false }, { key: FLAG_KEYS.IDENTITY_MEMBER_DIRECTORY, type: 'release', default: false }, { key: FLAG_KEYS.IDENTITY_EMPLOYEE_CONSOLE, type: 'release', default: false }, + // FF-EPIC-17-S7 — the master-admin portal fleet console reuses this master + // switch (see FLAG_KEYS doc). Registry `web_exposed` flipped false -> true + // to match: this entry is what makes GET /api/flags disclose it at all. + { key: FLAG_KEYS.MULTI_TENANT_PORTALS, type: 'release', default: false }, ] as const; diff --git a/packages/portal-admin-ui/package.json b/packages/portal-admin-ui/package.json index 1dc89aa70..ab13682c3 100644 --- a/packages/portal-admin-ui/package.json +++ b/packages/portal-admin-ui/package.json @@ -24,12 +24,14 @@ "peerDependencies": { "@fuzefront/design-system": "^1.0.0", "@fuzefront/portal-client": "^1.0.0", + "@fuzefront/security-client": "^0.7.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@fuzefront/design-system": "1.0.0", "@fuzefront/portal-client": "1.0.0", + "@fuzefront/security-client": "0.7.0", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.5.0", diff --git a/packages/portal-admin-ui/src/api/adminPortalsClient.test.ts b/packages/portal-admin-ui/src/api/adminPortalsClient.test.ts index bdd34e78f..8ee9470cd 100644 --- a/packages/portal-admin-ui/src/api/adminPortalsClient.test.ts +++ b/packages/portal-admin-ui/src/api/adminPortalsClient.test.ts @@ -1,81 +1,143 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect } from 'vitest' +import { createAdminPortalsClient, isPortalsForbidden, isSlugConflict } from './adminPortalsClient' +import { HttpError } from './http' -const httpMock = { - get: vi.fn(), - post: vi.fn(), - patch: vi.fn(), - delete: vi.fn(), +function mockFetch(body: unknown, status = 200, ok = status < 300) { + return async () => + ({ + ok, + status, + statusText: ok ? 'OK' : 'Error', + text: async () => JSON.stringify(body), + }) as Response } -vi.mock('axios', () => ({ - default: { - create: vi.fn(() => httpMock), - }, -})) +const PORTAL = { + orgId: 'org_acme', + parentOrgId: 'org_00000000-0000-0000-0000-000000000010', + name: 'Acme', + slug: 'acme', + kind: 'portal' as const, + status: 'active' as const, + isPortalRoot: true, + ownerEmail: 'owner@acme.example', + customDomain: null, + branding: { name: 'Acme' }, + billingMode: 'platform' as const, + appCatalogMode: 'inherit' as const, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +} -// Imported AFTER the mock so `PortalClient`'s internal `axios.create(...)` picks it up. -const { createAdminPortalsClient } = await import('./adminPortalsClient') +describe('createAdminPortalsClient — listPortals', () => { + it('hits GET /api/v1/security/portals with the cursor query, mapping the envelope through unchanged', async () => { + let calledUrl: string | undefined + const fetchImpl: typeof fetch = async url => { + calledUrl = String(url) + return mockFetch({ items: [PORTAL], page: { nextCursor: 'c1', hasMore: true } })() + } + const client = createAdminPortalsClient({ fetchImpl }) + const page = await client.listPortals({ limit: 25, cursor: 'c0', status: 'active' }) -describe('createAdminPortalsClient', () => { - beforeEach(() => { - httpMock.get.mockReset() - httpMock.post.mockReset() - httpMock.patch.mockReset() + expect(calledUrl).toBe('/api/v1/security/portals?limit=25&cursor=c0&status=active') + expect(page.items).toHaveLength(1) + expect(page.items[0].orgId).toBe('org_acme') + expect(page.page).toEqual({ nextCursor: 'c1', hasMore: true }) }) - it('lists portals against GET /api/v1/admin/portals, mapping the cursor envelope', async () => { - httpMock.get.mockResolvedValue({ - data: { items: [{ id: 'prt_1', slug: 'acme' }], page: { nextCursor: 'c1' } }, - }) - const client = createAdminPortalsClient({ getToken: () => 'tok' }) - const page = await client.listPortals({ limit: 10 }) + it('omits query params that were not provided (first-page default fetch)', async () => { + let calledUrl: string | undefined + const fetchImpl: typeof fetch = async url => { + calledUrl = String(url) + return mockFetch({ items: [], page: { nextCursor: null, hasMore: false } })() + } + const client = createAdminPortalsClient({ fetchImpl }) + await client.listPortals() + expect(calledUrl).toBe('/api/v1/security/portals') + }) - expect(httpMock.get).toHaveBeenCalledWith('/api/v1/admin/portals', { params: { status: undefined, q: undefined, limit: 10, cursor: undefined } }) - expect(page.items).toHaveLength(1) - expect(page.page.hasMore).toBe(true) + it('a non-platform-admin caller gets a 403 the flow can detect via isPortalsForbidden', async () => { + const client = createAdminPortalsClient({ + fetchImpl: mockFetch({ error: 'Forbidden', code: 'FORBIDDEN' }, 403, false), + }) + await expect(client.listPortals()).rejects.toSatisfy((err: unknown) => isPortalsForbidden(err)) }) +}) + +describe('createAdminPortalsClient — createPortal', () => { + it('POSTs the PortalCreate body to /api/v1/security/portals and resolves the created Portal', async () => { + let calledUrl: string | undefined + let calledBody: unknown + const fetchImpl: typeof fetch = async (url, init) => { + calledUrl = String(url) + calledBody = init?.body ? JSON.parse(String(init.body)) : undefined + return mockFetch(PORTAL, 201)() + } + const client = createAdminPortalsClient({ fetchImpl }) + const input = { + name: 'Acme', + slug: 'acme', + ownerEmail: 'owner@acme.example', + branding: { name: 'Acme' }, + billingMode: 'platform' as const, + appCatalogMode: 'inherit' as const, + } + const portal = await client.createPortal(input) - it('reports hasMore false on the last page (nextCursor null)', async () => { - httpMock.get.mockResolvedValue({ data: { items: [], page: { nextCursor: null } } }) - const client = createAdminPortalsClient() - const page = await client.listPortals() - expect(page.page.hasMore).toBe(false) + expect(calledUrl).toBe('/api/v1/security/portals') + expect(calledBody).toEqual(input) + expect(portal.orgId).toBe('org_acme') }) - it('creates a portal via POST /api/v1/admin/portals', async () => { - httpMock.post.mockResolvedValue({ data: { id: 'prt_new', slug: 'new-tenant' } }) - const client = createAdminPortalsClient({ getToken: () => 'tok' }) - const portal = await client.createPortal({ name: 'New', slug: 'new-tenant', ownerEmail: 'a@b.com', billingMode: 'free' }) - expect(portal.slug).toBe('new-tenant') - expect(httpMock.post).toHaveBeenCalledWith('/api/v1/admin/portals', { - name: 'New', - slug: 'new-tenant', - ownerEmail: 'a@b.com', - billingMode: 'free', + it('a duplicate slug 409 CONFLICT is detectable via isSlugConflict', async () => { + const client = createAdminPortalsClient({ + fetchImpl: mockFetch({ error: 'A portal with this slug already exists', code: 'CONFLICT' }, 409, false), }) + await expect( + client.createPortal({ name: 'Acme', slug: 'acme', ownerEmail: 'a@b.com', billingMode: 'free', appCatalogMode: 'inherit' }) + ).rejects.toSatisfy((err: unknown) => isSlugConflict(err)) }) - it('gets a single portal by id', async () => { - httpMock.get.mockResolvedValue({ data: { id: 'prt_1', slug: 'acme' } }) - const client = createAdminPortalsClient() - const portal = await client.getPortal('prt_1') - expect(portal.id).toBe('prt_1') - expect(httpMock.get).toHaveBeenCalledWith('/api/v1/admin/portals/prt_1') + it('a generic HttpError is NOT reported as a slug conflict', () => { + expect(isSlugConflict(new HttpError(500, 'boom', undefined))).toBe(false) + expect(isSlugConflict(new Error('boom'))).toBe(false) + }) +}) + +describe('createAdminPortalsClient — getPortal / suspendPortal / resumePortal', () => { + it('gets one portal by portalOrgId', async () => { + let calledUrl: string | undefined + const fetchImpl: typeof fetch = async url => { + calledUrl = String(url) + return mockFetch(PORTAL)() + } + const client = createAdminPortalsClient({ fetchImpl }) + const portal = await client.getPortal('org_acme') + expect(calledUrl).toBe('/api/v1/security/portals/org_acme') + expect(portal.orgId).toBe('org_acme') }) - it('suspends a portal via the semantic suspend action', async () => { - httpMock.post.mockResolvedValue({ data: { id: 'prt_1', status: 'suspended' } }) - const client = createAdminPortalsClient() - const suspended = await client.suspendPortal('prt_1') + it('suspends a portal via POST .../suspend', async () => { + let calledUrl: string | undefined + const fetchImpl: typeof fetch = async url => { + calledUrl = String(url) + return mockFetch({ ...PORTAL, status: 'suspended' })() + } + const client = createAdminPortalsClient({ fetchImpl }) + const suspended = await client.suspendPortal('org_acme') + expect(calledUrl).toBe('/api/v1/security/portals/org_acme/suspend') expect(suspended.status).toBe('suspended') - expect(httpMock.post).toHaveBeenCalledWith('/api/v1/admin/portals/prt_1/suspend') }) - it('resumes a portal via the semantic resume action', async () => { - httpMock.post.mockResolvedValue({ data: { id: 'prt_1', status: 'active' } }) - const client = createAdminPortalsClient() - const resumed = await client.resumePortal('prt_1') + it('resumes a portal via POST .../resume', async () => { + let calledUrl: string | undefined + const fetchImpl: typeof fetch = async url => { + calledUrl = String(url) + return mockFetch({ ...PORTAL, status: 'active' })() + } + const client = createAdminPortalsClient({ fetchImpl }) + const resumed = await client.resumePortal('org_acme') + expect(calledUrl).toBe('/api/v1/security/portals/org_acme/resume') expect(resumed.status).toBe('active') - expect(httpMock.post).toHaveBeenCalledWith('/api/v1/admin/portals/prt_1/resume') }) }) diff --git a/packages/portal-admin-ui/src/api/adminPortalsClient.ts b/packages/portal-admin-ui/src/api/adminPortalsClient.ts index 798c95643..7b646cc33 100644 --- a/packages/portal-admin-ui/src/api/adminPortalsClient.ts +++ b/packages/portal-admin-ui/src/api/adminPortalsClient.ts @@ -1,72 +1,68 @@ /** - * S2 — master-admin portal fleet API. Thin wrapper over the generated - * `@fuzefront/portal-client` (services/portal-service/openapi.yaml is its - * source of truth); every method here maps 1:1 onto a `PortalClient` method - * so the shape of a portal row stays a compile-time link to the frozen - * contract, exactly as `frontend/src/services/adminPortalsService.ts` already - * does for the Portals Directory feature. + * FF-EPIC-17-S7 — master-admin portal fleet API, migrated onto the REAL, + * merged org-tree portal contract (`@fuzefront/security-client` 0.7.0, + * PR #704): `GET/POST /api/v1/security/portals`, + * `GET /api/v1/security/portals/{portalOrgId}`, + * `POST .../{portalOrgId}/suspend` + `/resume`. * - * A fresh `PortalClient` is constructed per call (cheap — just `axios.create`) - * so the bearer token always reflects the CURRENT active account, matching - * this package's `getToken` callback convention rather than baking a token in - * at client-construction time. + * This SUPERSEDES the earlier build against the anticipated + * `@fuzefront/portal-client` (`/api/v1/admin/portals`) — see the + * security-client CHANGELOG's "Supersedes" note and `types.ts`'s + * `AdminPortal*` doc comment. Every shape below is taken straight from the + * generated `@fuzefront/security-client` contract types + * (`components['schemas']`), never hand-restated, so contract drift is a + * compile error. + * + * Uses this package's own `HttpClient` (same convention as every other + * `@fuzefront/*-ui` API client — see `identity-ui`'s `employeeClient.ts`) + * rather than a generated runtime client, because `@fuzefront/security-client` + * ships types + an OpenAPI doc only, no HTTP client class. */ -import { PortalClient } from '@fuzefront/portal-client' -import type { - AdminPortalsPage, - CreatePortalInput, - ListAdminPortalsParams, - Portal, - PortalStatus, -} from '../types' +import { HttpClient, HttpError, type HttpClientOptions } from './http' +import type { AdminPortal, AdminPortalCreate, AdminPortalPageEnvelope, ListAdminPortalFleetParams } from '../types' -export interface AdminPortalsClientOptions { - /** Same-origin base URL. Default ''. */ - baseUrl?: string - getToken?: () => string | null | undefined -} +export interface AdminPortalsClientOptions extends HttpClientOptions {} export interface AdminPortalsClient { - listPortals(params?: ListAdminPortalsParams): Promise - createPortal(input: CreatePortalInput): Promise - getPortal(portalId: string): Promise - suspendPortal(portalId: string): Promise - resumePortal(portalId: string): Promise + listPortals(params?: ListAdminPortalFleetParams): Promise + createPortal(input: AdminPortalCreate): Promise + getPortal(portalOrgId: string): Promise + suspendPortal(portalOrgId: string): Promise + resumePortal(portalOrgId: string): Promise } export function createAdminPortalsClient(opts: AdminPortalsClientOptions = {}): AdminPortalsClient { - const makeClient = () => - new PortalClient({ baseUrl: opts.baseUrl ?? '', token: opts.getToken?.() ?? undefined }) + const http = new HttpClient(opts) return { - async listPortals(params = {}) { - const page = await makeClient().listPortals({ - status: params.status as PortalStatus | undefined, - q: params.q, + listPortals(params = {}) { + return http.get('/api/v1/security/portals', { limit: params.limit, cursor: params.cursor, + status: params.status, }) - return { - items: page.items, - page: { nextCursor: page.page.nextCursor, hasMore: page.page.nextCursor !== null, total: page.page.total }, - } }, createPortal(input) { - return makeClient().createPortal({ - name: input.name, - slug: input.slug, - ownerEmail: input.ownerEmail, - billingMode: input.billingMode, - }) + return http.post('/api/v1/security/portals', input) }, - getPortal(portalId) { - return makeClient().getPortal(portalId) + getPortal(portalOrgId) { + return http.get(`/api/v1/security/portals/${encodeURIComponent(portalOrgId)}`) }, - suspendPortal(portalId) { - return makeClient().suspendPortal(portalId) + suspendPortal(portalOrgId) { + return http.post(`/api/v1/security/portals/${encodeURIComponent(portalOrgId)}/suspend`) }, - resumePortal(portalId) { - return makeClient().resumePortal(portalId) + resumePortal(portalOrgId) { + return http.post(`/api/v1/security/portals/${encodeURIComponent(portalOrgId)}/resume`) }, } } + +/** True when `err` is the fail-closed 403 the fleet endpoints return for a non-platform-admin. */ +export function isPortalsForbidden(err: unknown): boolean { + return err instanceof HttpError && err.status === 403 +} + +/** True when `err` is the 409 `CONFLICT` `createPortal` returns for a duplicate slug. */ +export function isSlugConflict(err: unknown): boolean { + return err instanceof HttpError && err.status === 409 && err.code === 'CONFLICT' +} diff --git a/packages/portal-admin-ui/src/api/http.ts b/packages/portal-admin-ui/src/api/http.ts index 47bdbb9e6..e81f6c8ef 100644 --- a/packages/portal-admin-ui/src/api/http.ts +++ b/packages/portal-admin-ui/src/api/http.ts @@ -29,8 +29,13 @@ export class HttpError extends Error { this.status = status this.body = body if (body && typeof body === 'object') { + // The family `ErrorBody` contract (`@fuzefront/security-client`'s + // `ErrorBody`) is `{ error: , code: }` — + // `code` is the machine-readable field callers branch on (e.g. + // `CONFLICT`, `FORBIDDEN`); `error` is prose. Prefer `code`, falling + // back to `error` only for a body that doesn't carry one. const record = body as Record - const code = record.error ?? record.code + const code = record.code ?? record.error if (typeof code === 'string') this.code = code } } @@ -44,7 +49,13 @@ export class HttpClient { constructor(opts: HttpClientOptions = {}) { this.baseUrl = opts.baseUrl ?? '' this.getToken = opts.getToken - this.fetchImpl = opts.fetchImpl ?? globalThis.fetch + // The global `fetch` is a native method that MUST be invoked with `this` + // bound to the global object. Storing the bare reference and later calling + // `this.fetchImpl(...)` re-binds `this` to the HttpClient instance, which + // browsers reject with "Failed to execute 'fetch' on 'Window': Illegal + // invocation" — breaking every real-browser request. Bind the fallback to + // `globalThis`; an injected `fetchImpl` (tests) is left as-is. + this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis) } async request(method: string, path: string, body?: unknown, params?: Record): Promise { diff --git a/packages/portal-admin-ui/src/components/master/CreatePortalDialog.tsx b/packages/portal-admin-ui/src/components/master/CreatePortalDialog.tsx index 4269dc354..0603ae0fb 100644 --- a/packages/portal-admin-ui/src/components/master/CreatePortalDialog.tsx +++ b/packages/portal-admin-ui/src/components/master/CreatePortalDialog.tsx @@ -1,17 +1,17 @@ import { useState } from 'react' import { Button, Input, Modal } from '@fuzefront/design-system' -import type { BillingMode } from '../../types' +import type { AdminPortalAppCatalogMode, AdminPortalBillingMode, AdminPortalCreate } from '../../types' export interface CreatePortalDialogProps { open: boolean submitting?: boolean - /** Set when the last submit came back 409 SLUG_TAKEN — rendered inline on the slug field. */ + /** Set when the last submit came back 409 CONFLICT on slug — rendered inline on the slug field. */ slugTakenError?: boolean onCancel: () => void - onSubmit: (input: { name: string; slug: string; ownerEmail: string; billingMode: BillingMode }) => void + onSubmit: (input: AdminPortalCreate) => void } -const PLAN_OPTIONS: Array<{ value: BillingMode; label: string; description: string }> = [ +const BILLING_OPTIONS: Array<{ value: AdminPortalBillingMode; label: string; description: string }> = [ { value: 'free', label: 'Free', description: 'No charge. FuzeFront-billed. Reseller billing off.' }, { value: 'platform', @@ -21,17 +21,30 @@ const PLAN_OPTIONS: Array<{ value: BillingMode; label: string; description: stri { value: 'reseller', label: 'Reseller · Connect', - description: "This portal bills its own customers via Stripe Connect. Unlocks the billing console (S4).", + description: 'This portal bills its own customers via Stripe Connect. Unlocks the billing console.', }, ] -/** Create-portal form (frame 02-create-portal). Form state is kept LOCAL so a - * 409 SLUG_TAKEN response never loses what the caller typed (see 04-master-states, d6). */ +const CATALOG_OPTIONS: Array<{ value: AdminPortalAppCatalogMode; label: string; description: string }> = [ + { value: 'inherit', label: 'Inherit platform catalog', description: 'The portal shows the platform-root app catalog.' }, + { value: 'custom', label: 'Custom catalog', description: 'The portal curates its own app set.' }, +] + +/** + * Create-portal form (frame 02-create-portal), migrated onto the REAL + * `PortalCreate` body (`@fuzefront/security-client` 0.7.0): `name`, `slug`, + * `ownerEmail`, the optional tenant attributes `customDomain` + `branding`, + * and `billingMode` / `appCatalogMode`. Form state is kept LOCAL so a 409 + * `CONFLICT` (duplicate slug) response never loses what the caller typed. + */ export function CreatePortalDialog({ open, submitting, slugTakenError, onCancel, onSubmit }: CreatePortalDialogProps) { const [name, setName] = useState('') const [slug, setSlug] = useState('') const [ownerEmail, setOwnerEmail] = useState('') - const [billingMode, setBillingMode] = useState('platform') + const [customDomain, setCustomDomain] = useState('') + const [tagline, setTagline] = useState('') + const [billingMode, setBillingMode] = useState('platform') + const [appCatalogMode, setAppCatalogMode] = useState('inherit') return ( @@ -39,7 +52,17 @@ export function CreatePortalDialog({ open, submitting, slugTakenError, onCancel,
{ e.preventDefault() - onSubmit({ name: name.trim(), slug: slug.trim(), ownerEmail: ownerEmail.trim(), billingMode }) + const trimmedName = name.trim() + const trimmedTagline = tagline.trim() + onSubmit({ + name: trimmedName, + slug: slug.trim(), + ownerEmail: ownerEmail.trim(), + customDomain: customDomain.trim() || undefined, + branding: { name: trimmedName, ...(trimmedTagline ? { tagline: trimmedTagline } : {}) }, + billingMode, + appCatalogMode, + }) }} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }} > @@ -63,7 +86,7 @@ export function CreatePortalDialog({ open, submitting, slugTakenError, onCancel, /> {slugTakenError && (

That slug is already in use by another portal. Slugs are unique and permanent — pick another. @@ -82,11 +105,59 @@ export function CreatePortalDialog({ open, submitting, slugTakenError, onCancel, placeholder="owner@company.com" required /> + setCustomDomain(e.target.value)} + placeholder="portal.acme.example" + /> + setTagline(e.target.value)} + placeholder="Custom login copy shown to this portal's users" + /> +

+ + App catalog + + {CATALOG_OPTIONS.map(opt => ( + + ))} +
Plan & billing mode - {PLAN_OPTIONS.map(opt => ( + {BILLING_OPTIONS.map(opt => (