feat(admin): add /admin/users/:oid/scopes screen + endpoints (ADR-0026 PR 2b)
Backend
-------
New REST surface at /api/admin/users/:oid/scopes guarded by
@RequireAdmin (Portal.Admin privilege per ADR-0020). Three endpoints:
GET list a user's scopes with the linked User + Person info
(no audit on reads)
POST grant a new scope - validates kind via SCOPE_KINDS catalogue,
validates value against Structure.code / Delegation.code /
Region.code for value-bearing kinds, rejects non-empty values
for valueless kinds, translates Prisma P2002 to a 400 with
"already granted"; emits admin.scope_granted (blocking
audit per ADR-0013)
DELETE :scopeId 404-protected against cross-user revocation;
emits admin.scope_revoked with the resolved tuple at the
moment of deletion
Two new audit types (AdminScopeGrantedInput / AdminScopeRevokedInput)
+ two AuditWriter methods. subject = "user:<oid>" so an auditor
pivots on the target of the change.
UserScope rows created here use source = "admin-ui" (distinct from
seed-written "seed" and Person-only "self-signin").
Frontend
--------
New Angular page at /admin/users/:oid/scopes (lazy-loaded). Lists
current scopes in a table with per-row Revoke buttons; "Grant a new
scope" form with kind dropdown + conditional value input + optional
expiresAt datetime. Friendly error mapping: 403 -> "no admin access",
404 -> "User not found, has this persona ever signed in or been
seeded?", server messages forwarded.
A11y per ADR-0016: aria-labelledby on form sections, role="status"
/ aria-live="polite" for load/error feedback, role="alert" for
submit errors, min-height 44px on every input/button, aria-label
on the Manage-scopes link carrying the user's displayName,
conditional aria-required + aria-describedby on the value input.
"Manage scopes" link added to each row of the existing /admin/users
list, navigating to the new page via the user's Entra oid.
Local: drift gate 4/24/7/3 clean; portal-bff lint 0 err; portal-admin
lint 0 err; both test suites green (portal-admin 71 tests, +8 for
user-scopes).
Closes the ADR-0026 trilogy alongside PRs #232 and #233. ADR-0025's
@RequireScope stack is now end-to-end live for the test tenant.
This commit is contained in:
@@ -4,6 +4,7 @@ import { authGuard } from 'feature-auth';
|
||||
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`;
|
||||
const userScopesTitle = $localize`:@@route.user-scopes.title:User scopes — APF Portal Admin`;
|
||||
const profileTitle = $localize`:@@route.profile.title:Profile — APF Portal Admin`;
|
||||
|
||||
export const appRoutes: Route[] = [
|
||||
@@ -23,6 +24,11 @@ export const appRoutes: Route[] = [
|
||||
loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage),
|
||||
title: usersTitle,
|
||||
},
|
||||
{
|
||||
path: 'users/:oid/scopes',
|
||||
loadComponent: () => import('./pages/user-scopes/user-scopes').then((m) => m.UserScopesPage),
|
||||
title: userScopesTitle,
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
canActivate: [authGuard],
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<section class="user-scopes">
|
||||
<header class="user-scopes-header">
|
||||
<p class="crumbs">
|
||||
<a routerLink="/users">← Back to users</a>
|
||||
</p>
|
||||
<h1 class="title">User scopes</h1>
|
||||
@if (page(); as p) {
|
||||
<p class="intro">
|
||||
Managing scopes for <strong>{{ p.user.displayName }}</strong> ({{ p.user.email ?? '—' }},
|
||||
<code>{{ p.user.oid }}</code>). Every grant emits <code>admin.scope_granted</code> and every
|
||||
revoke emits <code>admin.scope_revoked</code> — scope-management is itself auditable per
|
||||
ADR-0013.
|
||||
</p>
|
||||
}
|
||||
</header>
|
||||
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (page(); as p) {
|
||||
<section class="scopes-section" aria-labelledby="current-scopes-h">
|
||||
<h2 id="current-scopes-h" class="section-heading">Current scopes</h2>
|
||||
@if (p.scopes.length === 0) {
|
||||
<p class="empty">No scopes granted to this user yet.</p>
|
||||
} @else {
|
||||
<div class="table-wrap">
|
||||
<table class="scopes-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Scope</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Granted</th>
|
||||
<th scope="col">Expires</th>
|
||||
<th scope="col"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (scope of p.scopes; track scope.id) {
|
||||
<tr>
|
||||
<td class="cell-scope">
|
||||
<code>{{ formatScope(scope.kind, scope.value) }}</code>
|
||||
</td>
|
||||
<td class="cell-source">{{ scope.source }}</td>
|
||||
<td class="cell-timestamp">{{ formatTimestamp(scope.createdAt) }}</td>
|
||||
<td class="cell-timestamp">{{ formatTimestamp(scope.expiresAt) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--danger"
|
||||
(click)="revoke(scope.id, formatScope(scope.kind, scope.value))"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="grant-section" aria-labelledby="grant-h">
|
||||
<h2 id="grant-h" class="section-heading">Grant a new scope</h2>
|
||||
<form class="grant-form" (submit)="$event.preventDefault(); grant()">
|
||||
<div class="grant-grid">
|
||||
<label class="field">
|
||||
<span class="field-label">Kind</span>
|
||||
<select
|
||||
name="kind"
|
||||
[ngModel]="newKind()"
|
||||
(ngModelChange)="newKind.set($event)"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
@for (kind of scopeKinds; track kind) {
|
||||
<option [value]="kind">{{ kind }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field" [class.field--hidden]="!needsValue()">
|
||||
<span class="field-label">Value</span>
|
||||
<input
|
||||
type="text"
|
||||
name="value"
|
||||
[ngModel]="newValue()"
|
||||
(ngModelChange)="newValue.set($event)"
|
||||
[disabled]="submitting() || !needsValue()"
|
||||
placeholder="0330800013 / 33 / 75"
|
||||
[attr.aria-required]="needsValue() ? 'true' : 'false'"
|
||||
[attr.aria-describedby]="needsValue() ? 'value-hint' : null"
|
||||
/>
|
||||
@if (needsValue()) {
|
||||
<span id="value-hint" class="field-hint">
|
||||
For etablissement: Structure.code. For delegation: dept code (33, 2A). For region: INSEE
|
||||
region code (75).
|
||||
</span>
|
||||
}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Expires at (optional)</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="expiresAt"
|
||||
[ngModel]="newExpiresAt()"
|
||||
(ngModelChange)="newExpiresAt.set($event)"
|
||||
[disabled]="submitting()"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@if (submitError(); as msg) {
|
||||
<p class="submit-error" role="alert">{{ msg }}</p>
|
||||
}
|
||||
<div class="grant-actions">
|
||||
<button type="submit" class="btn btn--primary" [disabled]="submitting()">
|
||||
@if (submitting()) { Granting… } @else { Grant }
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,152 @@
|
||||
.user-scopes {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.user-scopes-header {
|
||||
.crumbs {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.intro {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary, #555);
|
||||
}
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.status-line--error {
|
||||
color: var(--color-danger, #b00020);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary, #555);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.scopes-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border, #ddd);
|
||||
}
|
||||
|
||||
.cell-scope code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.grant-section {
|
||||
background: var(--color-bg-surface, #fafafa);
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.grant-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
&.field--hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #555);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
border-radius: 0.25rem;
|
||||
min-height: 44px; /* WCAG 2.2 AA touch target (2.5.5 / 2.5.8). */
|
||||
}
|
||||
}
|
||||
|
||||
.submit-error {
|
||||
color: var(--color-danger, #b00020);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.grant-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--color-brand-accent-500, #de9415);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background: transparent;
|
||||
color: var(--color-danger, #b00020);
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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 { UserScopesService, type UserScope, type UserScopesPayload } from './user-scopes.service';
|
||||
|
||||
const BFF_BASE = 'http://bff.test/api';
|
||||
|
||||
const SCOPE: UserScope = {
|
||||
id: 'scope-1',
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
source: 'seed',
|
||||
createdAt: '2026-05-26T10:00:00.000Z',
|
||||
expiresAt: null,
|
||||
};
|
||||
|
||||
const PAGE: UserScopesPayload = {
|
||||
user: {
|
||||
oid: 'target-oid',
|
||||
userId: 'user-uuid-1',
|
||||
displayName: 'Jane Doe',
|
||||
email: 'jane@apf.example',
|
||||
},
|
||||
scopes: [SCOPE],
|
||||
};
|
||||
|
||||
function setup() {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
||||
],
|
||||
});
|
||||
return {
|
||||
service: TestBed.inject(UserScopesService),
|
||||
http: TestBed.inject(HttpTestingController),
|
||||
};
|
||||
}
|
||||
|
||||
describe('UserScopesService.list', () => {
|
||||
it('GETs /admin/users/:oid/scopes and returns the page', async () => {
|
||||
const { service, http } = setup();
|
||||
const promise = firstValueFrom(service.list('target-oid'));
|
||||
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(PAGE);
|
||||
await expect(promise).resolves.toEqual(PAGE);
|
||||
});
|
||||
|
||||
it('URL-encodes the oid', async () => {
|
||||
const { service, http } = setup();
|
||||
void firstValueFrom(service.list('weird/oid'));
|
||||
const req = http.expectOne(`${BFF_BASE}/admin/users/weird%2Foid/scopes`);
|
||||
req.flush(PAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesService.grant', () => {
|
||||
it('POSTs the input body and returns the created scope', async () => {
|
||||
const { service, http } = setup();
|
||||
const promise = firstValueFrom(
|
||||
service.grant('target-oid', {
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
expiresAt: '2026-12-31T23:59:59.000Z',
|
||||
}),
|
||||
);
|
||||
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
expiresAt: '2026-12-31T23:59:59.000Z',
|
||||
});
|
||||
req.flush(SCOPE);
|
||||
await expect(promise).resolves.toEqual(SCOPE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesService.revoke', () => {
|
||||
it('DELETEs the scope by id', async () => {
|
||||
const { service, http } = setup();
|
||||
const promise = firstValueFrom(service.revoke('target-oid', 'scope-1'));
|
||||
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes/scope-1`);
|
||||
expect(req.request.method).toBe('DELETE');
|
||||
req.flush(null);
|
||||
await expect(promise).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { AUTH_BFF_BASE_URL } from 'feature-auth';
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* One scope row as the SPA sees it — mirror of `UserScopeDto` on
|
||||
* the BFF side. ISO-string timestamps; the SPA never round-trips
|
||||
* `Date` instances per the audit-page convention.
|
||||
*/
|
||||
export interface UserScope {
|
||||
readonly id: string;
|
||||
readonly kind: string;
|
||||
readonly value: string;
|
||||
readonly source: string;
|
||||
readonly createdAt: string;
|
||||
readonly expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface UserScopesPayload {
|
||||
readonly user: {
|
||||
readonly oid: string;
|
||||
readonly userId: string;
|
||||
readonly displayName: string;
|
||||
readonly email: string | null;
|
||||
};
|
||||
readonly scopes: ReadonlyArray<UserScope>;
|
||||
}
|
||||
|
||||
export interface GrantUserScopeInput {
|
||||
readonly kind: string;
|
||||
readonly value?: string;
|
||||
readonly expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `UserScopesService` — Thin HttpClient wrapper around
|
||||
* `/api/admin/users/:oid/scopes`. Same shape convention as
|
||||
* `AdminUsersService` — `providedIn: 'root'` because the single
|
||||
* consumer is the `UserScopesPayload`.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserScopesService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
|
||||
|
||||
list(oid: string): Observable<UserScopesPayload> {
|
||||
return this.http.get<UserScopesPayload>(
|
||||
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`,
|
||||
);
|
||||
}
|
||||
|
||||
grant(oid: string, input: GrantUserScopeInput): Observable<UserScope> {
|
||||
return this.http.post<UserScope>(
|
||||
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
revoke(oid: string, scopeId: string): Observable<void> {
|
||||
return this.http.delete<void>(
|
||||
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes/${encodeURIComponent(scopeId)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { AUTH_BFF_BASE_URL } from 'feature-auth';
|
||||
import {
|
||||
UserScopesService,
|
||||
type GrantUserScopeInput,
|
||||
type UserScope,
|
||||
type UserScopesPayload,
|
||||
} from './user-scopes.service';
|
||||
import { UserScopesPage } from './user-scopes';
|
||||
|
||||
const SCOPE: UserScope = {
|
||||
id: 'scope-1',
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
source: 'seed',
|
||||
createdAt: '2026-05-26T10:00:00.000Z',
|
||||
expiresAt: null,
|
||||
};
|
||||
|
||||
const PAGE: UserScopesPayload = {
|
||||
user: {
|
||||
oid: 'target-oid',
|
||||
userId: 'user-uuid-1',
|
||||
displayName: 'Jane Doe',
|
||||
email: 'jane@apf.example',
|
||||
},
|
||||
scopes: [SCOPE],
|
||||
};
|
||||
|
||||
function setup(opts?: {
|
||||
list?: ReturnType<typeof vi.fn>;
|
||||
grant?: ReturnType<typeof vi.fn>;
|
||||
revoke?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const list = opts?.list ?? vi.fn().mockReturnValue(of(PAGE));
|
||||
const grant = opts?.grant ?? vi.fn().mockReturnValue(of(SCOPE));
|
||||
const revoke = opts?.revoke ?? vi.fn().mockReturnValue(of(undefined));
|
||||
TestBed.configureTestingModule({
|
||||
imports: [UserScopesPage],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
{ provide: AUTH_BFF_BASE_URL, useValue: 'http://bff.test/api' },
|
||||
{
|
||||
provide: UserScopesService,
|
||||
useValue: { list, grant, revoke },
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: new Map([['oid', 'target-oid']]) } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(UserScopesPage);
|
||||
return { fixture, list, grant, revoke };
|
||||
}
|
||||
|
||||
describe('UserScopesPage', () => {
|
||||
it('loads the scopes for the route param oid on init', async () => {
|
||||
const { fixture, list } = setup();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(list).toHaveBeenCalledWith('target-oid');
|
||||
});
|
||||
|
||||
it('surfaces a 404 with the seed/sign-in hint', async () => {
|
||||
const list = vi.fn().mockReturnValue(throwError(() => ({ status: 404 })));
|
||||
const { fixture } = setup({ list });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const html = (fixture.nativeElement as HTMLElement).innerHTML;
|
||||
expect(html).toMatch(/User not found/);
|
||||
});
|
||||
|
||||
it('forbids a 403 with a friendly error', async () => {
|
||||
const list = vi.fn().mockReturnValue(throwError(() => ({ status: 403 })));
|
||||
const { fixture } = setup({ list });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const html = (fixture.nativeElement as HTMLElement).innerHTML;
|
||||
expect(html).toMatch(/admin access/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesPage.grant', () => {
|
||||
it('sends value for value-bearing kinds + refreshes the list on success', async () => {
|
||||
let grantPayload: GrantUserScopeInput | null = null;
|
||||
const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => {
|
||||
grantPayload = payload;
|
||||
return of(SCOPE);
|
||||
});
|
||||
const list = vi.fn().mockReturnValue(of(PAGE));
|
||||
const { fixture } = setup({ list, grant });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
newKind: { set: (v: string) => void };
|
||||
newValue: { set: (v: string) => void };
|
||||
grant: () => Promise<void>;
|
||||
};
|
||||
component.newKind.set('etablissement');
|
||||
component.newValue.set('0330800013');
|
||||
await component.grant();
|
||||
expect(grant).toHaveBeenCalledTimes(1);
|
||||
expect(grantPayload).toEqual({ kind: 'etablissement', value: '0330800013' });
|
||||
// List re-fetched after the grant.
|
||||
expect(list).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('omits value for valueless kinds', async () => {
|
||||
let grantPayload: GrantUserScopeInput | null = null;
|
||||
const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => {
|
||||
grantPayload = payload;
|
||||
return of(SCOPE);
|
||||
});
|
||||
const { fixture } = setup({ grant });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
newKind: { set: (v: string) => void };
|
||||
grant: () => Promise<void>;
|
||||
};
|
||||
component.newKind.set('unrestricted');
|
||||
await component.grant();
|
||||
expect(grantPayload).toEqual({ kind: 'unrestricted' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
type OnInit,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
UserScopesService,
|
||||
type GrantUserScopeInput,
|
||||
type UserScopesPayload,
|
||||
} from './user-scopes.service';
|
||||
|
||||
/**
|
||||
* The six scope kinds from ADR-0025's catalogue. Hardcoded here so
|
||||
* the SPA can dropdown them without importing from `shared-auth`
|
||||
* (which would couple the admin app to the BFF lib path). Drift gate
|
||||
* lives BFF-side; if the catalogue grows, this list grows in lockstep.
|
||||
*/
|
||||
const SCOPE_KINDS = [
|
||||
'self',
|
||||
'etablissement',
|
||||
'delegation',
|
||||
'region',
|
||||
'siege',
|
||||
'unrestricted',
|
||||
] as const;
|
||||
|
||||
const VALUE_BEARING = new Set<string>(['etablissement', 'delegation', 'region']);
|
||||
|
||||
/**
|
||||
* Admin scope-management screen per [ADR-0026 PR 2b](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0026-person-user-portal-data-model.md).
|
||||
* Lists every UserScope row for the target user, lets the admin grant
|
||||
* a new one (kind dropdown + value + optional expiresAt), and revoke
|
||||
* existing ones inline.
|
||||
*
|
||||
* Reaches the BFF at `/api/admin/users/:oid/scopes`. The `:oid` URL
|
||||
* param is the affected user's Entra `oid` — same identifier the
|
||||
* `/admin/users` list page uses, so a "Manage scopes" link from that
|
||||
* list lands here without a lookup.
|
||||
*
|
||||
* A11y posture (per [ADR-0016](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)):
|
||||
* - Form labels associated via `<label>` wrapping or `for`.
|
||||
* - Live region for status/error messages (`role="status"`).
|
||||
* - Touch targets respect 44×44 min (button padding in the SCSS).
|
||||
* - Revoke buttons confirm via `window.confirm` to avoid accidental
|
||||
* destructive action on a row's keyboard activation.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-user-scopes-page',
|
||||
imports: [FormsModule, RouterLink],
|
||||
templateUrl: './user-scopes.html',
|
||||
styleUrl: './user-scopes.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class UserScopesPage implements OnInit {
|
||||
private readonly service = inject(UserScopesService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
protected readonly scopeKinds = SCOPE_KINDS;
|
||||
protected readonly oid = signal('');
|
||||
protected readonly page = signal<UserScopesPayload | null>(null);
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
|
||||
// Form state for the "grant new scope" row.
|
||||
protected readonly newKind = signal<string>('self');
|
||||
protected readonly newValue = signal('');
|
||||
protected readonly newExpiresAt = signal('');
|
||||
protected readonly submitting = signal(false);
|
||||
protected readonly submitError = signal<string | null>(null);
|
||||
|
||||
protected readonly needsValue = computed(() => VALUE_BEARING.has(this.newKind()));
|
||||
|
||||
ngOnInit(): void {
|
||||
const oid = this.route.snapshot.paramMap.get('oid') ?? '';
|
||||
this.oid.set(oid);
|
||||
void this.fetch();
|
||||
}
|
||||
|
||||
protected async fetch(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
const page = await firstValueFrom(this.service.list(this.oid()));
|
||||
this.page.set(page);
|
||||
} catch (err) {
|
||||
this.error.set(this.translateError(err, 'Could not load scopes for this user.'));
|
||||
this.page.set(null);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async grant(): Promise<void> {
|
||||
if (this.submitting()) return;
|
||||
this.submitting.set(true);
|
||||
this.submitError.set(null);
|
||||
const payload: GrantUserScopeInput = {
|
||||
kind: this.newKind(),
|
||||
...(this.needsValue() ? { value: this.newValue().trim() } : {}),
|
||||
...(this.newExpiresAt() ? { expiresAt: toIso(this.newExpiresAt()) } : {}),
|
||||
};
|
||||
try {
|
||||
await firstValueFrom(this.service.grant(this.oid(), payload));
|
||||
// Reset the form (kind reverts to 'self' as the "least
|
||||
// privileged" default), then refresh the list to show the new
|
||||
// row.
|
||||
this.newKind.set('self');
|
||||
this.newValue.set('');
|
||||
this.newExpiresAt.set('');
|
||||
await this.fetch();
|
||||
} catch (err) {
|
||||
this.submitError.set(this.translateError(err, 'Could not grant the scope.'));
|
||||
} finally {
|
||||
this.submitting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async revoke(scopeId: string, label: string): Promise<void> {
|
||||
const ok = window.confirm(`Revoke scope "${label}"?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await firstValueFrom(this.service.revoke(this.oid(), scopeId));
|
||||
await this.fetch();
|
||||
} catch (err) {
|
||||
this.error.set(this.translateError(err, 'Could not revoke the scope.'));
|
||||
}
|
||||
}
|
||||
|
||||
protected formatScope(kind: string, value: string): string {
|
||||
return value === '' ? kind : `${kind}:${value}`;
|
||||
}
|
||||
|
||||
protected formatTimestamp(iso: string | null): string {
|
||||
if (iso === null) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
private translateError(err: unknown, fallback: string): string {
|
||||
if (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'error' in err &&
|
||||
typeof (err as { error?: unknown }).error === 'object' &&
|
||||
(err as { error: { message?: unknown } }).error.message !== undefined
|
||||
) {
|
||||
const msg = (err as { error: { message?: unknown } }).error.message;
|
||||
if (typeof msg === 'string') return msg;
|
||||
if (Array.isArray(msg) && msg.every((m) => typeof m === 'string')) {
|
||||
return msg.join(' • ');
|
||||
}
|
||||
}
|
||||
const status = errorStatus(err);
|
||||
if (status === 403) return 'You do not have admin access on this session.';
|
||||
if (status === 404) return 'User not found — has this persona ever signed in or been seeded?';
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function toIso(localValue: string): string {
|
||||
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;
|
||||
}
|
||||
@@ -107,6 +107,7 @@
|
||||
<th scope="col">First seen</th>
|
||||
<th scope="col">Last seen</th>
|
||||
<th scope="col">OID</th>
|
||||
<th scope="col"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -120,6 +121,15 @@
|
||||
<td class="cell-timestamp">{{ formatTimestamp(user.firstSeenAt) }}</td>
|
||||
<td class="cell-timestamp">{{ formatTimestamp(user.lastSeenAt) }}</td>
|
||||
<td class="cell-oid">{{ user.oid }}</td>
|
||||
<td class="cell-actions">
|
||||
<a
|
||||
class="btn btn--secondary"
|
||||
[routerLink]="['/users', user.oid, 'scopes']"
|
||||
[attr.aria-label]="'Manage scopes for ' + user.displayName"
|
||||
>
|
||||
Manage scopes
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import {
|
||||
AdminUsersService,
|
||||
@@ -48,7 +49,7 @@ function setup(opts?: { initial?: AdminUsersPage | 'error'; status?: number }):
|
||||
});
|
||||
TestBed.configureTestingModule({
|
||||
imports: [UsersPage],
|
||||
providers: [{ provide: AdminUsersService, useValue: { query } }],
|
||||
providers: [{ provide: AdminUsersService, useValue: { query } }, provideRouter([])],
|
||||
});
|
||||
const fixture = TestBed.createComponent(UsersPage);
|
||||
return { fixture, query };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
AdminUsersService,
|
||||
@@ -26,7 +27,7 @@ const PAGE_SIZES = [25, 50, 100, 200] as const;
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-users-page',
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, RouterLink],
|
||||
templateUrl: './users.html',
|
||||
styleUrl: './users.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
Reference in New Issue
Block a user