feat(portal-admin): user directory viewer screen
CI / scan (pull_request) Successful in 2m39s
CI / commits (pull_request) Successful in 3m1s
CI / check (pull_request) Successful in 3m2s
CI / a11y (pull_request) Successful in 2m28s
CI / perf (pull_request) Successful in 5m52s

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).
This commit is contained in:
Julien Gautier
2026-05-14 20:11:32 +02:00
parent 1df99cd800
commit 00c7170d86
10 changed files with 938 additions and 1 deletions
+6
View File
@@ -2,6 +2,7 @@ import { Route } from '@angular/router';
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`; const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
const auditTitle = $localize`:@@route.audit.title:Audit log — 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[] = [ export const appRoutes: Route[] = [
{ {
@@ -15,6 +16,11 @@ export const appRoutes: Route[] = [
loadComponent: () => import('./pages/audit/audit').then((m) => m.AuditPage), loadComponent: () => import('./pages/audit/audit').then((m) => m.AuditPage),
title: auditTitle, 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 // Catch-all — unknown paths bounce to home. Same role as the
// wildcard in portal-shell (per PR #96): in production each locale // wildcard in portal-shell (per PR #96): in production each locale
// bundle ships with its own `<base href="/{locale}/">` and the // bundle ships with its own `<base href="/{locale}/">` and the
@@ -31,6 +31,14 @@ describe('AdminSidebar', () => {
expect(auditLink?.getAttribute('href')).toBe('/audit'); 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<HTMLAnchorElement>('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', () => { it('renders the not-yet-implemented entries as aria-disabled placeholders with a "Soon" badge', () => {
const root = render(); const root = render();
const disabled = Array.from(root.querySelectorAll('.nav-link--disabled')); const disabled = Array.from(root.querySelectorAll('.nav-link--disabled'));
@@ -27,7 +27,7 @@ const MENU: readonly AdminMenuItem[] = [
{ label: 'Audit log', icon: 'file-text', routerLink: '/audit' }, { label: 'Audit log', icon: 'file-text', routerLink: '/audit' },
{ label: 'CMS pages', icon: 'folder', routerLink: null }, { label: 'CMS pages', icon: 'folder', routerLink: null },
{ label: 'Menu management', icon: 'layout-dashboard', 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({ @Component({
@@ -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);
});
});
@@ -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<AdminUsersPage> {
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<AdminUsersPage>(`${this.bffBaseUrl}/admin/users`, { params });
}
}
@@ -0,0 +1,144 @@
<section class="users">
<header class="users-header">
<h1 class="title">Users</h1>
<p class="intro">
Read-only directory of every identity that has signed in to portal-shell or portal-admin per
<a href="https://github.com/adr/madr" rel="noopener" target="_blank">ADR-0020</a>. Every query
run on this page emits an <code>admin.users.query</code> audit row — directory access is
itself auditable.
</p>
</header>
<form class="filters" (submit)="$event.preventDefault(); search()" aria-labelledby="filters-h">
<h2 id="filters-h" class="filters-heading">Filters</h2>
<div class="filters-grid">
<label class="filter">
<span class="filter-label">Username starts with</span>
<input
type="text"
name="username"
[ngModel]="username()"
(ngModelChange)="username.set($event)"
placeholder="jane.doe"
/>
</label>
<label class="filter">
<span class="filter-label">Display name contains</span>
<input
type="text"
name="displayName"
[ngModel]="displayName()"
(ngModelChange)="displayName.set($event)"
placeholder="Doe"
/>
</label>
<label class="filter">
<span class="filter-label">Audience</span>
<select name="audience" [ngModel]="audience()" (ngModelChange)="audience.set($event)">
<option value="">Any</option>
<option value="workforce">workforce</option>
<option value="customer">customer</option>
</select>
</label>
<label class="filter">
<span class="filter-label">Last seen from</span>
<input
type="datetime-local"
name="lastSeenAtFrom"
[ngModel]="lastSeenAtFrom()"
(ngModelChange)="lastSeenAtFrom.set($event)"
/>
</label>
<label class="filter">
<span class="filter-label">Last seen to</span>
<input
type="datetime-local"
name="lastSeenAtTo"
[ngModel]="lastSeenAtTo()"
(ngModelChange)="lastSeenAtTo.set($event)"
/>
</label>
<label class="filter">
<span class="filter-label">Page size</span>
<select name="limit" [ngModel]="limit()" (ngModelChange)="limit.set(+$event)">
@for (size of pageSizes; track size) {
<option [value]="size">{{ size }}</option>
}
</select>
</label>
</div>
<div class="filters-actions">
<button type="submit" class="btn btn--primary" [disabled]="loading()">Apply filters</button>
<button
type="button"
class="btn btn--secondary"
(click)="clearFilters()"
[disabled]="loading()"
>
Reset
</button>
</div>
</form>
<div class="status-bar" role="status" aria-live="polite">
@if (loading()) {
<span class="status-line">Loading…</span>
} @else if (error(); as msg) {
<span class="status-line status-line--error">{{ msg }}</span>
} @else if (page(); as p) {
<span class="status-line">{{ resultRange() }}</span>
}
</div>
@if (page(); as p) { @if (p.items.length === 0) {
<p class="empty">No users match the current filters.</p>
} @else {
<div class="table-wrap">
<table class="users-table">
<thead>
<tr>
<th scope="col">Display name</th>
<th scope="col">Username</th>
<th scope="col">Audience</th>
<th scope="col">First seen</th>
<th scope="col">Last seen</th>
<th scope="col">OID</th>
</tr>
</thead>
<tbody>
@for (user of p.items; track user.oid) {
<tr>
<td class="cell-name">{{ user.displayName }}</td>
<td class="cell-username">{{ user.username }}</td>
<td class="cell-aud">
<span class="aud-badge">{{ user.audience }}</span>
</td>
<td class="cell-timestamp">{{ formatTimestamp(user.firstSeenAt) }}</td>
<td class="cell-timestamp">{{ formatTimestamp(user.lastSeenAt) }}</td>
<td class="cell-oid">{{ user.oid }}</td>
</tr>
}
</tbody>
</table>
</div>
<nav class="pagination" aria-label="Pagination">
<button
type="button"
class="btn btn--secondary"
(click)="previous()"
[disabled]="!hasPreviousPage() || loading()"
>
Previous
</button>
<button
type="button"
class="btn btn--secondary"
(click)="next()"
[disabled]="!hasNextPage() || loading()"
>
Next
</button>
</nav>
} }
</section>
@@ -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;
}
@@ -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<typeof TestBed.createComponent<UsersPage>>;
query: ReturnType<typeof vi.fn>;
}
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<void> {
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<HTMLTableRowElement>(
'.users-table tbody tr',
);
expect(rows.length).toBe(2);
});
it('shows the result range ("12 of 137") in the status bar', async () => {
const { fixture } = setup();
await flush(fixture);
expect(
(fixture.nativeElement as HTMLElement).querySelector('.status-line')?.textContent,
).toContain('12 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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLTableRowElement>('.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');
});
});
@@ -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<number>(50);
protected readonly offset = signal<number>(0);
protected readonly page = signal<AdminUsersPage | null>(null);
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(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<void> {
this.offset.set(0);
await this.fetch();
}
async next(): Promise<void> {
if (!this.hasNextPage()) return;
this.offset.update((o) => o + this.limit());
await this.fetch();
}
async previous(): Promise<void> {
if (!this.hasPreviousPage()) return;
this.offset.update((o) => Math.max(0, o - this.limit()));
await this.fetch();
}
async clearFilters(): Promise<void> {
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<void> {
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 {
// `<input type="datetime-local">` 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;
}
@@ -17,6 +17,10 @@
<source>Audit log — APF Portal Admin</source> <source>Audit log — APF Portal Admin</source>
<target>Journal daudit — Administration APF Portal</target> <target>Journal daudit — Administration APF Portal</target>
</trans-unit> </trans-unit>
<trans-unit id="route.users.title" datatype="html">
<source>Users — APF Portal Admin</source>
<target>Utilisateurs — Administration APF Portal</target>
</trans-unit>
<!-- page.home --> <!-- page.home -->
<trans-unit id="page.home.title" datatype="html"> <trans-unit id="page.home.title" datatype="html">