mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:10:45 +00:00
feat: add Control UI PWA web push support (#44590)
Adds browser PWA manifest and service worker support for the Control UI, plus gateway RPC methods and persisted Web Push subscription handling.
Maintainer verification:
- OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test src/infra/push-web.test.ts src/gateway/server-methods/push.test.ts src/gateway/control-ui.test.ts src/gateway/protocol/push.test.ts
- pnpm check:changed passed before final GitHub update-branch merge commit
- pnpm build
Source head: 0720024368
This commit is contained in:
@@ -1,2 +1,21 @@
|
||||
import "./styles.css";
|
||||
import "./ui/app.ts";
|
||||
|
||||
type ViteImportMeta = ImportMeta & {
|
||||
readonly env?: {
|
||||
readonly PROD?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const isProd = (import.meta as ViteImportMeta).env?.PROD === true;
|
||||
|
||||
if (isProd && "serviceWorker" in navigator) {
|
||||
void navigator.serviceWorker.register("./sw.js");
|
||||
} else if (!isProd && "serviceWorker" in navigator) {
|
||||
// Unregister any leftover dev SW to avoid stale cache issues.
|
||||
void navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||
for (const r of registrations) {
|
||||
void r.unregister();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ type GatewayHost = {
|
||||
execApprovalQueue: ExecApprovalRequest[];
|
||||
execApprovalError: string | null;
|
||||
updateAvailable: UpdateAvailable | null;
|
||||
reconcileWebPushState?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
type GatewayHostWithDeferredSessionMessageReload = GatewayHost & {
|
||||
@@ -339,6 +340,8 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
|
||||
void loadNodes(host as unknown as NodesState, { quiet: true });
|
||||
void loadDevices(host as unknown as DevicesState, { quiet: true });
|
||||
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||
// Re-run push reconciliation now that the gateway client is available.
|
||||
void host.reconcileWebPushState?.();
|
||||
},
|
||||
onClose: ({ code, reason, error }) => {
|
||||
if (host.client !== client) {
|
||||
|
||||
@@ -312,7 +312,14 @@ function dismissUpdateBanner(updateAvailable: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
const COMMUNICATION_SECTION_KEYS = ["channels", "messages", "broadcast", "talk", "audio"] as const;
|
||||
const COMMUNICATION_SECTION_KEYS = [
|
||||
"channels",
|
||||
"messages",
|
||||
"broadcast",
|
||||
"__notifications__",
|
||||
"talk",
|
||||
"audio",
|
||||
] as const;
|
||||
const APPEARANCE_SECTION_KEYS = ["__appearance__", "ui", "wizard"] as const;
|
||||
const AUTOMATION_SECTION_KEYS = [
|
||||
"commands",
|
||||
@@ -367,6 +374,10 @@ type ConfigTabOverrides = Pick<
|
||||
| "includeVirtualSections"
|
||||
| "settingsLayout"
|
||||
| "onBackToQuick"
|
||||
| "webPush"
|
||||
| "onWebPushSubscribe"
|
||||
| "onWebPushUnsubscribe"
|
||||
| "onWebPushTest"
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -1065,6 +1076,16 @@ export function renderApp(state: AppViewState) {
|
||||
onSubsectionChange: (section) => (state.communicationsActiveSubsection = section),
|
||||
navRootLabel: "Communication",
|
||||
includeSections: [...COMMUNICATION_SECTION_KEYS],
|
||||
includeVirtualSections: true,
|
||||
webPush: {
|
||||
supported: state.webPushSupported,
|
||||
permission: state.webPushPermission,
|
||||
subscribed: state.webPushSubscribed,
|
||||
loading: state.webPushLoading,
|
||||
},
|
||||
onWebPushSubscribe: () => state.handleWebPushSubscribe(),
|
||||
onWebPushUnsubscribe: () => state.handleWebPushUnsubscribe(),
|
||||
onWebPushTest: () => state.handleWebPushTest(),
|
||||
});
|
||||
case "appearance":
|
||||
return renderConfigTab({
|
||||
|
||||
@@ -451,4 +451,11 @@ export type AppViewState = {
|
||||
handleOpenSidebar: (content: SidebarContent) => void;
|
||||
handleCloseSidebar: () => void;
|
||||
handleSplitRatioChange: (ratio: number) => void;
|
||||
webPushSupported: boolean;
|
||||
webPushPermission: NotificationPermission | "unsupported";
|
||||
webPushSubscribed: boolean;
|
||||
webPushLoading: boolean;
|
||||
handleWebPushSubscribe: () => Promise<void>;
|
||||
handleWebPushUnsubscribe: () => Promise<void>;
|
||||
handleWebPushTest: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -504,6 +504,11 @@ export class OpenClawApp extends LitElement {
|
||||
@state() debugCallResult: string | null = null;
|
||||
@state() debugCallError: string | null = null;
|
||||
|
||||
@state() webPushSupported = false;
|
||||
@state() webPushPermission: NotificationPermission | "unsupported" = "unsupported";
|
||||
@state() webPushSubscribed = false;
|
||||
@state() webPushLoading = false;
|
||||
|
||||
@state() logsLoading = false;
|
||||
@state() logsError: string | null = null;
|
||||
@state() logsFile: string | null = null;
|
||||
@@ -574,6 +579,7 @@ export class OpenClawApp extends LitElement {
|
||||
};
|
||||
document.addEventListener("keydown", this.globalKeydownHandler);
|
||||
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
|
||||
void this.initWebPushState();
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
@@ -948,6 +954,97 @@ export class OpenClawApp extends LitElement {
|
||||
this.applySettings({ ...this.settings, splitRatio: newRatio });
|
||||
}
|
||||
|
||||
private async initWebPushState() {
|
||||
const supported =
|
||||
"serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
this.webPushSupported = supported;
|
||||
this.webPushPermission = supported ? Notification.permission : "unsupported";
|
||||
if (supported) {
|
||||
try {
|
||||
const { getExistingSubscription } = await import("./push-subscription.ts");
|
||||
const existing = await getExistingSubscription();
|
||||
this.webPushSubscribed = existing !== null;
|
||||
} catch {
|
||||
// ignore — just means we can't check
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-register local push subscription with the gateway after connect. */
|
||||
async reconcileWebPushState() {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Always check PushManager directly — initWebPushState may not have finished
|
||||
// yet if gateway connected quickly.
|
||||
const { getExistingSubscription } = await import("./push-subscription.ts");
|
||||
const existing = await getExistingSubscription();
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
this.webPushSubscribed = true;
|
||||
const subJson = existing.toJSON();
|
||||
if (subJson.endpoint && subJson.keys?.p256dh && subJson.keys?.auth) {
|
||||
await this.client.request("push.web.subscribe", {
|
||||
endpoint: subJson.endpoint,
|
||||
keys: { p256dh: subJson.keys.p256dh, auth: subJson.keys.auth },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — don't block if gateway is unreachable.
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebPushSubscribe() {
|
||||
if (!this.client || this.webPushLoading) {
|
||||
return;
|
||||
}
|
||||
this.webPushLoading = true;
|
||||
try {
|
||||
const { subscribeToWebPush } = await import("./push-subscription.ts");
|
||||
await subscribeToWebPush(this.client);
|
||||
this.webPushSubscribed = true;
|
||||
this.webPushPermission = Notification.permission;
|
||||
} catch (err) {
|
||||
this.lastError = String(err);
|
||||
} finally {
|
||||
this.webPushLoading = false;
|
||||
// Always refresh permission state — catches denied prompts too.
|
||||
if ("Notification" in window) {
|
||||
this.webPushPermission = Notification.permission;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebPushUnsubscribe() {
|
||||
if (!this.client || this.webPushLoading) {
|
||||
return;
|
||||
}
|
||||
this.webPushLoading = true;
|
||||
try {
|
||||
const { unsubscribeFromWebPush } = await import("./push-subscription.ts");
|
||||
await unsubscribeFromWebPush(this.client);
|
||||
this.webPushSubscribed = false;
|
||||
} catch (err) {
|
||||
this.lastError = String(err);
|
||||
} finally {
|
||||
this.webPushLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebPushTest() {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { sendTestWebPush } = await import("./push-subscription.ts");
|
||||
await sendTestWebPush(this.client);
|
||||
} catch (err) {
|
||||
this.lastError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return renderApp(this as unknown as AppViewState);
|
||||
}
|
||||
|
||||
141
ui/src/ui/push-subscription.ts
Normal file
141
ui/src/ui/push-subscription.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { GatewayBrowserClient } from "./gateway.ts";
|
||||
|
||||
export type WebPushState = {
|
||||
supported: boolean;
|
||||
permission: NotificationPermission | "unsupported";
|
||||
subscribed: boolean;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
/** Timeout (ms) for service-worker readiness. */
|
||||
const SW_READY_TIMEOUT = 10_000;
|
||||
|
||||
/**
|
||||
* Await service-worker readiness with a timeout so callers don't hang
|
||||
* indefinitely when registration fails or sw.js is unreachable.
|
||||
*/
|
||||
function swReady(): Promise<ServiceWorkerRegistration> {
|
||||
return Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Service worker not ready (timed out)")), SW_READY_TIMEOUT),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-safe base64 string to Uint8Array (for applicationServerKey).
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(base64);
|
||||
const output = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
output[i] = raw.charCodeAt(i);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the browser already has an active push subscription.
|
||||
*/
|
||||
export async function getExistingSubscription(): Promise<PushSubscription | null> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return null;
|
||||
}
|
||||
const registration = await swReady();
|
||||
return await registration.pushManager.getSubscription();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to web push notifications.
|
||||
* Requests notification permission if not already granted, fetches VAPID key
|
||||
* from the gateway, subscribes with the PushManager, and registers with the
|
||||
* gateway. If gateway registration fails, the local PushManager subscription
|
||||
* is rolled back to avoid local/server state divergence.
|
||||
*/
|
||||
export async function subscribeToWebPush(
|
||||
client: GatewayBrowserClient,
|
||||
): Promise<{ subscriptionId: string }> {
|
||||
// Request permission.
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") {
|
||||
throw new Error(`Notification permission ${permission}`);
|
||||
}
|
||||
|
||||
// Get VAPID public key from gateway.
|
||||
const vapidRes = await client.request("push.web.vapidPublicKey", {});
|
||||
const vapidPublicKey = (vapidRes as { vapidPublicKey: string }).vapidPublicKey;
|
||||
if (!vapidPublicKey) {
|
||||
throw new Error("Failed to retrieve VAPID public key");
|
||||
}
|
||||
|
||||
// Subscribe via PushManager.
|
||||
const registration = await swReady();
|
||||
const pushSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey).buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
const subJson = pushSubscription.toJSON();
|
||||
if (!subJson.endpoint || !subJson.keys?.p256dh || !subJson.keys?.auth) {
|
||||
throw new Error("Invalid push subscription from browser");
|
||||
}
|
||||
|
||||
// Register with gateway — roll back local subscription on failure.
|
||||
try {
|
||||
const registerRes = await client.request("push.web.subscribe", {
|
||||
endpoint: subJson.endpoint,
|
||||
keys: {
|
||||
p256dh: subJson.keys.p256dh,
|
||||
auth: subJson.keys.auth,
|
||||
},
|
||||
});
|
||||
|
||||
return registerRes as { subscriptionId: string };
|
||||
} catch (err) {
|
||||
// Gateway registration failed — unsubscribe locally to keep state consistent.
|
||||
try {
|
||||
await pushSubscription.unsubscribe();
|
||||
} catch {
|
||||
// Best-effort rollback.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from web push notifications.
|
||||
* Always unsubscribes locally even if the gateway request fails, to avoid
|
||||
* leaving the browser subscribed with no server-side record.
|
||||
*/
|
||||
export async function unsubscribeFromWebPush(client: GatewayBrowserClient): Promise<void> {
|
||||
const registration = await swReady();
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
// Notify gateway (best-effort — always unsubscribe locally afterward).
|
||||
try {
|
||||
await client.request("push.web.unsubscribe", {
|
||||
endpoint: subscription.endpoint,
|
||||
});
|
||||
} catch {
|
||||
// Gateway may be unreachable; still unsubscribe locally.
|
||||
}
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a test web push notification via the gateway.
|
||||
*/
|
||||
export async function sendTestWebPush(
|
||||
client: GatewayBrowserClient,
|
||||
options?: { title?: string; body?: string },
|
||||
): Promise<void> {
|
||||
await client.request("push.web.test", {
|
||||
title: options?.title,
|
||||
body: options?.body,
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,13 @@ const BORDER_RADIUS_LABELS: Record<BorderRadiusStop, string> = {
|
||||
100: "Full",
|
||||
};
|
||||
|
||||
export type WebPushUiState = {
|
||||
supported: boolean;
|
||||
permission: NotificationPermission | "unsupported";
|
||||
subscribed: boolean;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export type ConfigProps = {
|
||||
raw: string;
|
||||
originalRaw: string;
|
||||
@@ -87,6 +94,10 @@ export type ConfigProps = {
|
||||
settingsLayout?: "tabs" | "accordion";
|
||||
/** Callback to navigate back to Quick Settings. Shown in accordion mode. */
|
||||
onBackToQuick?: () => void;
|
||||
webPush?: WebPushUiState;
|
||||
onWebPushSubscribe?: () => void;
|
||||
onWebPushUnsubscribe?: () => void;
|
||||
onWebPushTest?: () => void;
|
||||
onRequestUpdate?: () => void;
|
||||
};
|
||||
|
||||
@@ -612,6 +623,105 @@ function focusCustomThemeImportInput() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotificationsSection(props: ConfigProps) {
|
||||
const push = props.webPush;
|
||||
if (!push) {
|
||||
return html`
|
||||
<div class="settings-appearance">
|
||||
<div class="settings-appearance__section">
|
||||
<h3 class="settings-appearance__heading">Push Notifications</h3>
|
||||
<p class="settings-appearance__hint">Not available in this browser.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const permissionLabel =
|
||||
push.permission === "granted"
|
||||
? "Granted"
|
||||
: push.permission === "denied"
|
||||
? "Denied"
|
||||
: push.permission === "default"
|
||||
? "Not requested"
|
||||
: "Unsupported";
|
||||
const statusDot = push.subscribed ? "settings-status-dot--ok" : "";
|
||||
|
||||
return html`
|
||||
<div class="settings-appearance">
|
||||
<div class="settings-appearance__section">
|
||||
<h3 class="settings-appearance__heading">Push Notifications</h3>
|
||||
<p class="settings-appearance__hint">
|
||||
Subscribe to receive browser push notifications from your gateway.
|
||||
</p>
|
||||
|
||||
<div class="settings-info-grid">
|
||||
<div class="settings-info-row">
|
||||
<span class="settings-info-row__label">Browser support</span>
|
||||
<span class="settings-info-row__value"
|
||||
>${push.supported ? "Available" : "Not supported"}</span
|
||||
>
|
||||
</div>
|
||||
<div class="settings-info-row">
|
||||
<span class="settings-info-row__label">Permission</span>
|
||||
<span class="settings-info-row__value">${permissionLabel}</span>
|
||||
</div>
|
||||
<div class="settings-info-row">
|
||||
<span class="settings-info-row__label">Status</span>
|
||||
<span class="settings-info-row__value">
|
||||
<span class="settings-status-dot ${statusDot}"></span>
|
||||
${push.subscribed ? "Subscribed" : "Not subscribed"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${push.supported && push.permission !== "denied"
|
||||
? html`
|
||||
<div class="settings-appearance__section">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
${push.subscribed
|
||||
? html`
|
||||
<button
|
||||
class="config-bar__btn"
|
||||
?disabled=${push.loading || !props.connected}
|
||||
@click=${() => props.onWebPushUnsubscribe?.()}
|
||||
>
|
||||
Unsubscribe
|
||||
</button>
|
||||
<button
|
||||
class="config-bar__btn"
|
||||
?disabled=${push.loading || !props.connected}
|
||||
@click=${() => props.onWebPushTest?.()}
|
||||
>
|
||||
Send test
|
||||
</button>
|
||||
`
|
||||
: html`
|
||||
<button
|
||||
class="config-bar__btn config-bar__btn--primary"
|
||||
?disabled=${push.loading || !props.connected}
|
||||
@click=${() => props.onWebPushSubscribe?.()}
|
||||
>
|
||||
${push.loading ? "Subscribing..." : "Enable notifications"}
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: push.permission === "denied"
|
||||
? html`
|
||||
<div class="settings-appearance__section">
|
||||
<p class="settings-appearance__hint">
|
||||
Notifications are blocked. Update your browser site permissions to allow
|
||||
notifications.
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAppearanceSection(props: ConfigProps) {
|
||||
const showCustomThemeImport = props.hasCustomTheme || props.customThemeImportExpanded === true;
|
||||
if (
|
||||
@@ -861,11 +971,14 @@ export function renderConfig(props: ConfigProps) {
|
||||
// Build categorised nav from schema - only include sections that exist in the schema
|
||||
const schemaProps = analysis.schema?.properties ?? {};
|
||||
|
||||
const VIRTUAL_SECTIONS = new Set(["__appearance__"]);
|
||||
const VIRTUAL_SECTIONS = new Set(["__appearance__", "__notifications__"]);
|
||||
const visibleCategories = SECTION_CATEGORIES.map((cat) =>
|
||||
Object.assign({}, cat, {
|
||||
sections: cat.sections.filter(
|
||||
(s) => (includeVirtualSections && VIRTUAL_SECTIONS.has(s.key)) || s.key in schemaProps,
|
||||
(s) =>
|
||||
((includeVirtualSections && VIRTUAL_SECTIONS.has(s.key)) || s.key in schemaProps) &&
|
||||
(!include || include.has(s.key)) &&
|
||||
(!exclude || !exclude.has(s.key)),
|
||||
),
|
||||
}),
|
||||
).filter((cat) => cat.sections.length > 0);
|
||||
@@ -1308,97 +1421,101 @@ export function renderConfig(props: ConfigProps) {
|
||||
? includeVirtualSections
|
||||
? renderAppearanceSection(props)
|
||||
: nothing
|
||||
: formMode === "form"
|
||||
? html`
|
||||
${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing}
|
||||
${props.schemaLoading
|
||||
? html`
|
||||
<div class="config-loading">
|
||||
<div class="config-loading__spinner"></div>
|
||||
<span>Loading schema…</span>
|
||||
</div>
|
||||
`
|
||||
: renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: props.uiHints,
|
||||
value: props.formValue,
|
||||
rawAvailable,
|
||||
disabled: props.loading || !props.formValue,
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
onPatch: props.onFormPatch,
|
||||
searchQuery: props.searchQuery,
|
||||
activeSection: props.activeSection,
|
||||
activeSubsection: effectiveSubsection,
|
||||
revealSensitive:
|
||||
props.activeSection === "env" ? envSensitiveVisible : false,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath: (path) => {
|
||||
toggleSensitivePathReveal(path);
|
||||
requestUpdate();
|
||||
},
|
||||
})}
|
||||
`
|
||||
: (() => {
|
||||
const sensitiveCount = countSensitiveConfigValues(
|
||||
props.formValue,
|
||||
[],
|
||||
props.uiHints,
|
||||
);
|
||||
const blurred = sensitiveCount > 0 && !cvs.rawRevealed;
|
||||
return html`
|
||||
${formUnsafe
|
||||
: props.activeSection === "__notifications__"
|
||||
? includeVirtualSections
|
||||
? renderNotificationsSection(props)
|
||||
: nothing
|
||||
: formMode === "form"
|
||||
? html`
|
||||
${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing}
|
||||
${props.schemaLoading
|
||||
? html`
|
||||
<div class="callout info" style="margin-bottom: 12px">
|
||||
Your config contains fields the form editor can't safely represent. Use
|
||||
Raw mode to edit those entries.
|
||||
<div class="config-loading">
|
||||
<div class="config-loading__spinner"></div>
|
||||
<span>Loading schema…</span>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="field config-raw-field">
|
||||
<span style="display:flex;align-items:center;gap:8px;">
|
||||
Raw config (JSON/JSON5)
|
||||
${sensitiveCount > 0
|
||||
? html`
|
||||
<span class="pill pill--sm"
|
||||
>${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"}
|
||||
${blurred ? "redacted" : "visible"}</span
|
||||
>
|
||||
<button
|
||||
class="btn btn--icon config-raw-toggle ${blurred ? "" : "active"}"
|
||||
title=${blurred
|
||||
? "Reveal sensitive values"
|
||||
: "Hide sensitive values"}
|
||||
aria-label="Toggle raw config redaction"
|
||||
aria-pressed=${!blurred}
|
||||
@click=${() => {
|
||||
cvs.rawRevealed = !cvs.rawRevealed;
|
||||
requestUpdate();
|
||||
}}
|
||||
>
|
||||
${blurred ? icons.eyeOff : icons.eye}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</span>
|
||||
${blurred
|
||||
: renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: props.uiHints,
|
||||
value: props.formValue,
|
||||
rawAvailable,
|
||||
disabled: props.loading || !props.formValue,
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
onPatch: props.onFormPatch,
|
||||
searchQuery: props.searchQuery,
|
||||
activeSection: props.activeSection,
|
||||
activeSubsection: effectiveSubsection,
|
||||
revealSensitive:
|
||||
props.activeSection === "env" ? envSensitiveVisible : false,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath: (path) => {
|
||||
toggleSensitivePathReveal(path);
|
||||
requestUpdate();
|
||||
},
|
||||
})}
|
||||
`
|
||||
: (() => {
|
||||
const sensitiveCount = countSensitiveConfigValues(
|
||||
props.formValue,
|
||||
[],
|
||||
props.uiHints,
|
||||
);
|
||||
const blurred = sensitiveCount > 0 && !cvs.rawRevealed;
|
||||
return html`
|
||||
${formUnsafe
|
||||
? html`
|
||||
<div class="callout info" style="margin-top: 12px">
|
||||
${sensitiveCount} sensitive value${sensitiveCount === 1 ? "" : "s"}
|
||||
hidden. Use the reveal button above to edit the raw config.
|
||||
<div class="callout info" style="margin-bottom: 12px">
|
||||
Your config contains fields the form editor can't safely represent.
|
||||
Use Raw mode to edit those entries.
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<textarea
|
||||
placeholder="Raw config (JSON/JSON5)"
|
||||
.value=${props.raw}
|
||||
@input=${(e: Event) => {
|
||||
props.onRawChange((e.target as HTMLTextAreaElement).value);
|
||||
}}
|
||||
></textarea>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
})()}
|
||||
: nothing}
|
||||
<div class="field config-raw-field">
|
||||
<span style="display:flex;align-items:center;gap:8px;">
|
||||
Raw config (JSON/JSON5)
|
||||
${sensitiveCount > 0
|
||||
? html`
|
||||
<span class="pill pill--sm"
|
||||
>${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"}
|
||||
${blurred ? "redacted" : "visible"}</span
|
||||
>
|
||||
<button
|
||||
class="btn btn--icon config-raw-toggle ${blurred ? "" : "active"}"
|
||||
title=${blurred
|
||||
? "Reveal sensitive values"
|
||||
: "Hide sensitive values"}
|
||||
aria-label="Toggle raw config redaction"
|
||||
aria-pressed=${!blurred}
|
||||
@click=${() => {
|
||||
cvs.rawRevealed = !cvs.rawRevealed;
|
||||
requestUpdate();
|
||||
}}
|
||||
>
|
||||
${blurred ? icons.eyeOff : icons.eye}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</span>
|
||||
${blurred
|
||||
? html`
|
||||
<div class="callout info" style="margin-top: 12px">
|
||||
${sensitiveCount} sensitive value${sensitiveCount === 1 ? "" : "s"}
|
||||
hidden. Use the reveal button above to edit the raw config.
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<textarea
|
||||
placeholder="Raw config (JSON/JSON5)"
|
||||
.value=${props.raw}
|
||||
@input=${(e: Event) => {
|
||||
props.onRawChange((e.target as HTMLTextAreaElement).value);
|
||||
}}
|
||||
></textarea>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
${props.issues.length > 0
|
||||
|
||||
Reference in New Issue
Block a user