feat(brief): WP-18 — ABAC capability spine (PRD-0002 phase P1)
Replace the FE-computed authorization anti-pattern in BriefStore.editable (derived from the unverified X-Role header) with server-computed decision flags, mirroring the existing HerregistratieDecisionsDto pattern: - Backend: Authz.cs is the single authorization helper — the SAME check (Authz.CanActOn) both gates BriefStore.Review's mutations and computes the BriefDecisionsDto flags shipped on every brief response, so emit and enforce can never drift. New GET /me returns coarse, role-derived capabilities (PRD-0002 SS6). - Every brief endpoint (including send, previously ungated on HttpContext) now returns a fresh BriefViewDto so decisions never go stale after a mutation. - FE: brief.store.ts reads canEdit/canApprove/canReject/canSend off the loaded decisions instead of computing them from currentRole(); the brief.machine carries decisions through every status transition. - New shared/domain/capability.ts + shared/application/access.store.ts + shared/infrastructure/me.adapter.ts: the general capability-spine infrastructure (AccessStore.can(), capabilityGuard) for future routes. Deviates from the original WP-18 draft by NOT renaming auth/domain's Session to a Principal union — ADR-0002 explicitly defers that refactor until a second actor exists, and the brief workflow's drafter/approver identity turned out to be a separate axis from the SSP login session entirely. See docs/backlog/WP-18-abac-capability-spine.md for the full as-built record. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { SessionStore } from './application/session.store';
|
||||
|
||||
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
|
||||
@@ -8,3 +10,22 @@ export const authGuard: CanActivateFn = () => {
|
||||
const router = inject(Router);
|
||||
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
|
||||
};
|
||||
|
||||
/**
|
||||
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
|
||||
* redirect. No route in this app currently needs a capability gate — brief's
|
||||
* canApprove/canReject/canSend are per-action, not per-page (both actors land on
|
||||
* the same `/brief` page and see different actions) — so this exists as the
|
||||
* available building block for the day a route-level gate is needed, e.g. a future
|
||||
* approver-only page.
|
||||
*/
|
||||
export function capabilityGuard(capability: Capability): CanActivateFn {
|
||||
return () => {
|
||||
const session = inject(SessionStore);
|
||||
const access = inject(AccessStore);
|
||||
const router = inject(Router);
|
||||
return session.isAuthenticated() && access.can(capability)
|
||||
? true
|
||||
: router.createUrlTree(['/login']);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { Role } from '@shared/domain/role';
|
||||
import { currentRole } from '@shared/infrastructure/role';
|
||||
import {
|
||||
Brief,
|
||||
allDiagnostics,
|
||||
@@ -11,13 +9,15 @@ import {
|
||||
unresolvedPlaceholders,
|
||||
} from '@brief/domain/brief';
|
||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||
import { BriefAdapter } from '@brief/infrastructure/brief.adapter';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
|
||||
/**
|
||||
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
||||
* read-model, and the commands (effects) that call the adapter and dispatch the
|
||||
* outcome. Mirrors `BigProfileStore`. All of `editable`, `diagnostics`,
|
||||
* `unresolved`, `canSubmit` are DERIVED here — never stored.
|
||||
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
|
||||
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
|
||||
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
|
||||
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BriefStore {
|
||||
@@ -25,7 +25,6 @@ export class BriefStore {
|
||||
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
readonly role: Role = currentRole();
|
||||
readonly busy = signal(false);
|
||||
readonly lastError = signal<string | null>(null);
|
||||
/** Surfaced autosave state for the indicator + aria-live region. */
|
||||
@@ -36,13 +35,14 @@ export class BriefStore {
|
||||
return s.tag === 'loaded' ? s.brief : null;
|
||||
});
|
||||
|
||||
/** The single source of truth for edit permission: drafter, and a status the reducer
|
||||
treats as editable (draft, or rejected — which reopens to draft on the first edit). */
|
||||
readonly editable = computed(() => {
|
||||
const b = this.brief();
|
||||
return (
|
||||
!!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter'
|
||||
);
|
||||
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
|
||||
readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);
|
||||
readonly canReject = computed(() => this.decisions()?.canReject ?? false);
|
||||
readonly canSend = computed(() => this.decisions()?.canSend ?? false);
|
||||
|
||||
private decisions = computed(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s.decisions : null;
|
||||
});
|
||||
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
||||
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
|
||||
@@ -54,12 +54,7 @@ export class BriefStore {
|
||||
|
||||
async load() {
|
||||
const r = await this.adapter.load();
|
||||
if (r.ok)
|
||||
this.store.dispatch({
|
||||
tag: 'BriefLoaded',
|
||||
brief: r.value.brief,
|
||||
availablePassages: r.value.availablePassages,
|
||||
});
|
||||
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||
}
|
||||
|
||||
@@ -71,7 +66,7 @@ export class BriefStore {
|
||||
|
||||
private saveTimer?: ReturnType<typeof setTimeout>;
|
||||
private scheduleSave() {
|
||||
if (!this.editable()) return;
|
||||
if (!this.canEdit()) return;
|
||||
clearTimeout(this.saveTimer);
|
||||
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
|
||||
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
|
||||
@@ -97,12 +92,7 @@ export class BriefStore {
|
||||
const r = await this.adapter.reset();
|
||||
this.busy.set(false);
|
||||
this.saveState.set('idle');
|
||||
if (r.ok)
|
||||
this.store.dispatch({
|
||||
tag: 'BriefLoaded',
|
||||
brief: r.value.brief,
|
||||
availablePassages: r.value.availablePassages,
|
||||
});
|
||||
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
else this.lastError.set(r.error);
|
||||
}
|
||||
|
||||
@@ -113,7 +103,7 @@ export class BriefStore {
|
||||
|
||||
// A transition: flush any pending save, call the server (authoritative), then mirror
|
||||
// the returned status through the pure reducer's guarded transition.
|
||||
private async transition(action: () => Promise<Result<string, Brief>>) {
|
||||
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||
this.busy.set(true);
|
||||
this.lastError.set(null);
|
||||
clearTimeout(this.saveTimer);
|
||||
@@ -127,14 +117,15 @@ export class BriefStore {
|
||||
this.applyServerStatus(r.value);
|
||||
}
|
||||
|
||||
private applyServerStatus(brief: Brief) {
|
||||
private applyServerStatus(view: BriefView) {
|
||||
const { brief, decisions } = view;
|
||||
const s = brief.status;
|
||||
switch (s.tag) {
|
||||
case 'submitted':
|
||||
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt });
|
||||
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });
|
||||
break;
|
||||
case 'approved':
|
||||
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt });
|
||||
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
|
||||
break;
|
||||
case 'rejected':
|
||||
this.store.dispatch({
|
||||
@@ -142,10 +133,11 @@ export class BriefStore {
|
||||
by: s.rejectedBy,
|
||||
at: s.rejectedAt,
|
||||
comments: s.comments,
|
||||
decisions,
|
||||
});
|
||||
break;
|
||||
case 'sent':
|
||||
this.store.dispatch({ tag: 'Sent', at: s.sentAt });
|
||||
this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });
|
||||
break;
|
||||
case 'draft':
|
||||
// reopened by a save on a rejected letter — reducer already handled it locally.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Brief, BriefStatus, LibraryPassage } from './brief';
|
||||
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { PlaceholderDef } from './placeholders';
|
||||
import { BriefState, BriefMsg, reduce } from './brief.machine';
|
||||
@@ -37,6 +37,15 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
||||
};
|
||||
}
|
||||
|
||||
// A machine test cares about status transitions, not who may act — a fixed,
|
||||
// unrestrictive fixture keeps every existing assertion focused on that.
|
||||
const decisions: BriefDecisions = {
|
||||
canEdit: true,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: true,
|
||||
};
|
||||
|
||||
const loaded = (
|
||||
status: BriefStatus = { tag: 'draft' },
|
||||
sections?: Brief['sections'],
|
||||
@@ -44,6 +53,7 @@ const loaded = (
|
||||
tag: 'loaded',
|
||||
brief: briefWith(status, sections),
|
||||
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||
decisions,
|
||||
});
|
||||
|
||||
const sectionBlocks = (s: BriefState, key: string) =>
|
||||
@@ -56,6 +66,7 @@ describe('brief.machine reduce', () => {
|
||||
tag: 'BriefLoaded',
|
||||
brief: briefWith({ tag: 'draft' }),
|
||||
availablePassages: [],
|
||||
decisions,
|
||||
}).tag,
|
||||
).toBe('loaded');
|
||||
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
||||
@@ -175,10 +186,10 @@ describe('brief.machine reduce', () => {
|
||||
|
||||
it('Submitted fires only from draft and only when required sections are filled', () => {
|
||||
// required 'aanhef' empty → no-op
|
||||
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't' })).toEqual(loaded());
|
||||
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded());
|
||||
// fill the required section, then submit
|
||||
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' });
|
||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't' });
|
||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
|
||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
||||
tag: 'submitted',
|
||||
submittedBy: 'u1',
|
||||
@@ -189,15 +200,21 @@ describe('brief.machine reduce', () => {
|
||||
it('approve/reject fire only from submitted; send only from approved', () => {
|
||||
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||
// approve from draft is a no-op
|
||||
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't' })).toEqual(loaded());
|
||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2' });
|
||||
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
|
||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
|
||||
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
|
||||
tag: 'approved',
|
||||
approvedBy: 'u2',
|
||||
approvedAt: 't2',
|
||||
});
|
||||
// reject carries comments
|
||||
const rejected = reduce(submitted, { tag: 'Rejected', by: 'u2', at: 't2', comments: 'nee' });
|
||||
const rejected = reduce(submitted, {
|
||||
tag: 'Rejected',
|
||||
by: 'u2',
|
||||
at: 't2',
|
||||
comments: 'nee',
|
||||
decisions,
|
||||
});
|
||||
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'u2',
|
||||
@@ -205,10 +222,27 @@ describe('brief.machine reduce', () => {
|
||||
comments: 'nee',
|
||||
});
|
||||
// send only from approved
|
||||
expect(reduce(submitted, { tag: 'Sent', at: 't' })).toBe(submitted);
|
||||
const sent = reduce(approved, { tag: 'Sent', at: 't3' });
|
||||
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
|
||||
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
|
||||
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
|
||||
});
|
||||
|
||||
it('a status transition replaces decisions with the fresh server value', () => {
|
||||
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||
const staleApprover: BriefDecisions = {
|
||||
canEdit: false,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: false,
|
||||
};
|
||||
const approved = reduce(submitted, {
|
||||
tag: 'Approved',
|
||||
by: 'u2',
|
||||
at: 't2',
|
||||
decisions: staleApprover,
|
||||
});
|
||||
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
|
||||
});
|
||||
});
|
||||
|
||||
function initialLoading(): BriefState {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
import {
|
||||
Brief,
|
||||
BriefDecisions,
|
||||
BriefStatus,
|
||||
LetterBlock,
|
||||
LetterSection,
|
||||
@@ -21,9 +22,11 @@ import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-te
|
||||
* it back to `draft`. Sections can never be added, removed, or reordered — there
|
||||
* is no Msg for it, so it is unrepresentable.
|
||||
*
|
||||
* Role (drafter vs approver) is NOT a reducer concern: the UI derives `editable` from
|
||||
* role+status and simply doesn't dispatch edits when the actor may not edit. The
|
||||
* reducer guards the status invariant; the UI guards the role invariant.
|
||||
* Authorization is NOT a reducer concern: `decisions` (canEdit/canApprove/canReject/
|
||||
* canSend) arrives from the server on every load and every status transition (PRD-0002
|
||||
* phase P1) and is carried through unchanged by the reducer — never recomputed here.
|
||||
* The reducer guards the status invariant; the server is the sole authority on who may
|
||||
* act on it.
|
||||
*
|
||||
* Note: there is no `PlaceholderInserted` Msg. The editor inserts a placeholder NODE
|
||||
* at the caret and emits the whole new block via `BlockContentEdited`; its insert menu
|
||||
@@ -33,23 +36,33 @@ import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-te
|
||||
|
||||
export type BriefState =
|
||||
| { tag: 'loading' }
|
||||
| { tag: 'loaded'; brief: Brief; availablePassages: readonly LibraryPassage[] }
|
||||
| {
|
||||
tag: 'loaded';
|
||||
brief: Brief;
|
||||
availablePassages: readonly LibraryPassage[];
|
||||
decisions: BriefDecisions;
|
||||
}
|
||||
| { tag: 'failed'; reason: string };
|
||||
|
||||
export const initial: BriefState = { tag: 'loading' };
|
||||
|
||||
export type BriefMsg =
|
||||
| { tag: 'BriefLoaded'; brief: Brief; availablePassages: readonly LibraryPassage[] }
|
||||
| {
|
||||
tag: 'BriefLoaded';
|
||||
brief: Brief;
|
||||
availablePassages: readonly LibraryPassage[];
|
||||
decisions: BriefDecisions;
|
||||
}
|
||||
| { tag: 'BriefLoadFailed'; reason: string }
|
||||
| { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select
|
||||
| { tag: 'FreeTextBlockAdded'; sectionKey: string }
|
||||
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
|
||||
| { tag: 'BlockRemoved'; blockId: string }
|
||||
| { tag: 'BlockMovedWithinSection'; blockId: string; toIndex: number }
|
||||
| { tag: 'Submitted'; by: string; at: string } // draft → submitted
|
||||
| { tag: 'Approved'; by: string; at: string } // submitted → approved
|
||||
| { tag: 'Rejected'; by: string; at: string; comments: string } // submitted → rejected
|
||||
| { tag: 'Sent'; at: string } // approved → sent
|
||||
| { tag: 'Submitted'; by: string; at: string; decisions: BriefDecisions } // draft → submitted
|
||||
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
|
||||
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
|
||||
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
|
||||
| { tag: 'Seed'; state: BriefState };
|
||||
|
||||
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
|
||||
@@ -158,7 +171,12 @@ function moveWithinSection(
|
||||
export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
switch (m.tag) {
|
||||
case 'BriefLoaded':
|
||||
return { tag: 'loaded', brief: m.brief, availablePassages: m.availablePassages };
|
||||
return {
|
||||
tag: 'loaded',
|
||||
brief: m.brief,
|
||||
availablePassages: m.availablePassages,
|
||||
decisions: m.decisions,
|
||||
};
|
||||
case 'BriefLoadFailed':
|
||||
return { tag: 'failed', reason: m.reason };
|
||||
case 'Seed':
|
||||
@@ -203,36 +221,41 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
s,
|
||||
'draft',
|
||||
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
|
||||
m.decisions,
|
||||
canSubmit,
|
||||
);
|
||||
case 'Approved':
|
||||
return transition(s, 'submitted', () => ({
|
||||
tag: 'approved',
|
||||
approvedBy: m.by,
|
||||
approvedAt: m.at,
|
||||
}));
|
||||
return transition(
|
||||
s,
|
||||
'submitted',
|
||||
() => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }),
|
||||
m.decisions,
|
||||
);
|
||||
case 'Rejected':
|
||||
return transition(s, 'submitted', () => ({
|
||||
tag: 'rejected',
|
||||
rejectedBy: m.by,
|
||||
rejectedAt: m.at,
|
||||
comments: m.comments,
|
||||
}));
|
||||
return transition(
|
||||
s,
|
||||
'submitted',
|
||||
() => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }),
|
||||
m.decisions,
|
||||
);
|
||||
case 'Sent':
|
||||
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }));
|
||||
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
|
||||
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
|
||||
/** A guarded status transition: only fires from `from`, and only if `guard` passes. */
|
||||
/** A guarded status transition: only fires from `from`, and only if `guard` passes.
|
||||
`decisions` replaces the prior server-computed flags — always fresh from the
|
||||
same response that carried the new status. */
|
||||
function transition(
|
||||
s: BriefState,
|
||||
from: BriefStatus['tag'],
|
||||
next: () => BriefStatus,
|
||||
decisions: BriefDecisions,
|
||||
guard: (b: Brief) => boolean = () => true,
|
||||
): BriefState {
|
||||
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
|
||||
return { ...s, brief: { ...s.brief, status: next() } };
|
||||
return { ...s, brief: { ...s.brief, status: next() }, decisions };
|
||||
}
|
||||
|
||||
@@ -107,3 +107,12 @@ export function unresolvedPlaceholders(brief: Brief): string[] {
|
||||
export function canSubmit(brief: Brief): boolean {
|
||||
return brief.sections.every((s) => !s.required || s.blocks.length > 0);
|
||||
}
|
||||
|
||||
/** Server-computed decision flags for the acting principal + this brief's live
|
||||
status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */
|
||||
export interface BriefDecisions {
|
||||
readonly canEdit: boolean;
|
||||
readonly canApprove: boolean;
|
||||
readonly canReject: boolean;
|
||||
readonly canSend: boolean;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ const view: BriefViewDto = {
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
|
||||
};
|
||||
|
||||
describe('brief.adapter parse boundary', () => {
|
||||
@@ -67,6 +68,19 @@ describe('brief.adapter parse boundary', () => {
|
||||
autoResolvable: true,
|
||||
fillable: false,
|
||||
});
|
||||
expect(r.value.decisions).toEqual({
|
||||
canEdit: false,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a view whose decisions are missing or malformed', () => {
|
||||
expect(parseBriefView({ ...view, decisions: undefined as never }).ok).toBe(false);
|
||||
expect(
|
||||
parseBriefView({ ...view, decisions: { ...view.decisions, canSend: 'yes' as never } }).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('narrows node variants and rejects unknown ones', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { runSubmit } from '@shared/application/submit';
|
||||
import {
|
||||
ApiClient,
|
||||
BriefDecisionsDto,
|
||||
BriefDto,
|
||||
BriefStatusDto,
|
||||
BriefViewDto,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from '@shared/infrastructure/api-client';
|
||||
import {
|
||||
Brief,
|
||||
BriefDecisions,
|
||||
BriefStatus,
|
||||
LetterBlock,
|
||||
LetterSection,
|
||||
@@ -35,6 +37,7 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
|
||||
export interface BriefView {
|
||||
readonly brief: Brief;
|
||||
readonly availablePassages: LibraryPassage[];
|
||||
readonly decisions: BriefDecisions;
|
||||
}
|
||||
|
||||
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
||||
@@ -49,32 +52,32 @@ export class BriefAdapter {
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
|
||||
async save(sections: readonly LetterSection[]): Promise<Result<string, Brief>> {
|
||||
async save(sections: readonly LetterSection[]): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(
|
||||
() => this.client.briefPUT({ sections: sections.map(sectionToDto) }),
|
||||
BRIEF_ACTION_FAILED,
|
||||
);
|
||||
return r.ok ? parseBrief(r.value) : r;
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
|
||||
async submit(): Promise<Result<string, Brief>> {
|
||||
async submit(): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.briefSubmit(), BRIEF_ACTION_FAILED);
|
||||
return r.ok ? parseBrief(r.value) : r;
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
|
||||
async approve(): Promise<Result<string, Brief>> {
|
||||
async approve(): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.approve(), BRIEF_ACTION_FAILED);
|
||||
return r.ok ? parseBrief(r.value) : r;
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
|
||||
async reject(comments: string): Promise<Result<string, Brief>> {
|
||||
async reject(comments: string): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.reject({ comments }), BRIEF_ACTION_FAILED);
|
||||
return r.ok ? parseBrief(r.value) : r;
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
|
||||
async send(): Promise<Result<string, Brief>> {
|
||||
async send(): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
|
||||
return r.ok ? parseBrief(r.value) : r;
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
|
||||
/** Demo "start over" — recreate a fresh brief server-side and return the new view. */
|
||||
@@ -281,17 +284,36 @@ export function parseBrief(dto: BriefDto): Result<string, Brief> {
|
||||
});
|
||||
}
|
||||
|
||||
function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, BriefDecisions> {
|
||||
if (
|
||||
typeof dto?.canEdit !== 'boolean' ||
|
||||
typeof dto.canApprove !== 'boolean' ||
|
||||
typeof dto.canReject !== 'boolean' ||
|
||||
typeof dto.canSend !== 'boolean'
|
||||
) {
|
||||
return err('brief-view: missing/invalid decisions');
|
||||
}
|
||||
return ok({
|
||||
canEdit: dto.canEdit,
|
||||
canApprove: dto.canApprove,
|
||||
canReject: dto.canReject,
|
||||
canSend: dto.canSend,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
|
||||
if (!dto.brief) return err('brief-view: missing brief');
|
||||
const brief = parseBrief(dto.brief);
|
||||
if (!brief.ok) return brief;
|
||||
const decisions = parseDecisions(dto.decisions);
|
||||
if (!decisions.ok) return decisions;
|
||||
const availablePassages: LibraryPassage[] = [];
|
||||
for (const p of dto.availablePassages ?? []) {
|
||||
const parsed = parsePassage(p);
|
||||
if (!parsed.ok) return parsed;
|
||||
availablePassages.push(parsed.value);
|
||||
}
|
||||
return ok({ brief: brief.value, availablePassages });
|
||||
return ok({ brief: brief.value, availablePassages, decisions: decisions.value });
|
||||
}
|
||||
|
||||
// --- toDto: domain → wire, for save (collapses the union to the flat shape) ---
|
||||
|
||||
@@ -58,8 +58,10 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
[brief]="brief()!"
|
||||
[availablePassages]="availablePassages()"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[editable]="store.editable()"
|
||||
[role]="store.role"
|
||||
[canEdit]="store.canEdit()"
|
||||
[canApprove]="store.canApprove()"
|
||||
[canReject]="store.canReject()"
|
||||
[canSend]="store.canSend()"
|
||||
[canSubmit]="store.canSubmit()"
|
||||
[busy]="store.busy()"
|
||||
(edit)="store.edit($event)"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { Role } from '@shared/domain/role';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
@@ -15,7 +14,9 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
|
||||
/** Organism: the whole letter — status badge, the editable sections (drafter) or a
|
||||
read-only preview (approver / locked), the diagnostics panel, and the action bar
|
||||
appropriate to status × role. Presentational: emits edit + transition intents. */
|
||||
appropriate to status × permission. Presentational: emits edit + transition
|
||||
intents; the canEdit/canApprove/canReject/canSend inputs are server-computed
|
||||
decision flags (PRD-0002 phase P1) — this component never derives them. */
|
||||
@Component({
|
||||
selector: 'app-letter-composer',
|
||||
imports: [
|
||||
@@ -67,7 +68,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
<app-rejection-comments mode="show" [comments]="rejectComments()" />
|
||||
}
|
||||
|
||||
@if (editable()) {
|
||||
@if (canEdit()) {
|
||||
<div class="sections">
|
||||
@for (section of brief().sections; track section.sectionKey) {
|
||||
<app-letter-section
|
||||
@@ -90,7 +91,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
<div class="bar">
|
||||
@switch (status()) {
|
||||
@case ('draft') {
|
||||
@if (editable()) {
|
||||
@if (canEdit()) {
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!canSubmit() || busy()"
|
||||
@@ -103,7 +104,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
}
|
||||
}
|
||||
@case ('rejected') {
|
||||
@if (editable()) {
|
||||
@if (canEdit()) {
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!canSubmit() || busy()"
|
||||
@@ -113,7 +114,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
}
|
||||
}
|
||||
@case ('submitted') {
|
||||
@if (role() === 'approver') {
|
||||
@if (canApprove() || canReject()) {
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
|
||||
approveLabel()
|
||||
}}</app-button>
|
||||
@@ -123,9 +124,11 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
}
|
||||
}
|
||||
@case ('approved') {
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{
|
||||
sendLabel()
|
||||
}}</app-button>
|
||||
@if (canSend()) {
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{
|
||||
sendLabel()
|
||||
}}</app-button>
|
||||
}
|
||||
}
|
||||
@case ('sent') {
|
||||
<app-alert type="ok">{{ sentText() }}</app-alert>
|
||||
@@ -138,8 +141,10 @@ export class LetterComposerComponent {
|
||||
brief = input.required<Brief>();
|
||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
editable = input(false);
|
||||
role = input.required<Role>();
|
||||
canEdit = input(false);
|
||||
canApprove = input(false);
|
||||
canReject = input(false);
|
||||
canSend = input(false);
|
||||
canSubmit = input(false);
|
||||
busy = input(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LetterComposerComponent } from './letter-composer.component';
|
||||
import { Brief, BriefStatus, LibraryPassage } from '@brief/domain/brief';
|
||||
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief';
|
||||
import { allDiagnostics } from '@brief/domain/brief';
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
@@ -103,18 +103,18 @@ function brief(status: BriefStatus): Brief {
|
||||
};
|
||||
}
|
||||
|
||||
const render = (b: Brief, editable: boolean, role: 'drafter' | 'approver') => ({
|
||||
const render = (b: Brief, decisions: BriefDecisions) => ({
|
||||
props: {
|
||||
brief: b,
|
||||
availablePassages: passages,
|
||||
diagnostics: allDiagnostics(b),
|
||||
editable,
|
||||
role,
|
||||
...decisions,
|
||||
canSubmit: true,
|
||||
busy: false,
|
||||
},
|
||||
template: `<app-letter-composer [brief]="brief" [availablePassages]="availablePassages" [diagnostics]="diagnostics"
|
||||
[editable]="editable" [role]="role" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
|
||||
[canEdit]="canEdit" [canApprove]="canApprove" [canReject]="canReject" [canSend]="canSend"
|
||||
[canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
|
||||
});
|
||||
|
||||
const meta: Meta<LetterComposerComponent> = {
|
||||
@@ -125,15 +125,22 @@ export default meta;
|
||||
type Story = StoryObj<LetterComposerComponent>;
|
||||
|
||||
export const DraftDrafter: Story = {
|
||||
render: () => render(brief({ tag: 'draft' }), true, 'drafter'),
|
||||
render: () =>
|
||||
render(brief({ tag: 'draft' }), {
|
||||
canEdit: true,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: false,
|
||||
}),
|
||||
};
|
||||
export const SubmittedApprover: Story = {
|
||||
render: () =>
|
||||
render(
|
||||
brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }),
|
||||
false,
|
||||
'approver',
|
||||
),
|
||||
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
|
||||
canEdit: false,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: false,
|
||||
}),
|
||||
};
|
||||
export const Rejected: Story = {
|
||||
render: () =>
|
||||
@@ -144,10 +151,15 @@ export const Rejected: Story = {
|
||||
rejectedAt: '2026-07-01',
|
||||
comments: 'Graag de aanhef formeler.',
|
||||
}),
|
||||
true,
|
||||
'drafter',
|
||||
{ canEdit: true, canApprove: false, canReject: false, canSend: false },
|
||||
),
|
||||
};
|
||||
export const Sent: Story = {
|
||||
render: () => render(brief({ tag: 'sent', sentAt: '2026-07-01' }), false, 'drafter'),
|
||||
render: () =>
|
||||
render(brief({ tag: 'sent', sentAt: '2026-07-01' }), {
|
||||
canEdit: false,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: false,
|
||||
}),
|
||||
};
|
||||
|
||||
36
src/app/shared/application/access.store.ts
Normal file
36
src/app/shared/application/access.store.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { MeAdapter, parseMe } from '@shared/infrastructure/me.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
/**
|
||||
* The current principal's capabilities (PRD-0002 §6) — one root singleton, like
|
||||
* `SessionStore`/`BigProfileStore`. Global capabilities load once from `GET /me`;
|
||||
* a screen's own decision DTO (e.g. `BriefViewDto.decisions`) covers anything tied
|
||||
* to a specific resource's live status — no extra round-trip needed for that.
|
||||
*
|
||||
* `can()` is deny-by-default: loading, failed, or an unrecognized capability all
|
||||
* resolve to `false`. This store never derives a capability from a role — it only
|
||||
* mirrors what the server already resolved.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AccessStore {
|
||||
private adapter = inject(MeAdapter);
|
||||
private meRes = this.adapter.meResource();
|
||||
|
||||
private capabilities = computed<RemoteData<Err, Capability[]>>(() => {
|
||||
const rd = fromResource(this.meRes);
|
||||
if (rd.tag !== 'Success') return rd;
|
||||
const parsed = parseMe(rd.value);
|
||||
return parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) };
|
||||
});
|
||||
|
||||
can(capability: Capability): boolean {
|
||||
const rd = this.capabilities();
|
||||
return rd.tag === 'Success' && rd.value.includes(capability);
|
||||
}
|
||||
}
|
||||
5
src/app/shared/domain/capability.ts
Normal file
5
src/app/shared/domain/capability.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* A stable, namespaced capability string (PRD-0002 §5a), e.g. `brief:approve`.
|
||||
* Server-resolved and opaque to the FE — never derived from a role client-side.
|
||||
*/
|
||||
export type Capability = 'brief:approve' | 'brief:reject' | 'brief:send';
|
||||
@@ -930,6 +930,42 @@ export class ApiClient {
|
||||
return Promise.resolve<SubmitApplicationResponse>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
me(): Promise<MeDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/me";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processMe(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processMe(response: Response): Promise<MeDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as MeDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<MeDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
@@ -969,7 +1005,7 @@ export class ApiClient {
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
briefPUT(body: SaveBriefRequest): Promise<BriefDto> {
|
||||
briefPUT(body: SaveBriefRequest): Promise<BriefViewDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/brief";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
@@ -989,13 +1025,13 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
protected processBriefPUT(response: Response): Promise<BriefDto> {
|
||||
protected processBriefPUT(response: Response): Promise<BriefViewDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 403) {
|
||||
@@ -1015,13 +1051,13 @@ export class ApiClient {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<BriefDto>(null as any);
|
||||
return Promise.resolve<BriefViewDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
briefSubmit(): Promise<BriefDto> {
|
||||
briefSubmit(): Promise<BriefViewDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/brief/submit";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
@@ -1037,13 +1073,13 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
protected processBriefSubmit(response: Response): Promise<BriefDto> {
|
||||
protected processBriefSubmit(response: Response): Promise<BriefViewDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 403) {
|
||||
@@ -1063,13 +1099,13 @@ export class ApiClient {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<BriefDto>(null as any);
|
||||
return Promise.resolve<BriefViewDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
approve(): Promise<BriefDto> {
|
||||
approve(): Promise<BriefViewDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/brief/approve";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
@@ -1085,13 +1121,13 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
protected processApprove(response: Response): Promise<BriefDto> {
|
||||
protected processApprove(response: Response): Promise<BriefViewDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 403) {
|
||||
@@ -1111,13 +1147,13 @@ export class ApiClient {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<BriefDto>(null as any);
|
||||
return Promise.resolve<BriefViewDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
reject(body: RejectBriefRequest): Promise<BriefDto> {
|
||||
reject(body: RejectBriefRequest): Promise<BriefViewDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/brief/reject";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
@@ -1137,13 +1173,13 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
protected processReject(response: Response): Promise<BriefDto> {
|
||||
protected processReject(response: Response): Promise<BriefViewDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 403) {
|
||||
@@ -1163,13 +1199,13 @@ export class ApiClient {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<BriefDto>(null as any);
|
||||
return Promise.resolve<BriefViewDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
send(): Promise<BriefDto> {
|
||||
send(): Promise<BriefViewDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/brief/send";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
@@ -1185,13 +1221,13 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
protected processSend(response: Response): Promise<BriefDto> {
|
||||
protected processSend(response: Response): Promise<BriefViewDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 409) {
|
||||
@@ -1205,7 +1241,7 @@ export class ApiClient {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<BriefDto>(null as any);
|
||||
return Promise.resolve<BriefViewDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1287,6 +1323,13 @@ export interface ApplicationSummaryDto {
|
||||
submittedAt?: string | undefined;
|
||||
}
|
||||
|
||||
export interface BriefDecisionsDto {
|
||||
canEdit?: boolean;
|
||||
canApprove?: boolean;
|
||||
canReject?: boolean;
|
||||
canSend?: boolean;
|
||||
}
|
||||
|
||||
export interface BriefDto {
|
||||
briefId?: string | undefined;
|
||||
beroep?: string | undefined;
|
||||
@@ -1312,6 +1355,7 @@ export interface BriefStatusDto {
|
||||
export interface BriefViewDto {
|
||||
brief?: BriefDto;
|
||||
availablePassages?: LibraryPassageDto[] | undefined;
|
||||
decisions?: BriefDecisionsDto;
|
||||
}
|
||||
|
||||
export interface BrpAddressDto {
|
||||
@@ -1423,6 +1467,10 @@ export interface ManualDiplomaPolicyDto {
|
||||
policyQuestions?: PolicyQuestionDto[] | undefined;
|
||||
}
|
||||
|
||||
export interface MeDto {
|
||||
capabilities?: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface ParagraphDto {
|
||||
nodes?: RichTextNodeDto[] | undefined;
|
||||
list?: string | undefined;
|
||||
|
||||
24
src/app/shared/infrastructure/me.adapter.spec.ts
Normal file
24
src/app/shared/infrastructure/me.adapter.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMe } from './me.adapter';
|
||||
|
||||
describe('parseMe (trust boundary)', () => {
|
||||
it('parses a known capability list', () => {
|
||||
const r = parseMe({ capabilities: ['brief:approve', 'brief:reject', 'brief:send'] });
|
||||
expect(r).toEqual({ ok: true, value: ['brief:approve', 'brief:reject', 'brief:send'] });
|
||||
});
|
||||
|
||||
it('parses an empty list (drafter — no capabilities)', () => {
|
||||
expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] });
|
||||
});
|
||||
|
||||
it('drops unrecognized capability strings instead of rejecting the response', () => {
|
||||
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
|
||||
expect(r).toEqual({ ok: true, value: ['brief:approve'] });
|
||||
});
|
||||
|
||||
it('rejects malformed responses instead of trusting them', () => {
|
||||
expect(parseMe(null).ok).toBe(false);
|
||||
expect(parseMe({}).ok).toBe(false);
|
||||
expect(parseMe({ capabilities: 'brief:approve' }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
33
src/app/shared/infrastructure/me.adapter.ts
Normal file
33
src/app/shared/infrastructure/me.adapter.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable, inject, resource } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
|
||||
const KNOWN: readonly Capability[] = ['brief:approve', 'brief:reject', 'brief:send'];
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for `GET /me` (PRD-0002 §6): the current principal's
|
||||
* coarse, role-derived capabilities — nav/menu-level, not tied to any one screen's
|
||||
* live status (contrast a screen's own decision DTO, e.g. `BriefViewDto.decisions`).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MeAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
meResource() {
|
||||
return resource({ loader: () => this.client.me() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust-boundary parse. An unrecognized capability string is dropped rather than
|
||||
* rejecting the whole response — deny-by-default already covers it (AccessStore.can
|
||||
* returns false for anything not in the set), and it lets the backend grow the
|
||||
* capability list without breaking an older FE build.
|
||||
*/
|
||||
export function parseMe(json: unknown): Result<string, Capability[]> {
|
||||
if (typeof json !== 'object' || json === null) return err('me: not an object');
|
||||
const dto = json as { capabilities?: unknown };
|
||||
if (!Array.isArray(dto.capabilities)) return err('me: missing/invalid capabilities');
|
||||
return ok(dto.capabilities.filter((c): c is Capability => KNOWN.includes(c as Capability)));
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import { Role } from '@shared/domain/role';
|
||||
* POC has one faked self-service user and no real identities, so the two-person
|
||||
* letter workflow (drafter vs approver) is driven by a `?role=` query param —
|
||||
* exactly the pattern of the `?scenario=` toggle. The backend receives it as an
|
||||
* `X-Role` header (see role.interceptor) and enforces the approver≠drafter rule;
|
||||
* the FE derives `editable` from it.
|
||||
* `X-Role` header (see role.interceptor), resolves it into a `Principal`
|
||||
* server-side, and is the sole authority on what that principal may do (PRD-0002
|
||||
* phase P1, `Authz.Can`) — the FE only renders the resulting decision flags, it no
|
||||
* longer derives permission from this value itself.
|
||||
*/
|
||||
export function currentRole(): Role {
|
||||
return new URLSearchParams(window.location.search).get('role') === 'approver'
|
||||
|
||||
Reference in New Issue
Block a user