From 00c7170d8663551a6e84b2659a8ee3498d5db56a Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Thu, 14 May 2026 20:11:32 +0200 Subject: [PATCH] feat(portal-admin): user directory viewer screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final PR of the portal-admin User-list chantier per ADR-0020 §"v1 scope — User list (read-only)". Ships the SPA viewer at /users that consumes GET /api/admin/users (PR #141) and renders a filter form + paginated table mirroring the audit viewer's shape (PR #136). 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 ''). providedIn 'root' — the users page is the single v1 consumer. - UsersPage component (apps/portal-admin/src/app/pages/users/ users.ts): signal-driven page composing: - Filter form: username (prefix), displayName (contains), audience enum, lastSeenAt range (datetime-local → ISO), page-size selector (25 / 50 / 100 / 200 matching BFF MAX_LIMIT). - 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 or Reset. - States: loading line, empty state, error with 403 vs 5xx differentiation matching the audit viewer's posture. Route + sidebar - /users route lazy-loaded in app.routes.ts with the route.users.title i18n marker. messages.fr.xlf updated with the French translation ('Utilisateurs — Administration APF Portal'); the prod build's i18nMissingTranslation=error policy fails otherwise. - AdminSidebar 'User list' entry promoted from aria-disabled placeholder ('Soon' badge) to a live RouterLink at /users. The matching spec is updated. Tests: +15 specs (AdminUsersService 3 covering URL params + empty + undefined; UsersPage 11 covering initial fetch, result range, empty/error states, filter forwarding, Apply offset reset, Reset, pagination boundaries, display badges; sidebar 1 for the promoted link). 45 portal-admin specs total. Build verified: 14.59 KB raw / 3.76 KB gzip for the lazy users chunk — well under any per-chunk budget. The /audit chunk remains 18.16 KB / 4.35 KB; the two viewers ship as separate lazy bundles since each pulls its own service and components. Chantier portal-admin User-list closed — 3 PRs total (140 schema + write, 141 read endpoint, this PR for the viewer). --- 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 + -- 2.30.2