feat: route exec approvals via gateway
This commit is contained in:
@@ -46,6 +46,7 @@ private struct ExecHostRequest: Codable {
|
||||
var needsScreenRecording: Bool?
|
||||
var agentId: String?
|
||||
var sessionKey: String?
|
||||
var approvalDecision: ExecApprovalDecision?
|
||||
}
|
||||
|
||||
private struct ExecHostRunResult: Codable {
|
||||
@@ -328,8 +329,21 @@ private enum ExecHostExecutor {
|
||||
return false
|
||||
}()
|
||||
|
||||
var approvedByAsk = false
|
||||
if requiresAsk {
|
||||
let approvalDecision = request.approvalDecision
|
||||
if approvalDecision == .deny {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: user denied",
|
||||
reason: "user-denied"))
|
||||
}
|
||||
|
||||
var approvedByAsk = approvalDecision != nil
|
||||
if requiresAsk, approvalDecision == nil {
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
@@ -364,6 +378,13 @@ private enum ExecHostExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
if approvalDecision == .allowAlways, security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
if !pattern.isEmpty {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
||||
}
|
||||
}
|
||||
|
||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
|
||||
@@ -477,6 +477,7 @@ export function createExecTool(
|
||||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
|
||||
|
||||
let approvedByAsk = false;
|
||||
let approvalDecision: "allow-once" | "allow-always" | null = null;
|
||||
if (requiresAsk) {
|
||||
const decisionResult = (await callGatewayTool(
|
||||
"exec.approval.request",
|
||||
@@ -504,20 +505,24 @@ export function createExecTool(
|
||||
if (!decision) {
|
||||
if (askFallback === "full") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-once";
|
||||
} else if (askFallback === "allowlist") {
|
||||
if (!allowlistMatch) {
|
||||
throw new Error("exec denied: approval required (approval UI not available)");
|
||||
}
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-once";
|
||||
} else {
|
||||
throw new Error("exec denied: approval required (approval UI not available)");
|
||||
}
|
||||
}
|
||||
if (decision === "allow-once") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-once";
|
||||
}
|
||||
if (decision === "allow-always") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-always";
|
||||
if (hostSecurity === "allowlist") {
|
||||
const pattern =
|
||||
resolution?.resolvedPath ??
|
||||
@@ -556,6 +561,7 @@ export function createExecTool(
|
||||
agentId: defaults?.agentId,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
approved: approvedByAsk,
|
||||
approvalDecision: approvalDecision ?? undefined,
|
||||
},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ExecHostRequest = {
|
||||
needsScreenRecording?: boolean | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
approvalDecision?: "allow-once" | "allow-always" | null;
|
||||
};
|
||||
|
||||
export type ExecHostRunResult = {
|
||||
|
||||
@@ -50,6 +50,7 @@ type SystemRunParams = {
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
approved?: boolean | null;
|
||||
approvalDecision?: string | null;
|
||||
};
|
||||
|
||||
type SystemWhichParams = {
|
||||
@@ -561,6 +562,10 @@ async function handleInvoke(
|
||||
const useMacAppExec =
|
||||
process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
|
||||
if (useMacAppExec) {
|
||||
const approvalDecision =
|
||||
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
||||
? params.approvalDecision
|
||||
: null;
|
||||
const execRequest: ExecHostRequest = {
|
||||
command: argv,
|
||||
rawCommand: rawCommand || null,
|
||||
@@ -570,6 +575,7 @@ async function handleInvoke(
|
||||
needsScreenRecording: params.needsScreenRecording ?? null,
|
||||
agentId: agentId ?? null,
|
||||
sessionKey: sessionKey ?? null,
|
||||
approvalDecision,
|
||||
};
|
||||
const response = await runViaMacAppExecHost({ approvals, request: execRequest });
|
||||
if (!response) {
|
||||
@@ -660,7 +666,11 @@ async function handleInvoke(
|
||||
ask === "always" ||
|
||||
(ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow);
|
||||
|
||||
const approvedByAsk = params.approved === true;
|
||||
const approvalDecision =
|
||||
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
||||
? params.approvalDecision
|
||||
: null;
|
||||
const approvedByAsk = approvalDecision !== null || params.approved === true;
|
||||
if (requiresAsk && !approvedByAsk) {
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
@@ -679,7 +689,7 @@ async function handleInvoke(
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (approvedByAsk && security === "allowlist") {
|
||||
if (approvalDecision === "allow-always" && security === "allowlist") {
|
||||
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
|
||||
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
|
||||
}
|
||||
|
||||
@@ -1181,3 +1181,96 @@
|
||||
border-radius: 10px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.exec-approval-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(8, 12, 18, 0.7);
|
||||
backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.exec-approval-card {
|
||||
width: min(560px, 100%);
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.35);
|
||||
animation: rise 0.25s ease;
|
||||
}
|
||||
|
||||
.exec-approval-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.exec-approval-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.exec-approval-sub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.exec-approval-queue {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.exec-approval-command {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.exec-approval-meta {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.exec-approval-meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.exec-approval-meta-row span:last-child {
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.exec-approval-error {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.exec-approval-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,14 @@ import {
|
||||
setLastActiveSessionKey,
|
||||
} from "./app-settings";
|
||||
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat";
|
||||
import {
|
||||
addExecApproval,
|
||||
parseExecApprovalRequested,
|
||||
parseExecApprovalResolved,
|
||||
removeExecApproval,
|
||||
} from "./controllers/exec-approval";
|
||||
import type { ClawdbotApp } from "./app";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
||||
|
||||
type GatewayHost = {
|
||||
settings: UiSettings;
|
||||
@@ -34,6 +41,8 @@ type GatewayHost = {
|
||||
debugHealth: HealthSnapshot | null;
|
||||
sessionKey: string;
|
||||
chatRunId: string | null;
|
||||
execApprovalQueue: ExecApprovalRequest[];
|
||||
execApprovalError: string | null;
|
||||
};
|
||||
|
||||
type SessionDefaultsSnapshot = {
|
||||
@@ -94,6 +103,8 @@ export function connectGateway(host: GatewayHost) {
|
||||
host.lastError = null;
|
||||
host.hello = null;
|
||||
host.connected = false;
|
||||
host.execApprovalQueue = [];
|
||||
host.execApprovalError = null;
|
||||
|
||||
host.client?.stop();
|
||||
host.client = new GatewayBrowserClient({
|
||||
@@ -175,6 +186,26 @@ export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") {
|
||||
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
|
||||
}
|
||||
|
||||
if (evt.event === "exec.approval.requested") {
|
||||
const entry = parseExecApprovalRequested(evt.payload);
|
||||
if (entry) {
|
||||
host.execApprovalQueue = addExecApproval(host.execApprovalQueue, entry);
|
||||
host.execApprovalError = null;
|
||||
const delay = Math.max(0, entry.expiresAtMs - Date.now() + 500);
|
||||
window.setTimeout(() => {
|
||||
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, entry.id);
|
||||
}, delay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === "exec.approval.resolved") {
|
||||
const resolved = parseExecApprovalResolved(evt.payload);
|
||||
if (resolved) {
|
||||
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, resolved.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
||||
|
||||
@@ -38,6 +38,7 @@ import { renderLogs } from "./views/logs";
|
||||
import { renderNodes } from "./views/nodes";
|
||||
import { renderOverview } from "./views/overview";
|
||||
import { renderSessions } from "./views/sessions";
|
||||
import { renderExecApprovalPrompt } from "./views/exec-approval";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
loadDevices,
|
||||
@@ -518,6 +519,7 @@ export function renderApp(state: AppViewState) {
|
||||
})
|
||||
: nothing}
|
||||
</main>
|
||||
${renderExecApprovalPrompt(state)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
ExecApprovalsSnapshot,
|
||||
} from "./controllers/exec-approvals";
|
||||
import type { DevicePairingList } from "./controllers/devices";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
||||
|
||||
export type AppViewState = {
|
||||
settings: UiSettings;
|
||||
@@ -60,6 +61,9 @@ export type AppViewState = {
|
||||
execApprovalsSelectedAgent: string | null;
|
||||
execApprovalsTarget: "gateway" | "node";
|
||||
execApprovalsTargetNodeId: string | null;
|
||||
execApprovalQueue: ExecApprovalRequest[];
|
||||
execApprovalBusy: boolean;
|
||||
execApprovalError: string | null;
|
||||
configLoading: boolean;
|
||||
configRaw: string;
|
||||
configValid: boolean | null;
|
||||
@@ -137,6 +141,7 @@ export type AppViewState = {
|
||||
handleWhatsAppLogout: () => Promise<void>;
|
||||
handleChannelConfigSave: () => Promise<void>;
|
||||
handleChannelConfigReload: () => Promise<void>;
|
||||
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
|
||||
handleConfigLoad: () => Promise<void>;
|
||||
handleConfigSave: () => Promise<void>;
|
||||
handleConfigApply: () => Promise<void>;
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
ExecApprovalsSnapshot,
|
||||
} from "./controllers/exec-approvals";
|
||||
import type { DevicePairingList } from "./controllers/devices";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
||||
import {
|
||||
resetToolStream as resetToolStreamInternal,
|
||||
toggleToolOutput as toggleToolOutputInternal,
|
||||
@@ -120,6 +121,9 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() execApprovalsSelectedAgent: string | null = null;
|
||||
@state() execApprovalsTarget: "gateway" | "node" = "gateway";
|
||||
@state() execApprovalsTargetNodeId: string | null = null;
|
||||
@state() execApprovalQueue: ExecApprovalRequest[] = [];
|
||||
@state() execApprovalBusy = false;
|
||||
@state() execApprovalError: string | null = null;
|
||||
|
||||
@state() configLoading = false;
|
||||
@state() configRaw = "{\n}\n";
|
||||
@@ -365,6 +369,24 @@ export class ClawdbotApp extends LitElement {
|
||||
await handleChannelConfigReloadInternal(this);
|
||||
}
|
||||
|
||||
async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") {
|
||||
const active = this.execApprovalQueue[0];
|
||||
if (!active || !this.client || this.execApprovalBusy) return;
|
||||
this.execApprovalBusy = true;
|
||||
this.execApprovalError = null;
|
||||
try {
|
||||
await this.client.request("exec.approval.resolve", {
|
||||
id: active.id,
|
||||
decision,
|
||||
});
|
||||
this.execApprovalQueue = this.execApprovalQueue.filter((entry) => entry.id !== active.id);
|
||||
} catch (err) {
|
||||
this.execApprovalError = `Exec approval failed: ${String(err)}`;
|
||||
} finally {
|
||||
this.execApprovalBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar handlers for tool output viewing
|
||||
handleOpenSidebar(content: string) {
|
||||
if (this.sidebarCloseTimer != null) {
|
||||
|
||||
85
ui/src/ui/controllers/exec-approval.ts
Normal file
85
ui/src/ui/controllers/exec-approval.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export type ExecApprovalRequestPayload = {
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
host?: string | null;
|
||||
security?: string | null;
|
||||
ask?: string | null;
|
||||
agentId?: string | null;
|
||||
resolvedPath?: string | null;
|
||||
sessionKey?: string | null;
|
||||
};
|
||||
|
||||
export type ExecApprovalRequest = {
|
||||
id: string;
|
||||
request: ExecApprovalRequestPayload;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
export type ExecApprovalResolved = {
|
||||
id: string;
|
||||
decision?: string | null;
|
||||
resolvedBy?: string | null;
|
||||
ts?: number | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null {
|
||||
if (!isRecord(payload)) return null;
|
||||
const id = typeof payload.id === "string" ? payload.id.trim() : "";
|
||||
const request = payload.request;
|
||||
if (!id || !isRecord(request)) return null;
|
||||
const command = typeof request.command === "string" ? request.command.trim() : "";
|
||||
if (!command) return null;
|
||||
const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0;
|
||||
const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0;
|
||||
if (!createdAtMs || !expiresAtMs) return null;
|
||||
return {
|
||||
id,
|
||||
request: {
|
||||
command,
|
||||
cwd: typeof request.cwd === "string" ? request.cwd : null,
|
||||
host: typeof request.host === "string" ? request.host : null,
|
||||
security: typeof request.security === "string" ? request.security : null,
|
||||
ask: typeof request.ask === "string" ? request.ask : null,
|
||||
agentId: typeof request.agentId === "string" ? request.agentId : null,
|
||||
resolvedPath: typeof request.resolvedPath === "string" ? request.resolvedPath : null,
|
||||
sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null,
|
||||
},
|
||||
createdAtMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseExecApprovalResolved(payload: unknown): ExecApprovalResolved | null {
|
||||
if (!isRecord(payload)) return null;
|
||||
const id = typeof payload.id === "string" ? payload.id.trim() : "";
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
decision: typeof payload.decision === "string" ? payload.decision : null,
|
||||
resolvedBy: typeof payload.resolvedBy === "string" ? payload.resolvedBy : null,
|
||||
ts: typeof payload.ts === "number" ? payload.ts : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneExecApprovalQueue(queue: ExecApprovalRequest[]): ExecApprovalRequest[] {
|
||||
const now = Date.now();
|
||||
return queue.filter((entry) => entry.expiresAtMs > now);
|
||||
}
|
||||
|
||||
export function addExecApproval(
|
||||
queue: ExecApprovalRequest[],
|
||||
entry: ExecApprovalRequest,
|
||||
): ExecApprovalRequest[] {
|
||||
const next = pruneExecApprovalQueue(queue).filter((item) => item.id !== entry.id);
|
||||
next.push(entry);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function removeExecApproval(queue: ExecApprovalRequest[], id: string): ExecApprovalRequest[] {
|
||||
return pruneExecApprovalQueue(queue).filter((entry) => entry.id !== id);
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export class GatewayBrowserClient {
|
||||
this.connectTimer = null;
|
||||
}
|
||||
const deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||
const scopes = ["operator.admin"];
|
||||
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
||||
const role = "operator";
|
||||
const storedToken = loadDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
|
||||
78
ui/src/ui/views/exec-approval.ts
Normal file
78
ui/src/ui/views/exec-approval.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import type { AppViewState } from "../app-view-state";
|
||||
|
||||
function formatRemaining(ms: number): string {
|
||||
const remaining = Math.max(0, ms);
|
||||
const totalSeconds = Math.floor(remaining / 1000);
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
function renderMetaRow(label: string, value?: string | null) {
|
||||
if (!value) return nothing;
|
||||
return html`<div class="exec-approval-meta-row"><span>${label}</span><span>${value}</span></div>`;
|
||||
}
|
||||
|
||||
export function renderExecApprovalPrompt(state: AppViewState) {
|
||||
const active = state.execApprovalQueue[0];
|
||||
if (!active) return nothing;
|
||||
const request = active.request;
|
||||
const remainingMs = active.expiresAtMs - Date.now();
|
||||
const remaining = remainingMs > 0 ? `expires in ${formatRemaining(remainingMs)}` : "expired";
|
||||
const queueCount = state.execApprovalQueue.length;
|
||||
return html`
|
||||
<div class="exec-approval-overlay" role="dialog" aria-live="polite">
|
||||
<div class="exec-approval-card">
|
||||
<div class="exec-approval-header">
|
||||
<div>
|
||||
<div class="exec-approval-title">Exec approval needed</div>
|
||||
<div class="exec-approval-sub">${remaining}</div>
|
||||
</div>
|
||||
${queueCount > 1
|
||||
? html`<div class="exec-approval-queue">${queueCount} pending</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="exec-approval-command mono">${request.command}</div>
|
||||
<div class="exec-approval-meta">
|
||||
${renderMetaRow("Host", request.host)}
|
||||
${renderMetaRow("Agent", request.agentId)}
|
||||
${renderMetaRow("Session", request.sessionKey)}
|
||||
${renderMetaRow("CWD", request.cwd)}
|
||||
${renderMetaRow("Resolved", request.resolvedPath)}
|
||||
${renderMetaRow("Security", request.security)}
|
||||
${renderMetaRow("Ask", request.ask)}
|
||||
</div>
|
||||
${state.execApprovalError
|
||||
? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
|
||||
: nothing}
|
||||
<div class="exec-approval-actions">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${state.execApprovalBusy}
|
||||
@click=${() => state.handleExecApprovalDecision("allow-once")}
|
||||
>
|
||||
Allow once
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${state.execApprovalBusy}
|
||||
@click=${() => state.handleExecApprovalDecision("allow-always")}
|
||||
>
|
||||
Always allow
|
||||
</button>
|
||||
<button
|
||||
class="btn danger"
|
||||
?disabled=${state.execApprovalBusy}
|
||||
@click=${() => state.handleExecApprovalDecision("deny")}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user