diff --git a/config/test-suites.ts b/config/test-suites.ts index 9f3ed295..407a7b1b 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -31,6 +31,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/capture-geometry-retry.browser.test.ts', 'test/codex-predictive-echo.test.ts', // also needs a real codex binary 'test/split-pane-terminal.browser.test.ts', + 'test/webhook-settings.browser.test.ts', 'test/split-pane-orchestration.browser.test.ts', 'test/split-pane-auto-collapse.browser.test.ts', ]; diff --git a/src/web/public/index.html b/src/web/public/index.html index 40d19750..56b95c8d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2601,6 +2601,53 @@ + +
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 6c5208e8..f0b3b668 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -416,6 +416,7 @@ Object.assign(CodemanApp.prototype, { // .checked fires no onchange, so the list's visibility (and lazy load) // needs an explicit sync on every open, not just a save. this.applyCliManagementVisibility(); + this.loadWebhook(); // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; document.getElementById('appSettingsUltracodeFloatingWindows').checked = @@ -1114,6 +1115,88 @@ Object.assign(CodemanApp.prototype, { this._updateCheck = null; }, + /** + * Webhook notifications (Settings → Notifications). Server-side config behind /api/webhook, not a + * settings-payload field: the URL is a secret, so it never round-trips through settings.json or + * this page. The URL box is write-only; the status line shows scheme + host only. + */ + _webhookSay(text, bad = false) { + const out = document.getElementById('webhookResult'); + if (!out) return; + out.textContent = text; + out.style.display = text ? 'block' : 'none'; + out.style.color = bad ? 'var(--danger, #e5534b)' : ''; + }, + + async loadWebhook() { + const group = document.getElementById('webhookGroup'); + if (!group) return; + const res = await this._api('/api/webhook'); + if (!res || !res.ok) { + group.style.display = 'none'; // not an admin in multi-user mode, or the server predates the route + return; + } + let body = null; + try { body = await res.json(); } catch { /* leave hidden */ } + if (!body || body.success === false) { group.style.display = 'none'; return; } + const d = body.data; + group.style.display = ''; + document.getElementById('webhookEnabled').checked = d.enabled === true; + document.getElementById('webhookKind').value = d.kind; + document.getElementById('webhookScope').value = d.scope; + const url = document.getElementById('webhookUrl'); + url.value = ''; + url.placeholder = d.hasUrl ? 'Saved. Paste a new URL to replace it' : 'https://ntfy.sh/your-topic'; + document.getElementById('webhookUrlHint').textContent = d.hasUrl ? `Saved: ${d.urlMasked}` : 'Nothing saved yet.'; + if (d.lastResult) { + const when = new Date(d.lastResult.at).toLocaleString(); + this._webhookSay( + d.lastResult.ok ? `Last delivery succeeded (${when}).` : `Last delivery failed (${when}): ${d.lastResult.error}`, + !d.lastResult.ok + ); + } else { + this._webhookSay(''); + } + }, + + async saveWebhook() { + const payload = { + enabled: document.getElementById('webhookEnabled').checked, + kind: document.getElementById('webhookKind').value, + scope: document.getElementById('webhookScope').value, + }; + const url = document.getElementById('webhookUrl').value.trim(); + if (url) payload.url = url; // blank = keep the saved one + const res = await this._api('/api/webhook', { method: 'PUT', body: payload }); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + this._webhookSay(body?.error || 'Could not save the webhook.', true); + return; + } + await this.loadWebhook(); + this._webhookSay('Saved.'); + }, + + async testWebhook() { + const btn = document.getElementById('webhookTestBtn'); + if (btn) btn.disabled = true; + this._webhookSay('Sending…'); + try { + const res = await this._apiPost('/api/webhook/test', {}); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + this._webhookSay(body?.error || 'Could not send the test.', true); + return; + } + const r = body.data; + this._webhookSay(r.ok ? 'Test sent. Check your phone or channel.' : `Delivery failed: ${r.error}`, !r.ok); + } finally { + if (btn) btn.disabled = false; + } + }, + _setUpdateResult(html) { const el = this.$('updateResult'); if (el) { el.style.display = 'block'; el.innerHTML = html; } diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 2a7e41b5..107a566a 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -28,6 +28,7 @@ export { registerWsRoutes } from './ws-routes.js'; export { registerVoiceRoutes } from './voice-routes.js'; export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js'; export { registerTabLayoutRoutes } from './tab-layout-routes.js'; +export { registerWebhookRoutes } from './webhook-routes.js'; export { registerCustomModelRoutes, refreshAllCustomModelHosts, diff --git a/src/web/routes/webhook-routes.ts b/src/web/routes/webhook-routes.ts new file mode 100644 index 00000000..e1bd33cc --- /dev/null +++ b/src/web/routes/webhook-routes.ts @@ -0,0 +1,115 @@ +/** + * @fileoverview Webhook notification settings (src/webhook-notify.ts). + * + * GET /api/webhook — the config WITHOUT its URL (scheme + host only), and the last delivery result + * PUT /api/webhook — change enabled / kind / url / scope; an empty `url` clears it + * POST /api/webhook/test — send one test message with the saved config + * + * The URL is a bearer secret (anyone holding a Slack/Discord webhook URL can post as it), so it is + * stored in its own 0600 file and never returned. In multi-user mode all three routes are admin only: + * the channel receives every session's events, the same reach an admin's own Web Push has. + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; +import { isAdmin, parseBody } from '../route-helpers.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { WebhookUpdateSchema } from '../schemas.js'; +import { + maskWebhookUrl, + readWebhookConfig, + webhookUrlProblem, + writeWebhookConfig, + type WebhookKind, + type WebhookNotifier, + type WebhookResult, + type WebhookScope, +} from '../../webhook-notify.js'; + +export interface WebhookStatus { + enabled: boolean; + kind: WebhookKind; + scope: WebhookScope; + hasUrl: boolean; + /** Scheme + host only; the path and query are the secret. */ + urlMasked: string; + lastResult: WebhookResult | null; +} + +export interface WebhookRouteDeps { + notifier: WebhookNotifier; + configDir: string; + /** The instance's window title, so a test message says which machine sent it. */ + hostTitle: () => string; +} + +export function registerWebhookRoutes(app: FastifyInstance, deps: WebhookRouteDeps): void { + const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse