mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
feat(ui): add install policy warning review
This commit is contained in:
@@ -2797,6 +2797,21 @@ export const en: TranslationMap = {
|
||||
installNamed: "Install {name}",
|
||||
acknowledgeRisk: "Acknowledge risk and install",
|
||||
defaultRiskWarning: "Review the ClawHub warning before installing this plugin.",
|
||||
policyReviewTitle: "Security review needed",
|
||||
policyReviewBodyUnknown: "Your install policy flagged this plugin. It has not been installed.",
|
||||
policyReviewBodyOne: "Your install policy found 1 warning. This plugin has not been installed.",
|
||||
policyReviewBodyMany:
|
||||
"Your install policy found {count} warnings. This plugin has not been installed.",
|
||||
policyReviewReason: "Why it was flagged",
|
||||
policyReviewFindings: "Policy findings",
|
||||
policyReviewFindingCount: "{count} found",
|
||||
policyReviewTechnicalDetails: "Scan details",
|
||||
policyReviewPolicyResponse: "Policy response",
|
||||
policyReviewRule: "Rule {count}",
|
||||
policyReviewLocation: "Location",
|
||||
policyReviewEvidence: "Evidence",
|
||||
policyReviewGuidance: "Continue only if you trust this plugin.",
|
||||
installAnyway: "Install anyway",
|
||||
connectToChange: "Connect to the gateway to change plugins.",
|
||||
adminRequired: "Browsing only. Plugin changes require operator.admin access.",
|
||||
changesDisabled: "Browsing only. This gateway does not allow plugin changes.",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GatewayRequestError } from "../../api/gateway.ts";
|
||||
import { readPluginInstallPolicyWarning } from "./install-policy-warning.ts";
|
||||
|
||||
describe("readPluginInstallPolicyWarning", () => {
|
||||
it("parses structured policy warnings", () => {
|
||||
const error = new GatewayRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Install requires approval",
|
||||
details: {
|
||||
installPolicyCode: "install_policy_warning_acknowledgement_required",
|
||||
targetName: " openclaw-kitchen-sink-fixture ",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: " ClawScan found issues to review. ",
|
||||
findings: [
|
||||
{
|
||||
ruleId: "semgrep-finding",
|
||||
severity: "warn",
|
||||
message: "Semgrep found a risky command.",
|
||||
file: "index.ts",
|
||||
line: 12,
|
||||
},
|
||||
],
|
||||
futureField: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(readPluginInstallPolicyWarning(error)).toEqual({
|
||||
installPolicyCode: "install_policy_warning_acknowledgement_required",
|
||||
targetName: "openclaw-kitchen-sink-fixture",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "ClawScan found issues to review.",
|
||||
findings: [
|
||||
{
|
||||
ruleId: "semgrep-finding",
|
||||
severity: "warn",
|
||||
message: "Semgrep found a risky command.",
|
||||
file: "index.ts",
|
||||
line: 12,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed policy warning details", () => {
|
||||
const error = new GatewayRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Install requires approval",
|
||||
details: {
|
||||
installPolicyCode: "install_policy_warning_acknowledgement_required",
|
||||
targetName: "fixture",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "Review required.",
|
||||
findings: [{ ruleId: "finding", severity: "warn", message: 42 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(readPluginInstallPolicyWarning(error)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { InstallPolicyWarningErrorDetails } from "../../../../packages/gateway-protocol/src/install-policy-warning-error-details.js";
|
||||
import { readInstallPolicyWarningErrorDetails } from "../../../../src/gateway/install-policy-warning-error-details.js";
|
||||
import { GatewayRequestError } from "../../api/gateway.ts";
|
||||
|
||||
export type PluginInstallPolicyWarningDetails = InstallPolicyWarningErrorDetails;
|
||||
|
||||
export function readPluginInstallPolicyWarning(
|
||||
error: unknown,
|
||||
): InstallPolicyWarningErrorDetails | undefined {
|
||||
if (!(error instanceof GatewayRequestError)) {
|
||||
return undefined;
|
||||
}
|
||||
return readInstallPolicyWarningErrorDetails(error.details);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
||||
import { fetchPluginIconBlobUrl } from "./icon-loader.ts";
|
||||
import { readPluginInstallPolicyWarning } from "./install-policy-warning.ts";
|
||||
import { PLUGINS_HUB_PANEL_ID, pluginsHubTabs, type PluginsHubTab } from "./plugins-hub.ts";
|
||||
import type { ConnectorSuggestion } from "./presentation.ts";
|
||||
import { pluginArtPath } from "./presentation.ts";
|
||||
@@ -782,6 +783,15 @@ class PluginsPage extends OpenClawLightDomElement {
|
||||
await this.refreshCatalogAfterMutation(client);
|
||||
},
|
||||
(error) => {
|
||||
const policyWarning = readPluginInstallPolicyWarning(error);
|
||||
if (policyWarning) {
|
||||
this.setMessage(rowKey, {
|
||||
kind: "warning",
|
||||
text: policyWarning.reason,
|
||||
installPolicyWarning: { details: policyWarning, request },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const trust = readPluginInstallTrustError(error);
|
||||
const packageName = request.source === "clawhub" ? request.packageName : null;
|
||||
if (packageName && pluginInstallNeedsRiskAcknowledgement(error)) {
|
||||
@@ -1030,6 +1040,7 @@ class PluginsPage extends OpenClawLightDomElement {
|
||||
onSetEnabled: (pluginId, enabled, rowKey) =>
|
||||
void this.updateEnabled(pluginId, enabled, rowKey),
|
||||
onInstall: (rowKey, request) => void this.install(rowKey, request),
|
||||
onDismissMessage: (rowKey) => this.setMessage(rowKey, null),
|
||||
onRequestUninstall: (rowKey) => this.setPendingRemoval(rowKey, true),
|
||||
onCancelUninstall: (rowKey) => this.setPendingRemoval(rowKey, false),
|
||||
onUninstall: (pluginId, rowKey) => void this.uninstall(pluginId, rowKey),
|
||||
|
||||
@@ -150,6 +150,23 @@ const installResult = {
|
||||
restartRequired: true,
|
||||
} satisfies PluginMutationResult;
|
||||
|
||||
const installPolicyWarning = {
|
||||
installPolicyCode: "install_policy_warning_acknowledgement_required",
|
||||
targetName: "@openclaw/lobster",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "ClawScan found issues to review.",
|
||||
findings: [
|
||||
{
|
||||
ruleId: "semgrep-finding",
|
||||
severity: "warn",
|
||||
message: "Semgrep found a risky command.",
|
||||
file: "index.ts",
|
||||
line: 12,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const enableWorkboardResult = {
|
||||
ok: true,
|
||||
plugin: workboardEnabled,
|
||||
@@ -643,6 +660,71 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reviews an install policy warning before sending an acknowledged retry", async () => {
|
||||
const context = await newContext();
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: pluginMethods,
|
||||
methodResponses: pluginMethodResponses(),
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}settings/plugins`);
|
||||
await page.getByRole("tab", { name: /^Discover/u }).click();
|
||||
const row = page.locator('[data-plugin-id="lobster"]');
|
||||
await row.waitFor({ state: "visible" });
|
||||
|
||||
await gateway.deferNext("plugins.install");
|
||||
await row.getByRole("button", { name: "Install Lobster", exact: true }).click();
|
||||
expect(requestParams(await gateway.waitForRequest("plugins.install"))).toEqual({
|
||||
source: "clawhub",
|
||||
packageName: "@openclaw/lobster",
|
||||
});
|
||||
await gateway.rejectDeferred("plugins.install", {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "raw terminal install-policy output",
|
||||
details: installPolicyWarning,
|
||||
});
|
||||
|
||||
const review = row.getByRole("alert");
|
||||
await review.waitFor({ state: "visible" });
|
||||
expect(await review.textContent()).toContain("Security review needed");
|
||||
expect(await review.textContent()).toContain("Your install policy found 1 warning");
|
||||
expect(await review.textContent()).toContain("This plugin has not been installed");
|
||||
expect(await review.textContent()).toContain("Semgrep found a risky command.");
|
||||
expect(await review.textContent()).not.toContain("raw terminal install-policy output");
|
||||
await captureScreenshot(page, "09-policy-review-desktop.png");
|
||||
|
||||
const installCountBeforeCancel = (await gateway.getRequests("plugins.install")).length;
|
||||
await review.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await review.waitFor({ state: "detached" });
|
||||
expect((await gateway.getRequests("plugins.install")).length).toBe(installCountBeforeCancel);
|
||||
|
||||
const installCountBeforeSecondAttempt = (await gateway.getRequests("plugins.install")).length;
|
||||
await gateway.deferNext("plugins.install");
|
||||
await row.getByRole("button", { name: "Install Lobster", exact: true }).click();
|
||||
await waitForNextRequest(gateway, "plugins.install", installCountBeforeSecondAttempt);
|
||||
await gateway.rejectDeferred("plugins.install", {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "raw terminal install-policy output",
|
||||
details: installPolicyWarning,
|
||||
});
|
||||
await review.waitFor({ state: "visible" });
|
||||
|
||||
const installCountBeforeRetry = (await gateway.getRequests("plugins.install")).length;
|
||||
await gateway.deferNext("plugins.install");
|
||||
await review.getByRole("button", { name: "Install anyway", exact: true }).click();
|
||||
const retry = await waitForNextRequest(gateway, "plugins.install", installCountBeforeRetry);
|
||||
expect(requestParams(retry)).toEqual({
|
||||
source: "clawhub",
|
||||
packageName: "@openclaw/lobster",
|
||||
acknowledgeInstallPolicyWarning: true,
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps plugin mutations unavailable to read-only operators while browse and search work", async () => {
|
||||
const context = await newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
@@ -65,6 +65,7 @@ function createProps(overrides: Partial<PluginsViewProps> = {}): PluginsViewProp
|
||||
onShowDetails: () => undefined,
|
||||
onSetEnabled: () => undefined,
|
||||
onInstall: () => undefined,
|
||||
onDismissMessage: () => undefined,
|
||||
onRequestUninstall: () => undefined,
|
||||
onCancelUninstall: () => undefined,
|
||||
onUninstall: () => undefined,
|
||||
@@ -659,6 +660,90 @@ describe("renderPlugins", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders install policy findings with cancel and acknowledged retry actions", () => {
|
||||
const plugin = createPlugin({
|
||||
id: "kitchen-sink",
|
||||
name: "OpenClaw Kitchen Sink",
|
||||
installed: false,
|
||||
enabled: false,
|
||||
state: "disabled",
|
||||
install: { source: "official", pluginId: "kitchen-sink" },
|
||||
});
|
||||
const key = pluginRowKey(plugin.id);
|
||||
const onInstall = vi.fn();
|
||||
const onDismissMessage = vi.fn();
|
||||
const onShowDetails = vi.fn();
|
||||
const request = { source: "official" as const, pluginId: "kitchen-sink" };
|
||||
const container = mount(
|
||||
createProps({
|
||||
activeTab: "discover",
|
||||
result: createResult([plugin]),
|
||||
messages: {
|
||||
[key]: {
|
||||
kind: "warning",
|
||||
text: "ClawScan found issues to review.",
|
||||
installPolicyWarning: {
|
||||
request,
|
||||
details: {
|
||||
installPolicyCode: "install_policy_warning_acknowledgement_required",
|
||||
targetName: "openclaw-kitchen-sink-fixture",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "ClawScan found issues to review.",
|
||||
findings: [
|
||||
{
|
||||
ruleId: "semgrep-finding",
|
||||
severity: "warn",
|
||||
message: "Semgrep found a risky command.",
|
||||
file: "index.ts",
|
||||
line: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
onInstall,
|
||||
onDismissMessage,
|
||||
onShowDetails,
|
||||
}),
|
||||
);
|
||||
|
||||
const row = expectDefined(
|
||||
container.querySelector<HTMLElement>('[data-plugin-id="kitchen-sink"]'),
|
||||
"kitchen sink plugin row",
|
||||
);
|
||||
const alert = expectDefined(row.querySelector('[role="alert"]'), "install policy warning");
|
||||
expect(normalizedText(alert)).toContain("Security review needed");
|
||||
expect(normalizedText(alert)).toContain("Your install policy found 1 warning");
|
||||
expect(normalizedText(alert)).toContain("This plugin has not been installed");
|
||||
expect(normalizedText(alert)).toContain("Policy findings");
|
||||
expect(normalizedText(alert)).toContain("Semgrep found a risky command.");
|
||||
const technicalDetails = expectDefined(
|
||||
alert.querySelector<HTMLDetailsElement>(".plugins-policy-review__details"),
|
||||
"install policy scan details",
|
||||
);
|
||||
expect(technicalDetails.open).toBe(false);
|
||||
expect(normalizedText(technicalDetails.querySelector("summary"))).toBe("Scan details");
|
||||
expect(
|
||||
technicalDetails?.querySelector(".plugins-policy-review__details-chevron svg"),
|
||||
).not.toBeNull();
|
||||
expect(normalizedText(technicalDetails)).toContain("semgrep-finding");
|
||||
expect(normalizedText(technicalDetails)).toContain("index.ts:12");
|
||||
technicalDetails.querySelector("summary")?.click();
|
||||
expect(technicalDetails.open).toBe(true);
|
||||
expect(onShowDetails).not.toHaveBeenCalled();
|
||||
|
||||
actionButton(alert, "Cancel")?.click();
|
||||
expect(onDismissMessage).toHaveBeenCalledWith(key);
|
||||
|
||||
actionButton(alert, "Install anyway")?.click();
|
||||
expect(onInstall).toHaveBeenCalledWith(key, {
|
||||
...request,
|
||||
acknowledgeInstallPolicyWarning: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("correlates installed ClawHub packages without a search runtime id", () => {
|
||||
const packageName = "@community/calendar-plus";
|
||||
const installed = createPlugin({
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type PluginListResult,
|
||||
type PluginSearchResult,
|
||||
} from "../../lib/plugins/index.ts";
|
||||
import type { PluginInstallPolicyWarningDetails } from "./install-policy-warning.ts";
|
||||
import {
|
||||
CONNECTOR_GROUP_ORDER,
|
||||
CONNECTOR_SUGGESTIONS,
|
||||
@@ -45,9 +46,13 @@ export type PluginsTab = "installed" | "discover";
|
||||
export type InstalledFilter = "all" | "enabled" | "disabled" | "issues";
|
||||
|
||||
export type PluginRowMessage = {
|
||||
kind: "success" | "error";
|
||||
kind: "success" | "error" | "warning";
|
||||
text: string;
|
||||
acknowledge?: { packageName: string; version?: string };
|
||||
installPolicyWarning?: {
|
||||
details: PluginInstallPolicyWarningDetails;
|
||||
request: PluginInstallRequest;
|
||||
};
|
||||
};
|
||||
|
||||
type PluginsViewProps = {
|
||||
@@ -81,6 +86,7 @@ type PluginsViewProps = {
|
||||
onShowDetails: (pluginId: string | null) => void;
|
||||
onSetEnabled: (pluginId: string, enabled: boolean, rowKey: string) => void;
|
||||
onInstall: (rowKey: string, request: PluginInstallRequest) => void;
|
||||
onDismissMessage: (rowKey: string) => void;
|
||||
onRequestUninstall: (rowKey: string) => void;
|
||||
onCancelUninstall: (rowKey: string) => void;
|
||||
onUninstall: (pluginId: string, rowKey: string) => void;
|
||||
@@ -378,6 +384,134 @@ function renderRowMessage(
|
||||
if (!message) {
|
||||
return nothing;
|
||||
}
|
||||
if (message.installPolicyWarning) {
|
||||
const { details, request } = message.installPolicyWarning;
|
||||
const findings = details.findings ?? [];
|
||||
const reviewBody =
|
||||
findings.length === 0
|
||||
? t("pluginsPage.policyReviewBodyUnknown")
|
||||
: findings.length === 1
|
||||
? t("pluginsPage.policyReviewBodyOne")
|
||||
: t("pluginsPage.policyReviewBodyMany", { count: String(findings.length) });
|
||||
return html`
|
||||
<div class="plugins-row-message plugins-row-message--warning" role="alert">
|
||||
<div class="plugins-policy-review__header">
|
||||
<span class="plugins-policy-review__icon" aria-hidden="true">
|
||||
${icons.alertTriangle}
|
||||
</span>
|
||||
<div>
|
||||
<strong>${t("pluginsPage.policyReviewTitle")}</strong>
|
||||
<span>${reviewBody}</span>
|
||||
</div>
|
||||
</div>
|
||||
${findings.length > 0
|
||||
? html`
|
||||
<section class="plugins-policy-review__findings-panel">
|
||||
<div class="plugins-policy-review__findings-heading">
|
||||
<span>${t("pluginsPage.policyReviewFindings")}</span>
|
||||
<span class="plugins-policy-review__count"
|
||||
>${t("pluginsPage.policyReviewFindingCount", {
|
||||
count: String(findings.length),
|
||||
})}</span
|
||||
>
|
||||
</div>
|
||||
<ul class="plugins-policy-review__findings">
|
||||
${findings.map(
|
||||
(finding) => html`
|
||||
<li>
|
||||
<span class="plugins-policy-review__finding-mark" aria-hidden="true"></span>
|
||||
<span>${finding.message}</span>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
`
|
||||
: html`
|
||||
<section class="plugins-policy-review__findings-panel">
|
||||
<span class="plugins-policy-review__findings-heading"
|
||||
>${t("pluginsPage.policyReviewReason")}</span
|
||||
>
|
||||
<p class="plugins-policy-review__reason">${details.reason}</p>
|
||||
</section>
|
||||
`}
|
||||
${findings.length > 0
|
||||
? html`
|
||||
<details class="plugins-policy-review__details">
|
||||
<summary>
|
||||
<span class="plugins-policy-review__details-chevron" aria-hidden="true"
|
||||
>${icons.chevronRight}</span
|
||||
>
|
||||
<span>${t("pluginsPage.policyReviewTechnicalDetails")}</span>
|
||||
</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>${t("pluginsPage.policyReviewPolicyResponse")}</dt>
|
||||
<dd>${details.reason}</dd>
|
||||
</div>
|
||||
${findings.map(
|
||||
(finding, index) => html`
|
||||
<div>
|
||||
<dt>${t("pluginsPage.policyReviewRule", { count: String(index + 1) })}</dt>
|
||||
<dd><code>${finding.ruleId}</code></dd>
|
||||
</div>
|
||||
${finding.file
|
||||
? html`
|
||||
<div>
|
||||
<dt>${t("pluginsPage.policyReviewLocation")}</dt>
|
||||
<dd>
|
||||
<code
|
||||
>${finding.file}${finding.line ? `:${finding.line}` : ""}</code
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${finding.evidence
|
||||
? html`
|
||||
<div>
|
||||
<dt>${t("pluginsPage.policyReviewEvidence")}</dt>
|
||||
<dd>${finding.evidence}</dd>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
`,
|
||||
)}
|
||||
</dl>
|
||||
</details>
|
||||
`
|
||||
: nothing}
|
||||
<div class="plugins-policy-review__footer">
|
||||
<span class="plugins-policy-review__guidance"
|
||||
>${t("pluginsPage.policyReviewGuidance")}</span
|
||||
>
|
||||
<div class="plugins-policy-review__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm"
|
||||
?disabled=${busy}
|
||||
@click=${() => props.onDismissMessage(key)}
|
||||
>
|
||||
${t("pluginsPage.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm danger"
|
||||
title=${props.mutationBlockedReason ?? ""}
|
||||
?disabled=${busy || !props.canMutate}
|
||||
@click=${() =>
|
||||
props.onInstall(key, {
|
||||
...request,
|
||||
acknowledgeInstallPolicyWarning: true,
|
||||
})}
|
||||
>
|
||||
${busy ? t("pluginsPage.installing") : t("pluginsPage.installAnyway")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const role = message.kind === "error" ? "alert" : "status";
|
||||
return html`
|
||||
<div class="plugins-row-message plugins-row-message--${message.kind}" role=${role}>
|
||||
@@ -408,7 +542,9 @@ function renderRowMessage(
|
||||
/** Ignore activations bubbling from interactive children so rows stay clickable. */
|
||||
function fromInteractiveChild(event: Event): boolean {
|
||||
return Boolean(
|
||||
(event.target as HTMLElement | null)?.closest("button, a, input, label, form, [role='menu']"),
|
||||
(event.target as HTMLElement | null)?.closest(
|
||||
"button, a, input, label, form, summary, [role='menu']",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -462,6 +462,213 @@ h3.plugins-subheader {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.plugins-row-message--warning {
|
||||
--plugins-policy-review-indent: calc(24px + var(--space-3));
|
||||
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) 0 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--warn) 28%, var(--border));
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: var(--control-ui-text-sm);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.plugins-policy-review__header {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.plugins-policy-review__header > div {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.plugins-policy-review__icon {
|
||||
display: inline-flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.plugins-policy-review__icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.plugins-policy-review__header strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--control-ui-text-md);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.plugins-policy-review__header span,
|
||||
.plugins-policy-review__guidance {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.plugins-policy-review__findings-panel {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
margin-left: var(--plugins-policy-review-indent);
|
||||
}
|
||||
|
||||
.plugins-policy-review__findings-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plugins-policy-review__count {
|
||||
color: var(--muted);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plugins-policy-review__count::before {
|
||||
margin-right: var(--space-2);
|
||||
color: var(--border-hover);
|
||||
content: "·";
|
||||
}
|
||||
|
||||
.plugins-policy-review__findings {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.plugins-policy-review__findings li {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--space-2);
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.plugins-policy-review__findings li + li {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.plugins-policy-review__finding-mark {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-top: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--warn);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.plugins-policy-review__reason {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plugins-policy-review__details {
|
||||
margin-left: var(--plugins-policy-review-indent);
|
||||
color: var(--muted);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
}
|
||||
|
||||
.plugins-policy-review__details summary {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.plugins-policy-review__details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plugins-policy-review__details summary::marker {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.plugins-policy-review__details-chevron {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--muted);
|
||||
transition: transform var(--duration-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.plugins-policy-review__details-chevron svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.plugins-policy-review__details[open] .plugins-policy-review__details-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.plugins-policy-review__details summary:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.plugins-policy-review__details summary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.plugins-policy-review__details dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-2) 0 0;
|
||||
}
|
||||
|
||||
.plugins-policy-review__details dl > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugins-policy-review__details dt {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.plugins-policy-review__details dd {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.plugins-policy-review__details code {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.plugins-policy-review__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) 0 0 var(--plugins-policy-review-indent);
|
||||
border-top: 1px solid color-mix(in srgb, var(--warn) 20%, var(--border));
|
||||
}
|
||||
|
||||
.plugins-policy-review__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.plugins-policy-review__actions .btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.plugins-row-message .btn {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-strong);
|
||||
@@ -716,4 +923,22 @@ h3.plugins-subheader {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.plugins-row-message--warning {
|
||||
padding: var(--space-2) 0 0;
|
||||
}
|
||||
|
||||
.plugins-policy-review__details dl {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.plugins-policy-review__footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.plugins-policy-review__actions,
|
||||
.plugins-policy-review__actions .btn {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user