feat: select users in file access explanations
This commit is contained in:
@@ -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.",
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user