feat(notify): HMAC-signed outgoing webhooks with per-tier secrets and test sender
Build / build (push) Successful in 10m36s
Build / build (push) Successful in 10m36s
Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.
Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.
Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.
Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).
API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.
UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.
Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.
Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
This commit is contained in:
@@ -346,6 +346,80 @@ export function regenerateStaticSiteWebhook(siteId: string): Promise<WebhookUrlR
|
||||
return post<WebhookUrlResponse>(`/api/sites/${siteId}/webhook/regenerate`);
|
||||
}
|
||||
|
||||
// ── Outgoing-webhook signing & test ────────────────────────────────
|
||||
|
||||
export interface NotificationSecretResponse {
|
||||
secret: string;
|
||||
has_secret: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationTestResult {
|
||||
url: string;
|
||||
tier: 'settings' | 'project' | 'stage' | 'site';
|
||||
status_code: number;
|
||||
latency_ms: number;
|
||||
signature_sent: boolean;
|
||||
delivery_id: string;
|
||||
response_snippet: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Settings (global) tier.
|
||||
export function getSettingsNotificationSecret(): Promise<NotificationSecretResponse> {
|
||||
return get<NotificationSecretResponse>('/api/settings/notification-secret');
|
||||
}
|
||||
export function regenerateSettingsNotificationSecret(): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>('/api/settings/notification-secret/regenerate');
|
||||
}
|
||||
export function disableSettingsNotificationSigning(): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>('/api/settings/notification-secret/disable');
|
||||
}
|
||||
export function testSettingsNotification(): Promise<NotificationTestResult> {
|
||||
return post<NotificationTestResult>('/api/settings/notification-test');
|
||||
}
|
||||
|
||||
// Project tier.
|
||||
export function getProjectNotificationSecret(projectId: string): Promise<NotificationSecretResponse> {
|
||||
return get<NotificationSecretResponse>(`/api/projects/${projectId}/notification-secret`);
|
||||
}
|
||||
export function regenerateProjectNotificationSecret(projectId: string): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>(`/api/projects/${projectId}/notification-secret/regenerate`);
|
||||
}
|
||||
export function disableProjectNotificationSigning(projectId: string): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>(`/api/projects/${projectId}/notification-secret/disable`);
|
||||
}
|
||||
export function testProjectNotification(projectId: string): Promise<NotificationTestResult> {
|
||||
return post<NotificationTestResult>(`/api/projects/${projectId}/notification-test`);
|
||||
}
|
||||
|
||||
// Stage tier.
|
||||
export function getStageNotificationSecret(projectId: string, stageId: string): Promise<NotificationSecretResponse> {
|
||||
return get<NotificationSecretResponse>(`/api/projects/${projectId}/stages/${stageId}/notification-secret`);
|
||||
}
|
||||
export function regenerateStageNotificationSecret(projectId: string, stageId: string): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>(`/api/projects/${projectId}/stages/${stageId}/notification-secret/regenerate`);
|
||||
}
|
||||
export function disableStageNotificationSigning(projectId: string, stageId: string): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>(`/api/projects/${projectId}/stages/${stageId}/notification-secret/disable`);
|
||||
}
|
||||
export function testStageNotification(projectId: string, stageId: string): Promise<NotificationTestResult> {
|
||||
return post<NotificationTestResult>(`/api/projects/${projectId}/stages/${stageId}/notification-test`);
|
||||
}
|
||||
|
||||
// Static-site tier.
|
||||
export function getStaticSiteNotificationSecret(siteId: string): Promise<NotificationSecretResponse> {
|
||||
return get<NotificationSecretResponse>(`/api/sites/${siteId}/notification-secret`);
|
||||
}
|
||||
export function regenerateStaticSiteNotificationSecret(siteId: string): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>(`/api/sites/${siteId}/notification-secret/regenerate`);
|
||||
}
|
||||
export function disableStaticSiteNotificationSigning(siteId: string): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>(`/api/sites/${siteId}/notification-secret/disable`);
|
||||
}
|
||||
export function testStaticSiteNotification(siteId: string): Promise<NotificationTestResult> {
|
||||
return post<NotificationTestResult>(`/api/sites/${siteId}/notification-test`);
|
||||
}
|
||||
|
||||
// ── Proxy Routes ───────────────────────────────────────────────────
|
||||
|
||||
export function listProxyRoutes(): Promise<ProxyRoute[]> {
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
<!--
|
||||
OutgoingWebhookPanel
|
||||
|
||||
Operator-facing controls for an outgoing webhook tier (global / project /
|
||||
stage / site). Three concerns in vertical flow:
|
||||
1. Signing state — secret reveal, copy, regenerate, disable.
|
||||
2. Send-test — fires a synthetic event and renders the receiver's
|
||||
response inline (status code, latency, body preview).
|
||||
3. Inheritance hint — when the parent didn't set a URL, shows that
|
||||
this tier will fall through to the next-most-general tier.
|
||||
|
||||
Parent supplies the four async callbacks; the panel doesn't know which
|
||||
tier it lives on. Mirrors the inbound-webhook WebhookPanel ergonomics so
|
||||
operators see a consistent visual language for every webhook surface.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import {
|
||||
IconCopy, IconRefresh, IconLoader, IconKey, IconShield, IconCheck, IconAlert
|
||||
} from '$lib/components/icons';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import type { NotificationSecretResponse, NotificationTestResult } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
// True when *this tier* has a URL configured (passed in by the
|
||||
// parent that owns the URL field). Drives whether "send test" can
|
||||
// fire and whether we render the inheritance hint.
|
||||
hasUrl: boolean;
|
||||
// Optional human-readable name of the tier we'd fall through to if
|
||||
// the URL is empty (e.g. "global settings" when on a project page).
|
||||
fallbackLabel?: string;
|
||||
fetchSecret: () => Promise<NotificationSecretResponse>;
|
||||
regenerateSecret: () => Promise<NotificationSecretResponse>;
|
||||
disableSigning: () => Promise<NotificationSecretResponse>;
|
||||
sendTest: () => Promise<NotificationTestResult>;
|
||||
}
|
||||
|
||||
let {
|
||||
title, description, hasUrl, fallbackLabel,
|
||||
fetchSecret, regenerateSecret, disableSigning, sendTest,
|
||||
}: Props = $props();
|
||||
|
||||
// Initial load is on-demand: showing the secret is an explicit
|
||||
// operator action, not a passive read of the page. This keeps the
|
||||
// secret out of the network response unless the operator asks for it.
|
||||
let secret = $state('');
|
||||
let hasSecret = $state(false);
|
||||
let revealed = $state(false);
|
||||
|
||||
let loading = $state(false);
|
||||
let regenerating = $state(false);
|
||||
let disabling = $state(false);
|
||||
let testing = $state(false);
|
||||
|
||||
let confirmRegenerate = $state(false);
|
||||
let confirmDisable = $state(false);
|
||||
|
||||
let testResult = $state<NotificationTestResult | null>(null);
|
||||
|
||||
// Tier presence-check happens once on mount so the "signing on/off"
|
||||
// pill is correct without revealing the secret.
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const res = await fetchSecret();
|
||||
hasSecret = res.has_secret;
|
||||
// Don't store the secret yet — only when revealed.
|
||||
} catch {
|
||||
// Silent — the panel still renders with hasSecret=false.
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetchSecret();
|
||||
secret = res.secret;
|
||||
hasSecret = res.has_secret;
|
||||
revealed = true;
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('outgoingWebhook.loadFailed'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerate() {
|
||||
confirmRegenerate = false;
|
||||
regenerating = true;
|
||||
try {
|
||||
const res = await regenerateSecret();
|
||||
secret = res.secret;
|
||||
hasSecret = true;
|
||||
revealed = true;
|
||||
toasts.success($t('outgoingWebhook.regenerated'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('outgoingWebhook.regenerateFailed'));
|
||||
} finally {
|
||||
regenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
confirmDisable = false;
|
||||
disabling = true;
|
||||
try {
|
||||
await disableSigning();
|
||||
secret = '';
|
||||
hasSecret = false;
|
||||
revealed = false;
|
||||
toasts.info($t('outgoingWebhook.disabled'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('outgoingWebhook.disableFailed'));
|
||||
} finally {
|
||||
disabling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
testing = true;
|
||||
testResult = null;
|
||||
try {
|
||||
testResult = await sendTest();
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('outgoingWebhook.testFailed'));
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy(value: string, successKey: string) {
|
||||
if (!value) return;
|
||||
navigator.clipboard.writeText(value).then(
|
||||
() => toasts.info($t(successKey)),
|
||||
() => toasts.error($t('outgoingWebhook.copyFailed')),
|
||||
);
|
||||
}
|
||||
|
||||
// Coarse classification of the test result so the status pill picks
|
||||
// the right colour band: 2xx green, 4xx amber, 5xx / network red.
|
||||
function resultClass(r: NotificationTestResult): string {
|
||||
if (r.error && !r.status_code) return 'failure';
|
||||
if (r.status_code >= 200 && r.status_code < 300) return 'success';
|
||||
if (r.status_code >= 400 && r.status_code < 500) return 'warn';
|
||||
return 'failure';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadStatus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<div class="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{title}</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{description}</p>
|
||||
</div>
|
||||
<!-- Signing-state pill: shown at all times so an operator can tell
|
||||
"signed" vs "unsigned" without revealing the secret. -->
|
||||
{#if hasSecret}
|
||||
<span class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[var(--color-success-light)] px-2.5 py-1 text-xs font-medium text-[var(--color-success)]">
|
||||
<IconShield size={12} />
|
||||
{$t('outgoingWebhook.signingOn')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[var(--surface-card-hover)] px-2.5 py-1 text-xs font-medium text-[var(--text-tertiary)]">
|
||||
<IconKey size={12} />
|
||||
{$t('outgoingWebhook.signingOff')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Inheritance hint: when this tier has no URL, real events fall
|
||||
through to the parent tier. Surfacing this prevents the surprise
|
||||
of "I configured the URL but events don't arrive". -->
|
||||
{#if !hasUrl}
|
||||
<div class="mb-4 flex items-start gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] px-3 py-2.5 text-sm text-[var(--text-secondary)]">
|
||||
<IconAlert size={16} />
|
||||
<span>
|
||||
{#if fallbackLabel}
|
||||
{$t('outgoingWebhook.fallbackTo', { label: fallbackLabel })}
|
||||
{:else}
|
||||
{$t('outgoingWebhook.noUrlConfigured')}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Secret control row.
|
||||
- When unrevealed, render an opaque placeholder + a "Reveal" CTA
|
||||
so the operator commits to the action of pulling the secret
|
||||
on screen.
|
||||
- When revealed, show the cleartext + copy. -->
|
||||
<div class="mb-2 text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
{$t('outgoingWebhook.signingSecret')}
|
||||
</div>
|
||||
<div class="flex items-stretch gap-2">
|
||||
<code class="flex-1 truncate rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] px-3 py-2.5 font-mono text-sm text-[var(--text-secondary)]">
|
||||
{#if revealed && secret}
|
||||
{secret}
|
||||
{:else if hasSecret}
|
||||
<span class="select-none text-[var(--text-tertiary)]">••••••••••••••••••••••••••••••••</span>
|
||||
{:else}
|
||||
<span class="select-none italic text-[var(--text-tertiary)]">{$t('outgoingWebhook.noSecret')}</span>
|
||||
{/if}
|
||||
</code>
|
||||
{#if revealed && secret}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleCopy(secret, 'outgoingWebhook.copied')}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2.5 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] active:animate-press"
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
{$t('outgoingWebhook.copy')}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleReveal}
|
||||
disabled={loading}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2.5 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if loading}<IconLoader size={16} />{:else}<IconKey size={16} />{/if}
|
||||
{hasSecret ? $t('outgoingWebhook.reveal') : $t('outgoingWebhook.generate')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action row: regenerate / disable. Both are destructive (regenerate
|
||||
invalidates every receiver verifying the old secret; disable lets
|
||||
subsequent events go out unsigned), so each opens a modal
|
||||
ConfirmDialog rather than relying on a slim inline confirm strip
|
||||
that's easy to miss next to the other buttons. -->
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (confirmRegenerate = true)}
|
||||
disabled={regenerating}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-3 py-1.5 text-sm font-medium text-[var(--color-danger)] transition-colors hover:bg-[var(--color-danger-light)] disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if regenerating}<IconLoader size={14} />{:else}<IconRefresh size={14} />{/if}
|
||||
{$t('outgoingWebhook.regenerate')}
|
||||
</button>
|
||||
{#if hasSecret}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (confirmDisable = true)}
|
||||
disabled={disabling}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if disabling}<IconLoader size={14} />{/if}
|
||||
{$t('outgoingWebhook.disable')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Send-test row. Disabled when no URL is configured *and* there's
|
||||
no fallback (otherwise the resolver would surface the inherited
|
||||
URL, and the test should reflect what a real event would do). -->
|
||||
<div class="mt-6 border-t border-[var(--border-primary)] pt-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-[var(--text-primary)]">{$t('outgoingWebhook.sendTestTitle')}</div>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('outgoingWebhook.sendTestHelp')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleTest}
|
||||
disabled={testing}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if testing}<IconLoader size={16} />{/if}
|
||||
{testing ? $t('outgoingWebhook.sending') : $t('outgoingWebhook.sendTest')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if testResult}
|
||||
{@const cls = resultClass(testResult)}
|
||||
<div
|
||||
class="mt-4 overflow-hidden rounded-lg border"
|
||||
class:border-[var(--color-success)]={cls === 'success'}
|
||||
class:border-[var(--color-warning)]={cls === 'warn'}
|
||||
class:border-[var(--color-danger)]={cls === 'failure'}
|
||||
>
|
||||
<!-- Result header: status pill + latency + signed badge -->
|
||||
<div class="flex flex-wrap items-center gap-3 px-4 py-3"
|
||||
class:bg-[var(--color-success-light)]={cls === 'success'}
|
||||
class:bg-[var(--color-warning-light)]={cls === 'warn'}
|
||||
class:bg-[var(--color-danger-light)]={cls === 'failure'}
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 font-mono text-sm font-semibold"
|
||||
class:text-[var(--color-success)]={cls === 'success'}
|
||||
class:text-[var(--color-warning)]={cls === 'warn'}
|
||||
class:text-[var(--color-danger)]={cls === 'failure'}
|
||||
>
|
||||
{#if cls === 'success'}<IconCheck size={14} />{:else}<IconAlert size={14} />{/if}
|
||||
{testResult.status_code || $t('outgoingWebhook.networkError')}
|
||||
</span>
|
||||
<span class="text-xs text-[var(--text-secondary)]">
|
||||
{testResult.latency_ms}ms
|
||||
</span>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
{$t('outgoingWebhook.tier')}: <span class="font-mono">{testResult.tier}</span>
|
||||
</span>
|
||||
{#if testResult.signature_sent}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-[var(--text-secondary)]">
|
||||
<IconShield size={12} />
|
||||
{$t('outgoingWebhook.signed')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{$t('outgoingWebhook.unsigned')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Result body: delivery ID (so operators can grep their
|
||||
receiver logs) + response preview if any. -->
|
||||
<div class="space-y-2 px-4 py-3">
|
||||
{#if testResult.error}
|
||||
<div class="text-sm text-[var(--color-danger)]">
|
||||
{testResult.error}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-xs">
|
||||
<span class="text-[var(--text-tertiary)]">{$t('outgoingWebhook.deliveryId')}:</span>
|
||||
<code class="ml-1 font-mono text-[var(--text-secondary)]">{testResult.delivery_id}</code>
|
||||
</div>
|
||||
{#if testResult.response_snippet}
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
{$t('outgoingWebhook.responseBody')}
|
||||
</div>
|
||||
<pre class="max-h-40 overflow-auto rounded border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-2 font-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{testResult.response_snippet}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal confirms for both destructive actions. Mounted outside the panel
|
||||
card so backdrop + scale-in animation cover the page, not just the
|
||||
panel rectangle (matches how the rest of Tinyforge surfaces deletes). -->
|
||||
<ConfirmDialog
|
||||
open={confirmRegenerate}
|
||||
title={$t('outgoingWebhook.confirmRegenerateTitle')}
|
||||
message={$t('outgoingWebhook.confirmRegenerate')}
|
||||
confirmLabel={$t('outgoingWebhook.regenerate')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleRegenerate}
|
||||
oncancel={() => (confirmRegenerate = false)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDisable}
|
||||
title={$t('outgoingWebhook.confirmDisableTitle')}
|
||||
message={$t('outgoingWebhook.confirmDisable')}
|
||||
confirmLabel={$t('outgoingWebhook.disable')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDisable}
|
||||
oncancel={() => (confirmDisable = false)}
|
||||
/>
|
||||
@@ -120,6 +120,16 @@
|
||||
"projectDetail": {
|
||||
"webhookTitle": "Project webhook",
|
||||
"webhookDesc": "POST an image reference to this URL from your CI pipeline to trigger a deploy. Stage routing uses each stage's tag pattern.",
|
||||
"outgoingWebhookTitle": "Outgoing webhook (project)",
|
||||
"outgoingWebhookDesc": "Where Tinyforge posts deploy events for this project. Stages can override; if none set, inherits from global settings.",
|
||||
"outgoingFallbackGlobal": "the global integrations setting",
|
||||
"notificationUrlLabel": "Outgoing webhook URL",
|
||||
"notificationUrlHelp": "Leave empty to inherit from global settings. Stages can override per-stage.",
|
||||
"stageNotificationUrlLabel": "Outgoing webhook URL (this stage)",
|
||||
"stageNotificationUrlHelp": "Leave empty to inherit from the project, then global settings.",
|
||||
"stageOutgoingTitle": "Outgoing webhook (stage)",
|
||||
"stageOutgoingDesc": "Where Tinyforge posts deploy events for this stage. Most-specific tier wins.",
|
||||
"stageFallbackLabel": "the project or global settings",
|
||||
"deleteProject": "Delete Project",
|
||||
"envVars": "Environment Variables",
|
||||
"volumes": "Volume Mounts",
|
||||
@@ -618,6 +628,11 @@
|
||||
"sites": {
|
||||
"webhookTitle": "Site webhook",
|
||||
"webhookDesc": "Point your Git provider's push webhook at this URL. Tinyforge will re-sync the site on matching refs (branch for push trigger, tag pattern for tag trigger). Send an empty body for an unconditional sync.",
|
||||
"outgoingUrlTitle": "Outgoing webhook URL (this site)",
|
||||
"outgoingUrlDesc": "Where Tinyforge posts site_sync_success / site_sync_failure events for this site. Empty falls through to global settings.",
|
||||
"outgoingWebhookTitle": "Outgoing webhook (site)",
|
||||
"outgoingWebhookDesc": "HMAC signing secret and test sender for the resolved outgoing URL.",
|
||||
"outgoingFallbackGlobal": "the global integrations setting",
|
||||
"title": "Static Sites",
|
||||
"addSite": "New Site",
|
||||
"newSite": "New Static Site",
|
||||
@@ -1168,6 +1183,43 @@
|
||||
"confirmYes": "Regenerate",
|
||||
"confirmNo": "Cancel"
|
||||
},
|
||||
"outgoingWebhook": {
|
||||
"signingOn": "Signed",
|
||||
"signingOff": "Unsigned",
|
||||
"signingSecret": "HMAC signing secret",
|
||||
"noSecret": "No signing secret — outgoing events are not signed.",
|
||||
"reveal": "Reveal",
|
||||
"generate": "Generate",
|
||||
"copy": "Copy",
|
||||
"copied": "Signing secret copied to clipboard",
|
||||
"copyFailed": "Failed to copy to clipboard",
|
||||
"loadFailed": "Failed to load signing secret",
|
||||
"regenerate": "Regenerate",
|
||||
"regenerated": "Signing secret regenerated",
|
||||
"regenerateFailed": "Failed to regenerate signing secret",
|
||||
"confirmRegenerateTitle": "Rotate signing secret?",
|
||||
"confirmRegenerate": "The current secret is invalidated immediately. Every receiver verifying it must be updated in lock-step or it will start rejecting events.",
|
||||
"confirmDisableTitle": "Disable HMAC signing?",
|
||||
"confirmDisable": "Future events go out without the X-Hub-Signature-256 header. Receivers that require signatures will reject them.",
|
||||
"confirmYes": "Confirm",
|
||||
"confirmNo": "Cancel",
|
||||
"disable": "Disable signing",
|
||||
"disabled": "Signing disabled",
|
||||
"disableFailed": "Failed to disable signing",
|
||||
"sendTestTitle": "Send a test event",
|
||||
"sendTestHelp": "Fires a synthetic \"test\" event to the resolved URL using the current secret.",
|
||||
"sendTest": "Send test",
|
||||
"sending": "Sending…",
|
||||
"testFailed": "Failed to send test event",
|
||||
"tier": "Tier",
|
||||
"signed": "Signed",
|
||||
"unsigned": "Unsigned",
|
||||
"deliveryId": "Delivery",
|
||||
"responseBody": "Response body",
|
||||
"networkError": "Network error",
|
||||
"fallbackTo": "No URL set on this tier — events will fall through to {label}.",
|
||||
"noUrlConfigured": "No URL set. Configure one above before sending a test."
|
||||
},
|
||||
"settingsMaintenance": {
|
||||
"title": "Maintenance",
|
||||
"thresholds": "Thresholds",
|
||||
|
||||
@@ -120,6 +120,16 @@
|
||||
"projectDetail": {
|
||||
"webhookTitle": "Webhook проекта",
|
||||
"webhookDesc": "Отправьте POST с image-ссылкой на этот URL из CI — и Tinyforge запустит деплой. Стейдж выбирается по tag_pattern.",
|
||||
"outgoingWebhookTitle": "Исходящий webhook (проект)",
|
||||
"outgoingWebhookDesc": "Куда Tinyforge отправляет события деплоя для этого проекта. Стейджи могут переопределить; если нигде не задано — используется глобальная настройка.",
|
||||
"outgoingFallbackGlobal": "глобальной настройки интеграций",
|
||||
"notificationUrlLabel": "URL исходящего webhook",
|
||||
"notificationUrlHelp": "Оставьте пустым для наследования из глобальных настроек. Стейджи могут переопределить.",
|
||||
"stageNotificationUrlLabel": "URL исходящего webhook (этот стейдж)",
|
||||
"stageNotificationUrlHelp": "Оставьте пустым для наследования от проекта, затем — из глобальных настроек.",
|
||||
"stageOutgoingTitle": "Исходящий webhook (стейдж)",
|
||||
"stageOutgoingDesc": "Куда Tinyforge отправляет события деплоя этого стейджа. Побеждает самый конкретный уровень.",
|
||||
"stageFallbackLabel": "проектной или глобальной настройки",
|
||||
"deleteProject": "Удалить проект",
|
||||
"envVars": "Переменные окружения",
|
||||
"volumes": "Тома",
|
||||
@@ -618,6 +628,11 @@
|
||||
"sites": {
|
||||
"webhookTitle": "Webhook сайта",
|
||||
"webhookDesc": "Укажите этот URL в push-вебхуке Git-провайдера. Tinyforge пересинхронизирует сайт при подходящей ref-ссылке (ветка для push, шаблон тега для tag). Пустое тело запускает синхронизацию безусловно.",
|
||||
"outgoingUrlTitle": "URL исходящего webhook (этот сайт)",
|
||||
"outgoingUrlDesc": "Куда Tinyforge отправляет события site_sync_success / site_sync_failure. Пусто — наследовать из глобальных настроек.",
|
||||
"outgoingWebhookTitle": "Исходящий webhook (сайт)",
|
||||
"outgoingWebhookDesc": "HMAC-секрет и тестовая отправка для разрешённого исходящего URL.",
|
||||
"outgoingFallbackGlobal": "глобальной настройки интеграций",
|
||||
"title": "Статические сайты",
|
||||
"addSite": "Новый сайт",
|
||||
"newSite": "Новый статический сайт",
|
||||
@@ -1168,6 +1183,43 @@
|
||||
"confirmYes": "Перегенерировать",
|
||||
"confirmNo": "Отмена"
|
||||
},
|
||||
"outgoingWebhook": {
|
||||
"signingOn": "Подпись включена",
|
||||
"signingOff": "Без подписи",
|
||||
"signingSecret": "HMAC-секрет",
|
||||
"noSecret": "Секрет не задан — исходящие события отправляются без подписи.",
|
||||
"reveal": "Показать",
|
||||
"generate": "Сгенерировать",
|
||||
"copy": "Копировать",
|
||||
"copied": "Секрет скопирован в буфер обмена",
|
||||
"copyFailed": "Не удалось скопировать",
|
||||
"loadFailed": "Не удалось загрузить секрет",
|
||||
"regenerate": "Перегенерировать",
|
||||
"regenerated": "Секрет перегенерирован",
|
||||
"regenerateFailed": "Не удалось перегенерировать секрет",
|
||||
"confirmRegenerateTitle": "Перегенерировать секрет?",
|
||||
"confirmRegenerate": "Текущий секрет инвалидируется немедленно. Все получатели должны быть обновлены синхронно — иначе начнут отклонять события.",
|
||||
"confirmDisableTitle": "Отключить HMAC-подпись?",
|
||||
"confirmDisable": "Будущие события пойдут без заголовка X-Hub-Signature-256. Получатели, требующие подпись, начнут их отклонять.",
|
||||
"confirmYes": "Подтвердить",
|
||||
"confirmNo": "Отмена",
|
||||
"disable": "Отключить подпись",
|
||||
"disabled": "Подпись отключена",
|
||||
"disableFailed": "Не удалось отключить подпись",
|
||||
"sendTestTitle": "Отправить тестовое событие",
|
||||
"sendTestHelp": "Отправляет синтетическое событие \"test\" на разрешённый URL с текущим секретом.",
|
||||
"sendTest": "Отправить тест",
|
||||
"sending": "Отправка…",
|
||||
"testFailed": "Не удалось отправить тестовое событие",
|
||||
"tier": "Уровень",
|
||||
"signed": "Подписано",
|
||||
"unsigned": "Без подписи",
|
||||
"deliveryId": "Доставка",
|
||||
"responseBody": "Тело ответа",
|
||||
"networkError": "Сетевая ошибка",
|
||||
"fallbackTo": "URL не задан на этом уровне — события унаследуются от {label}.",
|
||||
"noUrlConfigured": "URL не задан. Настройте его выше перед тестом."
|
||||
},
|
||||
"settingsMaintenance": {
|
||||
"title": "Обслуживание",
|
||||
"thresholds": "Пороги",
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Project {
|
||||
env: string;
|
||||
volumes: string;
|
||||
npm_access_list_id: number;
|
||||
notification_url: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -25,6 +26,7 @@ export interface Stage {
|
||||
enable_proxy: boolean;
|
||||
promote_from: string;
|
||||
subdomain: string;
|
||||
notification_url: string;
|
||||
cpu_limit: number;
|
||||
memory_limit: number;
|
||||
created_at: string;
|
||||
@@ -391,6 +393,7 @@ export interface StaticSite {
|
||||
error: string;
|
||||
storage_enabled: boolean;
|
||||
storage_limit_mb: number;
|
||||
notification_url: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
|
||||
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
import { IconShield } from '$lib/components/icons';
|
||||
@@ -43,6 +44,7 @@
|
||||
let editStageMaxInstances = $state('1');
|
||||
let editStageCpuLimit = $state('');
|
||||
let editStageMemoryLimit = $state('');
|
||||
let editStageNotificationUrl = $state('');
|
||||
let savingStage = $state(false);
|
||||
|
||||
function startEditStage(stage: Stage) {
|
||||
@@ -54,6 +56,7 @@
|
||||
editStageMaxInstances = String(stage.max_instances);
|
||||
editStageCpuLimit = stage.cpu_limit ? String(stage.cpu_limit) : '';
|
||||
editStageMemoryLimit = stage.memory_limit ? String(stage.memory_limit) : '';
|
||||
editStageNotificationUrl = stage.notification_url ?? '';
|
||||
}
|
||||
|
||||
async function handleUpdateStage() {
|
||||
@@ -68,6 +71,7 @@
|
||||
max_instances: parseInt(editStageMaxInstances) || 1,
|
||||
cpu_limit: parseFloat(editStageCpuLimit) || 0,
|
||||
memory_limit: parseInt(editStageMemoryLimit) || 0,
|
||||
notification_url: editStageNotificationUrl.trim(),
|
||||
});
|
||||
toasts.success($t('projectDetail.stageUpdated'));
|
||||
editingStageId = '';
|
||||
@@ -122,6 +126,7 @@
|
||||
let editHealthcheck = $state('');
|
||||
let editAccessListId = $state(0);
|
||||
let editAccessListName = $state('');
|
||||
let editNotificationUrl = $state('');
|
||||
let accessListPickerOpen = $state(false);
|
||||
let accessListPickerItems = $state<EntityPickerItem[]>([]);
|
||||
let loadingAccessLists = $state(false);
|
||||
@@ -162,6 +167,7 @@
|
||||
editHealthcheck = project.healthcheck || '';
|
||||
editAccessListId = project.npm_access_list_id || 0;
|
||||
editAccessListName = editAccessListId > 0 ? `Access List #${editAccessListId}` : '';
|
||||
editNotificationUrl = project.notification_url ?? '';
|
||||
editing = true;
|
||||
// Resolve access list name in background.
|
||||
if (editAccessListId > 0) {
|
||||
@@ -182,6 +188,7 @@
|
||||
port: parseInt(editPort) || 0,
|
||||
healthcheck: editHealthcheck.trim(),
|
||||
npm_access_list_id: editAccessListId,
|
||||
notification_url: editNotificationUrl.trim(),
|
||||
});
|
||||
toasts.success($t('projectDetail.projectUpdated'));
|
||||
editing = false;
|
||||
@@ -467,6 +474,7 @@
|
||||
<FormField label={$t('projectDetail.imageLabel')} name="editImage" bind:value={editImage} />
|
||||
<FormField label={$t('projectDetail.portLabel')} name="editPort" type="number" bind:value={editPort} />
|
||||
<FormField label={$t('projectDetail.healthcheckLabel')} name="editHealthcheck" bind:value={editHealthcheck} placeholder="/api/health" />
|
||||
<FormField label={$t('projectDetail.notificationUrlLabel')} name="editNotificationUrl" bind:value={editNotificationUrl} placeholder="https://notify.example.com/webhook" helpText={$t('projectDetail.notificationUrlHelp')} />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsNpm.accessList')}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -617,6 +625,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<FormField
|
||||
label={$t('projectDetail.stageNotificationUrlLabel')}
|
||||
name="editStageNotificationUrl"
|
||||
bind:value={editStageNotificationUrl}
|
||||
placeholder="https://notify.example.com/webhook"
|
||||
helpText={$t('projectDetail.stageNotificationUrlHelp')}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2 justify-end">
|
||||
<button type="button" onclick={() => { editingStageId = ''; }}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
@@ -629,6 +646,21 @@
|
||||
{savingStage ? $t('projectDetail.saving') : $t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Stage-scoped outgoing webhook controls. Lives inside the
|
||||
edit panel so operators see signing + test alongside the
|
||||
URL they're configuring; collapses on save/cancel. -->
|
||||
<div class="mt-4">
|
||||
<OutgoingWebhookPanel
|
||||
title={$t('projectDetail.stageOutgoingTitle')}
|
||||
description={$t('projectDetail.stageOutgoingDesc')}
|
||||
hasUrl={!!stage.notification_url}
|
||||
fallbackLabel={$t('projectDetail.stageFallbackLabel')}
|
||||
fetchSecret={() => api.getStageNotificationSecret(projectId, stage.id)}
|
||||
regenerateSecret={() => api.regenerateStageNotificationSecret(projectId, stage.id)}
|
||||
disableSigning={() => api.disableStageNotificationSigning(projectId, stage.id)}
|
||||
sendTest={() => api.testStageNotification(projectId, stage.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-between flex-wrap gap-2 border-b border-[var(--border-secondary)] px-5 py-4">
|
||||
@@ -768,7 +800,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Webhook -->
|
||||
<!-- Webhook (inbound: trigger deploys via this URL). -->
|
||||
<WebhookPanel
|
||||
title={$t('projectDetail.webhookTitle')}
|
||||
description={$t('projectDetail.webhookDesc')}
|
||||
@@ -776,6 +808,18 @@
|
||||
regenerateWebhook={() => api.regenerateProjectWebhook(projectId)}
|
||||
/>
|
||||
|
||||
<!-- Outgoing webhook (where Tinyforge sends events for THIS project). -->
|
||||
<OutgoingWebhookPanel
|
||||
title={$t('projectDetail.outgoingWebhookTitle')}
|
||||
description={$t('projectDetail.outgoingWebhookDesc')}
|
||||
hasUrl={!!project.notification_url}
|
||||
fallbackLabel={$t('projectDetail.outgoingFallbackGlobal')}
|
||||
fetchSecret={() => api.getProjectNotificationSecret(projectId)}
|
||||
regenerateSecret={() => api.regenerateProjectNotificationSecret(projectId)}
|
||||
disableSigning={() => api.disableProjectNotificationSigning(projectId)}
|
||||
sendTest={() => api.testProjectNotification(projectId)}
|
||||
/>
|
||||
|
||||
<!-- Deploy History Timeline -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.recentDeploys')}</h2>
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
<!--
|
||||
Settings › Integrations
|
||||
|
||||
Outward-facing hooks: where Tinyforge *sends* events (notification URL).
|
||||
Outward-facing hooks: where Tinyforge *sends* events.
|
||||
1. URL field (global / fallback) — saved via /api/settings.
|
||||
2. Outgoing-webhook panel — secret rotate, disable signing, send test.
|
||||
Inbound webhooks are per-project / per-site and live on their respective
|
||||
detail pages — this page no longer exposes a global "master" webhook.
|
||||
detail pages — this page deliberately does not surface them.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getSettings, updateSettings } from '$lib/api';
|
||||
import {
|
||||
getSettings, updateSettings,
|
||||
getSettingsNotificationSecret,
|
||||
regenerateSettingsNotificationSecret,
|
||||
disableSettingsNotificationSigning,
|
||||
testSettingsNotification,
|
||||
} from '$lib/api';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader } from '$lib/components/icons';
|
||||
@@ -17,6 +26,9 @@
|
||||
let saving = $state(false);
|
||||
|
||||
let notificationUrl = $state('');
|
||||
// Tracks the last persisted URL so the OutgoingWebhookPanel's hasUrl
|
||||
// flag reflects what the backend actually has, not unsaved input.
|
||||
let savedNotificationUrl = $state('');
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
function validateUrl(value: string): string {
|
||||
@@ -29,6 +41,7 @@
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
notificationUrl = settings.notification_url ?? '';
|
||||
savedNotificationUrl = notificationUrl;
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
||||
} finally {
|
||||
@@ -43,6 +56,7 @@
|
||||
saving = true;
|
||||
try {
|
||||
await updateSettings({ notification_url: notificationUrl.trim() });
|
||||
savedNotificationUrl = notificationUrl.trim();
|
||||
toasts.success($t('settingsGeneral.saved'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||
@@ -87,6 +101,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outgoing: signing secret + send test -->
|
||||
<OutgoingWebhookPanel
|
||||
title={$t('outgoingWebhook.signingSecret')}
|
||||
description={$t('settingsIntegrations.outgoingDesc')}
|
||||
hasUrl={!!savedNotificationUrl}
|
||||
fetchSecret={getSettingsNotificationSecret}
|
||||
regenerateSecret={regenerateSettingsNotificationSecret}
|
||||
disableSigning={disableSettingsNotificationSigning}
|
||||
sendTest={testSettingsNotification}
|
||||
/>
|
||||
|
||||
<!-- Inbound hooks now live per-entity. -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsIntegrations.incoming')}</h2>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
|
||||
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
|
||||
import ContainerStats from '$lib/components/ContainerStats.svelte';
|
||||
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
|
||||
import { IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons';
|
||||
@@ -22,6 +23,32 @@
|
||||
let confirmDelete = $state(false);
|
||||
let confirmDeleteSecretId = $state<string | null>(null);
|
||||
|
||||
// Outgoing notification URL inline editor. The site has no full edit
|
||||
// form on this page; this small input lets operators set/clear the
|
||||
// per-site URL without going back to the create wizard.
|
||||
let editNotificationUrl = $state('');
|
||||
let savingNotificationUrl = $state(false);
|
||||
|
||||
async function saveNotificationUrl() {
|
||||
if (!site) return;
|
||||
savingNotificationUrl = true;
|
||||
try {
|
||||
await api.updateStaticSite(site.id, { notification_url: editNotificationUrl.trim() });
|
||||
site = { ...site, notification_url: editNotificationUrl.trim() };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to save notification URL';
|
||||
} finally {
|
||||
savingNotificationUrl = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the editor with the loaded site once it arrives.
|
||||
$effect(() => {
|
||||
if (site && editNotificationUrl === '') {
|
||||
editNotificationUrl = site.notification_url ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
// Secret form.
|
||||
let showSecretForm = $state(false);
|
||||
let secretKey = $state('');
|
||||
@@ -279,7 +306,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Webhook -->
|
||||
<!-- Webhook (inbound: triggers a re-sync from the Git provider). -->
|
||||
<WebhookPanel
|
||||
title={$t('sites.webhookTitle')}
|
||||
description={$t('sites.webhookDesc')}
|
||||
@@ -287,6 +314,43 @@
|
||||
regenerateWebhook={() => api.regenerateStaticSiteWebhook(siteId!)}
|
||||
/>
|
||||
|
||||
<!-- Outgoing notification URL (per-site override; falls through to global). -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('sites.outgoingUrlTitle')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('sites.outgoingUrlDesc')}</p>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<FormField
|
||||
label=""
|
||||
name="siteNotificationUrl"
|
||||
bind:value={editNotificationUrl}
|
||||
placeholder="https://notify.example.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveNotificationUrl}
|
||||
disabled={savingNotificationUrl || editNotificationUrl === (site.notification_url ?? '')}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if savingNotificationUrl}<IconLoader size={16} />{/if}
|
||||
{$t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outgoing webhook (where Tinyforge posts site_sync_* events). -->
|
||||
<OutgoingWebhookPanel
|
||||
title={$t('sites.outgoingWebhookTitle')}
|
||||
description={$t('sites.outgoingWebhookDesc')}
|
||||
hasUrl={!!site.notification_url}
|
||||
fallbackLabel={$t('sites.outgoingFallbackGlobal')}
|
||||
fetchSecret={() => api.getStaticSiteNotificationSecret(siteId!)}
|
||||
regenerateSecret={() => api.regenerateStaticSiteNotificationSecret(siteId!)}
|
||||
disableSigning={() => api.disableStaticSiteNotificationSigning(siteId!)}
|
||||
sendTest={() => api.testStaticSiteNotification(siteId!)}
|
||||
/>
|
||||
|
||||
<!-- Secrets -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
|
||||
Reference in New Issue
Block a user