diff --git a/CHANGELOG.md b/CHANGELOG.md index cb16b24..99a247c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,20 @@ exposed-sentinel, last-error-red-treatment, and canRefresh=false- hides-the-button. +- **UsersPage sidebar nav entry (Audit 2026-05-11 Fix 11 — MED-11 + discoverability).** The MED-11 closure shipped `UsersPage.tsx` + wired + the `/auth/users` route in `web/src/main.tsx`, but the sidebar + navigation never gained a corresponding entry. Operators reached the + federated-user-admin surface (used during compliance audits — "show + me last login for every IdP-federated user") only by knowing the URL. + A page that exists but isn't navigable is a half-finished page. New + Users entry under the Auth section in `web/src/components/Layout.tsx` + sits between Sessions and Roles (federated-identity grouping). Three + Vitest tests in `Layout.test.tsx` pin the link's presence, the + `/auth/users` destination, and the DOM ordering relative to Sessions + so a future refactor that re-orders or removes the entry surfaces in + the diff. + - **Scope-aware actor-role revoke (Audit 2026-05-11 A-4).** HIGH-10 made it possible to grant the same role to the same actor at multiple scopes (e.g. `r-operator` on `profile=p-acme` AND `profile=p-globex`) diff --git a/web/src/components/Layout.test.tsx b/web/src/components/Layout.test.tsx index acf737b..430729f 100644 --- a/web/src/components/Layout.test.tsx +++ b/web/src/components/Layout.test.tsx @@ -125,3 +125,58 @@ describe('Layout — UX-001 Setup guide sidebar button', () => { } }); }); + +// ----------------------------------------------------------------------------- +// Audit 2026-05-11 Fix 11 — UsersPage sidebar nav entry (MED-11 discoverability) +// +// The MED-11 closure shipped UsersPage + wired the /auth/users route but left +// the sidebar without a nav entry. Operators had to know the URL to reach the +// federated-user-management surface. This test pins the link's presence + the +// expected destination + the data-testid (so future E2E coverage can target it +// without depending on visible label text — operators may rename "Users" to +// "Federated users" later). +// +// We do NOT mock useAuthMe here because Layout doesn't gate nav entries on +// permission today; every entry in the nav array renders unconditionally and +// the target page handles its own 403 state. If Layout starts gating nav +// entries in the future, these tests will fail at the visibility check and +// the new gate's mock needs to be added to renderLayout(). +// ----------------------------------------------------------------------------- + +describe('Layout — Fix 11 UsersPage nav entry', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it('renders a "Users" link in the sidebar with the nav-auth-users testid', () => { + renderLayout(); + const link = screen.getByTestId('nav-auth-users'); + expect(link).toBeInTheDocument(); + // The accessible name doubles as the operator-facing label and is what + // future testing-library `getByRole('link', { name: /Users/i })` queries + // will key off; pin it so a label rename surfaces in the diff. + expect(link.textContent).toContain('Users'); + }); + + it('the Users link points at /auth/users', () => { + renderLayout(); + const link = screen.getByTestId('nav-auth-users') as HTMLAnchorElement; + // NavLink renders an ; assert the destination matches the + // route wired in web/src/main.tsx so a future re-keying of either side + // surfaces here. We don't assert the full URL because MemoryRouter + // prepends nothing. + expect(link.getAttribute('href')).toBe('/auth/users'); + }); + + it('the Users link sits adjacent to the Sessions link (federated-identity grouping)', () => { + renderLayout(); + const sessions = screen.getByRole('link', { name: /Sessions/i }); + const users = screen.getByTestId('nav-auth-users'); + // DOM order: Sessions immediately precedes Users. The placement matters + // for the operator's mental model — both surfaces operate on the + // federated-identity stack. If the order flips, the diff should be + // intentional, not accidental. + expect(sessions.compareDocumentPosition(users) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index e5c91a5..91b0e92 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -30,6 +30,14 @@ const nav = [ // Bundle 2 Phase 8 — OIDC + Sessions. { to: '/auth/oidc/providers', label: 'OIDC Providers', icon: 'M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4' }, { to: '/auth/sessions', label: 'Sessions', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' }, + // Audit 2026-05-11 Fix 11 — UsersPage sidebar entry (MED-11 discoverability). + // The MED-11 closure wired UsersPage but no nav entry; operators had to know + // the URL /auth/users to reach the federated-user-management surface. This + // entry sits adjacent to Sessions because the two share the same mental + // model (federated identity admin). UsersPage handles its own 403 state for + // callers without auth.user.read so we don't need to gate the nav entry; + // every other entry in this array uses the same unconditional pattern. + { to: '/auth/users', label: 'Users', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z', testID: 'nav-auth-users' }, { to: '/auth/roles', label: 'Roles', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' }, { to: '/auth/keys', label: 'API Keys', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' }, { to: '/auth/approvals', label: 'Approvals', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' }, @@ -76,6 +84,7 @@ export default function Layout() { key={item.to} to={item.to} end={item.to === '/'} + data-testid={'testID' in item ? item.testID : undefined} className={({ isActive }) => `flex items-center gap-3 px-3 py-2 text-[13px] rounded transition-all duration-150 ${ isActive