feat(cases): enforce purpose-bound case governance
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user