feat: select users in campaign access explanations

This commit is contained in:
2026-08-20 06:16:43 +02:00
parent 5c27527725
commit d5b874c469
3 changed files with 57 additions and 9 deletions
+4 -2
View File
@@ -669,7 +669,9 @@ manifest = ModuleManifest(
"and ownership-transfer records describe governance visibility separately from the content grant. Saved imports expose " "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 " "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 " "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", layer="available",
documentation_types=("admin",), documentation_types=("admin",),
@@ -693,7 +695,7 @@ manifest = ModuleManifest(
kind="repository", kind="repository",
), ),
), ),
related_modules=("access", "mail", "postbox", "templates"), related_modules=("access", "audit", "mail", "policy", "postbox", "templates"),
translations={ translations={
"de": { "de": {
"title": "Zugriff auf untergeordnete Campaign-Nachweise erklaeren", "title": "Zugriff auf untergeordnete Campaign-Nachweise erklaeren",
+6 -2
View File
@@ -5,11 +5,15 @@ import {
type ReferenceOptionProvider type ReferenceOptionProvider
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery"; import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
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 CampaignListResponse = export type CampaignListResponse =
@@ -9,12 +9,14 @@ import {
} from "lucide-react"; } from "lucide-react";
import { import {
fetchResourceAccessExplanation, fetchResourceAccessExplanation,
fetchResourceAccessExplanationSubjects,
getCampaignShares, getCampaignShares,
campaignShareTargetProvider, campaignShareTargetProvider,
revokeCampaignShare, revokeCampaignShare,
upsertCampaignShare, upsertCampaignShare,
type CampaignShare, type CampaignShare,
type ResourceAccessExplanationResponse } from type ResourceAccessExplanationResponse,
type ResourceAccessExplanationSubjectsResponse } from
"../../../api/campaigns"; "../../../api/campaigns";
import { import {
Button, Button,
@@ -72,6 +74,8 @@ export default function CampaignAccessCard({
const [sharePermission, setSharePermission] = useState<"read" | "write">("read"); const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
const [accessExplanationOpen, setAccessExplanationOpen] = useState(false); const [accessExplanationOpen, setAccessExplanationOpen] = useState(false);
const [accessExplanation, setAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null); const [accessExplanation, setAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
const [accessExplanationSubjects, setAccessExplanationSubjects] = useState<ResourceAccessExplanationSubjectsResponse | null>(null);
const [accessExplanationSubjectId, setAccessExplanationSubjectId] = useState("");
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false); const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
const [ownershipTransfers, setOwnershipTransfers] = useState<OwnershipTransfer[]>([]); const [ownershipTransfers, setOwnershipTransfers] = useState<OwnershipTransfer[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -346,13 +350,24 @@ export default function CampaignAccessCard({
} }
async function openAccessExplanation() { async function openAccessExplanation() {
if (!auth.user?.id) return; const currentUserId = auth.principal?.membership_id || auth.user?.id;
if (!currentUserId) return;
setAccessExplanationOpen(true); setAccessExplanationOpen(true);
setAccessExplanation(null); setAccessExplanation(null);
setAccessExplanationSubjects(null);
setAccessExplanationLoading(true); setAccessExplanationLoading(true);
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.");
setAccessExplanationSubjects(subjects);
setAccessExplanationSubjectId(initialSubjectId);
setAccessExplanation(await fetchResourceAccessExplanation(settings, { setAccessExplanation(await fetchResourceAccessExplanation(settings, {
userId: auth.user.id, userId: initialSubjectId,
resourceType: "campaign", resourceType: "campaign",
resourceId: campaign.id, resourceId: campaign.id,
action: "campaigns:campaign:read", 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<DataGridColumn<CampaignShare>[]>(() => [ const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
{ {
id: "target", id: "target",
@@ -517,8 +552,15 @@ export default function CampaignAccessCard({
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField> <FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
</Dialog> </Dialog>
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-core.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}> <Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-core.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); setAccessExplanationSubjects(null); setAccessExplanationSubjectId(""); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); setAccessExplanationSubjects(null); setAccessExplanationSubjectId(""); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
<ResourceAccessExplanation loading={accessExplanationLoading} explanation={accessExplanation} fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} /> <ResourceAccessExplanation
loading={accessExplanationLoading}
explanation={accessExplanation}
subjects={accessExplanationSubjects}
selectedUserId={accessExplanationSubjectId}
onSelectedUserIdChange={(userId) => void selectAccessExplanationSubject(userId)}
fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id}
/>
</Dialog> </Dialog>
<ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} /> <ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />