feat: harden file sharing and integrity

This commit is contained in:
2026-07-30 14:26:47 +02:00
parent 85606d5580
commit 835eacfc5d
28 changed files with 2714 additions and 102 deletions

View File

@@ -1,4 +1,15 @@
import { ApiError, apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings, type DeltaDeletedItem, type FilesManagedFileLinkTarget } from "@govoplan/core-webui";
import {
ApiError,
apiFetch,
apiReferenceOptionProvider,
apiUrl,
authHeaders,
csrfToken,
type ApiSettings,
type DeltaDeletedItem,
type FilesManagedFileLinkTarget,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
export type {
AccessDecisionProvenanceItem,
@@ -24,11 +35,16 @@ export type FileSpace = {
export type FileShare = {
id: string;
file_asset_id: string;
target_type: string;
target_id: string;
permission: string;
created_by_user_id?: string | null;
created_at: string;
expires_at?: string | null;
revoked_at?: string | null;
revoked_by_user_id?: string | null;
active: boolean;
};
export type FileSourceProvenance = {
@@ -593,6 +609,12 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi
}
export type FileBulkShareResponse = {shares: FileShare[];shared_count: number;};
export type FileSharePayload = {
target_type: "user" | "group" | "tenant" | "campaign";
target_id: string;
permission: "read" | "write" | "manage";
expires_at?: string | null;
};
export function virtualFolderResourceId(options: {tenantId: string;ownerType: "user" | "group";ownerId: string;path: string;}): string {
return `virtual-folder:v1:${options.tenantId}:${options.ownerType}:${options.ownerId}:${base64Url(options.path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""))}`;
@@ -612,6 +634,54 @@ export function shareFilesWithTarget(settings: ApiSettings, fileIds: string[], t
});
}
export function listFileShares(
settings: ApiSettings,
fileId: string,
options: { includeInactive?: boolean } = {}
): Promise<{ shares: FileShare[] }> {
const suffix = options.includeInactive ? "?include_inactive=true" : "";
return apiFetch<{ shares: FileShare[] }>(
settings,
`/api/v1/files/${encodeURIComponent(fileId)}/shares${suffix}`
);
}
export function createFileShare(
settings: ApiSettings,
fileId: string,
payload: FileSharePayload
): Promise<FileShare> {
return apiFetch<FileShare>(
settings,
`/api/v1/files/${encodeURIComponent(fileId)}/shares`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function revokeFileShare(
settings: ApiSettings,
fileId: string,
shareId: string
): Promise<FileShare> {
return apiFetch<FileShare>(
settings,
`/api/v1/files/${encodeURIComponent(fileId)}/shares/${encodeURIComponent(shareId)}`,
{ method: "DELETE" }
);
}
export function fileShareTargetProvider(
settings: ApiSettings,
fileId: string,
targetType: "user" | "group"
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/api/v1/files/${encodeURIComponent(fileId)}/share-target-options`,
{ target_type: targetType }
);
}
export function evaluateFileConnectorPolicy(
settings: ApiSettings,
payload: {source_provenance: FileSourceProvenance;operation?: string;policy_sources?: FileConnectorPolicySource[];})

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Share2, Trash2, UploadCloud } from "lucide-react";
import {
Button,
ConfirmDialog,
@@ -48,6 +48,7 @@ import {
"../../api/files";
import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants";
import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents";
import FileShareDialog from "./components/FileShareDialog";
import type {
ContextMenuState,
DialogKind,
@@ -106,6 +107,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const canUpload = hasScope(auth, "files:upload");
const canOrganize = hasScope(auth, "files:organize");
const canDelete = hasScope(auth, "files:delete");
const canShare = hasScope(auth, "files:file:share");
const [spaces, setSpaces] = useState<FileSpace[]>(EMPTY_SPACES);
const [activeSpaceId, setActiveSpaceId] = useState("");
const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? spaces[0] ?? null;
@@ -154,6 +156,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null);
const [resourceAccessExplanation, setResourceAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
const [resourceAccessLoading, setResourceAccessLoading] = useState(false);
const [shareDialogFile, setShareDialogFile] = useState<ManagedFile | null>(null);
const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState();
const {
dialog,
@@ -245,6 +248,14 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
onOpenFolder: openFolder
});
const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]);
const shareManageableFile = useMemo(() => {
if (!canShare || selectedFiles.length !== 1 || selectedFolderPaths.size > 0) return null;
const file = selectedFiles[0];
const ownsFile = file.owner_type === "user"
? file.owner_id === auth.user.id
: auth.groups.some((group) => group.id === file.owner_id);
return ownsFile || hasScope(auth, "files:file:admin") ? file : null;
}, [auth, canShare, selectedFiles, selectedFolderPaths]);
const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]);
const accessExplainableTarget = useMemo(
() => accessTargetForSelection(selectedFileIds, selectedFolderPaths, activeSpaceId),
@@ -1896,6 +1907,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
<Link2 size={16} aria-hidden="true" /> i18n:govoplan-files.add_space.e4d674d4
</Button>
<Button onClick={() => void downloadSelection()} disabled={busy || activeSpaceIsConnector || !canDownload || selectedDownloadFileIds.length === 0}><Download size={16} aria-hidden="true" /> {downloadLabel}</Button>
<Button onClick={() => shareManageableFile && setShareDialogFile(shareManageableFile)} disabled={busy || activeSpaceIsConnector || !shareManageableFile}><Share2 size={16} aria-hidden="true" /> Manage shares</Button>
<Button onClick={() => openDialog("create-folder", toolbarTarget())} disabled={busy || !activeSpace || activeSpaceIsConnector || !canOrganize}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.create_folder.97bafaba</Button>
<Button onClick={() => openTransferDialog("move")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><MoveRight size={16} aria-hidden="true" /> i18n:govoplan-files.move.76cdb950</Button>
<Button onClick={() => openTransferDialog("copy")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</Button>
@@ -2307,6 +2319,17 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
</FileDialog>
}
{shareDialogFile &&
<FileShareDialog
settings={settings}
file={shareDialogFile}
tenantId={auth.active_tenant?.id || auth.tenant.id}
onClose={() => setShareDialogFile(null)}
onChanged={() => {
if (activeSpace) void loadSpaceContents(activeSpace, { silent: true });
}} />
}
{dialog === "upload" &&
<FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
<FileDropZone
@@ -2709,7 +2732,15 @@ function FileSearchRow({
}
function activeCampaignShares(file: ManagedFile) {
return (file.shares ?? []).filter((share) => share.target_type === "campaign" && !share.revoked_at);
return (file.shares ?? []).filter((share) =>
share.target_type === "campaign" && fileShareIsActive(share)
);
}
function fileShareIsActive(share: NonNullable<ManagedFile["shares"]>[number]): boolean {
if (typeof share.active === "boolean") return share.active;
return !share.revoked_at
&& (!share.expires_at || new Date(share.expires_at).getTime() > Date.now());
}
function activeCampaignShareCount(file: ManagedFile): number {

View File

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

View File

@@ -1024,7 +1024,14 @@ renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"])
function isLinkedToTarget(file: ManagedFile, target?: FilesManagedFileLinkTarget): boolean {
if (!target) return false;
return Boolean(file.shares?.some((share) => share.target_type === target.type && share.target_id === target.id && !share.revoked_at));
return Boolean(file.shares?.some((share) =>
share.target_type === target.type
&& share.target_id === target.id
&& (typeof share.active === "boolean"
? share.active
: !share.revoked_at
&& (!share.expires_at || new Date(share.expires_at).getTime() > Date.now()))
));
}
function readRememberedState(key: string): RememberedChooserState {

View File

@@ -288,6 +288,35 @@
width: min(820px, 100%);
}
.file-dialog:has(.file-share-dialog-content) {
width: min(1080px, 100%);
}
.file-share-dialog-content {
display: grid;
min-width: 0;
gap: 16px;
}
.file-share-form {
display: grid;
grid-template-columns: minmax(130px, .7fr) minmax(220px, 1.4fr) minmax(120px, .7fr) minmax(190px, 1fr) auto;
gap: 12px;
align-items: end;
}
@media (max-width: 900px) {
.file-share-form {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
}
@media (max-width: 620px) {
.file-share-form {
grid-template-columns: minmax(0, 1fr);
}
}
.connector-sync-grid {
display: grid;
grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr);