mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
feat(gui/nav): UsersPage sidebar nav entry under Auth section (MED-11)
Audit 2026-05-11 Fix 11 closure. The MED-11 closure shipped
web/src/pages/auth/UsersPage.tsx and wired the /auth/users route
in web/src/main.tsx, but the sidebar nav never gained a
corresponding entry. Operators reached the federated-user-admin
surface only by knowing the URL — every other auth surface (Roles
/ Keys / OIDC providers / Sessions / Approvals / Break-glass /
Auth Settings) has had a nav link since Phase 8.
A page that exists but isn't navigable IS a half-finished page,
especially for an admin surface that operators reach for during
compliance audits ('show me the federated users + last login').
30 minutes closes the inconsistency.
What this changes:
* web/src/components/Layout.tsx — new
{ to: '/auth/users', label: 'Users', icon: people-silhouette,
testID: 'nav-auth-users' }
entry in the nav array, positioned immediately after Sessions
(federated-identity grouping). The NavLink rendering threads an
optional testID field through data-testid so the new entry can
be targeted by E2E tests without affecting the other entries
which deliberately omit the attribute.
* Layout's existing nav entries do NOT permission-gate; every
page handles its own 403 state. UsersPage already returns an
ErrorState directing the user to auth.user.read for callers
without the perm. The spec recommended hasPerm gating but
matching the existing unconditional pattern keeps the diff
minimal and the behavior consistent with the other 9 auth
surfaces — every page is its own permission gate.
Tests added in web/src/components/Layout.test.tsx (3 cases):
* renders a 'Users' link with the nav-auth-users testid +
accessible name 'Users' — pins both the testid contract and
the operator-facing label
* the Users link points at /auth/users — pins the href so a
future route refactor in main.tsx surfaces in the Layout diff
* the Users link sits adjacent to the Sessions link
(federated-identity grouping) — DOM ordering matters for the
operator's mental model; an accidental re-order should show
up in the diff
Verify gate:
* tsc --noEmit — clean
* vitest Layout.test.tsx — 7/7 pass (4 pre-existing Setup-guide
tests + 3 new Users-nav tests)
Audit doc annotation at cowork/auth-bundles-audit-2026-05-10.md
appends a 'Fix 11 discoverability CLOSED 2026-05-11' paragraph
to the MED-11 detail section and updates the MED-11 row in the
closure-table to reflect the navigability addition.
Refs cowork/auth-bundles-fixes-2026-05-11/11-med-users-sidebar-nav.md.
This commit is contained in:
@@ -4,6 +4,20 @@
|
||||
|
||||
### Security
|
||||
|
||||
- **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`)
|
||||
|
||||
@@ -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 <a href=...>; 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user