feat(portal-admin): user directory viewer screen
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:
@@ -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 `<base href="/{locale}/">` and the
|
||||
|
||||
@@ -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<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', () => {
|
||||
const root = render();
|
||||
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: '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({
|
||||
|
||||
@@ -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 ("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<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>
|
||||
<target>Journal d’audit — Administration APF Portal</target>
|
||||
</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 -->
|
||||
<trans-unit id="page.home.title" datatype="html">
|
||||
|
||||
Reference in New Issue
Block a user