353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
import { Plus, Trash2 } from "lucide-react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Button,
|
|
ConfirmDialog,
|
|
Dialog,
|
|
DocumentationHelpLink,
|
|
DismissibleAlert,
|
|
FormField,
|
|
IconButton,
|
|
ReferenceSelect,
|
|
ToggleSwitch,
|
|
i18nMessage,
|
|
useUnsavedChanges,
|
|
useUnsavedDraftGuard,
|
|
type ApiSettings
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
caseShareTargetProvider,
|
|
updateCase,
|
|
type CaseGrant,
|
|
type CaseRecord
|
|
} from "../../api/cases";
|
|
import {
|
|
CASES_FIELDS_DOCUMENTATION,
|
|
CASES_I18N
|
|
} from "./interfacePatterns";
|
|
|
|
|
|
type TargetType = "user" | "group";
|
|
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;
|
|
}) {
|
|
const [restricted, setRestricted] = useState(record.access_mode === "restricted");
|
|
const [grants, setGrants] = useState<CaseGrant[]>(record.access_grants);
|
|
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("");
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
const idempotencyKey = useRef(crypto.randomUUID());
|
|
const { requestDiscard } = useUnsavedChanges();
|
|
const targetProvider = useMemo(
|
|
() => caseShareTargetProvider(settings, record.reference.object_id, targetType, purpose),
|
|
[purpose, record.reference.object_id, settings, targetType]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setRestricted(record.access_mode === "restricted");
|
|
setGrants(record.access_grants);
|
|
setTargetType("user");
|
|
setTargetId("");
|
|
setPermission("read");
|
|
setAllowedPurposes(purpose);
|
|
setChangeReason("");
|
|
setError("");
|
|
setConfirmOpen(false);
|
|
idempotencyKey.current = crypto.randomUUID();
|
|
}, [open, purpose, record]);
|
|
|
|
const changed = restricted !== (record.access_mode === "restricted")
|
|
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
|
|
const draftDirty = changed || Boolean(targetId.trim() || changeReason.trim());
|
|
|
|
function discardDraft() {
|
|
setRestricted(record.access_mode === "restricted");
|
|
setGrants(record.access_grants);
|
|
setTargetType("user");
|
|
setTargetId("");
|
|
setPermission("read");
|
|
setAllowedPurposes(purpose);
|
|
setChangeReason("");
|
|
setError("");
|
|
}
|
|
|
|
function addGrant() {
|
|
const subjectId = targetId.trim();
|
|
const purposes = splitPurposes(allowedPurposes);
|
|
if (!subjectId || purposes.length === 0) return;
|
|
const subjectKind = targetType === "user" ? "account" : "group";
|
|
setGrants((current) => [
|
|
...current.filter(
|
|
(item) => !(item.subject_kind === subjectKind && item.subject_id === subjectId)
|
|
),
|
|
{
|
|
subject_kind: subjectKind,
|
|
subject_id: subjectId,
|
|
permissions: [permission],
|
|
allowed_purposes: purposes
|
|
}
|
|
]);
|
|
setTargetId("");
|
|
}
|
|
|
|
async function save(): Promise<boolean> {
|
|
if (!changed || !changeReason.trim()) return false;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const saved = await updateCase(settings, record.reference.object_id, {
|
|
expected_revision: record.revision,
|
|
recorded_at: new Date().toISOString(),
|
|
change_reason: changeReason.trim(),
|
|
idempotency_key: idempotencyKey.current,
|
|
purpose,
|
|
access_mode: restricted ? "restricted" : "tenant",
|
|
access_grants: grants
|
|
});
|
|
onSaved(saved);
|
|
setRestricted(saved.access_mode === "restricted");
|
|
setGrants(saved.access_grants);
|
|
setTargetId("");
|
|
setChangeReason("");
|
|
idempotencyKey.current = crypto.randomUUID();
|
|
return true;
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : "Case access could not be saved.");
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
useUnsavedDraftGuard({
|
|
dirty: open && draftDirty,
|
|
title: "i18n:govoplan-cases.unsaved_access_title",
|
|
message: "i18n:govoplan-cases.unsaved_access_message",
|
|
onSave: save,
|
|
onDiscard: discardDraft
|
|
});
|
|
|
|
function close() {
|
|
if (busy) return;
|
|
if (draftDirty) requestDiscard(onClose);
|
|
else onClose();
|
|
}
|
|
|
|
async function confirmSave() {
|
|
const saved = await save();
|
|
if (!saved) return;
|
|
setConfirmOpen(false);
|
|
onClose();
|
|
}
|
|
|
|
const saveDisabledReason = busy
|
|
? CASES_I18N.saving
|
|
: !changed
|
|
? CASES_I18N.noChanges
|
|
: !changeReason.trim()
|
|
? CASES_I18N.incomplete
|
|
: undefined;
|
|
|
|
return (
|
|
<>
|
|
<Dialog
|
|
open={open}
|
|
title={i18nMessage("i18n:govoplan-cases.case_access_title", { value0: record.case_number })}
|
|
onClose={close}
|
|
closeDisabled={busy}
|
|
portal
|
|
className="case-share-dialog"
|
|
helpContextId="cases.detail.access"
|
|
footer={
|
|
<>
|
|
<Button disabled={busy} onClick={close}>Cancel</Button>
|
|
<Button
|
|
variant="primary"
|
|
disabledReason={saveDisabledReason}
|
|
onClick={() => setConfirmOpen(true)}
|
|
helpContextId="cases.detail.access"
|
|
>
|
|
{busy ? "Saving" : "Save access"}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="case-share-content">
|
|
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
|
|
{error ? (
|
|
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
|
|
) : null}
|
|
<ToggleSwitch
|
|
label="Case visibility"
|
|
inactiveLabel="Tenant"
|
|
activeLabel="Restricted"
|
|
checked={restricted}
|
|
disabled={busy}
|
|
onChange={setRestricted}
|
|
/>
|
|
<p className="case-share-explanation">
|
|
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">
|
|
<FormField label="Target type" documentation={CASES_FIELDS_DOCUMENTATION}>
|
|
<select
|
|
value={targetType}
|
|
disabled={busy}
|
|
onChange={(event) => {
|
|
setTargetType(event.target.value as TargetType);
|
|
setTargetId("");
|
|
}}
|
|
>
|
|
<option value="user">User</option>
|
|
<option value="group">Group</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Target" documentation={CASES_FIELDS_DOCUMENTATION}>
|
|
<ReferenceSelect
|
|
value={targetId}
|
|
onChange={setTargetId}
|
|
provider={targetProvider}
|
|
disabled={busy}
|
|
placeholder={`Select a ${targetType}`}
|
|
aria-label={`Case access ${targetType}`}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Permission" documentation={CASES_FIELDS_DOCUMENTATION}>
|
|
<select
|
|
value={permission}
|
|
disabled={busy}
|
|
onChange={(event) => setPermission(event.target.value as Permission)}
|
|
>
|
|
<option value="read">Read</option>
|
|
<option value="update">Update</option>
|
|
<option value="share">Share</option>
|
|
<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 : splitPurposes(allowedPurposes).length === 0 ? "Declare at least one allowed purpose." : undefined}
|
|
onClick={addGrant}
|
|
/>
|
|
</div>
|
|
|
|
<div className="case-share-grants" aria-label="Explicit access grants">
|
|
{grants.length === 0 ? (
|
|
<p>No explicit access grants.</p>
|
|
) : grants.map((grant) => (
|
|
<div
|
|
key={`${grant.subject_kind}:${grant.subject_id}`}
|
|
className="case-share-grant"
|
|
>
|
|
<span>
|
|
<strong>{humanize(grant.subject_kind)}</strong>
|
|
{grant.subject_id}
|
|
</span>
|
|
<select
|
|
aria-label={`Permission for ${grant.subject_id}`}
|
|
value={grant.permissions[0] ?? "read"}
|
|
disabled={busy}
|
|
onChange={(event) => setGrants((current) => current.map((item) =>
|
|
item === grant
|
|
? { ...item, permissions: [event.target.value as Permission] }
|
|
: item
|
|
))}
|
|
>
|
|
<option value="read">Read</option>
|
|
<option value="update">Update</option>
|
|
<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>
|
|
))}
|
|
</div>
|
|
|
|
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
|
|
<input
|
|
value={changeReason}
|
|
disabled={busy}
|
|
maxLength={1000}
|
|
onChange={(event) => setChangeReason(event.target.value)}
|
|
placeholder="Why is case access changing?"
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</Dialog>
|
|
<ConfirmDialog
|
|
open={confirmOpen}
|
|
title="i18n:govoplan-cases.access_confirm_title"
|
|
message={i18nMessage("i18n:govoplan-cases.access_confirm_message", {
|
|
value0: record.case_number,
|
|
value1: restricted
|
|
? "i18n:govoplan-cases.visibility_restricted"
|
|
: "i18n:govoplan-cases.visibility_tenant",
|
|
value2: grants.length
|
|
})}
|
|
confirmLabel="Save access"
|
|
busy={busy}
|
|
onCancel={() => setConfirmOpen(false)}
|
|
onConfirm={() => void confirmSave()}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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)));
|
|
}
|