diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 83107ae..8dcf8a3 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -669,7 +669,9 @@ manifest = ModuleManifest( "and ownership-transfer records describe governance visibility separately from the content grant. Saved imports expose " "only their stable source identity and revision, while independently user-owned mapping profiles never inherit a Campaign " "share. Persisted validation, build, snapshot, and review artifacts use typed version references. Reusable templates and " - "durable export packages stay with their optional owning modules and fail closed when Campaign is asked to explain them." + "durable export packages stay with their optional owning modules and fail closed when Campaign is asked to explain them. " + "The shared explanation dialog evaluates the signed-in user by default. Policy may permit a tenant-bounded selected-user " + "administrator diagnostic; Access returns only permitted subject metadata and records every cross-user explanation in audit evidence." ), layer="available", documentation_types=("admin",), @@ -693,7 +695,7 @@ manifest = ModuleManifest( kind="repository", ), ), - related_modules=("access", "mail", "postbox", "templates"), + related_modules=("access", "audit", "mail", "policy", "postbox", "templates"), translations={ "de": { "title": "Zugriff auf untergeordnete Campaign-Nachweise erklaeren", diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 1bcb53a..6a4cd43 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -5,11 +5,15 @@ import { type ReferenceOptionProvider } from "@govoplan/core-webui"; import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery"; -export { fetchResourceAccessExplanation } from "@govoplan/core-webui"; +export { + fetchResourceAccessExplanation, + fetchResourceAccessExplanationSubjects +} from "@govoplan/core-webui"; export type { AccessDecisionProvenanceItem, ResourceAccessExplanationUser as AccessExplanationUser, - ResourceAccessExplanationResponse + ResourceAccessExplanationResponse, + ResourceAccessExplanationSubjectsResponse } from "@govoplan/core-webui"; export type CampaignListResponse = diff --git a/webui/src/features/campaigns/components/CampaignAccessCard.tsx b/webui/src/features/campaigns/components/CampaignAccessCard.tsx index 824e4d8..9035f0d 100644 --- a/webui/src/features/campaigns/components/CampaignAccessCard.tsx +++ b/webui/src/features/campaigns/components/CampaignAccessCard.tsx @@ -9,12 +9,14 @@ import { } from "lucide-react"; import { fetchResourceAccessExplanation, + fetchResourceAccessExplanationSubjects, getCampaignShares, campaignShareTargetProvider, revokeCampaignShare, upsertCampaignShare, type CampaignShare, - type ResourceAccessExplanationResponse } from + type ResourceAccessExplanationResponse, + type ResourceAccessExplanationSubjectsResponse } from "../../../api/campaigns"; import { Button, @@ -72,6 +74,8 @@ export default function CampaignAccessCard({ const [sharePermission, setSharePermission] = useState<"read" | "write">("read"); const [accessExplanationOpen, setAccessExplanationOpen] = useState(false); const [accessExplanation, setAccessExplanation] = useState(null); + const [accessExplanationSubjects, setAccessExplanationSubjects] = useState(null); + const [accessExplanationSubjectId, setAccessExplanationSubjectId] = useState(""); const [accessExplanationLoading, setAccessExplanationLoading] = useState(false); const [ownershipTransfers, setOwnershipTransfers] = useState([]); const [busy, setBusy] = useState(false); @@ -346,13 +350,24 @@ export default function CampaignAccessCard({ } async function openAccessExplanation() { - if (!auth.user?.id) return; + const currentUserId = auth.principal?.membership_id || auth.user?.id; + if (!currentUserId) return; setAccessExplanationOpen(true); setAccessExplanation(null); + setAccessExplanationSubjects(null); setAccessExplanationLoading(true); 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."); + setAccessExplanationSubjects(subjects); + setAccessExplanationSubjectId(initialSubjectId); setAccessExplanation(await fetchResourceAccessExplanation(settings, { - userId: auth.user.id, + userId: initialSubjectId, resourceType: "campaign", resourceId: campaign.id, action: "campaigns:campaign:read", @@ -366,6 +381,26 @@ export default function CampaignAccessCard({ } } + async function selectAccessExplanationSubject(userId: string) { + if (userId === accessExplanationSubjectId) return; + setAccessExplanationLoading(true); + try { + const explanation = await fetchResourceAccessExplanation(settings, { + userId, + resourceType: "campaign", + resourceId: campaign.id, + action: "campaigns:campaign:read", + tenantId: (auth.active_tenant ?? auth.tenant)?.id + }); + setAccessExplanation(explanation); + setAccessExplanationSubjectId(userId); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } finally { + setAccessExplanationLoading(false); + } + } + const columns = useMemo[]>(() => [ { id: "target", @@ -517,8 +552,15 @@ export default function CampaignAccessCard({ - { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); } }} footer={}> - + { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); setAccessExplanationSubjects(null); setAccessExplanationSubjectId(""); } }} footer={}> + void selectAccessExplanationSubject(userId)} + fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} + /> setRevokeTarget(null)} onConfirm={() => void revoke()} />