Add file resource access explanations

This commit is contained in:
2026-07-11 00:39:39 +02:00
parent ba7fb2def7
commit 58c0441763
7 changed files with 625 additions and 5 deletions

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, Link2, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
import {
Button,
ConfirmDialog,
@@ -22,6 +22,7 @@ import {
deleteFolder,
downloadFile,
downloadFilesAsZip,
fetchResourceAccessExplanation,
listFilesDelta,
listFileConnectorProfiles,
listFileSpaces,
@@ -30,6 +31,7 @@ import {
syncFileConnectorFile,
transferFiles,
uploadFiles,
virtualFolderResourceId,
type ConflictResolution,
type ConflictStrategy,
type FileConnectorBrowseItem,
@@ -38,7 +40,8 @@ import {
type FileFolder,
type FileSpace,
type ManagedFile,
type RenameResponse } from
type RenameResponse,
type ResourceAccessExplanationResponse } from
"../../api/files";
import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants";
import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents";
@@ -84,6 +87,12 @@ import { useFileDialogs } from "./hooks/useFileDialogs";
import { useFileDragDropState } from "./hooks/useFileDragDropState";
type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing";
type FileAccessExplanationTarget = {
resourceType: "file" | "folder";
resourceId: string;
label: string;
action: string;
};
const DEFAULT_FILE_LIST_ROW_HEIGHT = 58;
const FILE_LIST_OVERSCAN_ROWS = 8;
@@ -133,6 +142,9 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const [connectorSpaceError, setConnectorSpaceError] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null);
const [resourceAccessExplanation, setResourceAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
const [resourceAccessLoading, setResourceAccessLoading] = useState(false);
const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState();
const {
dialog,
@@ -216,6 +228,15 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
});
const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]);
const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]);
const accessExplainableTarget = useMemo(
() => accessTargetForSelection(selectedFileIds, selectedFolderPaths, activeSpaceId),
[activeSpaceId, filesBySpace, foldersBySpace, selectedFileIds, selectedFolderPaths]
);
const canExplainResourceAccess =
hasScope(auth, "admin:users:read") ||
hasScope(auth, "admin:roles:read") ||
hasScope(auth, "access:membership:read") ||
hasScope(auth, "access:role:read");
const folderCrumbs = useMemo(() => folderBreadcrumbs(currentFolder), [currentFolder]);
const activeDialogTarget = dialogTarget ?? (activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null);
const activeDialogSpace = activeDialogTarget ? spaces.find((space) => space.id === activeDialogTarget.spaceId) ?? null : null;
@@ -1456,6 +1477,74 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
await deleteSelected(fileIds, folderPaths, space);
}
function accessTargetForSelection(fileIds: Set<string>, folderPaths: Set<string>, spaceId: string): FileAccessExplanationTarget | null {
if (fileIds.size === 1 && folderPaths.size === 0) {
const fileId = Array.from(fileIds)[0];
const file = filesInSpace(spaceId).find((item) => item.id === fileId);
return file ? {
resourceType: "file",
resourceId: file.id,
label: file.display_path || file.filename,
action: "files:file:read"
} : null;
}
if (fileIds.size === 0 && folderPaths.size === 1) {
const folderPath = normalizeFolder(Array.from(folderPaths)[0]);
const folder = foldersInSpace(spaceId).find((item) => normalizeFolder(item.path) === folderPath);
if (folder) {
return {
resourceType: "folder",
resourceId: folder.id,
label: folder.path,
action: "files:file:read"
};
}
const space = findSpace(spaceId);
const tenantId = (auth.active_tenant ?? auth.tenant)?.id;
if (!space || !tenantId || !folderPath) return null;
return {
resourceType: "folder",
resourceId: virtualFolderResourceId({ tenantId, ownerType: space.owner_type, ownerId: space.owner_id, path: folderPath }),
label: folderPath,
action: "files:file:read"
};
}
return null;
}
function accessTargetForContext(menu: ContextMenuState | null): FileAccessExplanationTarget | null {
const { fileIds, folderPaths } = selectedSetsForContext(menu);
return accessTargetForSelection(fileIds, folderPaths, menu?.spaceId ?? activeSpaceId);
}
function openAccessExplanationForContext(menu: ContextMenuState | null) {
const target = accessTargetForContext(menu);
setContextMenu(null);
if (target) void openAccessExplanation(target);
}
async function openAccessExplanation(target: FileAccessExplanationTarget): Promise<void> {
if (!auth.user?.id) return;
setAccessExplanationTarget(target);
setResourceAccessExplanation(null);
setResourceAccessLoading(true);
setError("");
try {
setResourceAccessExplanation(await fetchResourceAccessExplanation(settings, {
userId: auth.user.id,
resourceType: target.resourceType,
resourceId: target.resourceId,
action: target.action,
tenantId: (auth.active_tenant ?? auth.tenant)?.id
}));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setAccessExplanationTarget(null);
} finally {
setResourceAccessLoading(false);
}
}
function openTransferDialogForContext(menu: ContextMenuState | null, mode: TransferMode) {
const sets = selectedSetsForContext(menu);
const space = spaceForContext(menu);
@@ -1688,6 +1777,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
<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>
{hasSelection && <Button onClick={openRenameDialog} disabled={busy || activeSpaceIsConnector || !canOrganize}>{selectedEntryCount === 1 ? "i18n:govoplan-files.rename.d3f4cb89" : "i18n:govoplan-files.bulk_rename.7dcaa624"}</Button>}
<Button onClick={() => accessExplainableTarget && void openAccessExplanation(accessExplainableTarget)} disabled={busy || activeSpaceIsConnector || !canExplainResourceAccess || !accessExplainableTarget}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</Button>
<Button variant="danger" onClick={() => void deleteSelected()} disabled={busy || activeSpaceIsConnector || !canDelete || !hasSelection}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button>
{activeSpaceIsConnector &&
<Button onClick={() => activeSpace && void loadConnectorSpaceContents(activeSpace)} disabled={busy || connectorSpaceLoading || !activeSpace}>
@@ -2055,16 +2145,30 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
canDownload={canDownload}
canOrganize={canOrganize}
canDelete={canDelete}
canExplainAccess={canExplainResourceAccess && Boolean(accessTargetForContext(contextMenu))}
downloadLabel={downloadLabelForSets(selectedSetsForContext(contextMenu), contextMenu.spaceId ?? activeSpaceId)}
onCreateFolder={() => openCreateFolderDialogForContext(contextMenu)}
onUpload={() => openUploadDialogForContext(contextMenu)}
onDownload={() => void downloadContextSelection(contextMenu)}
onMove={() => openTransferDialogForContext(contextMenu, "move")}
onCopy={() => openTransferDialogForContext(contextMenu, "copy")}
onExplainAccess={() => openAccessExplanationForContext(contextMenu)}
onDelete={() => void deleteContextSelection(contextMenu)} />
}
{accessExplanationTarget &&
<FileDialog title="i18n:govoplan-files.access_explanation.75ee7f62" onClose={() => {if (!resourceAccessLoading) {setAccessExplanationTarget(null);setResourceAccessExplanation(null);}}}>
<ResourceAccessExplanationContent
loading={resourceAccessLoading}
explanation={resourceAccessExplanation}
fallbackResourceLabel={accessExplanationTarget.label} />
<div className="button-row compact-actions align-end">
<Button onClick={() => {setAccessExplanationTarget(null);setResourceAccessExplanation(null);}} disabled={resourceAccessLoading}>i18n:govoplan-files.close.bbfa773e</Button>
</div>
</FileDialog>
}
{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
@@ -2350,6 +2454,71 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
}
function ResourceAccessExplanationContent({
loading,
explanation,
fallbackResourceLabel
}: {loading: boolean;explanation: ResourceAccessExplanationResponse | null;fallbackResourceLabel: string;}) {
if (loading) return <p className="muted small-note">i18n:govoplan-files.loading_access_explanation.04a7c934</p>;
if (!explanation) return null;
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || fallbackResourceLabel || explanation.resource_id;
return (
<>
<div className="form-grid compact responsive-form-grid">
<div><span className="form-label">i18n:govoplan-files.user.9f8a2389</span><p>{userLabel}</p></div>
<div><span className="form-label">i18n:govoplan-files.resource.d1c626a9</span><p>{resourceLabel}</p></div>
<div><span className="form-label">i18n:govoplan-files.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
<div><span className="form-label">i18n:govoplan-files.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
</div>
{explanation.provenance.length === 0 ?
<p className="muted small-note">i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e</p> :
<div className="admin-assignment-grid">
{explanation.provenance.map((item, index) =>
<div key={`${item.kind}:${item.id ?? index}`}>
<strong>{provenanceKindLabel(item.kind)}</strong>
<div className="muted small-note">
{item.source || "i18n:govoplan-files.no_source.6dcf9723"}
{item.id && <> · <code>{item.id}</code></>}
</div>
{item.label && <p>{item.label}</p>}
{Object.keys(item.details ?? {}).length > 0 && <p className="muted small-note">{formatProvenanceDetails(item.details ?? {})}</p>}
</div>
)}
</div>
}
</>);
}
function provenanceKindLabel(kind: string): string {
switch (kind) {
case "resource":
return "i18n:govoplan-files.resource.d1c626a9";
case "owner":
return "i18n:govoplan-files.owner.89ff3122";
case "share":
return "i18n:govoplan-files.share.09ca55ca";
case "policy":
return "i18n:govoplan-files.policy.0b779a05";
case "role":
return "i18n:govoplan-files.role.b5b4a5a2";
case "right":
return "i18n:govoplan-files.permission.2f81a22d";
default:
return kind;
}
}
function formatProvenanceDetails(details: Record<string, unknown>): string {
return Object.entries(details).map(([key, value]) => `${key}: ${formatProvenanceValue(value)}`).join("; ");
}
function formatProvenanceValue(value: unknown): string {
if (value === null || value === undefined) return "i18n:govoplan-files.none.6eef6648";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function FileSearchRow({
value,
caseSensitive,