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 sqlalchemy import and_, or_
|
||||||
|
|
||||||
|
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
|
||||||
from govoplan_core.core.campaigns import (
|
from govoplan_core.core.campaigns import (
|
||||||
CampaignAccessProvider,
|
CampaignAccessProvider,
|
||||||
CampaignDeliveryTaskProvider,
|
CampaignDeliveryTaskProvider,
|
||||||
@@ -13,10 +14,14 @@ from govoplan_core.core.campaigns import (
|
|||||||
CampaignPolicyContextProvider,
|
CampaignPolicyContextProvider,
|
||||||
CampaignRetentionProvider,
|
CampaignRetentionProvider,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
|
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
|
||||||
|
|
||||||
|
|
||||||
|
READ_ACTIONS = {"campaigns:campaign:read", "campaign:read"}
|
||||||
|
|
||||||
|
|
||||||
class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider):
|
class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider):
|
||||||
def get_campaign_mail_policy_context(
|
def get_campaign_mail_policy_context(
|
||||||
self,
|
self,
|
||||||
@@ -105,11 +110,122 @@ class CampaignAccessService(CampaignAccessProvider):
|
|||||||
)
|
)
|
||||||
return share is not None
|
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:
|
def access_capability(context: object) -> CampaignAccessService:
|
||||||
return 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):
|
class CampaignPolicyContextService(CampaignPolicyContextProvider):
|
||||||
def get_campaign_policy_context(
|
def get_campaign_policy_context(
|
||||||
self,
|
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 CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
|
||||||
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
|
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 = {
|
export type CampaignUpdatePayload = {
|
||||||
external_id?: string | null;
|
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`);
|
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[]> {
|
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
|
||||||
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
|
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
|
||||||
return response.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">
|
<div className="dashboard-grid below-grid">
|
||||||
<Card title="i18n:govoplan-campaign.delivery_defaults.33cc3b97" collapsible>
|
<Card title="i18n:govoplan-campaign.delivery_defaults.33cc3b97" collapsible>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
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 {
|
import {
|
||||||
|
fetchResourceAccessExplanation,
|
||||||
getCampaignShares,
|
getCampaignShares,
|
||||||
getCampaignShareTargets,
|
getCampaignShareTargets,
|
||||||
revokeCampaignShare,
|
revokeCampaignShare,
|
||||||
updateCampaignOwner,
|
updateCampaignOwner,
|
||||||
upsertCampaignShare,
|
upsertCampaignShare,
|
||||||
type CampaignShare,
|
type CampaignShare,
|
||||||
type CampaignShareTargets } from
|
type CampaignShareTargets,
|
||||||
|
type ResourceAccessExplanationResponse } from
|
||||||
"../../../api/campaigns";
|
"../../../api/campaigns";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Card } 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 DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
|
||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { FormField } 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";
|
type TargetType = "user" | "group";
|
||||||
|
|
||||||
export default function CampaignAccessCard({
|
export default function CampaignAccessCard({
|
||||||
settings,
|
settings,
|
||||||
|
auth,
|
||||||
campaign,
|
campaign,
|
||||||
onChanged,
|
onChanged,
|
||||||
onError
|
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 [available, setAvailable] = useState<boolean | null>(null);
|
||||||
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
|
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
|
||||||
const [shares, setShares] = useState<CampaignShare[]>([]);
|
const [shares, setShares] = useState<CampaignShare[]>([]);
|
||||||
@@ -40,11 +44,19 @@ export default function CampaignAccessCard({
|
|||||||
const [shareType, setShareType] = useState<TargetType>("user");
|
const [shareType, setShareType] = useState<TargetType>("user");
|
||||||
const [shareTargetId, setShareTargetId] = useState("");
|
const [shareTargetId, setShareTargetId] = useState("");
|
||||||
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
|
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 [busy, setBusy] = useState(false);
|
||||||
const currentOwnerType: TargetType = campaign.owner_group_id ? "group" : "user";
|
const currentOwnerType: TargetType = campaign.owner_group_id ? "group" : "user";
|
||||||
const currentOwnerId = campaign.owner_group_id || campaign.owner_user_id || "";
|
const currentOwnerId = campaign.owner_group_id || campaign.owner_user_id || "";
|
||||||
const ownerDirty = ownerOpen && (ownerType !== currentOwnerType || ownerId !== currentOwnerId);
|
const ownerDirty = ownerOpen && (ownerType !== currentOwnerType || ownerId !== currentOwnerId);
|
||||||
const shareDirty = shareOpen && (shareType !== "user" || Boolean(shareTargetId) || sharePermission !== "read");
|
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({
|
useUnsavedDraftGuard({
|
||||||
dirty: ownerDirty || shareDirty,
|
dirty: ownerDirty || shareDirty,
|
||||||
@@ -146,6 +158,27 @@ export default function CampaignAccessCard({
|
|||||||
{setBusy(false);}
|
{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>[]>(() => [
|
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
|
||||||
{
|
{
|
||||||
id: "target",
|
id: "target",
|
||||||
@@ -170,7 +203,7 @@ export default function CampaignAccessCard({
|
|||||||
|
|
||||||
return (
|
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><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>
|
<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"} />
|
<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>
|
<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-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()} />
|
<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.accept_non_blocking_review_conditions.47895387": "Accept non-blocking review conditions?",
|
||||||
"i18n:govoplan-campaign.accepted.61a0572c": "Accepted",
|
"i18n:govoplan-campaign.accepted.61a0572c": "Accepted",
|
||||||
"i18n:govoplan-campaign.access.2f81a22d": "Access",
|
"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.actions.c3cd636a": "Actions",
|
||||||
"i18n:govoplan-campaign.active_inline_recipients.8ba58f6e": "Active inline recipients",
|
"i18n:govoplan-campaign.active_inline_recipients.8ba58f6e": "Active inline recipients",
|
||||||
"i18n:govoplan-campaign.active.a733b809": "Active",
|
"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.accept_non_blocking_review_conditions.47895387": "Accept non-blocking review conditions?",
|
||||||
"i18n:govoplan-campaign.accepted.61a0572c": "Accepted",
|
"i18n:govoplan-campaign.accepted.61a0572c": "Accepted",
|
||||||
"i18n:govoplan-campaign.access.2f81a22d": "Zugriff",
|
"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.actions.c3cd636a": "Aktionen",
|
||||||
"i18n:govoplan-campaign.active_inline_recipients.8ba58f6e": "Active inline recipients",
|
"i18n:govoplan-campaign.active_inline_recipients.8ba58f6e": "Active inline recipients",
|
||||||
"i18n:govoplan-campaign.active.a733b809": "Aktiv",
|
"i18n:govoplan-campaign.active.a733b809": "Aktiv",
|
||||||
|
|||||||
Reference in New Issue
Block a user