Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions frontend/src/__tests__/MasterAdminPortalsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={['/admin/portals']}>
<Routes>
<Route path="/admin/portals" element={<MasterAdminPortalsPage />} />
</Routes>
</MemoryRouter>
)
}

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('<MasterAdminPortalsPage />', () => {
let fetchMock: ReturnType<typeof vi.fn>

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()
})
})
})
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions packages/feature-flags/flag-registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,21 @@ 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.
Drop the flag and the pre-epic code path.
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
Expand Down
23 changes: 23 additions & 0 deletions packages/feature-flags/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand All @@ -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;
2 changes: 2 additions & 0 deletions packages/portal-admin-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading