Add file resource access explanations
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, 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,
|
||||
|
||||
Reference in New Issue
Block a user