Add campaign resource access explanations
This commit is contained in:
@@ -4,6 +4,7 @@ from collections.abc import Iterable, Mapping
|
||||
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
|
||||
from govoplan_core.core.campaigns import (
|
||||
CampaignAccessProvider,
|
||||
CampaignDeliveryTaskProvider,
|
||||
@@ -13,10 +14,14 @@ from govoplan_core.core.campaigns import (
|
||||
CampaignPolicyContextProvider,
|
||||
CampaignRetentionProvider,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
|
||||
|
||||
|
||||
READ_ACTIONS = {"campaigns:campaign:read", "campaign:read"}
|
||||
|
||||
|
||||
class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider):
|
||||
def get_campaign_mail_policy_context(
|
||||
self,
|
||||
@@ -105,11 +110,122 @@ class CampaignAccessService(CampaignAccessProvider):
|
||||
)
|
||||
return share is not None
|
||||
|
||||
def explain_resource_provenance(
|
||||
self,
|
||||
session: object,
|
||||
principal: PrincipalRef,
|
||||
*,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
action: str,
|
||||
) -> tuple[AccessDecisionProvenance, ...]:
|
||||
normalized_type = resource_type.lower().strip()
|
||||
if normalized_type not in {"campaign", "campaign_object", "campaigns:campaign"}:
|
||||
return ()
|
||||
campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined]
|
||||
if campaign is None or (principal.tenant_id and campaign.tenant_id != principal.tenant_id):
|
||||
return (
|
||||
AccessDecisionProvenance(
|
||||
kind="resource",
|
||||
id=resource_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
source="campaigns.not_found",
|
||||
details={"resource_type": "campaign", "found": False},
|
||||
),
|
||||
)
|
||||
items = [
|
||||
AccessDecisionProvenance(
|
||||
kind="resource",
|
||||
id=campaign.id,
|
||||
label=campaign.name,
|
||||
tenant_id=campaign.tenant_id,
|
||||
source="campaigns.campaign",
|
||||
details={
|
||||
"resource_type": "campaign",
|
||||
"external_id": campaign.external_id,
|
||||
"status": campaign.status,
|
||||
},
|
||||
)
|
||||
]
|
||||
items.extend(_owner_provenance(campaign, principal))
|
||||
items.extend(_tenant_admin_provenance(principal))
|
||||
permission_values = {"read", "write"} if action in READ_ACTIONS else {"write"}
|
||||
shares = (
|
||||
session.query(CampaignShare) # type: ignore[attr-defined]
|
||||
.filter(
|
||||
CampaignShare.tenant_id == campaign.tenant_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
CampaignShare.permission.in_(sorted(permission_values)),
|
||||
or_(
|
||||
(CampaignShare.target_type == "user") & (CampaignShare.target_id == principal.membership_id),
|
||||
(CampaignShare.target_type == "group") & (CampaignShare.target_id.in_(sorted(principal.group_ids))),
|
||||
),
|
||||
)
|
||||
.order_by(CampaignShare.target_type.asc(), CampaignShare.target_id.asc())
|
||||
.all()
|
||||
)
|
||||
for share in shares:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="share",
|
||||
id=share.id,
|
||||
label=share.permission,
|
||||
tenant_id=share.tenant_id,
|
||||
source="campaigns.share",
|
||||
details={
|
||||
"target_type": share.target_type,
|
||||
"target_id": share.target_id,
|
||||
"permission": share.permission,
|
||||
},
|
||||
)
|
||||
)
|
||||
return tuple(items)
|
||||
|
||||
|
||||
def access_capability(context: object) -> CampaignAccessService:
|
||||
return CampaignAccessService()
|
||||
|
||||
|
||||
def _owner_provenance(campaign: Campaign, principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]:
|
||||
if campaign.owner_user_id and campaign.owner_user_id == principal.membership_id:
|
||||
return (
|
||||
AccessDecisionProvenance(
|
||||
kind="owner",
|
||||
id=campaign.owner_user_id,
|
||||
tenant_id=campaign.tenant_id,
|
||||
source="campaigns.owner",
|
||||
details={"owner_type": "user"},
|
||||
),
|
||||
)
|
||||
if campaign.owner_group_id and campaign.owner_group_id in principal.group_ids:
|
||||
return (
|
||||
AccessDecisionProvenance(
|
||||
kind="owner",
|
||||
id=campaign.owner_group_id,
|
||||
tenant_id=campaign.tenant_id,
|
||||
source="campaigns.owner",
|
||||
details={"owner_type": "group"},
|
||||
),
|
||||
)
|
||||
return ()
|
||||
|
||||
|
||||
def _tenant_admin_provenance(principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]:
|
||||
if not scopes_grant_compatible(principal.scopes, "tenant:*"):
|
||||
return ()
|
||||
return (
|
||||
AccessDecisionProvenance(
|
||||
kind="policy",
|
||||
id="tenant:*",
|
||||
label="tenant:*",
|
||||
tenant_id=principal.tenant_id,
|
||||
source="campaigns.tenant_admin",
|
||||
details={"grant": "tenant_admin"},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CampaignPolicyContextService(CampaignPolicyContextProvider):
|
||||
def get_campaign_policy_context(
|
||||
self,
|
||||
|
||||
95
tests/test_access_provider.py
Normal file
95
tests/test_access_provider.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.capabilities import CampaignAccessService
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
TENANT_ID = "tenant-1"
|
||||
USER_ID = "user-1"
|
||||
OTHER_USER_ID = "user-2"
|
||||
GROUP_ID = "group-1"
|
||||
|
||||
|
||||
class CampaignAccessProviderTests(unittest.TestCase):
|
||||
def test_campaign_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
|
||||
session = _session()
|
||||
_seed_access_subjects(session)
|
||||
owned = Campaign(id="campaign-owned", tenant_id=TENANT_ID, owner_user_id=USER_ID, external_id="owned", name="Owned campaign")
|
||||
shared = Campaign(id="campaign-shared", tenant_id=TENANT_ID, owner_user_id=OTHER_USER_ID, external_id="shared", name="Shared campaign")
|
||||
session.add_all([
|
||||
owned,
|
||||
shared,
|
||||
CampaignShare(id="share-group", tenant_id=TENANT_ID, campaign_id=shared.id, target_type="group", target_id=GROUP_ID, permission="read"),
|
||||
])
|
||||
session.commit()
|
||||
|
||||
service = CampaignAccessService()
|
||||
owned_items = service.explain_resource_provenance(session, _principal(), resource_type="campaign", resource_id=owned.id, action="campaigns:campaign:read")
|
||||
shared_items = service.explain_resource_provenance(session, _principal(group_ids={GROUP_ID}), resource_type="campaign", resource_id=shared.id, action="campaigns:campaign:read")
|
||||
admin_items = service.explain_resource_provenance(session, _principal(scopes={"tenant:*"}), resource_type="campaign", resource_id=shared.id, action="campaigns:campaign:read")
|
||||
missing_items = service.explain_resource_provenance(session, _principal(), resource_type="campaign", resource_id="missing-campaign", action="campaigns:campaign:read")
|
||||
|
||||
self.assertTrue(any(item.kind == "resource" and item.source == "campaigns.campaign" and item.id == owned.id for item in owned_items))
|
||||
self.assertTrue(any(item.kind == "owner" and item.id == USER_ID for item in owned_items))
|
||||
self.assertTrue(any(item.kind == "share" and item.id == "share-group" for item in shared_items))
|
||||
self.assertTrue(any(item.kind == "policy" and item.id == "tenant:*" for item in admin_items))
|
||||
self.assertEqual("campaigns.not_found", missing_items[0].source)
|
||||
self.assertIs(missing_items[0].details["found"], False)
|
||||
|
||||
def test_campaign_access_provider_requires_write_share_for_write_actions(self) -> None:
|
||||
session = _session()
|
||||
_seed_access_subjects(session)
|
||||
campaign = Campaign(id="campaign-write", tenant_id=TENANT_ID, owner_user_id=OTHER_USER_ID, external_id="write", name="Write campaign")
|
||||
session.add_all([
|
||||
campaign,
|
||||
CampaignShare(id="share-read", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="group", target_id=GROUP_ID, permission="read"),
|
||||
CampaignShare(id="share-write", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="user", target_id=USER_ID, permission="write"),
|
||||
])
|
||||
session.commit()
|
||||
|
||||
service = CampaignAccessService()
|
||||
items = service.explain_resource_provenance(
|
||||
session,
|
||||
_principal(group_ids={GROUP_ID}),
|
||||
resource_type="campaign",
|
||||
resource_id=campaign.id,
|
||||
action="campaigns:campaign:update",
|
||||
)
|
||||
|
||||
self.assertTrue(any(item.kind == "share" and item.id == "share-write" for item in items))
|
||||
self.assertFalse(any(item.kind == "share" and item.id == "share-read" for item in items))
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, Campaign.__table__, CampaignShare.__table__])
|
||||
return sessionmaker(bind=engine, future=True)()
|
||||
|
||||
|
||||
def _seed_access_subjects(session) -> None:
|
||||
session.add_all([
|
||||
Account(id="account-1", email="one@example.test", normalized_email="one@example.test"),
|
||||
Account(id="account-2", email="two@example.test", normalized_email="two@example.test"),
|
||||
User(id=USER_ID, tenant_id=TENANT_ID, account_id="account-1", email="one@example.test"),
|
||||
User(id=OTHER_USER_ID, tenant_id=TENANT_ID, account_id="account-2", email="two@example.test"),
|
||||
Group(id=GROUP_ID, tenant_id=TENANT_ID, slug="group", name="Group"),
|
||||
])
|
||||
session.commit()
|
||||
|
||||
|
||||
def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef:
|
||||
return PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id=USER_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
scopes=frozenset(scopes or {"campaigns:campaign:read"}),
|
||||
group_ids=frozenset(group_ids or set()),
|
||||
)
|
||||
@@ -22,6 +22,27 @@ export type CampaignShare = {
|
||||
|
||||
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
|
||||
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
|
||||
export type AccessExplanationUser = {
|
||||
id: string;
|
||||
account_id?: string | null;
|
||||
email?: string | null;
|
||||
display_name?: string | null;
|
||||
};
|
||||
export type AccessDecisionProvenanceItem = {
|
||||
kind: string;
|
||||
id?: string | null;
|
||||
label?: string | null;
|
||||
tenant_id?: string | null;
|
||||
source?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
export type ResourceAccessExplanationResponse = {
|
||||
user: AccessExplanationUser;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
action: string;
|
||||
provenance: AccessDecisionProvenanceItem[];
|
||||
};
|
||||
|
||||
export type CampaignUpdatePayload = {
|
||||
external_id?: string | null;
|
||||
@@ -834,6 +855,20 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
|
||||
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
|
||||
}
|
||||
|
||||
export function fetchResourceAccessExplanation(
|
||||
settings: ApiSettings,
|
||||
options: {userId: string;resourceType: string;resourceId: string;action: string;tenantId?: string | null;})
|
||||
: Promise<ResourceAccessExplanationResponse> {
|
||||
const params = new URLSearchParams({
|
||||
user_id: options.userId,
|
||||
resource_type: options.resourceType,
|
||||
resource_id: options.resourceId,
|
||||
action: options.action
|
||||
});
|
||||
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
||||
return apiFetch<ResourceAccessExplanationResponse>(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
|
||||
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
|
||||
return response.shares;
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
</> :
|
||||
|
||||
<>
|
||||
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
|
||||
{data.campaign && <CampaignAccessCard settings={settings} auth={auth} campaign={data.campaign} onChanged={reload} onError={setError} />}
|
||||
|
||||
<div className="dashboard-grid below-grid">
|
||||
<Card title="i18n:govoplan-campaign.delivery_defaults.33cc3b97" collapsible>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ApiSettings, CampaignListItem } from "../../../types";
|
||||
import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import {
|
||||
fetchResourceAccessExplanation,
|
||||
getCampaignShares,
|
||||
getCampaignShareTargets,
|
||||
revokeCampaignShare,
|
||||
updateCampaignOwner,
|
||||
upsertCampaignShare,
|
||||
type CampaignShare,
|
||||
type CampaignShareTargets } from
|
||||
type CampaignShareTargets,
|
||||
type ResourceAccessExplanationResponse } from
|
||||
"../../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
@@ -15,11 +18,12 @@ import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { StatusBadge, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
type TargetType = "user" | "group";
|
||||
|
||||
export default function CampaignAccessCard({
|
||||
settings,
|
||||
auth,
|
||||
campaign,
|
||||
onChanged,
|
||||
onError
|
||||
@@ -28,7 +32,7 @@ export default function CampaignAccessCard({
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;campaign: CampaignListItem;onChanged: () => Promise<void>;onError: (message: string) => void;}) {
|
||||
}: {settings: ApiSettings;auth: AuthInfo;campaign: CampaignListItem;onChanged: () => Promise<void>;onError: (message: string) => void;}) {
|
||||
const [available, setAvailable] = useState<boolean | null>(null);
|
||||
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
|
||||
const [shares, setShares] = useState<CampaignShare[]>([]);
|
||||
@@ -40,11 +44,19 @@ export default function CampaignAccessCard({
|
||||
const [shareType, setShareType] = useState<TargetType>("user");
|
||||
const [shareTargetId, setShareTargetId] = useState("");
|
||||
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
|
||||
const [accessExplanationOpen, setAccessExplanationOpen] = useState(false);
|
||||
const [accessExplanation, setAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
|
||||
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const currentOwnerType: TargetType = campaign.owner_group_id ? "group" : "user";
|
||||
const currentOwnerId = campaign.owner_group_id || campaign.owner_user_id || "";
|
||||
const ownerDirty = ownerOpen && (ownerType !== currentOwnerType || ownerId !== currentOwnerId);
|
||||
const shareDirty = shareOpen && (shareType !== "user" || Boolean(shareTargetId) || sharePermission !== "read");
|
||||
const canExplainResourceAccess =
|
||||
hasScope(auth, "admin:users:read") ||
|
||||
hasScope(auth, "admin:roles:read") ||
|
||||
hasScope(auth, "access:membership:read") ||
|
||||
hasScope(auth, "access:role:read");
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: ownerDirty || shareDirty,
|
||||
@@ -146,6 +158,27 @@ export default function CampaignAccessCard({
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function openAccessExplanation() {
|
||||
if (!auth.user?.id) return;
|
||||
setAccessExplanationOpen(true);
|
||||
setAccessExplanation(null);
|
||||
setAccessExplanationLoading(true);
|
||||
try {
|
||||
setAccessExplanation(await fetchResourceAccessExplanation(settings, {
|
||||
userId: auth.user.id,
|
||||
resourceType: "campaign",
|
||||
resourceId: campaign.id,
|
||||
action: "campaigns:campaign:read",
|
||||
tenantId: (auth.active_tenant ?? auth.tenant)?.id
|
||||
}));
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : String(err));
|
||||
setAccessExplanationOpen(false);
|
||||
} finally {
|
||||
setAccessExplanationLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
|
||||
{
|
||||
id: "target",
|
||||
@@ -170,7 +203,7 @@ export default function CampaignAccessCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card title="i18n:govoplan-campaign.ownership_and_sharing.867283c0" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.change_owner.d3ce16a8</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.share.09ca55ca</Button></div>}>
|
||||
<Card title="i18n:govoplan-campaign.ownership_and_sharing.867283c0" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.change_owner.d3ce16a8</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.share.09ca55ca</Button><Button onClick={() => void openAccessExplanation()} disabled={available !== true || !canExplainResourceAccess}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-campaign.explain_access.4d5fac37</Button></div>}>
|
||||
<p><strong>i18n:govoplan-campaign.owner.719379ae</strong> {ownerLabel}</p>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.permissions_define_what_a_role_may_do_ownership_.9f8baaa2</p>
|
||||
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "i18n:govoplan-campaign.loading_campaign_access.056299e3" : "i18n:govoplan-campaign.this_campaign_has_no_explicit_shares.978012d1"} />
|
||||
@@ -188,7 +221,76 @@ 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>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.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>}>
|
||||
<ResourceAccessExplanationContent loading={accessExplanationLoading} explanation={accessExplanation} fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} />
|
||||
</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()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function ResourceAccessExplanationContent({
|
||||
loading,
|
||||
explanation,
|
||||
fallbackResourceLabel
|
||||
}: {loading: boolean;explanation: ResourceAccessExplanationResponse | null;fallbackResourceLabel: string;}) {
|
||||
if (loading) return <p className="muted small-note">i18n:govoplan-campaign.loading_access_explanation.04a7c934</p>;
|
||||
if (!explanation) return null;
|
||||
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
|
||||
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || fallbackResourceLabel || explanation.resource_id;
|
||||
return (
|
||||
<>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<div><span className="form-label">i18n:govoplan-campaign.user.9f8a2389</span><p>{userLabel}</p></div>
|
||||
<div><span className="form-label">i18n:govoplan-campaign.resource.d1c626a9</span><p>{resourceLabel}</p></div>
|
||||
<div><span className="form-label">i18n:govoplan-campaign.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
|
||||
<div><span className="form-label">i18n:govoplan-campaign.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
|
||||
</div>
|
||||
{explanation.provenance.length === 0 ?
|
||||
<p className="muted small-note">i18n:govoplan-campaign.no_access_evidence_was_returned.84a21e4e</p> :
|
||||
<div className="admin-assignment-grid">
|
||||
{explanation.provenance.map((item, index) =>
|
||||
<div key={`${item.kind}:${item.id ?? index}`}>
|
||||
<strong>{provenanceKindLabel(item.kind)}</strong>
|
||||
<div className="muted small-note">
|
||||
{item.source || "i18n:govoplan-campaign.no_source.6dcf9723"}
|
||||
{item.id && <> · <code>{item.id}</code></>}
|
||||
</div>
|
||||
{item.label && <p>{item.label}</p>}
|
||||
{Object.keys(item.details ?? {}).length > 0 && <p className="muted small-note">{formatProvenanceDetails(item.details ?? {})}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</>);
|
||||
}
|
||||
|
||||
function provenanceKindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "resource":
|
||||
return "i18n:govoplan-campaign.resource.d1c626a9";
|
||||
case "owner":
|
||||
return "i18n:govoplan-campaign.owner.89ff3122";
|
||||
case "share":
|
||||
return "i18n:govoplan-campaign.share.09ca55ca";
|
||||
case "policy":
|
||||
return "i18n:govoplan-campaign.policy.0b779a05";
|
||||
case "role":
|
||||
return "i18n:govoplan-campaign.role.b5b4a5a2";
|
||||
case "right":
|
||||
return "i18n:govoplan-campaign.permission.2f81a22d";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
function formatProvenanceDetails(details: Record<string, unknown>): string {
|
||||
return Object.entries(details).map(([key, value]) => `${key}: ${formatProvenanceValue(value)}`).join("; ");
|
||||
}
|
||||
|
||||
function formatProvenanceValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "i18n:govoplan-campaign.none.6eef6648";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,19 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.accept_non_blocking_review_conditions.47895387": "Accept non-blocking review conditions?",
|
||||
"i18n:govoplan-campaign.accepted.61a0572c": "Accepted",
|
||||
"i18n:govoplan-campaign.access.2f81a22d": "Access",
|
||||
"i18n:govoplan-campaign.access_explanation.75ee7f62": "Access explanation",
|
||||
"i18n:govoplan-campaign.action.97c89a4d": "Action",
|
||||
"i18n:govoplan-campaign.evidence.8487d192": "Evidence",
|
||||
"i18n:govoplan-campaign.explain_access.4d5fac37": "Explain access",
|
||||
"i18n:govoplan-campaign.loading_access_explanation.04a7c934": "Loading access explanation...",
|
||||
"i18n:govoplan-campaign.no_access_evidence_was_returned.84a21e4e": "No access evidence was returned.",
|
||||
"i18n:govoplan-campaign.no_source.6dcf9723": "No source",
|
||||
"i18n:govoplan-campaign.none.6eef6648": "None",
|
||||
"i18n:govoplan-campaign.permission.2f81a22d": "Permission",
|
||||
"i18n:govoplan-campaign.policy.0b779a05": "Policy",
|
||||
"i18n:govoplan-campaign.resource.d1c626a9": "Resource",
|
||||
"i18n:govoplan-campaign.role.b5b4a5a2": "Role",
|
||||
"i18n:govoplan-campaign.share.09ca55ca": "Share",
|
||||
"i18n:govoplan-campaign.actions.c3cd636a": "Actions",
|
||||
"i18n:govoplan-campaign.active_inline_recipients.8ba58f6e": "Active inline recipients",
|
||||
"i18n:govoplan-campaign.active.a733b809": "Active",
|
||||
@@ -1128,6 +1141,19 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.accept_non_blocking_review_conditions.47895387": "Accept non-blocking review conditions?",
|
||||
"i18n:govoplan-campaign.accepted.61a0572c": "Accepted",
|
||||
"i18n:govoplan-campaign.access.2f81a22d": "Zugriff",
|
||||
"i18n:govoplan-campaign.access_explanation.75ee7f62": "Zugriffserklärung",
|
||||
"i18n:govoplan-campaign.action.97c89a4d": "Aktion",
|
||||
"i18n:govoplan-campaign.evidence.8487d192": "Nachweise",
|
||||
"i18n:govoplan-campaign.explain_access.4d5fac37": "Zugriff erklären",
|
||||
"i18n:govoplan-campaign.loading_access_explanation.04a7c934": "Zugriffserklärung wird geladen...",
|
||||
"i18n:govoplan-campaign.no_access_evidence_was_returned.84a21e4e": "Es wurden keine Zugriffsnachweise zurückgegeben.",
|
||||
"i18n:govoplan-campaign.no_source.6dcf9723": "Keine Quelle",
|
||||
"i18n:govoplan-campaign.none.6eef6648": "Keine",
|
||||
"i18n:govoplan-campaign.permission.2f81a22d": "Berechtigung",
|
||||
"i18n:govoplan-campaign.policy.0b779a05": "Richtlinie",
|
||||
"i18n:govoplan-campaign.resource.d1c626a9": "Ressource",
|
||||
"i18n:govoplan-campaign.role.b5b4a5a2": "Rolle",
|
||||
"i18n:govoplan-campaign.share.09ca55ca": "Freigabe",
|
||||
"i18n:govoplan-campaign.actions.c3cd636a": "Aktionen",
|
||||
"i18n:govoplan-campaign.active_inline_recipients.8ba58f6e": "Active inline recipients",
|
||||
"i18n:govoplan-campaign.active.a733b809": "Aktiv",
|
||||
|
||||
Reference in New Issue
Block a user