feat(records): enforce purpose-bound restricted access
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/records-webui",
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -165,6 +165,21 @@ export type RecordTransferPackage = {
|
||||
recorded_at: string;
|
||||
};
|
||||
|
||||
export type RecordAccessGrant = {
|
||||
grant_id: string;
|
||||
record_id: string;
|
||||
revision: number;
|
||||
status: "active" | "revoked" | string;
|
||||
subject_type: "account" | "membership" | "group" | "role" | "function_assignment" | "delegation";
|
||||
subject_id: string;
|
||||
actions: Array<"read" | "write" | "manage">;
|
||||
allowed_purposes: string[];
|
||||
reason: string;
|
||||
valid_from: string;
|
||||
valid_to?: string | null;
|
||||
recorded_at: string;
|
||||
};
|
||||
|
||||
export type RecordDetail = {
|
||||
record: RecordEntry;
|
||||
volumes: RecordVolume[];
|
||||
@@ -179,6 +194,8 @@ export type RecordDetail = {
|
||||
purpose: string;
|
||||
current_authorization: boolean;
|
||||
access_mode: string;
|
||||
grant_id?: string | null;
|
||||
grant_subject_type?: string | null;
|
||||
limitations: string[];
|
||||
};
|
||||
};
|
||||
@@ -209,6 +226,7 @@ export function listRecords(
|
||||
state?: string;
|
||||
classId?: string;
|
||||
filePlanNodeId?: string;
|
||||
purpose?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
},
|
||||
@@ -219,13 +237,19 @@ export function listRecords(
|
||||
state: options.state,
|
||||
class_id: options.classId,
|
||||
file_plan_node_id: options.filePlanNodeId,
|
||||
purpose: options.purpose,
|
||||
offset: options.offset,
|
||||
limit: options.limit ?? 50
|
||||
}), { signal });
|
||||
}
|
||||
|
||||
export function getRecord(settings: ApiSettings, recordId: string, signal?: AbortSignal): Promise<RecordDetail> {
|
||||
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}`, { signal });
|
||||
export function getRecord(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
purpose?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<RecordDetail> {
|
||||
return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}`, { purpose }), { signal });
|
||||
}
|
||||
|
||||
export function getRecordCatalog(settings: ApiSettings, signal?: AbortSignal): Promise<RecordCatalog> {
|
||||
@@ -362,9 +386,36 @@ export function dispatchRecordTransfer(
|
||||
export function getRecordRecoveryStatus(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
purpose: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, unknown>> {
|
||||
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/recovery`, { signal });
|
||||
return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}/recovery`, { purpose }), { signal });
|
||||
}
|
||||
|
||||
export function listRecordAccessGrants(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
purpose: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ grants: RecordAccessGrant[] }> {
|
||||
return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}/access-grants`, { purpose }), { signal });
|
||||
}
|
||||
|
||||
export function createRecordAccessGrant(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordAccessGrant> {
|
||||
return recordMutation(settings, recordId, "access-grants", payload);
|
||||
}
|
||||
|
||||
export function revokeRecordAccessGrant(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
grantId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordAccessGrant> {
|
||||
return recordMutation(settings, recordId, `access-grants/${encodeURIComponent(grantId)}/revoke`, payload);
|
||||
}
|
||||
|
||||
function recordMutation<T>(
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogForm,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
LoadingIndicator,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createRecordAccessGrant,
|
||||
listRecordAccessGrants,
|
||||
revokeRecordAccessGrant,
|
||||
type RecordAccessGrant,
|
||||
type RecordEntry
|
||||
} from "../../api/records";
|
||||
|
||||
type SubjectType = RecordAccessGrant["subject_type"];
|
||||
|
||||
export function RecordAccessGrantsDialog({
|
||||
open,
|
||||
settings,
|
||||
record,
|
||||
managementPurpose,
|
||||
onClose,
|
||||
onChanged
|
||||
}: {
|
||||
open: boolean;
|
||||
settings: ApiSettings;
|
||||
record: RecordEntry | null;
|
||||
managementPurpose: string;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [grants, setGrants] = useState<RecordAccessGrant[]>([]);
|
||||
const [subjectType, setSubjectType] = useState<SubjectType>("account");
|
||||
const [subjectId, setSubjectId] = useState("");
|
||||
const [read, setRead] = useState(true);
|
||||
const [write, setWrite] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const [allowedPurposes, setAllowedPurposes] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [validTo, setValidTo] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const reload = () => {
|
||||
if (!open || !record || !managementPurpose.trim()) return;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
listRecordAccessGrants(
|
||||
settings,
|
||||
record.record_id,
|
||||
managementPurpose.trim(),
|
||||
controller.signal
|
||||
).then((result) => setGrants(result.grants)).catch((cause) => {
|
||||
if ((cause as Error).name !== "AbortError") {
|
||||
setError(errorMessage(cause, "The restricted access grants could not be loaded."));
|
||||
}
|
||||
}).finally(() => setLoading(false));
|
||||
return () => controller.abort();
|
||||
};
|
||||
|
||||
useEffect(reload, [managementPurpose, open, record?.record_id, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSubjectType("account");
|
||||
setSubjectId("");
|
||||
setRead(true);
|
||||
setWrite(false);
|
||||
setManage(false);
|
||||
setAllowedPurposes("");
|
||||
setReason("");
|
||||
setValidTo("");
|
||||
}, [open]);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!record) return;
|
||||
const actions = [read ? "read" : "", write ? "write" : "", manage ? "manage" : ""].filter(Boolean);
|
||||
const purposes = splitValues(allowedPurposes);
|
||||
if (!subjectId.trim() || actions.length === 0 || purposes.length === 0 || !reason.trim()) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await createRecordAccessGrant(settings, record.record_id, {
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId.trim(),
|
||||
actions,
|
||||
allowed_purposes: purposes,
|
||||
reason: reason.trim(),
|
||||
purpose: managementPurpose.trim(),
|
||||
recorded_at: new Date().toISOString(),
|
||||
valid_to: validTo ? new Date(validTo).toISOString() : null,
|
||||
institutional_context: {},
|
||||
idempotency_key: randomId()
|
||||
});
|
||||
setSubjectId("");
|
||||
setAllowedPurposes("");
|
||||
setReason("");
|
||||
setValidTo("");
|
||||
onChanged();
|
||||
reload();
|
||||
} catch (cause) {
|
||||
setError(errorMessage(cause, "The restricted access grant could not be created."));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(grant: RecordAccessGrant) {
|
||||
if (!record || !window.confirm("Revoke this restricted-record access grant?")) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await revokeRecordAccessGrant(settings, record.record_id, grant.grant_id, {
|
||||
expected_revision: grant.revision,
|
||||
reason: "Access grant revoked by a Records administrator.",
|
||||
purpose: managementPurpose.trim(),
|
||||
recorded_at: new Date().toISOString(),
|
||||
idempotency_key: randomId()
|
||||
});
|
||||
onChanged();
|
||||
reload();
|
||||
} catch (cause) {
|
||||
setError(errorMessage(cause, "The restricted access grant could not be revoked."));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const actionsSelected = read || write || manage;
|
||||
const canSubmit = Boolean(
|
||||
record && managementPurpose.trim() && subjectId.trim() && actionsSelected &&
|
||||
splitValues(allowedPurposes).length && reason.trim()
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title="Restricted record access"
|
||||
onClose={onClose}
|
||||
closeDisabled={saving}
|
||||
portal
|
||||
className="records-dialog records-access-dialog"
|
||||
helpContextId="records.restricted-access"
|
||||
footer={<Button type="button" variant="secondary" onClick={onClose} disabled={saving}>Close</Button>}
|
||||
>
|
||||
<p>
|
||||
Grants are effective-dated and match an exact subject, action, and declared purpose.
|
||||
Current grants also govern historical record views.
|
||||
</p>
|
||||
<p><strong>Management purpose:</strong> {managementPurpose || "Not supplied"}</p>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{loading ? <LoadingIndicator label="Loading access grants" /> : grants.length === 0 ? (
|
||||
<StatePanel size="inline" description="No current access grants are available." />
|
||||
) : (
|
||||
<div className="records-grant-list">
|
||||
{grants.map((grant) => (
|
||||
<div className="records-grant-row" key={grant.grant_id}>
|
||||
<div>
|
||||
<strong>{humanize(grant.subject_type)} · {grant.subject_id}</strong>
|
||||
<span>{grant.actions.map(humanize).join(", ")} · {grant.allowed_purposes.join(", ")}</span>
|
||||
<small>{grant.reason}</small>
|
||||
</div>
|
||||
<StatusBadge status={grant.status === "active" ? "active" : "neutral"} label={humanize(grant.status)} />
|
||||
{grant.status === "active" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={() => revoke(grant)}
|
||||
disabled={saving}
|
||||
helpContextId="records.restricted-access"
|
||||
helpModuleId="records"
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogForm id="records-access-grant-form" className="records-dialog-form" onSubmit={submit}>
|
||||
<h3>Add access grant</h3>
|
||||
<FormField label="Subject type">
|
||||
<select value={subjectType} onChange={(event) => setSubjectType(event.target.value as SubjectType)}>
|
||||
<option value="account">Account</option>
|
||||
<option value="membership">Membership</option>
|
||||
<option value="group">Group</option>
|
||||
<option value="role">Role</option>
|
||||
<option value="function_assignment">Function assignment</option>
|
||||
<option value="delegation">Delegation</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Subject identifier"><input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} required /></FormField>
|
||||
<FormField label="Actions">
|
||||
<div className="records-grant-actions">
|
||||
<label><input type="checkbox" checked={read} onChange={(event) => setRead(event.target.checked)} /> Read</label>
|
||||
<label><input type="checkbox" checked={write} onChange={(event) => setWrite(event.target.checked)} /> Write</label>
|
||||
<label><input type="checkbox" checked={manage} onChange={(event) => setManage(event.target.checked)} /> Manage</label>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Allowed purposes"><textarea value={allowedPurposes} onChange={(event) => setAllowedPurposes(event.target.value)} rows={3} placeholder="One purpose per line" required /></FormField>
|
||||
<FormField label="Valid until"><input type="datetime-local" value={validTo} onChange={(event) => setValidTo(event.target.value)} /></FormField>
|
||||
<FormField label="Reason"><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} required /></FormField>
|
||||
<Button type="submit" variant="primary" disabledReason={saving ? "The grant is being saved." : !canSubmit ? "Complete the subject, action, purpose, and reason." : undefined}>
|
||||
{saving ? "Saving" : "Add grant"}
|
||||
</Button>
|
||||
</DialogForm>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function splitValues(value: string): string[] {
|
||||
return Array.from(new Set(value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown, fallback: string): string {
|
||||
return reason instanceof Error && reason.message ? reason.message : fallback;
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export function RecordLifecyclePanel({
|
||||
openAction("recovery");
|
||||
setSaving(true);
|
||||
try {
|
||||
setRecovery(await getRecordRecoveryStatus(settings, record.record_id));
|
||||
setRecovery(await getRecordRecoveryStatus(settings, record.record_id, purpose.trim()));
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, "Recovery evidence could not be loaded."));
|
||||
} finally {
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type RecordSourceProvider
|
||||
} from "../../api/records";
|
||||
import { RecordCatalogDialog } from "./RecordCatalogDialog";
|
||||
import { RecordAccessGrantsDialog } from "./RecordAccessGrantsDialog";
|
||||
import { RecordLifecyclePanel } from "./RecordLifecyclePanel";
|
||||
import {
|
||||
RECORDS_DOCUMENTATION,
|
||||
@@ -78,6 +79,12 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [query, setQuery] = useState("");
|
||||
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||
const [accessPurpose, setAccessPurpose] = useState(
|
||||
() => searchParams.get("purpose") ?? ""
|
||||
);
|
||||
const [submittedAccessPurpose, setSubmittedAccessPurpose] = useState(
|
||||
() => searchParams.get("purpose") ?? ""
|
||||
);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||
const [selectedRecordId, setSelectedRecordId] = useState(
|
||||
() => searchParams.get("recordId") ?? ""
|
||||
@@ -94,6 +101,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [fileOpen, setFileOpen] = useState(false);
|
||||
const [catalogOpen, setCatalogOpen] = useState(false);
|
||||
const [accessGrantsOpen, setAccessGrantsOpen] = useState(false);
|
||||
const canWrite = auth.scopes.includes("records:workspace:write") ||
|
||||
auth.scopes.includes("records:workspace:admin");
|
||||
const canAdmin = auth.scopes.includes("records:workspace:admin");
|
||||
@@ -133,6 +141,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
listRecords(settings, {
|
||||
query: submittedQuery,
|
||||
filePlanNodeId: selectedNodeId || undefined,
|
||||
purpose: submittedAccessPurpose || undefined,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
limit: PAGE_SIZE
|
||||
}, controller.signal).
|
||||
@@ -153,7 +162,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [page, reloadKey, selectedNodeId, selectionKey, settings, submittedQuery]);
|
||||
}, [page, reloadKey, selectedNodeId, selectionKey, settings, submittedAccessPurpose, submittedQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRecordId) {
|
||||
@@ -164,7 +173,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
const controller = new AbortController();
|
||||
setDetailLoading(true);
|
||||
setDetailError("");
|
||||
getRecord(settings, selectedRecordId, controller.signal).
|
||||
getRecord(settings, selectedRecordId, submittedAccessPurpose || undefined, controller.signal).
|
||||
then(setDetail).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
@@ -174,7 +183,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
}).
|
||||
finally(() => setDetailLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [reloadKey, selectedRecordId, selectionKey, settings]);
|
||||
}, [reloadKey, selectedRecordId, selectionKey, settings, submittedAccessPurpose]);
|
||||
|
||||
useEffect(() => {
|
||||
const current = searchParams.get("recordId") ?? "";
|
||||
@@ -182,8 +191,10 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (selectedRecordId) next.set("recordId", selectedRecordId);
|
||||
else next.delete("recordId");
|
||||
if (submittedAccessPurpose) next.set("purpose", submittedAccessPurpose);
|
||||
else next.delete("purpose");
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [searchParams, selectedRecordId, setSearchParams]);
|
||||
}, [searchParams, selectedRecordId, setSearchParams, submittedAccessPurpose]);
|
||||
|
||||
const classesById = useMemo(
|
||||
() => new Map(catalog.classes.map((item) => [item.class_id, item])),
|
||||
@@ -231,6 +242,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
event.preventDefault();
|
||||
setPage(1);
|
||||
setSubmittedQuery(query.trim());
|
||||
setSubmittedAccessPurpose(accessPurpose.trim());
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -267,6 +279,12 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
aria-label="Search records"
|
||||
placeholder="Search records"
|
||||
/>
|
||||
<input
|
||||
value={accessPurpose}
|
||||
onChange={(event) => setAccessPurpose(event.target.value)}
|
||||
aria-label="Access purpose"
|
||||
placeholder="Access purpose"
|
||||
/>
|
||||
</FilterBar>
|
||||
<span className="records-result-count">
|
||||
{i18nMessage("i18n:govoplan-records.record_count", { value0: total })}
|
||||
@@ -376,9 +394,11 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
archiveProviders={archiveProviders}
|
||||
canWrite={canWrite}
|
||||
canAdmin={canAdmin}
|
||||
accessPurpose={submittedAccessPurpose}
|
||||
hasSources={sources.length > 0}
|
||||
onEdit={() => setEditOpen(true)}
|
||||
onFile={() => setFileOpen(true)}
|
||||
onManageAccess={() => setAccessGrantsOpen(true)}
|
||||
onChanged={reload}
|
||||
/>
|
||||
)}
|
||||
@@ -391,11 +411,16 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
mode="create"
|
||||
settings={settings}
|
||||
catalog={catalog}
|
||||
canAdmin={canAdmin}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSaved={(saved) => {
|
||||
setCreateOpen(false);
|
||||
setSelectedNodeId("");
|
||||
setSelectedRecordId(saved.record_id);
|
||||
if (saved.access_mode === "restricted") {
|
||||
setAccessPurpose(saved.purpose);
|
||||
setSubmittedAccessPurpose(saved.purpose);
|
||||
}
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
@@ -404,6 +429,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
mode="edit"
|
||||
settings={settings}
|
||||
catalog={catalog}
|
||||
canAdmin={canAdmin}
|
||||
record={detail?.record ?? null}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSaved={(saved) => {
|
||||
@@ -436,6 +462,14 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
<RecordAccessGrantsDialog
|
||||
open={accessGrantsOpen}
|
||||
settings={settings}
|
||||
record={detail?.record ?? null}
|
||||
managementPurpose={submittedAccessPurpose}
|
||||
onClose={() => setAccessGrantsOpen(false)}
|
||||
onChanged={reload}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -447,9 +481,11 @@ function RecordDetailPanel({
|
||||
archiveProviders,
|
||||
canWrite,
|
||||
canAdmin,
|
||||
accessPurpose,
|
||||
hasSources,
|
||||
onEdit,
|
||||
onFile,
|
||||
onManageAccess,
|
||||
onChanged
|
||||
}: {
|
||||
detail: RecordDetail;
|
||||
@@ -458,9 +494,11 @@ function RecordDetailPanel({
|
||||
archiveProviders: RecordArchiveProvider[];
|
||||
canWrite: boolean;
|
||||
canAdmin: boolean;
|
||||
accessPurpose: string;
|
||||
hasSources: boolean;
|
||||
onEdit: () => void;
|
||||
onFile: () => void;
|
||||
onManageAccess: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const record = detail.record;
|
||||
@@ -473,6 +511,16 @@ function RecordDetailPanel({
|
||||
<h1>{record.title}</h1>
|
||||
</div>
|
||||
<div className="records-detail-actions">
|
||||
{record.access_mode === "restricted" && canAdmin && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onManageAccess}
|
||||
disabledReason={!accessPurpose ? "Enter the management purpose in the access-purpose filter first." : undefined}
|
||||
>
|
||||
Manage access
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" onClick={onEdit} disabledReason={!canWrite ? "Your account may not revise records." : undefined}>
|
||||
<Pencil size={16} aria-hidden="true" />
|
||||
Edit
|
||||
@@ -576,6 +624,11 @@ function RecordDetailPanel({
|
||||
<p>{detail.access_explanation.reason}</p>
|
||||
<dl>
|
||||
<div><dt>Record purpose</dt><dd>{record.purpose}</dd></div>
|
||||
<div><dt>Access mode</dt><dd>{humanize(record.access_mode)}</dd></div>
|
||||
<div><dt>Evaluated purpose</dt><dd>{detail.access_explanation.purpose || "Tenant access"}</dd></div>
|
||||
{detail.access_explanation.grant_subject_type && (
|
||||
<div><dt>Matched grant</dt><dd>{humanize(detail.access_explanation.grant_subject_type)}</dd></div>
|
||||
)}
|
||||
<div><dt>Authorization</dt><dd>{detail.access_explanation.current_authorization ? "Current authorization applied" : "Not evaluated"}</dd></div>
|
||||
</dl>
|
||||
{detail.access_explanation.limitations.map((limitation) => (
|
||||
@@ -591,6 +644,7 @@ function RecordDialog({
|
||||
mode,
|
||||
settings,
|
||||
catalog,
|
||||
canAdmin,
|
||||
record,
|
||||
onClose,
|
||||
onSaved
|
||||
@@ -599,6 +653,7 @@ function RecordDialog({
|
||||
mode: "create" | "edit";
|
||||
settings: PlatformRouteContext["settings"];
|
||||
catalog: RecordCatalog;
|
||||
canAdmin: boolean;
|
||||
record?: RecordEntry | null;
|
||||
onClose: () => void;
|
||||
onSaved: (record: RecordEntry) => void;
|
||||
@@ -611,6 +666,8 @@ function RecordDialog({
|
||||
const [state, setState] = useState<"planned" | "open">("open");
|
||||
const [classification, setClassification] = useState("");
|
||||
const [purpose, setPurpose] = useState("");
|
||||
const [accessMode, setAccessMode] = useState<"tenant" | "restricted">("tenant");
|
||||
const [initialPurposes, setInitialPurposes] = useState("");
|
||||
const [changeReason, setChangeReason] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -624,15 +681,22 @@ function RecordDialog({
|
||||
setState(record?.state === "planned" ? "planned" : "open");
|
||||
setClassification(record?.classification ?? "");
|
||||
setPurpose(record?.purpose ?? "");
|
||||
setAccessMode(record?.access_mode ?? firstClass?.access_mode ?? "tenant");
|
||||
setInitialPurposes(record?.access_mode === "restricted" ? record.purpose : "");
|
||||
setChangeReason("");
|
||||
setError("");
|
||||
}, [firstClass?.class_id, open, record]);
|
||||
|
||||
const selectedClass = catalog.classes.find((item) => item.class_id === classId);
|
||||
const effectiveAccessMode = selectedClass?.access_mode === "restricted" ? "restricted" : accessMode;
|
||||
const needsInitialPurposes = effectiveAccessMode === "restricted" &&
|
||||
(mode === "create" || record?.access_mode !== "restricted");
|
||||
const parsedInitialPurposes = splitValues(initialPurposes);
|
||||
const restrictedPurposeMissing = needsInitialPurposes && !parsedInitialPurposes.includes(purpose.trim());
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim())) return;
|
||||
if (!selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || restrictedPurposeMissing || (mode === "create" && !recordNumber.trim())) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -644,6 +708,10 @@ function RecordDialog({
|
||||
...(mode === "create" || record?.state === "planned" || record?.state === "open" ? { state } : {}),
|
||||
classification: classification.trim() || null,
|
||||
purpose: purpose.trim(),
|
||||
...(mode === "create" || effectiveAccessMode !== record?.access_mode
|
||||
? { access_mode: effectiveAccessMode }
|
||||
: {}),
|
||||
...(needsInitialPurposes ? { initial_purposes: parsedInitialPurposes } : {}),
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: changeReason.trim(),
|
||||
idempotency_key: randomId()
|
||||
@@ -653,7 +721,6 @@ function RecordDialog({
|
||||
...common,
|
||||
record_number: recordNumber.trim(),
|
||||
source_authority_mode: "native_authoritative",
|
||||
access_mode: "tenant",
|
||||
valid_from: new Date().toISOString(),
|
||||
institutional_context: {}
|
||||
})
|
||||
@@ -681,7 +748,7 @@ function RecordDialog({
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button type="submit" form="records-record-form" variant="primary" disabledReason={saving ? "The record is being saved." : !selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim()) ? "Complete all required record fields." : undefined}>
|
||||
<Button type="submit" form="records-record-form" variant="primary" disabledReason={saving ? "The record is being saved." : restrictedPurposeMissing ? "Include the action purpose in the initial allowed purposes." : !selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim()) ? "Complete all required record fields." : undefined}>
|
||||
{saving ? "Saving" : "Save record"}
|
||||
</Button>
|
||||
</>
|
||||
@@ -716,6 +783,27 @@ function RecordDialog({
|
||||
<FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}>
|
||||
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
|
||||
</FormField>
|
||||
<FormField label="Access mode" helpContextId="records.field.access-mode">
|
||||
<select
|
||||
value={effectiveAccessMode}
|
||||
onChange={(event) => setAccessMode(event.target.value as "tenant" | "restricted")}
|
||||
disabled={selectedClass?.access_mode === "restricted" || (mode === "edit" && !canAdmin)}
|
||||
>
|
||||
<option value="tenant">Tenant-wide permission</option>
|
||||
<option value="restricted">Purpose-bound grants</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{needsInitialPurposes && (
|
||||
<FormField label="Initial allowed purposes" helpContextId="records.field.initial-purposes">
|
||||
<textarea
|
||||
value={initialPurposes}
|
||||
onChange={(event) => setInitialPurposes(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="One purpose per line"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Description" helpContextId="records.field.description">
|
||||
<textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={4} />
|
||||
</FormField>
|
||||
@@ -928,6 +1016,10 @@ function humanize(value: string): string {
|
||||
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function splitValues(value: string): string[] {
|
||||
return Array.from(new Set(value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
|
||||
@@ -166,7 +166,49 @@ const en = {
|
||||
"Enter a reason for this action.": "Enter a reason for this action.",
|
||||
"Enter the hold authority.": "Enter the hold authority.",
|
||||
"Enter a volume label.": "Enter a volume label.",
|
||||
"Select an available archive profile.": "Select an available archive profile."
|
||||
"Select an available archive profile.": "Select an available archive profile.",
|
||||
"Access purpose": "Access purpose",
|
||||
"Manage access": "Manage access",
|
||||
"Enter the management purpose in the access-purpose filter first.": "Enter the management purpose in the access-purpose filter first.",
|
||||
"Access mode": "Access mode",
|
||||
"Evaluated purpose": "Evaluated purpose",
|
||||
"Tenant access": "Tenant access",
|
||||
"Matched grant": "Matched grant",
|
||||
"Tenant-wide permission": "Tenant-wide permission",
|
||||
"Purpose-bound grants": "Purpose-bound grants",
|
||||
"Initial allowed purposes": "Initial allowed purposes",
|
||||
"One purpose per line": "One purpose per line",
|
||||
"Include the action purpose in the initial allowed purposes.": "Include the action purpose in the initial allowed purposes.",
|
||||
"Restricted record access": "Restricted record access",
|
||||
"Management purpose:": "Management purpose:",
|
||||
"Not supplied": "Not supplied",
|
||||
"Loading access grants": "Loading access grants",
|
||||
"No current access grants are available.": "No current access grants are available.",
|
||||
"Add access grant": "Add access grant",
|
||||
"Subject type": "Subject type",
|
||||
"Subject identifier": "Subject identifier",
|
||||
"Actions": "Actions",
|
||||
"Allowed purposes": "Allowed purposes",
|
||||
"Valid until": "Valid until",
|
||||
"Read": "Read",
|
||||
"Write": "Write",
|
||||
"Manage": "Manage",
|
||||
"Account": "Account",
|
||||
"Membership": "Membership",
|
||||
"Group": "Group",
|
||||
"Role": "Role",
|
||||
"Function assignment": "Function assignment",
|
||||
"Delegation": "Delegation",
|
||||
"Revoke": "Revoke",
|
||||
"Add grant": "Add grant",
|
||||
"The restricted access grants could not be loaded.": "The restricted access grants could not be loaded.",
|
||||
"The restricted access grant could not be created.": "The restricted access grant could not be created.",
|
||||
"The restricted access grant could not be revoked.": "The restricted access grant could not be revoked.",
|
||||
"The grant is being saved.": "The grant is being saved.",
|
||||
"Complete the subject, action, purpose, and reason.": "Complete the subject, action, purpose, and reason.",
|
||||
"Grants are effective-dated and match an exact subject, action, and declared purpose. Current grants also govern historical record views.": "Grants are effective-dated and match an exact subject, action, and declared purpose. Current grants also govern historical record views.",
|
||||
"Active": "Active",
|
||||
"Revoked": "Revoked"
|
||||
} as const;
|
||||
|
||||
const de: Record<keyof typeof en, string> = {
|
||||
@@ -334,7 +376,49 @@ const de: Record<keyof typeof en, string> = {
|
||||
"Enter a reason for this action.": "Geben Sie eine Begründung für diese Aktion an.",
|
||||
"Enter the hold authority.": "Geben Sie die anordnende Stelle der Sperre an.",
|
||||
"Enter a volume label.": "Geben Sie eine Bandbezeichnung an.",
|
||||
"Select an available archive profile.": "Wählen Sie ein verfügbares Archivprofil."
|
||||
"Select an available archive profile.": "Wählen Sie ein verfügbares Archivprofil.",
|
||||
"Access purpose": "Zugriffszweck",
|
||||
"Manage access": "Zugriff verwalten",
|
||||
"Enter the management purpose in the access-purpose filter first.": "Geben Sie zuerst den Verwaltungszweck im Zugriffszweckfilter an.",
|
||||
"Access mode": "Zugriffsmodus",
|
||||
"Evaluated purpose": "Geprüfter Zweck",
|
||||
"Tenant access": "Mandantenzugriff",
|
||||
"Matched grant": "Passende Freigabe",
|
||||
"Tenant-wide permission": "Mandantenweite Berechtigung",
|
||||
"Purpose-bound grants": "Zweckgebundene Freigaben",
|
||||
"Initial allowed purposes": "Anfänglich erlaubte Zwecke",
|
||||
"One purpose per line": "Ein Zweck pro Zeile",
|
||||
"Include the action purpose in the initial allowed purposes.": "Nehmen Sie den Aktionszweck in die anfänglich erlaubten Zwecke auf.",
|
||||
"Restricted record access": "Zugriff auf besonders geschützte Akten",
|
||||
"Management purpose:": "Verwaltungszweck:",
|
||||
"Not supplied": "Nicht angegeben",
|
||||
"Loading access grants": "Zugriffsfreigaben werden geladen",
|
||||
"No current access grants are available.": "Es sind keine aktuellen Zugriffsfreigaben vorhanden.",
|
||||
"Add access grant": "Zugriffsfreigabe hinzufügen",
|
||||
"Subject type": "Subjekttyp",
|
||||
"Subject identifier": "Subjektkennung",
|
||||
"Actions": "Aktionen",
|
||||
"Allowed purposes": "Erlaubte Zwecke",
|
||||
"Valid until": "Gültig bis",
|
||||
"Read": "Lesen",
|
||||
"Write": "Schreiben",
|
||||
"Manage": "Verwalten",
|
||||
"Account": "Konto",
|
||||
"Membership": "Mitgliedschaft",
|
||||
"Group": "Gruppe",
|
||||
"Role": "Rolle",
|
||||
"Function assignment": "Funktionszuordnung",
|
||||
"Delegation": "Delegation",
|
||||
"Revoke": "Entziehen",
|
||||
"Add grant": "Freigabe hinzufügen",
|
||||
"The restricted access grants could not be loaded.": "Die Freigaben für besonders geschützte Akten konnten nicht geladen werden.",
|
||||
"The restricted access grant could not be created.": "Die Freigabe für die besonders geschützte Akte konnte nicht angelegt werden.",
|
||||
"The restricted access grant could not be revoked.": "Die Freigabe für die besonders geschützte Akte konnte nicht entzogen werden.",
|
||||
"The grant is being saved.": "Die Freigabe wird gespeichert.",
|
||||
"Complete the subject, action, purpose, and reason.": "Vervollständigen Sie Subjekt, Aktion, Zweck und Begründung.",
|
||||
"Grants are effective-dated and match an exact subject, action, and declared purpose. Current grants also govern historical record views.": "Freigaben sind zeitlich wirksam und gelten für ein eindeutiges Subjekt, eine Aktion und einen erklärten Zweck. Aktuelle Freigaben bestimmen auch den Zugriff auf historische Aktenansichten.",
|
||||
"Active": "Aktiv",
|
||||
"Revoked": "Entzogen"
|
||||
};
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||
|
||||
@@ -553,6 +553,43 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.records-grant-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-block: 16px;
|
||||
}
|
||||
|
||||
.records-grant-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.records-grant-row > div,
|
||||
.records-grant-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.records-grant-row > div {
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.records-grant-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.records-grant-actions label {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.records-workspace {
|
||||
grid-template-columns: minmax(190px, 220px) minmax(330px, 0.85fr) minmax(390px, 1fr);
|
||||
@@ -623,4 +660,8 @@
|
||||
.records-dialog-form > .form-field:has(textarea) {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.records-grant-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user