From e90e88ec450b702e2d22bbf5f8788f7385ca8caf Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Thu, 14 May 2026 20:15:21 +0200 Subject: [PATCH] feat(portal-admin): user directory viewer screen (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Final PR of the **portal-admin User-list chantier** per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"v1 scope — User list (read-only)". Ships the SPA viewer at `/users` that consumes [`GET /api/admin/users`](apps/portal-bff/src/admin/admin-users.controller.ts) (PR #141) and renders a filter form + paginated table mirroring the audit viewer's shape (PR #136). The full chantier is now closed: schema + sign-in upsert (#140) → read endpoint (#141) → this viewer. ## What lands ### [`AdminUsersService`](apps/portal-admin/src/app/pages/users/admin-users.service.ts) Thin `HttpClient` wrapper around `GET /api/admin/users`. Drops empty-string filter values (Nest's `ValidationPipe` rejects `?foo=` as `foo === ''`). `providedIn: 'root'` — the users page is the single v1 consumer. ### [`UsersPage`](apps/portal-admin/src/app/pages/users/users.ts) Signal-driven page mirroring the `/audit` viewer: | Knob | State | | --- | --- | | `username`, `displayName`, `audience`, `lastSeenAtFrom`, `lastSeenAtTo` | One signal per filter. | | `limit`, `offset` | Pagination. Default 50, cap 200 to match the BFF's `MAX_LIMIT`. | | `page`, `loading`, `error` | Async triplet. | | `hasNextPage`, `hasPreviousPage`, `resultRange` | Computed for the pagination controls + the "1–50 of 137" status line. | UI: - **Filter form** — username (prefix), displayName (contains), audience enum, lastSeenAt range (`datetime-local` inputs → ISO), page-size selector. Apply + Reset. - **Result table** — displayName, username, audience badge, firstSeen, lastSeen (locale-formatted), oid (monospaced). - **Pagination** — Previous / Next disabled at boundaries. Offset resets to 0 on Apply Filters / Reset. - **States** — explicit loading, empty (`"No users match the current filters."`), error. **403 surfaces "you do not have access"**; everything else collapses to a generic retry message — same posture as the audit viewer. ### Routing + i18n + sidebar - `/users` route lazy-loaded in [`app.routes.ts`](apps/portal-admin/src/app/app.routes.ts) with `route.users.title` i18n marker. - [`messages.fr.xlf`](apps/portal-admin/src/locale/messages.fr.xlf) updated with the French translation (`Utilisateurs — Administration APF Portal`); the prod build's `i18nMissingTranslation=error` policy would fail otherwise. - [`AdminSidebar`](apps/portal-admin/src/app/components/sidebar/sidebar.ts) "User list" entry promoted from `aria-disabled` placeholder ("Soon" badge) to a live `RouterLink` at `/users`. The matching spec is updated. ## Notes for the reviewer - The shape mirrors `AuditPage` (#136) intentionally — same signal layout, same flush dance in the spec, same 403/5xx error split, same datetime-local-to-ISO conversion. A future contributor coming from one viewer lands familiar code on the other. - Each viewer ships as a separate lazy chunk (`users` = 3.76 KB gzip, `audit` = 4.35 KB gzip). No shared filter-form lib promoted yet — the duplication is mechanical and small (~120 LOC each), and ADR-0020's next admin modules (CMS, menu management) may diverge enough that premature factoring would be expensive to undo. - No sign-in counts column. The BFF endpoint exposes the directory data only (`firstSeenAt` / `lastSeenAt`). Joining sign-in counts from `audit.events` via `HashUserIdService` is deferred until admin demand justifies the extra query per row. ## Test plan - [x] `pnpm nx test portal-admin` — **45 specs pass** (was 30; +15: AdminUsersService 3, UsersPage 11, sidebar 1). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Build emits the lazy `users` chunk at **14.59 KB raw / 3.76 KB gzip** — comfortably under the per-chunk lazy budget. - [ ] e2e — pending Entra `admin` role assignment + at least a couple of sign-in events to populate the directory. Once both exist: sign in via `/api/admin/auth/login`, navigate to `/users`, see the directory populate with each row's displayName + lastSeen, exercise the filters, observe `admin.users.query` rows in `audit.events` per fetch. ## What's next — portal-admin v1 chantiers This PR closes **User list**. Remaining ADR-0020 v1 modules: - **Menu management** — CRUD on `cms.menu_items`; `GET /api/me/menu` consumed by portal-shell + `/api/admin/menu` admin CRUD. Medium chantier. - **CMS pages** — multi-locale editorial content (`cms.pages` with slug + locale + body markdown), admin editor screen, portal-shell consumer for the rendering. Bigger chantier. Out of immediate scope: - The strategic security baseline ADR (paused awaiting RSSI input on ASVS / HDS / GDPR / NIS 2). - Per-integration downstream ADRs (the strategy layer from #137 + #138 + #139 is ready to be assembled around the first real consumer). --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/142 --- apps/portal-admin/src/app/app.routes.ts | 6 + .../app/components/sidebar/sidebar.spec.ts | 8 + .../src/app/components/sidebar/sidebar.ts | 2 +- .../pages/users/admin-users.service.spec.ts | 86 ++++++ .../app/pages/users/admin-users.service.ts | 63 ++++ .../src/app/pages/users/users.html | 144 +++++++++ .../src/app/pages/users/users.scss | 285 ++++++++++++++++++ .../src/app/pages/users/users.spec.ts | 183 +++++++++++ .../portal-admin/src/app/pages/users/users.ts | 158 ++++++++++ apps/portal-admin/src/locale/messages.fr.xlf | 4 + 10 files changed, 938 insertions(+), 1 deletion(-) create mode 100644 apps/portal-admin/src/app/pages/users/admin-users.service.spec.ts create mode 100644 apps/portal-admin/src/app/pages/users/admin-users.service.ts create mode 100644 apps/portal-admin/src/app/pages/users/users.html create mode 100644 apps/portal-admin/src/app/pages/users/users.scss create mode 100644 apps/portal-admin/src/app/pages/users/users.spec.ts create mode 100644 apps/portal-admin/src/app/pages/users/users.ts diff --git a/apps/portal-admin/src/app/app.routes.ts b/apps/portal-admin/src/app/app.routes.ts index 8dc4a65..e5cf2e9 100644 --- a/apps/portal-admin/src/app/app.routes.ts +++ b/apps/portal-admin/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { Route } from '@angular/router'; const homeTitle = $localize`:@@route.home.title:APF Portal Admin`; const auditTitle = $localize`:@@route.audit.title:Audit log — APF Portal Admin`; +const usersTitle = $localize`:@@route.users.title:Users — APF Portal Admin`; export const appRoutes: Route[] = [ { @@ -15,6 +16,11 @@ export const appRoutes: Route[] = [ loadComponent: () => import('./pages/audit/audit').then((m) => m.AuditPage), title: auditTitle, }, + { + path: 'users', + loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage), + title: usersTitle, + }, // Catch-all — unknown paths bounce to home. Same role as the // wildcard in portal-shell (per PR #96): in production each locale // bundle ships with its own `` and the diff --git a/apps/portal-admin/src/app/components/sidebar/sidebar.spec.ts b/apps/portal-admin/src/app/components/sidebar/sidebar.spec.ts index c5ba493..c80bf26 100644 --- a/apps/portal-admin/src/app/components/sidebar/sidebar.spec.ts +++ b/apps/portal-admin/src/app/components/sidebar/sidebar.spec.ts @@ -31,6 +31,14 @@ describe('AdminSidebar', () => { expect(auditLink?.getAttribute('href')).toBe('/audit'); }); + it('exposes the User list entry as a live router link', () => { + const root = render(); + const links = Array.from(root.querySelectorAll('a.nav-link')); + const usersLink = links.find((a) => a.textContent?.includes('User list')); + expect(usersLink).toBeTruthy(); + expect(usersLink?.getAttribute('href')).toBe('/users'); + }); + it('renders the not-yet-implemented entries as aria-disabled placeholders with a "Soon" badge', () => { const root = render(); const disabled = Array.from(root.querySelectorAll('.nav-link--disabled')); diff --git a/apps/portal-admin/src/app/components/sidebar/sidebar.ts b/apps/portal-admin/src/app/components/sidebar/sidebar.ts index 4456efb..4c21b52 100644 --- a/apps/portal-admin/src/app/components/sidebar/sidebar.ts +++ b/apps/portal-admin/src/app/components/sidebar/sidebar.ts @@ -27,7 +27,7 @@ const MENU: readonly AdminMenuItem[] = [ { label: 'Audit log', icon: 'file-text', routerLink: '/audit' }, { label: 'CMS pages', icon: 'folder', routerLink: null }, { label: 'Menu management', icon: 'layout-dashboard', routerLink: null }, - { label: 'User list', icon: 'building-2', routerLink: null }, + { label: 'User list', icon: 'building-2', routerLink: '/users' }, ]; @Component({ diff --git a/apps/portal-admin/src/app/pages/users/admin-users.service.spec.ts b/apps/portal-admin/src/app/pages/users/admin-users.service.spec.ts new file mode 100644 index 0000000..7b0591e --- /dev/null +++ b/apps/portal-admin/src/app/pages/users/admin-users.service.spec.ts @@ -0,0 +1,86 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { AUTH_BFF_BASE_URL } from 'feature-auth'; +import { AdminUsersService, type AdminUsersPage } from './admin-users.service'; + +const BFF_BASE = 'http://bff.test/api'; +const USERS_URL = `${BFF_BASE}/admin/users`; + +const PAGE: AdminUsersPage = { + items: [ + { + oid: 'user-oid', + tid: 'tenant-1', + audience: 'workforce', + username: 'jane.doe@apf.example', + displayName: 'Jane Doe', + firstSeenAt: '2026-05-10T10:00:00.000Z', + lastSeenAt: '2026-05-14T08:30:00.000Z', + }, + ], + total: 1, + limit: 50, + offset: 0, +}; + +function setup() { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE }, + ], + }); + return { + service: TestBed.inject(AdminUsersService), + http: TestBed.inject(HttpTestingController), + }; +} + +describe('AdminUsersService.query', () => { + it('GETs /admin/users with the populated filter fields as URL params', async () => { + const { service, http } = setup(); + const promise = firstValueFrom( + service.query({ + username: 'jane', + audience: 'workforce', + limit: 25, + offset: 50, + }), + ); + const req = http.expectOne((r) => r.url === USERS_URL); + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('username')).toBe('jane'); + expect(req.request.params.get('audience')).toBe('workforce'); + expect(req.request.params.get('limit')).toBe('25'); + expect(req.request.params.get('offset')).toBe('50'); + req.flush(PAGE); + await expect(promise).resolves.toEqual(PAGE); + }); + + it('omits filter fields that are empty strings', async () => { + const { service, http } = setup(); + void firstValueFrom( + service.query({ + username: '', + displayName: '', + limit: 50, + }), + ); + const req = http.expectOne((r) => r.url === USERS_URL); + expect(req.request.params.has('username')).toBe(false); + expect(req.request.params.has('displayName')).toBe(false); + expect(req.request.params.get('limit')).toBe('50'); + req.flush(PAGE); + }); + + it('omits undefined filter fields', async () => { + const { service, http } = setup(); + void firstValueFrom(service.query({})); + const req = http.expectOne((r) => r.url === USERS_URL); + expect(req.request.params.keys().length).toBe(0); + req.flush(PAGE); + }); +}); diff --git a/apps/portal-admin/src/app/pages/users/admin-users.service.ts b/apps/portal-admin/src/app/pages/users/admin-users.service.ts new file mode 100644 index 0000000..ff1a2ce --- /dev/null +++ b/apps/portal-admin/src/app/pages/users/admin-users.service.ts @@ -0,0 +1,63 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { AUTH_BFF_BASE_URL } from 'feature-auth'; +import type { Observable } from 'rxjs'; + +/** + * One row of the BFF's `GET /api/admin/users` response. Mirrors + * `AdminUserDto` on the BFF side — ISO-string timestamps, no + * `actor_id_hash` (the audit-module invariant per ADR-0013 keeps + * the salted hash inside the audit module; the admin directory + * uses Entra `oid` directly). + */ +export interface AdminUser { + readonly oid: string; + readonly tid: string; + readonly audience: string; + readonly username: string; + readonly displayName: string; + readonly firstSeenAt: string; + readonly lastSeenAt: string; +} + +export interface AdminUsersPage { + readonly items: readonly AdminUser[]; + readonly total: number; + readonly limit: number; + readonly offset: number; +} + +/** Filter+pagination shape sent as query params. Mirrors `AdminUsersQueryDto`. */ +export interface AdminUsersQuery { + readonly username?: string | undefined; + readonly displayName?: string | undefined; + readonly audience?: 'workforce' | 'customer' | undefined; + readonly lastSeenAtFrom?: string | undefined; + readonly lastSeenAtTo?: string | undefined; + readonly limit?: number | undefined; + readonly offset?: number | undefined; +} + +/** + * Thin HttpClient wrapper around `GET /api/admin/users`. Same shape + * as `AuditEventsService` — provided in `root` because the users + * page is the only v1 consumer, no per-page DI overhead worth it. + */ +@Injectable({ providedIn: 'root' }) +export class AdminUsersService { + private readonly http = inject(HttpClient); + private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL); + + query(filters: AdminUsersQuery): Observable { + let params = new HttpParams(); + for (const [key, value] of Object.entries(filters)) { + // Drop empty strings — Nest's ValidationPipe rejects `?foo=` + // as `foo === ''` for IsString / IsISO8601 validators. + if (value === undefined || value === null || value === '') { + continue; + } + params = params.set(key, String(value)); + } + return this.http.get(`${this.bffBaseUrl}/admin/users`, { params }); + } +} diff --git a/apps/portal-admin/src/app/pages/users/users.html b/apps/portal-admin/src/app/pages/users/users.html new file mode 100644 index 0000000..da662ac --- /dev/null +++ b/apps/portal-admin/src/app/pages/users/users.html @@ -0,0 +1,144 @@ +
+
+

Users

+

+ Read-only directory of every identity that has signed in to portal-shell or portal-admin per + ADR-0020. Every query + run on this page emits an admin.users.query audit row — directory access is + itself auditable. +

+
+ +
+

Filters

+
+ + + + + + +
+
+ + +
+
+ +
+ @if (loading()) { + Loading… + } @else if (error(); as msg) { + {{ msg }} + } @else if (page(); as p) { + {{ resultRange() }} + } +
+ + @if (page(); as p) { @if (p.items.length === 0) { +

No users match the current filters.

+ } @else { +
+ + + + + + + + + + + + + @for (user of p.items; track user.oid) { + + + + + + + + + } + +
Display nameUsernameAudienceFirst seenLast seenOID
{{ user.displayName }}{{ user.username }} + {{ user.audience }} + {{ formatTimestamp(user.firstSeenAt) }}{{ formatTimestamp(user.lastSeenAt) }}{{ user.oid }}
+
+ + + } } +
diff --git a/apps/portal-admin/src/app/pages/users/users.scss b/apps/portal-admin/src/app/pages/users/users.scss new file mode 100644 index 0000000..bb7f5ad --- /dev/null +++ b/apps/portal-admin/src/app/pages/users/users.scss @@ -0,0 +1,285 @@ +.users { + max-width: 1200px; + margin: 0 auto; +} + +.users-header { + margin-bottom: 1.5rem; +} + +.title { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 0.25rem; +} + +.intro { + margin: 0; + font-size: 0.875rem; + color: #6b7280; + + code { + padding: 0.0625rem 0.25rem; + border-radius: 0.25rem; + background-color: rgba(0, 0, 0, 0.05); + font-family: ui-monospace, monospace; + font-size: 0.875em; + } + + a { + color: var(--color-brand-primary-600, #2563eb); + text-decoration: underline; + } +} + +:host-context(.dark) .intro { + color: #9ca3af; + code { + background-color: rgba(255, 255, 255, 0.08); + } + a { + color: var(--color-brand-primary-300, #93c5fd); + } +} + +.filters { + padding: 1rem; + margin-bottom: 1rem; + background-color: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; +} + +:host-context(.dark) .filters { + background-color: #111827; + border-color: #1f2937; +} + +.filters-heading { + margin: 0 0 0.75rem; + font-size: 1rem; + font-weight: 600; +} + +.filters-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; +} + +.filter { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.filter-label { + font-size: 0.75rem; + font-weight: 500; + color: #6b7280; +} + +:host-context(.dark) .filter-label { + color: #9ca3af; +} + +.filter input, +.filter select { + height: 2rem; + padding: 0 0.5rem; + border: 1px solid #d1d5db; + border-radius: 0.25rem; + background-color: #ffffff; + color: inherit; + font-size: 0.875rem; + + &:focus-visible { + outline: 2px solid var(--color-brand-primary-500, #2563eb); + outline-offset: 1px; + border-color: transparent; + } +} + +:host-context(.dark) .filter input, +:host-context(.dark) .filter select { + background-color: #0f172a; + border-color: #374151; + color: #e5e7eb; +} + +.filters-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.875rem; +} + +.btn { + display: inline-flex; + align-items: center; + height: 2.25rem; + padding: 0 0.875rem; + border: 1px solid transparent; + border-radius: 0.25rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid var(--color-brand-primary-500, #2563eb); + outline-offset: 2px; + } +} + +.btn--primary { + background-color: var(--color-brand-primary-600, #2563eb); + color: #ffffff; + + &:hover:not(:disabled) { + background-color: var(--color-brand-primary-700, #1d4ed8); + } +} + +.btn--secondary { + background-color: transparent; + border-color: #d1d5db; + color: inherit; + + &:hover:not(:disabled) { + background-color: rgba(0, 0, 0, 0.04); + } +} + +:host-context(.dark) .btn--secondary:hover:not(:disabled) { + background-color: rgba(255, 255, 255, 0.06); +} + +.status-bar { + margin-bottom: 0.5rem; + min-height: 1.25rem; + font-size: 0.875rem; + color: #6b7280; +} + +.status-line--error { + color: #b91c1c; + font-weight: 500; +} + +:host-context(.dark) .status-bar { + color: #9ca3af; +} + +:host-context(.dark) .status-line--error { + color: #fca5a5; +} + +.empty { + padding: 1.5rem; + text-align: center; + color: #6b7280; + background-color: #ffffff; + border: 1px dashed #d1d5db; + border-radius: 0.5rem; +} + +:host-context(.dark) .empty { + background-color: #111827; + border-color: #374151; + color: #9ca3af; +} + +.table-wrap { + overflow-x: auto; + background-color: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; +} + +:host-context(.dark) .table-wrap { + background-color: #111827; + border-color: #1f2937; +} + +.users-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + + th, + td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #f3f4f6; + vertical-align: top; + } + + th { + font-weight: 600; + background-color: #f9fafb; + position: sticky; + top: 0; + } +} + +:host-context(.dark) .users-table { + th, + td { + border-bottom-color: #1f2937; + } + th { + background-color: #1f2937; + } +} + +.cell-name { + font-weight: 500; +} + +.cell-username, +.cell-timestamp, +.cell-oid { + font-family: ui-monospace, monospace; +} + +.cell-oid { + word-break: break-all; + font-size: 0.75rem; + color: #6b7280; +} + +:host-context(.dark) .cell-oid { + color: #9ca3af; +} + +.cell-timestamp { + white-space: nowrap; + font-size: 0.8125rem; +} + +.aud-badge { + display: inline-block; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + background-color: rgba(0, 0, 0, 0.06); + color: #1f2937; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; +} + +:host-context(.dark) .aud-badge { + background-color: rgba(255, 255, 255, 0.08); + color: #e5e7eb; +} + +.pagination { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 0.75rem; +} diff --git a/apps/portal-admin/src/app/pages/users/users.spec.ts b/apps/portal-admin/src/app/pages/users/users.spec.ts new file mode 100644 index 0000000..2f6d7db --- /dev/null +++ b/apps/portal-admin/src/app/pages/users/users.spec.ts @@ -0,0 +1,183 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { + AdminUsersService, + type AdminUsersPage, + type AdminUsersQuery, +} from './admin-users.service'; +import { UsersPage } from './users'; + +const PAGE: AdminUsersPage = { + items: [ + { + oid: 'jane-oid', + tid: 'tenant-1', + audience: 'workforce', + username: 'jane.doe@apf.example', + displayName: 'Jane Doe', + firstSeenAt: '2026-05-10T10:00:00.000Z', + lastSeenAt: '2026-05-14T08:30:00.000Z', + }, + { + oid: 'admin-oid', + tid: 'tenant-1', + audience: 'workforce', + username: 'admin@apf.example', + displayName: 'Admin Smith', + firstSeenAt: '2026-05-09T08:00:00.000Z', + lastSeenAt: '2026-05-13T17:45:00.000Z', + }, + ], + total: 137, + limit: 50, + offset: 0, +}; + +interface Fixture { + fixture: ReturnType>; + query: ReturnType; +} + +function setup(opts?: { initial?: AdminUsersPage | 'error'; status?: number }): Fixture { + const initial = opts?.initial ?? PAGE; + const query = vi.fn().mockImplementation(() => { + if (initial === 'error') { + return throwError(() => ({ status: opts?.status ?? 500 })); + } + return of(initial); + }); + TestBed.configureTestingModule({ + imports: [UsersPage], + providers: [{ provide: AdminUsersService, useValue: { query } }], + }); + const fixture = TestBed.createComponent(UsersPage); + return { fixture, query }; +} + +async function flush(fixture: Fixture['fixture']): Promise { + fixture.detectChanges(); + await Promise.resolve(); + await Promise.resolve(); + fixture.detectChanges(); + await fixture.whenStable(); +} + +describe('UsersPage', () => { + it('runs an initial unfiltered query on init and renders the result table', async () => { + const { fixture, query } = setup(); + await flush(fixture); + expect(query).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledWith(expect.objectContaining({ limit: 50, offset: 0 })); + const rows = (fixture.nativeElement as HTMLElement).querySelectorAll( + '.users-table tbody tr', + ); + expect(rows.length).toBe(2); + }); + + it('shows the result range ("1–2 of 137") in the status bar', async () => { + const { fixture } = setup(); + await flush(fixture); + expect( + (fixture.nativeElement as HTMLElement).querySelector('.status-line')?.textContent, + ).toContain('1–2 of 137'); + }); + + it('renders the empty state when the page has no items', async () => { + const { fixture } = setup({ + initial: { items: [], total: 0, limit: 50, offset: 0 }, + }); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('.empty')?.textContent).toContain('No users'); + expect(root.querySelector('.users-table')).toBeNull(); + }); + + it('renders a permission-aware error message on 403', async () => { + const { fixture } = setup({ initial: 'error', status: 403 }); + await flush(fixture); + const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error'); + expect(status?.textContent).toContain('do not have access'); + }); + + it('renders a generic error message on 5xx', async () => { + const { fixture } = setup({ initial: 'error', status: 500 }); + await flush(fixture); + const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error'); + expect(status?.textContent).toContain('Could not load'); + }); + + it('passes the populated filter fields to the service on Apply', async () => { + const { fixture, query } = setup(); + await flush(fixture); + const component = fixture.componentInstance; + (component as unknown as { username: { set: (v: string) => void } }).username.set('jane'); + (component as unknown as { audience: { set: (v: string) => void } }).audience.set('workforce'); + await component.search(); + await flush(fixture); + expect(query).toHaveBeenCalledTimes(2); + const lastCall = query.mock.calls[1]?.[0] as AdminUsersQuery; + expect(lastCall.username).toBe('jane'); + expect(lastCall.audience).toBe('workforce'); + expect(lastCall.offset).toBe(0); + }); + + it('resets the offset to 0 on each Apply', async () => { + const { fixture, query } = setup(); + await flush(fixture); + const component = fixture.componentInstance; + await component.next(); + await flush(fixture); + expect((query.mock.calls[1]?.[0] as AdminUsersQuery).offset).toBe(50); + await component.search(); + await flush(fixture); + expect((query.mock.calls[2]?.[0] as AdminUsersQuery).offset).toBe(0); + }); + + it('clearFilters empties every filter signal and re-queries from offset 0', async () => { + const { fixture, query } = setup(); + await flush(fixture); + const component = fixture.componentInstance; + (component as unknown as { username: { set: (v: string) => void } }).username.set('x'); + (component as unknown as { displayName: { set: (v: string) => void } }).displayName.set('y'); + await component.clearFilters(); + await flush(fixture); + const lastCall = query.mock.calls[1]?.[0] as AdminUsersQuery; + expect(lastCall.username).toBeUndefined(); + expect(lastCall.displayName).toBeUndefined(); + expect(lastCall.offset).toBe(0); + }); + + it('Next is disabled when the loaded page covers the total', async () => { + const { fixture } = setup({ + initial: { items: PAGE.items, total: 2, limit: 50, offset: 0 }, + }); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + const nextBtn = Array.from(root.querySelectorAll('.pagination .btn')).find( + (b) => b.textContent?.trim() === 'Next', + ); + expect(nextBtn?.disabled).toBe(true); + }); + + it('Previous is disabled at offset 0', async () => { + const { fixture } = setup(); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + const prevBtn = Array.from(root.querySelectorAll('.pagination .btn')).find( + (b) => b.textContent?.trim() === 'Previous', + ); + expect(prevBtn?.disabled).toBe(true); + }); + + it('renders the displayName + username + audience badge per row', async () => { + const { fixture } = setup(); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + const rows = root.querySelectorAll('.users-table tbody tr'); + expect(rows[0]?.querySelector('.cell-name')?.textContent?.trim()).toBe('Jane Doe'); + expect(rows[0]?.querySelector('.cell-username')?.textContent?.trim()).toBe( + 'jane.doe@apf.example', + ); + expect(rows[0]?.querySelector('.aud-badge')?.textContent?.trim()).toBe('workforce'); + }); +}); diff --git a/apps/portal-admin/src/app/pages/users/users.ts b/apps/portal-admin/src/app/pages/users/users.ts new file mode 100644 index 0000000..04ee7a0 --- /dev/null +++ b/apps/portal-admin/src/app/pages/users/users.ts @@ -0,0 +1,158 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; +import { + AdminUsersService, + type AdminUsersPage, + type AdminUsersQuery, +} from './admin-users.service'; + +/** Page-size options. Matches the BFF's `DEFAULT_LIMIT` (50) and `MAX_LIMIT` (200). */ +const PAGE_SIZES = [25, 50, 100, 200] as const; + +/** + * User directory viewer per ADR-0020's v1 admin catalogue. Consumes + * `GET /api/admin/users` from the BFF (PR #141) and renders: + * + * - Filter row: username prefix, displayName contains, + * audience, last-seen-at range, page-size selector. + * - Paginated table with `oid`, `displayName`, `username`, + * `audience`, `firstSeenAt`, `lastSeenAt` columns. + * - Pagination controls + loading / empty / error states. + * + * Mirrors the `/audit` page (PR #136) in shape and signal layout + * so a future contributor lands familiar code regardless of which + * admin module they open first. + */ +@Component({ + selector: 'app-users-page', + imports: [FormsModule], + templateUrl: './users.html', + styleUrl: './users.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UsersPage { + private readonly service = inject(AdminUsersService); + + protected readonly pageSizes = PAGE_SIZES; + + protected readonly username = signal(''); + protected readonly displayName = signal(''); + protected readonly audience = signal<'' | 'workforce' | 'customer'>(''); + protected readonly lastSeenAtFrom = signal(''); + protected readonly lastSeenAtTo = signal(''); + + protected readonly limit = signal(50); + protected readonly offset = signal(0); + + protected readonly page = signal(null); + protected readonly loading = signal(false); + protected readonly error = signal(null); + + protected readonly hasNextPage = computed(() => { + const p = this.page(); + if (p === null) return false; + return p.offset + p.items.length < p.total; + }); + + protected readonly hasPreviousPage = computed(() => { + const p = this.page(); + if (p === null) return false; + return p.offset > 0; + }); + + protected readonly resultRange = computed(() => { + const p = this.page(); + if (p === null) return ''; + if (p.items.length === 0) return p.total === 0 ? 'No users' : `0 of ${p.total}`; + const first = p.offset + 1; + const last = p.offset + p.items.length; + return `${first}–${last} of ${p.total}`; + }); + + async search(): Promise { + this.offset.set(0); + await this.fetch(); + } + + async next(): Promise { + if (!this.hasNextPage()) return; + this.offset.update((o) => o + this.limit()); + await this.fetch(); + } + + async previous(): Promise { + if (!this.hasPreviousPage()) return; + this.offset.update((o) => Math.max(0, o - this.limit())); + await this.fetch(); + } + + async clearFilters(): Promise { + this.username.set(''); + this.displayName.set(''); + this.audience.set(''); + this.lastSeenAtFrom.set(''); + this.lastSeenAtTo.set(''); + this.offset.set(0); + await this.fetch(); + } + + protected formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } + } + + private async fetch(): Promise { + this.loading.set(true); + this.error.set(null); + try { + const page = await firstValueFrom(this.service.query(this.buildFilters())); + this.page.set(page); + } catch (err) { + const status = errorStatus(err); + this.error.set( + status === 403 + ? 'You do not have access to the user directory on this session.' + : 'Could not load the user directory. Try again in a moment.', + ); + this.page.set(null); + } finally { + this.loading.set(false); + } + } + + private buildFilters(): AdminUsersQuery { + return { + username: this.username().trim() || undefined, + displayName: this.displayName().trim() || undefined, + audience: this.audience() || undefined, + lastSeenAtFrom: this.lastSeenAtFrom() ? toIso(this.lastSeenAtFrom()) : undefined, + lastSeenAtTo: this.lastSeenAtTo() ? toIso(this.lastSeenAtTo()) : undefined, + limit: this.limit(), + offset: this.offset(), + }; + } + + ngOnInit(): void { + void this.fetch(); + } +} + +function toIso(localValue: string): string { + // `` emits `YYYY-MM-DDTHH:mm` (no + // timezone). Treat as the user's local time and convert to ISO + // 8601 so the BFF's IsISO8601 validator accepts it. + const d = new Date(localValue); + return Number.isNaN(d.getTime()) ? localValue : d.toISOString(); +} + +function errorStatus(err: unknown): number | null { + if (typeof err === 'object' && err !== null && 'status' in err) { + const s = (err as { status: unknown }).status; + return typeof s === 'number' ? s : null; + } + return null; +} diff --git a/apps/portal-admin/src/locale/messages.fr.xlf b/apps/portal-admin/src/locale/messages.fr.xlf index 0d5ec9a..463a920 100644 --- a/apps/portal-admin/src/locale/messages.fr.xlf +++ b/apps/portal-admin/src/locale/messages.fr.xlf @@ -17,6 +17,10 @@ Audit log — APF Portal Admin Journal d’audit — Administration APF Portal + + Users — APF Portal Admin + Utilisateurs — Administration APF Portal +