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

@@ -559,6 +559,55 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi
export type FileBulkShareResponse = {shares: FileShare[];shared_count: number;};
export type AccessExplanationUser = {
id: string;
account_id?: string | null;
email?: string | null;
display_name?: string | null;
};
export type AccessDecisionProvenanceItem = {
kind: string;
id?: string | null;
label?: string | null;
tenant_id?: string | null;
source?: string | null;
details?: Record<string, unknown>;
};
export type ResourceAccessExplanationResponse = {
user: AccessExplanationUser;
resource_type: string;
resource_id: string;
action: string;
provenance: AccessDecisionProvenanceItem[];
};
export function fetchResourceAccessExplanation(
settings: ApiSettings,
options: {userId: string;resourceType: string;resourceId: string;action: string;tenantId?: string | null;})
: Promise<ResourceAccessExplanationResponse> {
const params = new URLSearchParams({
user_id: options.userId,
resource_type: options.resourceType,
resource_id: options.resourceId,
action: options.action
});
if (options.tenantId) params.set("tenant_id", options.tenantId);
return apiFetch<ResourceAccessExplanationResponse>(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
}
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, ""))}`;
}
function base64Url(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = "";
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
export function shareFilesWithTarget(settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget): Promise<FileBulkShareResponse> {
return apiFetch<FileBulkShareResponse>(settings, "/api/v1/files/bulk-shares", {
method: "POST",

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,

View File

@@ -1,5 +1,5 @@
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { Copy, Download, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
import { Copy, Download, FolderOpen, Home, KeyRound, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext, i18nMessage } from "@govoplan/core-webui";
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
@@ -220,12 +220,14 @@ export function FileContextMenu({
canDownload,
canOrganize,
canDelete,
canExplainAccess,
downloadLabel,
onCreateFolder,
onUpload,
onDownload,
onMove,
onCopy,
onExplainAccess,
onDelete
@@ -242,7 +244,7 @@ export function FileContextMenu({
}: {menu: ContextMenuState;hasSelection: boolean;canCreateFolder: boolean;canUpload: boolean;canDownload: boolean;canOrganize: boolean;canDelete: boolean;downloadLabel: string;onCreateFolder: () => void;onUpload: () => void;onDownload: () => void;onMove: () => void;onCopy: () => void;onDelete: () => void;}) {
}: {menu: ContextMenuState;hasSelection: boolean;canCreateFolder: boolean;canUpload: boolean;canDownload: boolean;canOrganize: boolean;canDelete: boolean;canExplainAccess: boolean;downloadLabel: string;onCreateFolder: () => void;onUpload: () => void;onDownload: () => void;onMove: () => void;onCopy: () => void;onExplainAccess: () => void;onDelete: () => void;}) {
const showNewFolder = true;
const showDelete = menu.target !== "empty";
const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth;
@@ -261,6 +263,7 @@ export function FileContextMenu({
<button type="button" role="menuitem" onClick={onDownload} disabled={!hasSelection || !canDownload}><Download size={15} aria-hidden="true" /> {downloadLabel}</button>
<button type="button" role="menuitem" onClick={onMove} disabled={!hasSelection || !canOrganize}><MoveRight size={15} aria-hidden="true" /> i18n:govoplan-files.move.8a74a26e</button>
<button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> i18n:govoplan-files.copy.92556c6d</button>
<button type="button" role="menuitem" onClick={onExplainAccess} disabled={!canExplainAccess}><KeyRound size={15} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</button>
{showDelete && <button type="button" role="menuitem" className="danger" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</button>}
</div>);
@@ -332,4 +335,4 @@ export function FileConflictDialog({
</div>
</FileDialog>);
}
}

View File

@@ -14,6 +14,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.allowed_paths.c953b4be": "Allowed paths",
"i18n:govoplan-files.allowed_providers.82a9c23e": "Allowed providers",
"i18n:govoplan-files.allowed.77c7b490": "Allowed",
"i18n:govoplan-files.access_explanation.75ee7f62": "Access explanation",
"i18n:govoplan-files.action.97c89a4d": "Action",
"i18n:govoplan-files.already_at_the_root_folder.1238f110": "Already at the root folder",
"i18n:govoplan-files.and.a0d93385": "… and",
"i18n:govoplan-files.anonymous.9bed5104": "Anonymous",
@@ -24,6 +26,18 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.attachments_attachment_sources.87d92d5b": "Attachments → Attachment sources",
"i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543": "Audit-relevant files stay retained when deleted from active browsing.",
"i18n:govoplan-files.available.974948f2": " · Available",
"i18n:govoplan-files.close.bbfa773e": "Close",
"i18n:govoplan-files.evidence.8487d192": "Evidence",
"i18n:govoplan-files.explain_access.4d5fac37": "Explain access",
"i18n:govoplan-files.loading_access_explanation.04a7c934": "Loading access explanation...",
"i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e": "No access evidence was returned.",
"i18n:govoplan-files.no_source.6dcf9723": "No source",
"i18n:govoplan-files.owner.89ff3122": "Owner",
"i18n:govoplan-files.permission.2f81a22d": "Permission",
"i18n:govoplan-files.policy.0b779a05": "Policy",
"i18n:govoplan-files.resource.d1c626a9": "Resource",
"i18n:govoplan-files.role.b5b4a5a2": "Role",
"i18n:govoplan-files.share.09ca55ca": "Share",
"i18n:govoplan-files.back_to_folder.34ba1ed1": "Back to folder",
"i18n:govoplan-files.base_path.6a4867ca": "Base path",
"i18n:govoplan-files.blocked_by_policy.8d971f86": "Blocked by policy",
@@ -357,6 +371,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.allowed_paths.c953b4be": "Allowed paths",
"i18n:govoplan-files.allowed_providers.82a9c23e": "Allowed providers",
"i18n:govoplan-files.allowed.77c7b490": "Allowed",
"i18n:govoplan-files.access_explanation.75ee7f62": "Zugriffserklärung",
"i18n:govoplan-files.action.97c89a4d": "Aktion",
"i18n:govoplan-files.already_at_the_root_folder.1238f110": "Already at the root folder",
"i18n:govoplan-files.and.a0d93385": "… and",
"i18n:govoplan-files.anonymous.9bed5104": "Anonymous",
@@ -367,6 +383,18 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.attachments_attachment_sources.87d92d5b": "Attachments → Attachment sources",
"i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543": "Audit-relevant files stay retained when deleted from active browsing.",
"i18n:govoplan-files.available.974948f2": " · Available",
"i18n:govoplan-files.close.bbfa773e": "Schließen",
"i18n:govoplan-files.evidence.8487d192": "Nachweise",
"i18n:govoplan-files.explain_access.4d5fac37": "Zugriff erklären",
"i18n:govoplan-files.loading_access_explanation.04a7c934": "Zugriffserklärung wird geladen...",
"i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e": "Es wurden keine Zugriffsnachweise zurückgegeben.",
"i18n:govoplan-files.no_source.6dcf9723": "Keine Quelle",
"i18n:govoplan-files.owner.89ff3122": "Eigentümer",
"i18n:govoplan-files.permission.2f81a22d": "Berechtigung",
"i18n:govoplan-files.policy.0b779a05": "Richtlinie",
"i18n:govoplan-files.resource.d1c626a9": "Ressource",
"i18n:govoplan-files.role.b5b4a5a2": "Rolle",
"i18n:govoplan-files.share.09ca55ca": "Freigabe",
"i18n:govoplan-files.back_to_folder.34ba1ed1": "Back to folder",
"i18n:govoplan-files.base_path.6a4867ca": "Base path",
"i18n:govoplan-files.blocked_by_policy.8d971f86": "Blocked by policy",