feat(cases): enforce purpose-bound case governance
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-24 20:05:31 +02:00
parent 9435116299
commit 17415d8831
28 changed files with 786 additions and 142 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/cases-webui",
"version": "0.1.22",
"version": "0.1.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+22 -7
View File
@@ -59,11 +59,14 @@ export type CaseRecord = {
};
export type CaseGrant = {
subject_kind: "account" | "identity" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account";
subject_kind: "account" | "identity" | "membership" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account" | "delegation";
subject_id: string;
permissions: Array<"read" | "update" | "share" | "admin">;
allowed_purposes: string[];
};
export const DEFAULT_CASE_ACCESS_PURPOSE = "cases.casework";
export type CaseStatusDefinition = {
status_key: string;
label: string;
@@ -101,6 +104,7 @@ export type CaseTimelineEntry = {
event_type: string;
case_revision: number;
summary: string;
purpose: string;
actor_id?: string | null;
occurred_at: string;
audit_event_id?: string | null;
@@ -140,6 +144,7 @@ export function listCases(
query?: string;
statuses?: string[];
caseTypes?: string[];
purpose?: string;
offset?: number;
limit?: number;
},
@@ -149,6 +154,7 @@ export function listCases(
query: options.query,
status_key: options.statuses,
case_type_key: options.caseTypes,
purpose: options.purpose,
offset: options.offset,
limit: options.limit
}), { signal });
@@ -157,9 +163,10 @@ export function listCases(
export function getCase(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, { signal });
return apiFetch<CaseRecord>(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}`, { purpose }), { signal });
}
export function listCaseCatalog(
@@ -172,27 +179,30 @@ export function listCaseCatalog(
export function caseHistory(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): Promise<{ revisions: CaseRecord[] }> {
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/history`, { signal });
return apiFetch(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/history`, { purpose }), { signal });
}
export function caseTimeline(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): Promise<{ entries: CaseTimelineEntry[] }> {
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal });
return apiFetch(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { purpose }), { signal });
}
export function listCaseDecisions(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): Promise<{ decisions: FormalDecision[] }> {
return apiFetch(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`, { purpose }),
{ signal }
);
}
@@ -209,6 +219,7 @@ export function recordCaseDecision(
conditions: string[];
change_reason: string;
idempotency_key: string;
purpose: string;
}
): Promise<CaseDecisionResult> {
return apiFetch(
@@ -229,6 +240,7 @@ export function updateCase(
recorded_at: string;
change_reason: string;
idempotency_key: string;
purpose: string;
title?: string;
status_key?: string;
access_mode?: "tenant" | "restricted";
@@ -249,6 +261,7 @@ export function linkCaseEvidence(
recorded_at: string;
change_reason: string;
idempotency_key: string;
purpose: string;
reference: {
owner_module: string;
kind: string;
@@ -275,6 +288,7 @@ export function unlinkCaseEvidence(
recorded_at: string;
change_reason: string;
idempotency_key: string;
purpose: string;
}
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(
@@ -287,11 +301,12 @@ export function unlinkCaseEvidence(
export function caseShareTargetProvider(
settings: ApiSettings,
caseId: string,
targetType: "user" | "group"
targetType: "user" | "group",
purpose: string
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/share-target-options`,
{ target_type: targetType }
{ target_type: targetType, purpose }
);
}
@@ -21,12 +21,14 @@ import { CASES_FIELDS_DOCUMENTATION } from "./interfacePatterns";
export default function CaseDecisionDialog({
settings,
record,
purpose,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
purpose: string;
open: boolean;
onClose: () => void;
onSaved: (result: CaseDecisionResult) => void;
@@ -82,7 +84,8 @@ export default function CaseDecisionDialog({
reasoning: reasoning.trim(),
conditions: conditions.split("\n").map((item) => item.trim()).filter(Boolean),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current
idempotency_key: idempotencyKey.current,
purpose
});
setConfirmOpen(false);
onSaved(result);
+23 -11
View File
@@ -1,6 +1,6 @@
import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router";
import { useParams, useSearchParams } from "react-router";
import { ActionToolbar,
ActionBlockerHint,
Button,
@@ -22,6 +22,7 @@ import { ActionToolbar,
type QuickAccessResult
} from "@govoplan/core-webui";
import {
DEFAULT_CASE_ACCESS_PURPOSE,
caseHistory,
caseTimeline,
getCase,
@@ -48,6 +49,8 @@ import {
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
const { caseId = "" } = useParams();
const [searchParams] = useSearchParams();
const accessPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
const navigate = useGuardedNavigate();
const [record, setRecord] = useState<CaseRecord | null>(null);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
@@ -92,20 +95,20 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
tenantId: record.reference.tenant_id,
label: `${record.case_number} · ${record.title}`,
version: record.reference.version ?? String(record.revision),
path: `/cases/${encodeURIComponent(record.reference.object_id)}`
} : null, [record]);
path: `/cases/${encodeURIComponent(record.reference.object_id)}?purpose=${encodeURIComponent(accessPurpose)}`
} : null, [accessPurpose, record]);
useRegisterActiveObject(activeCase);
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
setError("");
return Promise.all([
getCase(settings, caseId, signal),
getCase(settings, caseId, accessPurpose, signal),
listCaseCatalog(settings, signal),
caseHistory(settings, caseId, signal),
caseTimeline(settings, caseId, signal),
caseHistory(settings, caseId, accessPurpose, signal),
caseTimeline(settings, caseId, accessPurpose, signal),
canReadDecisions
? listCaseDecisions(settings, caseId, signal)
? listCaseDecisions(settings, caseId, accessPurpose, signal)
: Promise.resolve({ decisions: [] as FormalDecision[] })
]).
then(([nextRecord, nextCatalog, nextHistory, nextTimeline, nextDecisions]) => {
@@ -119,7 +122,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
setChangeReason("");
}).
finally(() => setLoading(false));
}, [canReadDecisions, caseId, settings]);
}, [accessPurpose, canReadDecisions, caseId, settings]);
useEffect(() => {
const controller = new AbortController();
@@ -179,6 +182,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
recorded_at: new Date().toISOString(),
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
idempotency_key: crypto.randomUUID(),
purpose: accessPurpose,
reference: {
owner_module: reference.ownerModule,
kind: reference.kind,
@@ -203,7 +207,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
QUICK_ACCESS_RESULT_EVENT,
receiveReference
);
}, [canUpdate, caseId, draftDirty, load, record, settings]);
}, [accessPurpose, canUpdate, caseId, draftDirty, load, record, settings]);
function discardDraft() {
if (!record) return;
@@ -222,6 +226,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current,
purpose: accessPurpose,
...(title.trim() !== record.title ? { title: title.trim() } : {}),
...(status !== record.status_key ? { status_key: status } : {})
});
@@ -260,7 +265,8 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
idempotency_key: crypto.randomUUID()
idempotency_key: crypto.randomUUID(),
purpose: accessPurpose
}
);
setRecord(saved);
@@ -296,7 +302,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<main className="cases-page">
<div className="case-detail-shell">
<ActionToolbar className="case-detail-toolbar">
<button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}>
<button type="button" className="btn btn-ghost" onClick={() => navigate(`/cases?purpose=${encodeURIComponent(accessPurpose)}`)}>
<ArrowLeft size={16} aria-hidden="true" />
Cases
</button>
@@ -325,6 +331,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
icon={<Share2 size={16} />}
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
onClick={() => setShareOpen(true)}
helpContextId="cases.detail.access"
/>
</div>
) : null}
@@ -404,6 +411,8 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<Fact label="Opened" value={formatDateTime(record.opened_at)} />
<Fact label="Deadline" value={formatDateTime(record.deadline_at)} />
<Fact label="Revision" value={String(record.revision)} />
<Fact label="Access purpose" value={accessPurpose} />
<Fact label="Access mode" value={humanize(record.access_mode)} />
<Fact label="Last change" value={record.change_reason} />
</div>
@@ -444,6 +453,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<li key={entry.event_id}>
<strong>{humanize(entry.event_type)}</strong>
<span>{entry.summary}</span>
<span>{entry.purpose}</span>
<time>{formatDateTime(entry.occurred_at)}</time>
</li>
)}
@@ -470,6 +480,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<CaseShareDialog
settings={settings}
record={record}
purpose={accessPurpose}
open={shareOpen}
onClose={() => setShareOpen(false)}
onSaved={(saved) => {
@@ -484,6 +495,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<CaseDecisionDialog
settings={settings}
record={record}
purpose={accessPurpose}
open={decisionOpen}
onClose={() => setDecisionOpen(false)}
onSaved={(result) => {
+42 -9
View File
@@ -33,12 +33,14 @@ type Permission = CaseGrant["permissions"][number];
export default function CaseShareDialog({
settings,
record,
purpose,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
purpose: string;
open: boolean;
onClose: () => void;
onSaved: (record: CaseRecord) => void;
@@ -48,6 +50,7 @@ export default function CaseShareDialog({
const [targetType, setTargetType] = useState<TargetType>("user");
const [targetId, setTargetId] = useState("");
const [permission, setPermission] = useState<Permission>("read");
const [allowedPurposes, setAllowedPurposes] = useState(purpose);
const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -55,8 +58,8 @@ export default function CaseShareDialog({
const idempotencyKey = useRef(crypto.randomUUID());
const { requestDiscard } = useUnsavedChanges();
const targetProvider = useMemo(
() => caseShareTargetProvider(settings, record.reference.object_id, targetType),
[record.reference.object_id, settings, targetType]
() => caseShareTargetProvider(settings, record.reference.object_id, targetType, purpose),
[purpose, record.reference.object_id, settings, targetType]
);
useEffect(() => {
@@ -66,11 +69,12 @@ export default function CaseShareDialog({
setTargetType("user");
setTargetId("");
setPermission("read");
setAllowedPurposes(purpose);
setChangeReason("");
setError("");
setConfirmOpen(false);
idempotencyKey.current = crypto.randomUUID();
}, [open, record]);
}, [open, purpose, record]);
const changed = restricted !== (record.access_mode === "restricted")
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
@@ -82,13 +86,15 @@ export default function CaseShareDialog({
setTargetType("user");
setTargetId("");
setPermission("read");
setAllowedPurposes(purpose);
setChangeReason("");
setError("");
}
function addGrant() {
const subjectId = targetId.trim();
if (!subjectId) return;
const purposes = splitPurposes(allowedPurposes);
if (!subjectId || purposes.length === 0) return;
const subjectKind = targetType === "user" ? "account" : "group";
setGrants((current) => [
...current.filter(
@@ -97,7 +103,8 @@ export default function CaseShareDialog({
{
subject_kind: subjectKind,
subject_id: subjectId,
permissions: [permission]
permissions: [permission],
allowed_purposes: purposes
}
]);
setTargetId("");
@@ -113,6 +120,7 @@ export default function CaseShareDialog({
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current,
purpose,
access_mode: restricted ? "restricted" : "tenant",
access_grants: grants
});
@@ -169,6 +177,7 @@ export default function CaseShareDialog({
closeDisabled={busy}
portal
className="case-share-dialog"
helpContextId="cases.detail.access"
footer={
<>
<Button disabled={busy} onClick={close}>Cancel</Button>
@@ -176,6 +185,7 @@ export default function CaseShareDialog({
variant="primary"
disabledReason={saveDisabledReason}
onClick={() => setConfirmOpen(true)}
helpContextId="cases.detail.access"
>
{busy ? "Saving" : "Save access"}
</Button>
@@ -196,9 +206,9 @@ export default function CaseShareDialog({
onChange={setRestricted}
/>
<p className="case-share-explanation">
Tenant cases follow the Cases read permission. Restricted cases are visible only to
their creator, case administrators, assigned functions or units, and the explicit
grants below.
Tenant cases follow the Cases read permission. Restricted cases require a current
grant whose permission and exact allowed purpose both match the attempted action.
The creating account receives a purpose-bound custodian grant.
</p>
<div className="case-share-add-row">
@@ -237,11 +247,19 @@ export default function CaseShareDialog({
<option value="admin">Administer</option>
</select>
</FormField>
<FormField label="Allowed purposes" helpContextId="cases.field.allowed-purposes" documentation={CASES_FIELDS_DOCUMENTATION}>
<input
value={allowedPurposes}
disabled={busy}
onChange={(event) => setAllowedPurposes(event.target.value)}
placeholder="cases.casework, cases.search"
/>
</FormField>
<IconButton
label="Add access grant"
icon={<Plus size={16} />}
variant="primary"
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : undefined}
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : splitPurposes(allowedPurposes).length === 0 ? "Declare at least one allowed purpose." : undefined}
onClick={addGrant}
/>
</div>
@@ -273,11 +291,22 @@ export default function CaseShareDialog({
<option value="share">Share</option>
<option value="admin">Administer</option>
</select>
<input
aria-label={`Allowed purposes for ${grant.subject_id}`}
value={grant.allowed_purposes.join(", ")}
disabled={busy}
onChange={(event) => setGrants((current) => current.map((item) =>
item === grant
? { ...item, allowed_purposes: splitPurposes(event.target.value) }
: item
))}
/>
<IconButton
label={`Remove access for ${grant.subject_id}`}
icon={<Trash2 size={16} />}
variant="danger"
disabled={busy}
helpContextId="cases.detail.access"
onClick={() => setGrants((current) => current.filter((item) => item !== grant))}
/>
</div>
@@ -317,3 +346,7 @@ export default function CaseShareDialog({
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function splitPurposes(value: string): string[] {
return Array.from(new Set(value.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean)));
}
+18 -2
View File
@@ -1,5 +1,6 @@
import { Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router";
import { Button,
DocumentationHelpLink,
DismissibleAlert,
@@ -18,6 +19,7 @@ import { Button,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
DEFAULT_CASE_ACCESS_PURPOSE,
listCaseCatalog,
listCases,
type CaseCatalog,
@@ -28,8 +30,12 @@ import { CASES_DOCUMENTATION, CASES_I18N } from "./interfacePatterns";
export default function CasesPage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const [searchParams] = useSearchParams();
const initialPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [purpose, setPurpose] = useState(initialPurpose);
const [submittedPurpose, setSubmittedPurpose] = useState(initialPurpose);
const [status, setStatus] = useState("");
const [cases, setCases] = useState<CaseRecord[]>([]);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
@@ -46,6 +52,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
listCases(settings, {
query: submittedQuery,
statuses: status ? [status] : undefined,
purpose: submittedPurpose,
limit: 200
}, controller.signal),
listCaseCatalog(settings, controller.signal)
@@ -62,7 +69,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [reloadKey, settings, status, submittedQuery]);
}, [reloadKey, settings, status, submittedPurpose, submittedQuery]);
const statusLabels = useMemo(
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
@@ -76,6 +83,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
function submit(event: FormEvent) {
event.preventDefault();
setSubmittedQuery(query.trim());
setSubmittedPurpose(purpose.trim());
}
return (
@@ -96,6 +104,14 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
aria-label="Search cases"
placeholder="Search cases"
/>
<input
value={purpose}
onChange={(event) => setPurpose(event.target.value)}
aria-label="Case access purpose"
placeholder="Case access purpose"
data-help-context-id="cases.field.access-purpose"
required
/>
<Button
type="submit"
variant="primary"
@@ -135,7 +151,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
<SelectionListItem
selected={false}
key={item.reference.object_id}
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}`)}>
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}?purpose=${encodeURIComponent(submittedPurpose)}`)}>
<SelectionListItemContent title={item.title} description={`${item.case_number} · ${typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)} · ${formatDate(item.deadline_at)}`} />
<StatusBadge
status={statusDefinition?.terminal ? "inactive" : "active"}
+12
View File
@@ -81,6 +81,12 @@ const en = {
"Permission": "Permission",
"Save access": "Save access",
"No explicit access grants.": "No explicit access grants.",
"Case access purpose": "Case access purpose",
"Access purpose": "Access purpose",
"Access mode": "Access mode",
"Allowed purposes": "Allowed purposes",
"Declare at least one allowed purpose.": "Declare at least one allowed purpose.",
"Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.": "Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.",
"Record formal decision": "Record formal decision",
"File Case in eAkte": "File Case in eAkte",
"File Decision in eAkte": "File Decision in eAkte",
@@ -184,6 +190,12 @@ const de: Record<keyof typeof en, string> = {
"Permission": "Berechtigung",
"Save access": "Zugriff speichern",
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben.",
"Case access purpose": "Zugriffszweck für Vorgänge",
"Access purpose": "Zugriffszweck",
"Access mode": "Zugriffsmodus",
"Allowed purposes": "Erlaubte Zwecke",
"Declare at least one allowed purpose.": "Geben Sie mindestens einen erlaubten Zweck an.",
"Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.": "Mandantenweit sichtbare Vorgänge folgen der Cases-Leseberechtigung. Eingeschränkte Vorgänge verlangen eine aktuelle Freigabe, deren Berechtigung und exakter erlaubter Zweck beide zur Aktion passen. Das anlegende Konto erhält eine zweckgebundene Custodian-Freigabe.",
"Record formal decision": "Formelle Entscheidung erfassen",
"File Case in eAkte": "Vorgang in eAkte verakten",
"File Decision in eAkte": "Entscheidung in eAkte verakten",