feat: harden file sharing and integrity
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user