feat: harden file sharing and integrity
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DataGrid,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
ReferenceSelect,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
type ApiSettings,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createFileShare,
|
||||
fileShareTargetProvider,
|
||||
listFileShares,
|
||||
revokeFileShare,
|
||||
type FileShare,
|
||||
type ManagedFile
|
||||
} from "../../../api/files";
|
||||
import { FileDialog } from "./FileManagerComponents";
|
||||
import { formatDate } from "../utils/fileManagerUtils";
|
||||
|
||||
type ShareTargetType = "user" | "group" | "tenant";
|
||||
type SharePermission = "read" | "write" | "manage";
|
||||
|
||||
export default function FileShareDialog({
|
||||
settings,
|
||||
file,
|
||||
tenantId,
|
||||
onClose,
|
||||
onChanged
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
file: ManagedFile;
|
||||
tenantId: string;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [shares, setShares] = useState<FileShare[]>([]);
|
||||
const [targetType, setTargetType] = useState<ShareTargetType>("user");
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [permission, setPermission] = useState<SharePermission>("read");
|
||||
const [expiresAt, setExpiresAt] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [revokeTarget, setRevokeTarget] = useState<FileShare | null>(null);
|
||||
const targetProvider = useMemo(
|
||||
() => targetType === "tenant"
|
||||
? null
|
||||
: fileShareTargetProvider(settings, file.id, targetType),
|
||||
[file.id, settings, targetType]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [file.id]);
|
||||
|
||||
async function refresh() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listFileShares(settings, file.id, {
|
||||
includeInactive: true
|
||||
});
|
||||
setShares(response.shares);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function grant() {
|
||||
const effectiveTargetId = targetType === "tenant" ? tenantId : targetId;
|
||||
if (!effectiveTargetId) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await createFileShare(settings, file.id, {
|
||||
target_type: targetType,
|
||||
target_id: effectiveTargetId,
|
||||
permission,
|
||||
expires_at: expiresAt ? new Date(expiresAt).toISOString() : null
|
||||
});
|
||||
setTargetId("");
|
||||
setExpiresAt("");
|
||||
await refresh();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
if (!revokeTarget) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await revokeFileShare(settings, file.id, revokeTarget.id);
|
||||
setRevokeTarget(null);
|
||||
await refresh();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<FileShare>[]>(() => [
|
||||
{
|
||||
id: "target",
|
||||
header: "Target",
|
||||
minWidth: 220,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (share) => `${share.target_type}: ${share.target_id}`
|
||||
},
|
||||
{
|
||||
id: "permission",
|
||||
header: "Permission",
|
||||
width: 115,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (share) => share.permission
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: "Created",
|
||||
minWidth: 160,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
value: (share) => formatDate(share.created_at)
|
||||
},
|
||||
{
|
||||
id: "creator",
|
||||
header: "Created by",
|
||||
minWidth: 150,
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
value: (share) => share.created_by_user_id || "System"
|
||||
},
|
||||
{
|
||||
id: "expiry",
|
||||
header: "Expires",
|
||||
minWidth: 160,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
value: (share) => share.expires_at ? formatDate(share.expires_at) : "No expiry"
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
width: 115,
|
||||
sortable: true,
|
||||
value: (share) => shareStatus(share),
|
||||
render: (share) => (
|
||||
<StatusBadge
|
||||
status={share.active ? "active" : "inactive"}
|
||||
label={shareStatus(share)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 72,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (share) => (
|
||||
<TableActionGroup
|
||||
minimumSlots={1}
|
||||
actions={[
|
||||
{
|
||||
id: "revoke",
|
||||
label: "Revoke share",
|
||||
icon: <Trash2 size={16} aria-hidden="true" />,
|
||||
variant: "danger",
|
||||
disabled: !share.active || busy,
|
||||
disabledReason: !share.active ? "This share is already inactive." : undefined,
|
||||
onClick: () => setRevokeTarget(share)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
], [busy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileDialog title={`Manage shares - ${file.filename}`} onClose={() => !busy && onClose()}>
|
||||
<div className="file-share-dialog-content">
|
||||
{error ? (
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
<div className="file-share-form">
|
||||
<FormField label="Target type">
|
||||
<select
|
||||
value={targetType}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setTargetType(event.target.value as ShareTargetType);
|
||||
setTargetId("");
|
||||
}}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="group">Group</option>
|
||||
<option value="tenant">Entire tenant</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Target">
|
||||
{targetType === "tenant" ? (
|
||||
<input value={tenantId} disabled readOnly />
|
||||
) : targetProvider ? (
|
||||
<ReferenceSelect
|
||||
value={targetId}
|
||||
onChange={setTargetId}
|
||||
provider={targetProvider}
|
||||
disabled={busy}
|
||||
placeholder={`Select a ${targetType}`}
|
||||
aria-label={`Share target ${targetType}`}
|
||||
required
|
||||
/>
|
||||
) : null}
|
||||
</FormField>
|
||||
<FormField label="Permission">
|
||||
<select
|
||||
value={permission}
|
||||
disabled={busy}
|
||||
onChange={(event) => setPermission(event.target.value as SharePermission)}
|
||||
>
|
||||
<option value="read">Read</option>
|
||||
<option value="write">Write</option>
|
||||
<option value="manage">Manage</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Expires (optional)">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={expiresAt}
|
||||
min={minimumLocalDateTime()}
|
||||
disabled={busy}
|
||||
onChange={(event) => setExpiresAt(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || (targetType !== "tenant" && !targetId)}
|
||||
onClick={() => void grant()}
|
||||
>
|
||||
Grant access
|
||||
</Button>
|
||||
</div>
|
||||
<DataGrid
|
||||
id={`file-${file.id}-shares`}
|
||||
rows={shares}
|
||||
columns={columns}
|
||||
getRowKey={(share) => share.id}
|
||||
emptyText={busy ? "Loading shares..." : "This file has no direct shares."}
|
||||
initialFit="container"
|
||||
/>
|
||||
<div className="button-row compact-actions align-end">
|
||||
<Button onClick={onClose} disabled={busy}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FileDialog>
|
||||
<ConfirmDialog
|
||||
open={Boolean(revokeTarget)}
|
||||
title="Revoke file share"
|
||||
message="Revoke this direct grant? Other independent grants remain in effect."
|
||||
confirmLabel="Revoke"
|
||||
cancelLabel="Cancel"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onConfirm={() => void revoke()}
|
||||
onCancel={() => setRevokeTarget(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function shareStatus(share: FileShare): string {
|
||||
if (share.revoked_at) return "Revoked";
|
||||
if (share.expires_at && new Date(share.expires_at).getTime() <= Date.now()) {
|
||||
return "Expired";
|
||||
}
|
||||
return "Active";
|
||||
}
|
||||
|
||||
function minimumLocalDateTime(): string {
|
||||
const now = new Date(Date.now() + 60_000);
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
Reference in New Issue
Block a user