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
+3 -1
View File
@@ -670,6 +670,8 @@ manifest = ModuleManifest(
"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()} />