feat: select users in file access explanations

This commit is contained in:
2026-08-20 06:16:43 +02:00
parent a9c7a7a40c
commit 95aef18955
3 changed files with 53 additions and 9 deletions
+3 -2
View File
@@ -978,7 +978,7 @@ manifest = ModuleManifest(
summary="List, grant, update, expire, or revoke direct read, write, and manage access without changing ownership.", summary="List, grant, update, expire, or revoke direct read, write, and manage access without changing ownership.",
body=( body=(
"File owners and file administrators can manage direct shares for users, groups, the tenant, and Campaign. Expired and revoked grants stop authorizing access immediately while independent active grants remain effective. " "File owners and file administrators can manage direct shares for users, groups, the tenant, and Campaign. Expired and revoked grants stop authorizing access immediately while independent active grants remain effective. "
"The Files share dialog lists active and historical grants, and revocation is idempotent." "The Files share dialog lists active and historical grants, and revocation is idempotent. The access explanation action evaluates the signed-in user by default. When Policy permits selected-user diagnostics, the same shared dialog can evaluate another active user in the current tenant; those cross-user diagnostics are recorded in audit evidence and do not grant access."
), ),
layer="available", layer="available",
documentation_types=("user",), documentation_types=("user",),
@@ -1003,7 +1003,7 @@ manifest = ModuleManifest(
kind="repository", kind="repository",
), ),
), ),
related_modules=("campaigns",), related_modules=("access", "audit", "campaigns", "policy"),
unlocks=( unlocks=(
"A supporting process can grant governed file access without changing file ownership.", "A supporting process can grant governed file access without changing file ownership.",
), ),
@@ -1025,6 +1025,7 @@ manifest = ModuleManifest(
"limitations": [ "limitations": [
"Campaign-target grants are normally created by the Campaign integration rather than selected manually in the Files dialog.", "Campaign-target grants are normally created by the Campaign integration rather than selected manually in the Files dialog.",
"A user may retain access through another active direct grant or ownership path after one share is revoked.", "A user may retain access through another active direct grant or ownership path after one share is revoked.",
"Selected-user explanations require Policy permission and are diagnostics only; they do not impersonate the user or grant file access.",
], ],
"outcome": "Direct access has the requested permission and lifetime while the managed asset keeps its owner.", "outcome": "Direct access has the requested permission and lifetime while the managed asset keeps its owner.",
"verification": "Test one intended and one denied path, then expire or revoke the grant and verify that only independent access paths remain.", "verification": "Test one intended and one denied path, then expire or revoke the grant and verify that only independent access paths remain.",
+6 -2
View File
@@ -10,11 +10,15 @@ import {
type FilesManagedFileLinkTarget, type FilesManagedFileLinkTarget,
type ReferenceOptionProvider type ReferenceOptionProvider
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
export { fetchResourceAccessExplanation } from "@govoplan/core-webui"; export {
fetchResourceAccessExplanation,
fetchResourceAccessExplanationSubjects
} from "@govoplan/core-webui";
export type { export type {
AccessDecisionProvenanceItem, AccessDecisionProvenanceItem,
ResourceAccessExplanationUser as AccessExplanationUser, ResourceAccessExplanationUser as AccessExplanationUser,
ResourceAccessExplanationResponse ResourceAccessExplanationResponse,
ResourceAccessExplanationSubjectsResponse
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
export type FileSpace = { export type FileSpace = {
+44 -5
View File
@@ -29,6 +29,7 @@ import {
downloadFile, downloadFile,
downloadFilesAsZip, downloadFilesAsZip,
fetchResourceAccessExplanation, fetchResourceAccessExplanation,
fetchResourceAccessExplanationSubjects,
listFilesDelta, listFilesDelta,
listFilesByProperties, listFilesByProperties,
listFileConnectorProfiles, listFileConnectorProfiles,
@@ -52,7 +53,8 @@ import {
type FileSpace, type FileSpace,
type ManagedFile, type ManagedFile,
type RenameResponse, type RenameResponse,
type ResourceAccessExplanationResponse } from type ResourceAccessExplanationResponse,
type ResourceAccessExplanationSubjectsResponse } from
"../../api/files"; "../../api/files";
import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants"; import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants";
import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents"; import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents";
@@ -176,6 +178,8 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const [error, setError] = useState(""); const [error, setError] = useState("");
const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null); const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null);
const [resourceAccessExplanation, setResourceAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null); const [resourceAccessExplanation, setResourceAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
const [resourceAccessSubjects, setResourceAccessSubjects] = useState<ResourceAccessExplanationSubjectsResponse | null>(null);
const [resourceAccessSubjectId, setResourceAccessSubjectId] = useState("");
const [resourceAccessLoading, setResourceAccessLoading] = useState(false); const [resourceAccessLoading, setResourceAccessLoading] = useState(false);
const [shareDialogFile, setShareDialogFile] = useState<ManagedFile | null>(null); const [shareDialogFile, setShareDialogFile] = useState<ManagedFile | null>(null);
const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState(); const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState();
@@ -1911,14 +1915,25 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
} }
async function openAccessExplanation(target: FileAccessExplanationTarget): Promise<void> { async function openAccessExplanation(target: FileAccessExplanationTarget): Promise<void> {
if (!auth.user?.id) return; const currentUserId = auth.principal?.membership_id || auth.user?.id;
if (!currentUserId) return;
setAccessExplanationTarget(target); setAccessExplanationTarget(target);
setResourceAccessExplanation(null); setResourceAccessExplanation(null);
setResourceAccessSubjects(null);
setResourceAccessLoading(true); setResourceAccessLoading(true);
setError(""); setError("");
try { try {
const subjects = await fetchResourceAccessExplanationSubjects(settings, {
tenantId: (auth.active_tenant ?? auth.tenant)?.id
});
const initialSubjectId = subjects.users.some((user) => user.id === currentUserId)
? currentUserId
: subjects.users[0]?.id;
if (!initialSubjectId) throw new Error("No permitted access-explanation subject is available.");
setResourceAccessSubjects(subjects);
setResourceAccessSubjectId(initialSubjectId);
setResourceAccessExplanation(await fetchResourceAccessExplanation(settings, { setResourceAccessExplanation(await fetchResourceAccessExplanation(settings, {
userId: auth.user.id, userId: initialSubjectId,
resourceType: target.resourceType, resourceType: target.resourceType,
resourceId: target.resourceId, resourceId: target.resourceId,
action: target.action, action: target.action,
@@ -1932,6 +1947,27 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
} }
} }
async function selectAccessExplanationSubject(userId: string): Promise<void> {
if (!accessExplanationTarget || userId === resourceAccessSubjectId) return;
setResourceAccessLoading(true);
setError("");
try {
const explanation = await fetchResourceAccessExplanation(settings, {
userId,
resourceType: accessExplanationTarget.resourceType,
resourceId: accessExplanationTarget.resourceId,
action: accessExplanationTarget.action,
tenantId: (auth.active_tenant ?? auth.tenant)?.id
});
setResourceAccessExplanation(explanation);
setResourceAccessSubjectId(userId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setResourceAccessLoading(false);
}
}
function openTransferDialogForContext(menu: ContextMenuState | null, mode: TransferMode) { function openTransferDialogForContext(menu: ContextMenuState | null, mode: TransferMode) {
const sets = selectedSetsForContext(menu); const sets = selectedSetsForContext(menu);
const space = spaceForContext(menu); const space = spaceForContext(menu);
@@ -2595,13 +2631,16 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
} }
{accessExplanationTarget && {accessExplanationTarget &&
<FileDialog title="i18n:govoplan-core.access_explanation.75ee7f62" onClose={() => {if (!resourceAccessLoading) {setAccessExplanationTarget(null);setResourceAccessExplanation(null);}}}> <FileDialog title="i18n:govoplan-core.access_explanation.75ee7f62" onClose={() => {if (!resourceAccessLoading) {setAccessExplanationTarget(null);setResourceAccessExplanation(null);setResourceAccessSubjects(null);setResourceAccessSubjectId("");}}}>
<ResourceAccessExplanation <ResourceAccessExplanation
loading={resourceAccessLoading} loading={resourceAccessLoading}
explanation={resourceAccessExplanation} explanation={resourceAccessExplanation}
subjects={resourceAccessSubjects}
selectedUserId={resourceAccessSubjectId}
onSelectedUserIdChange={(userId) => void selectAccessExplanationSubject(userId)}
fallbackResourceLabel={accessExplanationTarget.label} /> fallbackResourceLabel={accessExplanationTarget.label} />
<div className="button-row compact-actions align-end"> <div className="button-row compact-actions align-end">
<Button onClick={() => {setAccessExplanationTarget(null);setResourceAccessExplanation(null);}} disabled={resourceAccessLoading}>i18n:govoplan-files.close.bbfa773e</Button> <Button onClick={() => {setAccessExplanationTarget(null);setResourceAccessExplanation(null);setResourceAccessSubjects(null);setResourceAccessSubjectId("");}} disabled={resourceAccessLoading}>i18n:govoplan-files.close.bbfa773e</Button>
</div> </div>
</FileDialog> </FileDialog>
} }