Files
govoplan-files/webui/src/features/files/components/FileShareDialog.tsx
T

301 lines
8.5 KiB
TypeScript

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);
}