refactor(webui): use core access explanation

This commit is contained in:
2026-07-21 13:18:40 +02:00
parent 641bead0d8
commit 002ca4b371
4 changed files with 11 additions and 105 deletions

View File

@@ -22,7 +22,7 @@
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -17,7 +17,7 @@
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -1,5 +1,11 @@
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
import { apiDownload, apiFetch } from "./client";
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
export type {
AccessDecisionProvenanceItem,
ResourceAccessExplanationUser as AccessExplanationUser,
ResourceAccessExplanationResponse
} from "@govoplan/core-webui";
export type CampaignListResponse =
CampaignListItem[] |
@@ -22,27 +28,6 @@ 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;
@@ -995,20 +980,6 @@ 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;

View File

@@ -12,7 +12,7 @@ import {
type CampaignShareTargets,
type ResourceAccessExplanationResponse } from
"../../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Button, ResourceAccessExplanation } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
@@ -221,76 +221,11 @@ 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 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>}>
<ResourceAccessExplanation 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);
}