feat(portal-admin): user directory viewer screen #142
@@ -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 ("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>
|
<source>Audit log — APF Portal Admin</source>
|
||||||
<target>Journal d’audit — Administration APF Portal</target>
|
<target>Journal d’audit — 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user