feat: add integrations

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-08 23:00:14 +01:00
parent 5a5f9a3003
commit 646581ff44
65 changed files with 7774 additions and 127 deletions

View File

@@ -83,5 +83,54 @@
</button>
</form>
</div>
{# Notification preferences #}
<div class="mb-12">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Notification preferences</h2>
<p class="text-sm text-gray-500 mb-4">Choose which events trigger notifications on each channel.</p>
<div class="border border-gray-200 rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th class="text-left px-4 py-3 font-medium text-gray-700">Event</th>
<th class="text-center px-4 py-3 font-medium text-gray-700 w-24">CLI</th>
<th class="text-center px-4 py-3 font-medium text-gray-700 w-24">Slack</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% set event_types = [
{"key": "NOTIFICATION_TYPE_RELEASE_ANNOTATED", "label": "Release annotated"},
{"key": "NOTIFICATION_TYPE_RELEASE_STARTED", "label": "Release started"},
{"key": "NOTIFICATION_TYPE_RELEASE_SUCCEEDED", "label": "Release succeeded"},
{"key": "NOTIFICATION_TYPE_RELEASE_FAILED", "label": "Release failed"},
] %}
{% set channels = [
{"key": "NOTIFICATION_CHANNEL_CLI", "label": "CLI"},
{"key": "NOTIFICATION_CHANNEL_SLACK", "label": "Slack"},
] %}
{% for event in event_types %}
<tr>
<td class="px-4 py-3 text-gray-700">{{ event.label }}</td>
{% for ch in channels %}
{% set pref_key = event.key ~ "|" ~ ch.key %}
{% set is_enabled = pref_key in enabled_prefs %}
<td class="text-center px-4 py-3">
<form method="POST" action="/settings/account/notifications" class="inline">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="notification_type" value="{{ event.key }}">
<input type="hidden" name="channel" value="{{ ch.key }}">
<input type="hidden" name="enabled" value="{{ 'false' if is_enabled else 'true' }}">
<button type="submit" class="w-8 h-5 rounded-full relative transition-colors {{ 'bg-gray-900' if is_enabled else 'bg-gray-300' }}" title="{{ 'Disable' if is_enabled else 'Enable' }} {{ event.label | lower }} via {{ ch.label }}">
<span class="block w-3.5 h-3.5 rounded-full bg-white shadow-sm absolute top-0.5 transition-all {{ 'left-[calc(100%-1.125rem)]' if is_enabled else 'left-0.5' }}"></span>
</button>
</form>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}

View File

@@ -227,6 +227,14 @@
</div>
{% endif %}
{# ── Spec (forest.cue) ──────────────────────────────────────── #}
{% if artifact_spec %}
<div class="mb-8">
<h2 class="text-sm font-semibold text-gray-900 mb-3">Spec</h2>
<spec-viewer content="{{ artifact_spec | e }}" filename="forest.cue"></spec-viewer>
</div>
{% endif %}
{# ── Details ───────────────────────────────────────────────── #}
{% if artifact.commit_message or artifact.repo_url or artifact.source_email or artifact.run_url %}
<div class="mb-8">

View File

@@ -0,0 +1,105 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-2xl mx-auto px-4 py-12">
<div class="mb-8">
<a href="/orgs/{{ current_org }}/settings/integrations" class="text-sm text-gray-500 hover:text-gray-700">&larr; All integrations</a>
</div>
<div class="flex items-start gap-4 mb-8">
<div class="w-14 h-14 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="#4A154B">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold">Install Slack</h1>
<p class="text-sm text-gray-500 mt-1">Post deployment notifications directly to Slack channels from <strong>{{ current_org }}</strong>.</p>
</div>
</div>
{% if error is defined and error %}
<div class="mb-6 px-4 py-3 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg">{{ error }}</div>
{% endif %}
{# ── How it works ──────────────────────────────────────────── #}
<div class="mb-8 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<h3 class="text-sm font-semibold text-gray-700 mb-2">How it works</h3>
<ul class="text-sm text-gray-600 space-y-1.5">
<li class="flex items-start gap-2">
<span class="text-gray-400 mt-0.5">1.</span>
<span>Rich Block Kit messages with release metadata, status badges, and color-coded sidebars</span>
</li>
<li class="flex items-start gap-2">
<span class="text-gray-400 mt-0.5">2.</span>
<span>Notifications include organisation, project, destination, commit, branch, and author</span>
</li>
<li class="flex items-start gap-2">
<span class="text-gray-400 mt-0.5">3.</span>
<span>Configure which events trigger notifications (releases started, succeeded, failed, annotated)</span>
</li>
<li class="flex items-start gap-2">
<span class="text-gray-400 mt-0.5">4.</span>
<span>Failed deliveries are retried up to 3 times with exponential backoff</span>
</li>
</ul>
</div>
{% if has_slack_oauth %}
{# ── OAuth "Add to Slack" flow ─────────────────────────────── #}
<div class="mb-8">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Connect with Slack</h3>
<p class="text-sm text-gray-500 mb-4">Click the button below to authorize Forage to post to a Slack channel. You'll choose which channel during the Slack authorization flow.</p>
<a href="{{ slack_oauth_url }}" class="inline-flex items-center gap-3 px-5 py-3 bg-[#4A154B] text-white rounded-lg hover:bg-[#3e1240] transition-colors font-medium text-sm">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>
Add to Slack
</a>
</div>
<div class="relative my-8">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-gray-200"></div></div>
<div class="relative flex justify-center text-sm"><span class="px-3 bg-white text-gray-400">or use a webhook URL</span></div>
</div>
{% endif %}
{# ── Manual webhook URL form ───────────────────────────────── #}
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/slack" class="space-y-5">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
{% if has_slack_oauth %}
<p class="text-sm text-gray-500 mb-2">Alternatively, paste a Slack Incoming Webhook URL directly. Create one in your <a href="https://api.slack.com/apps" target="_blank" rel="noopener" class="text-gray-700 underline hover:text-gray-900">Slack App settings</a>.</p>
{% else %}
<p class="text-sm text-gray-500 mb-2">Paste a Slack Incoming Webhook URL. Create one in your <a href="https://api.slack.com/apps" target="_blank" rel="noopener" class="text-gray-700 underline hover:text-gray-900">Slack App settings</a> under <strong>Incoming Webhooks</strong>.</p>
{% endif %}
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" id="name" name="name" placeholder="e.g. #deploys" required
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
<p class="text-xs text-gray-400 mt-1">A friendly name to identify this integration</p>
</div>
<div>
<label for="webhook_url" class="block text-sm font-medium text-gray-700 mb-1">Webhook URL</label>
<input type="url" id="webhook_url" name="webhook_url" placeholder="https://hooks.slack.com/services/T.../B.../..." required
class="w-full px-3 py-2 text-sm font-mono border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
<p class="text-xs text-gray-400 mt-1">Must be a <code class="bg-gray-200 px-1 py-0.5 rounded">https://hooks.slack.com/</code> URL</p>
</div>
<div>
<label for="channel_name" class="block text-sm font-medium text-gray-700 mb-1">Channel name <span class="font-normal text-gray-400">(optional)</span></label>
<input type="text" id="channel_name" name="channel_name" placeholder="#deploys"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
<p class="text-xs text-gray-400 mt-1">For display purposes only (defaults to #general)</p>
</div>
<div class="pt-2">
<button type="submit" class="w-full px-4 py-2.5 text-sm font-medium bg-gray-900 text-white rounded-md hover:bg-gray-800 transition-colors">
Install Slack integration
</button>
</div>
</form>
</section>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-2xl mx-auto px-4 py-12">
<div class="mb-8">
<a href="/orgs/{{ current_org }}/settings/integrations" class="text-sm text-gray-500 hover:text-gray-700">&larr; All integrations</a>
</div>
<div class="flex items-start gap-4 mb-8">
<div class="w-14 h-14 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
<svg class="w-7 h-7 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
</div>
<div>
<h1 class="text-2xl font-bold">Install Webhook</h1>
<p class="text-sm text-gray-500 mt-1">Send HTTP POST requests to your endpoint when deployment events occur in <strong>{{ current_org }}</strong>.</p>
</div>
</div>
{% if error is defined and error %}
<div class="mb-6 px-4 py-3 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg">{{ error }}</div>
{% endif %}
{# ── How it works ──────────────────────────────────────────── #}
<div class="mb-8 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<h3 class="text-sm font-semibold text-gray-700 mb-2">How it works</h3>
<ul class="text-sm text-gray-600 space-y-1.5">
<li class="flex items-start gap-2">
<span class="text-gray-400 mt-0.5">1.</span>
<span>Forage sends a <code class="text-xs bg-gray-200 px-1 py-0.5 rounded">POST</code> request with a JSON payload to your URL</span>
</li>
<li class="flex items-start gap-2">
<span class="text-gray-400 mt-0.5">2.</span>
<span>Payloads include event type, release metadata, project, and organisation</span>
</li>
<li class="flex items-start gap-2">
<span class="text-gray-400 mt-0.5">3.</span>
<span>Optional HMAC-SHA256 signing via <code class="text-xs bg-gray-200 px-1 py-0.5 rounded">X-Forage-Signature</code> header</span>
</li>
<li class="flex items-start gap-2">
<span class="text-gray-400 mt-0.5">4.</span>
<span>Failed deliveries are retried up to 3 times with exponential backoff</span>
</li>
</ul>
</div>
{# ── Setup form ────────────────────────────────────────────── #}
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/webhook" class="space-y-5">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" id="name" name="name" placeholder="e.g. Production alerts" required
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
<p class="text-xs text-gray-400 mt-1">A friendly name to identify this webhook</p>
</div>
<div>
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">Payload URL</label>
<input type="url" id="url" name="url" placeholder="https://example.com/webhooks/forage" required
class="w-full px-3 py-2 text-sm font-mono border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
<p class="text-xs text-gray-400 mt-1">Must use HTTPS (HTTP allowed for localhost only)</p>
</div>
<div>
<label for="secret" class="block text-sm font-medium text-gray-700 mb-1">Secret <span class="font-normal text-gray-400">(optional)</span></label>
<input type="text" id="secret" name="secret" placeholder="whsec_..."
class="w-full px-3 py-2 text-sm font-mono border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
<p class="text-xs text-gray-400 mt-1">Used to compute <code class="bg-gray-200 px-1 py-0.5 rounded">X-Forage-Signature</code> (HMAC-SHA256) so you can verify payloads are from Forage</p>
</div>
<div class="pt-2">
<button type="submit" class="w-full px-4 py-2.5 text-sm font-medium bg-gray-900 text-white rounded-md hover:bg-gray-800 transition-colors">
Install webhook
</button>
</div>
</form>
</section>
{% endblock %}

View File

@@ -0,0 +1,157 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-3xl mx-auto px-4 py-12">
<div class="mb-8">
<a href="/orgs/{{ current_org }}/settings/integrations" class="text-sm text-gray-500 hover:text-gray-700">&larr; All integrations</a>
</div>
{# ── Header ───────────────────────────────────────────────── #}
<div class="flex items-start justify-between mb-8">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
{% if integration.integration_type == "webhook" %}
<svg class="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
{% elif integration.integration_type == "slack" %}
<svg class="w-6 h-6 text-gray-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z"/>
</svg>
{% endif %}
</div>
<div>
<h1 class="text-2xl font-bold">{{ integration.name }}</h1>
<div class="flex items-center gap-2 mt-1">
<span class="text-sm text-gray-500">{{ integration.type_display }}</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ 'bg-green-50 text-green-700' if integration.enabled else 'bg-gray-100 text-gray-500' }}">
{{ "Active" if integration.enabled else "Paused" }}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/toggle" class="inline">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="enabled" value="{{ 'false' if integration.enabled else 'true' }}">
<button type="submit" class="px-3 py-1.5 text-sm border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
{{ "Pause" if integration.enabled else "Resume" }}
</button>
</form>
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/delete" class="inline" onsubmit="return confirm('Uninstall this integration? This cannot be undone.')">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<button type="submit" class="px-3 py-1.5 text-sm text-red-600 border border-red-200 rounded-md hover:bg-red-50 transition-colors">
Uninstall
</button>
</form>
</div>
</div>
{% if test_sent is defined and test_sent %}
<div class="mb-6 px-4 py-3 text-sm text-green-700 bg-green-50 border border-green-200 rounded-lg">
Test notification sent. Check your endpoint for delivery.
</div>
{% endif %}
{# ── Configuration ────────────────────────────────────────── #}
<div class="mb-8">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Configuration</h2>
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
{% if config.type_name == "Webhook" %}
<div class="px-4 py-3 flex items-center justify-between">
<span class="text-sm text-gray-500">Payload URL</span>
<span class="text-sm text-gray-900 font-mono">{{ config.detail }}</span>
</div>
{% if config.has_secret is defined and config.has_secret %}
<div class="px-4 py-3 flex items-center justify-between">
<span class="text-sm text-gray-500">Signing</span>
<span class="text-sm text-gray-900">HMAC-SHA256 enabled</span>
</div>
{% endif %}
{% else %}
<div class="px-4 py-3">
<span class="text-sm text-gray-900">{{ config.detail }}</span>
</div>
{% endif %}
</div>
</div>
{# ── Events ───────────────────────────────────────────────── #}
<div class="mb-8">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Events</h2>
<p class="text-sm text-gray-500 mb-3">Choose which deployment events trigger this integration.</p>
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
{% for rule in rules %}
<div class="flex items-center justify-between px-4 py-3">
<div>
<span class="text-sm text-gray-700">{{ rule.label }}</span>
</div>
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/rules" class="inline">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="notification_type" value="{{ rule.notification_type }}">
<input type="hidden" name="enabled" value="{{ 'false' if rule.enabled else 'true' }}">
<button type="submit" class="w-9 h-5 rounded-full relative transition-colors {{ 'bg-gray-900' if rule.enabled else 'bg-gray-300' }}" title="{{ 'Disable' if rule.enabled else 'Enable' }} {{ rule.label | lower }}">
<span class="block w-3.5 h-3.5 rounded-full bg-white shadow-sm absolute top-[3px] transition-all {{ 'left-[calc(100%-1.125rem)]' if rule.enabled else 'left-[3px]' }}"></span>
</button>
</form>
</div>
{% endfor %}
</div>
</div>
{# ── Recent deliveries ────────────────────────────────────── #}
<div class="mb-8">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Recent deliveries</h2>
{% if deliveries | length > 0 %}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Notification</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Time</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Error</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
{% for d in deliveries %}
<tr>
<td class="px-4 py-2 whitespace-nowrap">
{% if d.status == "delivered" %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-50 text-green-700">Delivered</span>
{% elif d.status == "failed" %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-50 text-red-700">Failed</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-50 text-yellow-700">Pending</span>
{% endif %}
</td>
<td class="px-4 py-2 text-sm text-gray-700 font-mono truncate max-w-[200px]" title="{{ d.notification_id }}">{{ d.notification_id[:12] }}{% if d.notification_id | length > 12 %}&hellip;{% endif %}</td>
<td class="px-4 py-2 text-sm text-gray-500 whitespace-nowrap">{{ d.attempted_at[:19] | replace("T", " ") }} UTC</td>
<td class="px-4 py-2 text-sm text-red-600 truncate max-w-[250px]" title="{{ d.error_message }}">{{ d.error_message | default("—", true) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="border border-gray-200 rounded-lg p-6 text-center">
<p class="text-sm text-gray-500">No deliveries yet. Send a test event or wait for a deployment notification.</p>
</div>
{% endif %}
</div>
{# ── Test ─────────────────────────────────────────────────── #}
<div class="mb-8">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Testing</h2>
<div class="border border-gray-200 rounded-lg p-4">
<p class="text-sm text-gray-500 mb-3">Send a test <code class="text-xs bg-gray-100 px-1 py-0.5 rounded">release_succeeded</code> event to verify your endpoint is receiving payloads correctly.</p>
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/test">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<button type="submit" class="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
Send test event
</button>
</form>
</div>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,75 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-2xl mx-auto px-4 py-12">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-50 mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 class="text-2xl font-bold">{{ integration.type_display }} installed</h1>
<p class="text-sm text-gray-500 mt-1"><strong>{{ integration.name }}</strong> is now active in {{ current_org }}.</p>
</div>
{# ── API Token (shown once) ───────────────────────────────── #}
{% if api_token %}
<div class="mb-8 p-5 bg-amber-50 border border-amber-200 rounded-lg">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-amber-600 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<div class="min-w-0 flex-1">
<h3 class="text-sm font-semibold text-amber-800 mb-1">API Token</h3>
<p class="text-sm text-amber-700 mb-3">This token allows the integration to query the Forage API. Copy it now &mdash; it won't be shown again.</p>
<div class="relative">
<code id="api-token" class="block w-full px-3 py-2 text-sm font-mono bg-white border border-amber-300 rounded-md text-gray-900 break-all select-all">{{ api_token }}</code>
<button type="button" onclick="copyToken()" id="copy-btn" class="absolute top-1.5 right-1.5 px-2 py-1 text-xs bg-amber-100 text-amber-700 rounded hover:bg-amber-200 transition-colors">
Copy
</button>
</div>
</div>
</div>
</div>
{% endif %}
{# ── What's next ──────────────────────────────────────────── #}
<div class="mb-8 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<h3 class="text-sm font-semibold text-gray-700 mb-2">What's next</h3>
<ul class="text-sm text-gray-600 space-y-1.5">
<li class="flex items-start gap-2">
<span class="text-gray-400 mt-0.5">1.</span>
<span>Configure which events trigger notifications on the integration settings page</span>
</li>
<li class="flex items-start gap-2">
<span class="text-gray-400 mt-0.5">2.</span>
<span>Use the API token to query releases, projects, and notifications from your service</span>
</li>
<li class="flex items-start gap-2">
<span class="text-gray-400 mt-0.5">3.</span>
<span>Send a test event to verify your endpoint receives payloads correctly</span>
</li>
</ul>
</div>
<div class="flex gap-3">
<a href="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}" class="flex-1 text-center px-4 py-2.5 text-sm font-medium bg-gray-900 text-white rounded-md hover:bg-gray-800 transition-colors">
Configure integration
</a>
<a href="/orgs/{{ current_org }}/settings/integrations" class="flex-1 text-center px-4 py-2.5 text-sm font-medium border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
Back to integrations
</a>
</div>
</section>
<script>
function copyToken() {
var token = document.getElementById('api-token').textContent;
navigator.clipboard.writeText(token).then(function() {
var btn = document.getElementById('copy-btn');
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 2000);
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,140 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-4xl mx-auto px-4 py-12">
<div class="mb-8">
<h1 class="text-2xl font-bold">Integrations</h1>
<p class="text-sm text-gray-500 mt-1">Connect tools and services to receive deployment notifications from {{ current_org }}.</p>
</div>
{% if error is defined and error %}
<div class="mb-6 px-4 py-3 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg">{{ error }}</div>
{% endif %}
{# ── Installed integrations ─────────────────────────────────── #}
{% if integrations | length > 0 %}
<div class="mb-12">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Installed</h2>
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
{% for integ in integrations %}
<a href="/orgs/{{ current_org }}/settings/integrations/{{ integ.id }}" class="flex items-center justify-between px-5 py-4 hover:bg-gray-50 transition-colors first:rounded-t-lg last:rounded-b-lg">
<div class="flex items-center gap-4">
{# Icon #}
<div class="w-10 h-10 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
{% if integ.integration_type == "webhook" %}
<svg class="w-5 h-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
{% elif integ.integration_type == "slack" %}
<svg class="w-5 h-5 text-gray-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>
{% endif %}
</div>
<div>
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ integ.name }}</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ 'bg-green-50 text-green-700' if integ.enabled else 'bg-gray-100 text-gray-500' }}">
{{ "Active" if integ.enabled else "Paused" }}
</span>
</div>
<div class="text-sm text-gray-500 mt-0.5">
{{ integ.type_display }}
&middot;
{% if integ.enabled_rules == integ.total_rules %}
All events
{% elif integ.enabled_rules == 0 %}
No events
{% else %}
{{ integ.enabled_rules }}/{{ integ.total_rules }} events
{% endif %}
</div>
</div>
</div>
<div class="text-gray-400">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{# ── Available integrations (marketplace) ─────────────────── #}
<div>
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Available integrations</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{# Webhook #}
<a href="/orgs/{{ current_org }}/settings/integrations/install/webhook" class="group border border-gray-200 rounded-lg p-5 hover:border-gray-300 hover:shadow-sm transition-all">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0 group-hover:border-gray-300">
<svg class="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">Webhook</span>
</div>
<p class="text-sm text-gray-500 mt-1">Send HTTP POST notifications to any URL when deployments happen. Supports HMAC-SHA256 payload signing and custom headers.</p>
</div>
</div>
</a>
{# Slack #}
<div class="border border-gray-200 rounded-lg p-5 opacity-60">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-500">Slack</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">Coming soon</span>
</div>
<p class="text-sm text-gray-400 mt-1">Post deployment notifications directly to Slack channels. Rich formatting with release details, status, and quick links.</p>
</div>
</div>
</div>
{# Discord #}
<div class="border border-gray-200 rounded-lg p-5 opacity-60">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-500">Discord</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">Coming soon</span>
</div>
<p class="text-sm text-gray-400 mt-1">Send deployment updates to Discord channels via webhook. Includes embeds with release metadata and status.</p>
</div>
</div>
</div>
{# Email #}
<div class="border border-gray-200 rounded-lg p-5 opacity-60">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
<svg class="w-6 h-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-500">Email</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">Coming soon</span>
</div>
<p class="text-sm text-gray-400 mt-1">Email notifications for deployment events. Configure recipients and digest frequency per project.</p>
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}