From 6526f37aae17f1b60ac2527fef846f75fe759e2a Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 21 Jul 2026 13:18:30 +0200 Subject: [PATCH] feat(webui): centralize resource access explanations --- webui/package.json | 3 +- webui/src/api/resourceAccess.ts | 35 ++++++++ webui/src/api/resourceAccessContracts.ts | 34 ++++++++ .../components/ResourceAccessExplanation.tsx | 84 +++++++++++++++++++ webui/src/i18n/generatedTranslations.ts | 24 ++++++ webui/src/index.ts | 3 + .../resource-access-explanation.test.tsx | 73 ++++++++++++++++ webui/tsconfig.component-tests.json | 2 + 8 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 webui/src/api/resourceAccess.ts create mode 100644 webui/src/api/resourceAccessContracts.ts create mode 100644 webui/src/components/ResourceAccessExplanation.tsx create mode 100644 webui/tests/resource-access-explanation.test.tsx diff --git a/webui/package.json b/webui/package.json index 90bb0aa..6a60fdc 100644 --- a/webui/package.json +++ b/webui/package.json @@ -26,7 +26,8 @@ "test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs", "test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js", "test:module-permutations": "node scripts/test-module-permutations.mjs", - "test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js" + "test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js", + "test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js" }, "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", diff --git a/webui/src/api/resourceAccess.ts b/webui/src/api/resourceAccess.ts new file mode 100644 index 0000000..7798c20 --- /dev/null +++ b/webui/src/api/resourceAccess.ts @@ -0,0 +1,35 @@ +import type { ApiSettings } from "../types"; +import { apiFetch, apiPath } from "./client"; +import type { + AccessDecisionProvenanceItem, + ResourceAccessExplanationOptions, + ResourceAccessExplanationResponse, + ResourceAccessExplanationUser +} from "./resourceAccessContracts"; +export type { + AccessDecisionProvenanceItem, + ResourceAccessExplanationOptions, + ResourceAccessExplanationResponse, + ResourceAccessExplanationUser +} from "./resourceAccessContracts"; + +/** + * Fetches the platform's optional resource-access explanation endpoint. + * Core owns the transport contract without requiring the Access module that + * contributes the endpoint at runtime. + */ +export function fetchResourceAccessExplanation< + TUser extends ResourceAccessExplanationUser = ResourceAccessExplanationUser, + TProvenance extends AccessDecisionProvenanceItem = AccessDecisionProvenanceItem +>( + settings: ApiSettings, + options: ResourceAccessExplanationOptions +): Promise> { + return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation", { + user_id: options.userId, + resource_type: options.resourceType, + resource_id: options.resourceId, + action: options.action, + tenant_id: options.tenantId + })); +} diff --git a/webui/src/api/resourceAccessContracts.ts b/webui/src/api/resourceAccessContracts.ts new file mode 100644 index 0000000..79efdaa --- /dev/null +++ b/webui/src/api/resourceAccessContracts.ts @@ -0,0 +1,34 @@ +export type ResourceAccessExplanationUser = { + 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; +}; + +export type ResourceAccessExplanationResponse< + TUser extends ResourceAccessExplanationUser = ResourceAccessExplanationUser, + TProvenance extends AccessDecisionProvenanceItem = AccessDecisionProvenanceItem +> = { + user: TUser; + resource_type: string; + resource_id: string; + action: string; + provenance: TProvenance[]; +}; + +export type ResourceAccessExplanationOptions = { + userId: string; + resourceType: string; + resourceId: string; + action: string; + tenantId?: string | null; +}; diff --git a/webui/src/components/ResourceAccessExplanation.tsx b/webui/src/components/ResourceAccessExplanation.tsx new file mode 100644 index 0000000..968f1e0 --- /dev/null +++ b/webui/src/components/ResourceAccessExplanation.tsx @@ -0,0 +1,84 @@ +import type { ResourceAccessExplanationResponse } from "../api/resourceAccessContracts"; + +export type ResourceAccessExplanationProps = { + loading?: boolean; + explanation?: ResourceAccessExplanationResponse | null; + fallbackResourceLabel?: string; +}; + +export default function ResourceAccessExplanation({ + loading = false, + explanation, + fallbackResourceLabel = "" +}: ResourceAccessExplanationProps) { + if (loading) { + return

i18n:govoplan-core.loading_access_explanation.04a7c934

; + } + 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 ( + <> +
+
i18n:govoplan-core.user.9f8a2389

{userLabel}

+
i18n:govoplan-core.resource.d1c626a9

{resourceLabel}

+
i18n:govoplan-core.action.97c89a4d

{explanation.action}

+
i18n:govoplan-core.evidence.8487d192

{explanation.provenance.length}

+
+ {explanation.provenance.length === 0 ? ( +

i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e

+ ) : ( +
+ {explanation.provenance.map((item, index) => ( +
+ {resourceAccessProvenanceKindLabel(item.kind)} +
+ {item.source || "i18n:govoplan-core.no_source.6dcf9723"} + {item.id && <> · {item.id}} +
+ {item.label &&

{item.label}

} + {Object.keys(item.details ?? {}).length > 0 && ( +

{formatResourceAccessProvenanceDetails(item.details ?? {})}

+ )} +
+ ))} +
+ )} + + ); +} + +export function resourceAccessProvenanceKindLabel(kind: string): string { + switch (kind) { + case "resource": + return "i18n:govoplan-core.resource.d1c626a9"; + case "owner": + return "i18n:govoplan-core.owner.89ff3122"; + case "share": + return "i18n:govoplan-core.share.09ca55ca"; + case "policy": + return "i18n:govoplan-core.policy.0b779a05"; + case "role": + return "i18n:govoplan-core.role.b5b4a5a2"; + case "right": + return "i18n:govoplan-core.permission.2f81a22d"; + default: + return kind; + } +} + +export function formatResourceAccessProvenanceDetails(details: Record): string { + return Object.entries(details) + .map(([key, value]) => `${key}: ${formatResourceAccessProvenanceValue(value)}`) + .join("; "); +} + +function formatResourceAccessProvenanceValue(value: unknown): string { + if (value === null || value === undefined) return "i18n:govoplan-core.none.6eef6648"; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value) ?? String(value); +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index e7ac4b6..bee7f6f 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -6,12 +6,14 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.about.6b21fb79": "About", "i18n:govoplan-core.accent_color.e49578ed": "Accent color", "i18n:govoplan-core.access_is_restricted_sign_in_to_open_available_m.2a47a549": "Access is restricted. Sign in to open available modules and administration.", + "i18n:govoplan-core.access_explanation.75ee7f62": "Access explanation", "i18n:govoplan-core.accessible_to_you.266eca4e": "Accessible to you", "i18n:govoplan-core.account_context.5bb0a918": "Account context", "i18n:govoplan-core.account_display_name.03ed8fc5": "Account display name", "i18n:govoplan-core.account_id.c5e0db28": "Account ID", "i18n:govoplan-core.account_settings.82cf8a5f": "Account settings", "i18n:govoplan-core.account.f967543b": "ACCOUNT", + "i18n:govoplan-core.action.97c89a4d": "Action", "i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_m.2e6863d1": "Action or review severity when this rule finds multiple possible matches.", "i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_n.bc500ee1": "Action or review severity when this rule finds no matching file.", "i18n:govoplan-core.actions.c3cd636a": "Actions", @@ -167,6 +169,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.entry_did_not_export_a_platformwebmodule.9a157741": "entry did not export a PlatformWebModule", "i18n:govoplan-core.entry_integrity_check_failed.1d8f6b89": "entry integrity check failed", "i18n:govoplan-core.equals.09b6a6dc": "Equals", + "i18n:govoplan-core.evidence.8487d192": "Evidence", "i18n:govoplan-core.excel_spreadsheet.7107fea2": "Excel spreadsheet", "i18n:govoplan-core.expand.9869e506": "Expand", "i18n:govoplan-core.explicit_api_url.b117b4b8": "Explicit API URL", @@ -253,6 +256,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.less_or_equal.2860e695": "Less or equal", "i18n:govoplan-core.less_than.1d3d412a": "Less than", "i18n:govoplan-core.library_template.c62eb776": "Library template", + "i18n:govoplan-core.loading_access_explanation.04a7c934": "Loading access explanation...", "i18n:govoplan-core.loading_administration_data.643bd894": "Loading administration data...", "i18n:govoplan-core.loading_campaigns.43ea3afa": "Loading campaigns...", "i18n:govoplan-core.loading_data.089f19c5": "Loading data…", @@ -314,6 +318,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.next_month.8abf7cf1": "Next month", "i18n:govoplan-core.next_page.4bfc194b": "Next page", "i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade": "Next passes will add functionality here.", + "i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e": "No access evidence was returned.", "i18n:govoplan-core.no_accessible_campaigns_found.0b74419a": "No accessible campaigns found.", "i18n:govoplan-core.no_address_added_yet.809c4247": "No address added yet.", "i18n:govoplan-core.no_entries_configured.48e7b97c": "No entries configured.", @@ -323,6 +328,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.no_value_available": "No {value0} available.", "i18n:govoplan-core.no_readable_body_content.37643a01": "No readable body content.", "i18n:govoplan-core.no_rows_found.0c1a8d34": "No rows found.", + "i18n:govoplan-core.no_source.6dcf9723": "No source", "i18n:govoplan-core.no_subject.49b20da0": "(no subject)", "i18n:govoplan-core.no_values_are_configured_for_this_column.16e935e8": "No values are configured for this column.", "i18n:govoplan-core.none.6eef6648": "None", @@ -343,6 +349,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.optional_html_version_of_the_email_body_for_rich.3f01db97": "Optional HTML version of the email body for richer formatting.", "i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14": "Optional local alias for the active tenant. It does not replace the global account name in the title bar.", "i18n:govoplan-core.or_click_to_select_files.91b05dc1": "or click to select files", + "i18n:govoplan-core.owner.89ff3122": "Owner", "i18n:govoplan-core.owner_policy.1e8df143": "Owner policy", "i18n:govoplan-core.p_no_html_body_content_p.c305bfc6": "

No HTML body content.

", "i18n:govoplan-core.page.fb06270f": "Page", @@ -351,6 +358,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.password.8be3c943": "Password", "i18n:govoplan-core.path_or_identifier_for_an_external_recipient_sou.f7da6e8f": "Path or identifier for an external recipient source such as a CSV file.", "i18n:govoplan-core.pdf.d613d88c": "PDF", + "i18n:govoplan-core.permission.2f81a22d": "Permission", "i18n:govoplan-core.permit_per_recipient_sender_overrides_when_build.62e46d13": "Permit per-recipient sender overrides when building messages.", "i18n:govoplan-core.permit_recipient_rows_to_define_their_own_bcc_re.46d737dc": "Permit recipient rows to define their own BCC recipients.", "i18n:govoplan-core.permit_recipient_rows_to_define_their_own_cc_rec.0247b3cd": "Permit recipient rows to define their own CC recipients.", @@ -366,6 +374,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.please_wait_while_the_local_session_is_verified.1dbd8fd3": "Please wait while the local session is verified.", "i18n:govoplan-core.png_image.6715d4b8": "PNG image", "i18n:govoplan-core.preferences_saved.c8cd3501": "Preferences saved.", + "i18n:govoplan-core.policy.0b779a05": "Policy", "i18n:govoplan-core.policies.8d611849": "Policies", "i18n:govoplan-core.port.fe035157": "Port", "i18n:govoplan-core.powerpoint_presentation.b32b7115": "PowerPoint presentation", @@ -403,10 +412,12 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.request_failed.9fcda32c": "Request failed", "i18n:govoplan-core.required.eed6bfb4": "Required", "i18n:govoplan-core.resize.f52dc753": "Resize", + "i18n:govoplan-core.resource.d1c626a9": "Resource", "i18n:govoplan-core.retention_policy_saved.eb577758": "Retention policy saved.", "i18n:govoplan-core.retention_policy.962fb418": "Retention policy", "i18n:govoplan-core.reusable_template_record_this_campaign_should_re.5896529b": "Reusable template record this campaign should refer to once the template backend is available.", "i18n:govoplan-core.review_send.1627617d": "Review & Send", + "i18n:govoplan-core.role.b5b4a5a2": "Role", "i18n:govoplan-core.rows_per_page.af2f9c1b": "Rows per page", "i18n:govoplan-core.sa.50cf95ce": "Sa", "i18n:govoplan-core.same_origin_proxied.c39e6e2b": "Same-origin / proxied", @@ -428,6 +439,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.server.cb0cb170": "Server", "i18n:govoplan-core.session_expired.b828190e": "Session expired", "i18n:govoplan-core.session_state.b7a3d0f4": "Session state", + "i18n:govoplan-core.share.09ca55ca": "Share", "i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627": "Set concrete system retention values. Blank day fields mean unlimited retention.", "i18n:govoplan-core.settings.c7f73bb5": "Settings", "i18n:govoplan-core.show_content.0528d8d2": "Show content", @@ -563,12 +575,14 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.about.6b21fb79": "About", "i18n:govoplan-core.accent_color.e49578ed": "Accent color", "i18n:govoplan-core.access_is_restricted_sign_in_to_open_available_m.2a47a549": "Access is restricted. Sign in to open available modules and administration.", + "i18n:govoplan-core.access_explanation.75ee7f62": "Zugriffserklärung", "i18n:govoplan-core.accessible_to_you.266eca4e": "Accessible to you", "i18n:govoplan-core.account_context.5bb0a918": "Account context", "i18n:govoplan-core.account_display_name.03ed8fc5": "Account display name", "i18n:govoplan-core.account_id.c5e0db28": "Account ID", "i18n:govoplan-core.account_settings.82cf8a5f": "Account settings", "i18n:govoplan-core.account.f967543b": "ACCOUNT", + "i18n:govoplan-core.action.97c89a4d": "Aktion", "i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_m.2e6863d1": "Action or review severity when this rule finds multiple possible matches.", "i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_n.bc500ee1": "Action or review severity when this rule finds no matching file.", "i18n:govoplan-core.actions.c3cd636a": "Aktionen", @@ -724,6 +738,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.entry_did_not_export_a_platformwebmodule.9a157741": "entry did not export a PlatformWebModule", "i18n:govoplan-core.entry_integrity_check_failed.1d8f6b89": "entry integrity check failed", "i18n:govoplan-core.equals.09b6a6dc": "Equals", + "i18n:govoplan-core.evidence.8487d192": "Nachweise", "i18n:govoplan-core.excel_spreadsheet.7107fea2": "Excel spreadsheet", "i18n:govoplan-core.expand.9869e506": "Expand", "i18n:govoplan-core.explicit_api_url.b117b4b8": "Explicit API URL", @@ -810,6 +825,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.less_or_equal.2860e695": "Less or equal", "i18n:govoplan-core.less_than.1d3d412a": "Less than", "i18n:govoplan-core.library_template.c62eb776": "Library template", + "i18n:govoplan-core.loading_access_explanation.04a7c934": "Zugriffserklärung wird geladen...", "i18n:govoplan-core.loading_administration_data.643bd894": "Loading administration data...", "i18n:govoplan-core.loading_campaigns.43ea3afa": "Loading campaigns...", "i18n:govoplan-core.loading_data.089f19c5": "Loading data…", @@ -871,6 +887,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.next_month.8abf7cf1": "Next month", "i18n:govoplan-core.next_page.4bfc194b": "Next page", "i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade": "Die nächsten Durchläufe ergänzen hier die Funktionalität.", + "i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e": "Es wurden keine Zugriffsnachweise zurückgegeben.", "i18n:govoplan-core.no_accessible_campaigns_found.0b74419a": "No accessible campaigns found.", "i18n:govoplan-core.no_address_added_yet.809c4247": "No address added yet.", "i18n:govoplan-core.no_entries_configured.48e7b97c": "No entries configured.", @@ -880,6 +897,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.no_value_available": "Keine {value0} verfügbar.", "i18n:govoplan-core.no_readable_body_content.37643a01": "No readable body content.", "i18n:govoplan-core.no_rows_found.0c1a8d34": "No rows found.", + "i18n:govoplan-core.no_source.6dcf9723": "Keine Quelle", "i18n:govoplan-core.no_subject.49b20da0": "(no subject)", "i18n:govoplan-core.no_values_are_configured_for_this_column.16e935e8": "No values are configured for this column.", "i18n:govoplan-core.none.6eef6648": "Keine", @@ -900,6 +918,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.optional_html_version_of_the_email_body_for_rich.3f01db97": "Optional HTML version of the email body for richer formatting.", "i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14": "Optional local alias for the active tenant. It does not replace the global account name in the title bar.", "i18n:govoplan-core.or_click_to_select_files.91b05dc1": "or click to select files", + "i18n:govoplan-core.owner.89ff3122": "Eigentümer", "i18n:govoplan-core.owner_policy.1e8df143": "Owner policy", "i18n:govoplan-core.p_no_html_body_content_p.c305bfc6": "

No HTML body content.

", "i18n:govoplan-core.page.fb06270f": "Page", @@ -908,6 +927,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.password.8be3c943": "Passwort", "i18n:govoplan-core.path_or_identifier_for_an_external_recipient_sou.f7da6e8f": "Path or identifier for an external recipient source such as a CSV file.", "i18n:govoplan-core.pdf.d613d88c": "PDF", + "i18n:govoplan-core.permission.2f81a22d": "Berechtigung", "i18n:govoplan-core.permit_per_recipient_sender_overrides_when_build.62e46d13": "Permit per-recipient sender overrides when building messages.", "i18n:govoplan-core.permit_recipient_rows_to_define_their_own_bcc_re.46d737dc": "Permit recipient rows to define their own BCC recipients.", "i18n:govoplan-core.permit_recipient_rows_to_define_their_own_cc_rec.0247b3cd": "Permit recipient rows to define their own CC recipients.", @@ -923,6 +943,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.please_wait_while_the_local_session_is_verified.1dbd8fd3": "Bitte warten Sie, während die lokale Sitzung geprüft wird.", "i18n:govoplan-core.png_image.6715d4b8": "PNG image", "i18n:govoplan-core.preferences_saved.c8cd3501": "Einstellungen gespeichert.", + "i18n:govoplan-core.policy.0b779a05": "Richtlinie", "i18n:govoplan-core.policies.8d611849": "Richtlinien", "i18n:govoplan-core.port.fe035157": "Port", "i18n:govoplan-core.powerpoint_presentation.b32b7115": "PowerPoint presentation", @@ -960,10 +981,12 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.request_failed.9fcda32c": "Request failed", "i18n:govoplan-core.required.eed6bfb4": "Required", "i18n:govoplan-core.resize.f52dc753": "Resize", + "i18n:govoplan-core.resource.d1c626a9": "Ressource", "i18n:govoplan-core.retention_policy_saved.eb577758": "Retention policy saved.", "i18n:govoplan-core.retention_policy.962fb418": "Retention policy", "i18n:govoplan-core.reusable_template_record_this_campaign_should_re.5896529b": "Reusable template record this campaign should refer to once the template backend is available.", "i18n:govoplan-core.review_send.1627617d": "Prüfen & Senden", + "i18n:govoplan-core.role.b5b4a5a2": "Rolle", "i18n:govoplan-core.rows_per_page.af2f9c1b": "Rows per page", "i18n:govoplan-core.sa.50cf95ce": "Sa", "i18n:govoplan-core.same_origin_proxied.c39e6e2b": "Same-origin / proxied", @@ -985,6 +1008,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.server.cb0cb170": "Server", "i18n:govoplan-core.session_expired.b828190e": "Sitzung abgelaufen", "i18n:govoplan-core.session_state.b7a3d0f4": "Session state", + "i18n:govoplan-core.share.09ca55ca": "Freigabe", "i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627": "Set concrete system retention values. Blank day fields mean unlimited retention.", "i18n:govoplan-core.settings.c7f73bb5": "Einstellungen", "i18n:govoplan-core.show_content.0528d8d2": "Show content", diff --git a/webui/src/index.ts b/webui/src/index.ts index 8ba36c4..bd53a49 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -24,6 +24,7 @@ export type { MockMailboxMessageResponse } from "./api/mailContracts"; export * from "./api/privacyRetention"; +export * from "./api/resourceAccess"; export * from "./platform/modules"; export * from "./platform/ModuleContext"; export * from "./platform/moduleEvents"; @@ -80,6 +81,8 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel"; export { default as PageTitle } from "./components/PageTitle"; export { default as PasswordField } from "./components/PasswordField"; +export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation"; +export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation"; export type { PasswordFieldProps } from "./components/PasswordField"; export { PolicyRow, PolicySection, PolicyTable } from "./components/PolicyTable"; export { default as PolicyPathHelp, normalizePolicySourcePathItems } from "./components/PolicyPathHelp"; diff --git a/webui/tests/resource-access-explanation.test.tsx b/webui/tests/resource-access-explanation.test.tsx new file mode 100644 index 0000000..67ccb55 --- /dev/null +++ b/webui/tests/resource-access-explanation.test.tsx @@ -0,0 +1,73 @@ +function assert(condition: unknown, message = "assertion failed"): void { + if (!condition) throw new Error(message); +} + +function assertEqual(actual: T, expected: T, message = "values should be equal"): void { + if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`); +} + +import { renderToStaticMarkup } from "react-dom/server"; +import ResourceAccessExplanation, { + formatResourceAccessProvenanceDetails, + resourceAccessProvenanceKindLabel +} from "../src/components/ResourceAccessExplanation"; + +const loadingMarkup = renderToStaticMarkup(); +assert( + loadingMarkup.includes("i18n:govoplan-core.loading_access_explanation.04a7c934"), + "loading copy is owned by Core" +); + +const explanationMarkup = renderToStaticMarkup( + +); +assert(explanationMarkup.includes("Reader"), "the preferred user label is rendered"); +assert(explanationMarkup.includes("Quarterly notice"), "resource provenance overrides the fallback label"); +assert(explanationMarkup.includes("campaigns:campaign:read"), "the evaluated action is rendered"); +assert(explanationMarkup.includes("i18n:govoplan-core.permission.2f81a22d"), "known provenance kinds use Core wording"); +assert(explanationMarkup.includes("i18n:govoplan-core.no_source.6dcf9723"), "missing sources use Core wording"); +assert(!explanationMarkup.includes("govoplan-campaign"), "the shared component does not depend on Campaign wording"); +assert(!explanationMarkup.includes("govoplan-files"), "the shared component does not depend on Files wording"); + +const emptyMarkup = renderToStaticMarkup( + +); +assert(emptyMarkup.includes("Fallback resource"), "the caller fallback labels resources without resource provenance"); +assert( + emptyMarkup.includes("i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e"), + "empty evidence uses Core wording" +); + +assertEqual(resourceAccessProvenanceKindLabel("custom"), "custom", "unknown provenance kinds remain visible"); +assertEqual( + formatResourceAccessProvenanceDetails({ count: 2, inherited: false, source: null, nested: { id: "role-1" } }), + 'count: 2; inherited: false; source: i18n:govoplan-core.none.6eef6648; nested: {"id":"role-1"}', + "provenance details retain the established compact format" +); diff --git a/webui/tsconfig.component-tests.json b/webui/tsconfig.component-tests.json index 14c8fbd..9c7edf2 100644 --- a/webui/tsconfig.component-tests.json +++ b/webui/tsconfig.component-tests.json @@ -21,10 +21,12 @@ "tests/data-grid-actions.test.tsx", "tests/dialog-focus.test.tsx", "tests/mail-components.test.tsx", + "tests/resource-access-explanation.test.tsx", "src/components/CredentialPanel.tsx", "src/components/email/EmailAddressInput.tsx", "src/components/PasswordField.tsx", "src/components/MessageDisplayPanel.tsx", + "src/components/ResourceAccessExplanation.tsx", "src/components/mail/MailServerSettingsPanel.tsx", "src/components/Button.tsx", "src/components/DismissibleAlert.tsx",