feat(forge): per-user forge connections — keyring, host-keyed resolution, project pin (#2778)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s
A forge token is a user's credential, not an instance's. The single admin-settings config is replaced by per-user keyring rows (one per forge host), and every server-side forge read runs on the PROJECT OWNER's keyring: - forge_connections table + projects.forge_connection_id pin (migration 0078, which also carries the existing admin config into the first admin's row and deletes the old setting keys — no legacy dual-read) - get_forge() replaced by get_forges(owner_id, project_id) -> ForgeSelector; resolve(repo) picks the connection whose host serves the repo. A pinned project uses ONLY its pinned connection; a stale pin (ownership moved) is ignored, never honored across users - env FORGE_* config survives as an implicit entry for admin owners only; a stored row for the same host beats it - consumers threaded: pull-time freshness (owner of the note), coverage (owner of the project), coverage routes' configured flag - routes: /api/settings/forge-connections CRUD + per-connection test (own-rows only, tokens never returned); /api/admin/forge shrinks to /api/admin/forge-webhook (secret only); PUT /api/projects/<id>/forge pins, owner-or-admin asking, owner's connections only - UI: Git Forges card moves to Settings -> Integrations as a connection list; webhook secret stays in the admin Config tab; owner-only forge select on the project coverage card - backups exclude forge_connections (credentials, api_keys precedent) and the pin, so restores fall back to keyring resolution Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
|
||||
import { apiGet, apiPatch, apiDelete, apiPost, apiPut } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
@@ -48,6 +49,7 @@ interface Project {
|
||||
status: "active" | "paused" | "completed" | "archived";
|
||||
color: string | null;
|
||||
design_system_id: number | null;
|
||||
forge_connection_id: number | null;
|
||||
permission?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -472,12 +474,56 @@ async function refreshCoverage() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Forge pin (#2778) — which of the OWNER's connections serves this
|
||||
project. Owner-only UI: the select lists the viewer's own keyring, which
|
||||
is only the eligible set when the viewer IS the owner. ── */
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const isProjectOwner = computed(
|
||||
() => !!project.value && project.value.user_id === authStore.user?.id
|
||||
);
|
||||
interface ForgeConnectionOption { id: number; host: string; kind: string }
|
||||
const forgeOptions = ref<ForgeConnectionOption[]>([]);
|
||||
const forgePin = ref<number | null>(null);
|
||||
const savingForgePin = ref(false);
|
||||
|
||||
async function loadForgeOptions() {
|
||||
if (!isProjectOwner.value) return;
|
||||
try {
|
||||
const res = await apiGet<{ connections: ForgeConnectionOption[] }>(
|
||||
"/api/settings/forge-connections"
|
||||
);
|
||||
forgeOptions.value = res.connections;
|
||||
forgePin.value = project.value?.forge_connection_id ?? null;
|
||||
} catch {
|
||||
forgeOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveForgePin() {
|
||||
savingForgePin.value = true;
|
||||
try {
|
||||
await apiPut(`/api/projects/${projectId.value}/forge`, {
|
||||
connection_id: forgePin.value,
|
||||
});
|
||||
if (project.value) project.value.forge_connection_id = forgePin.value;
|
||||
await loadCoverage();
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toast.show(body?.error || "Failed to change the project's forge", "error");
|
||||
forgePin.value = project.value?.forge_connection_id ?? null;
|
||||
} finally {
|
||||
savingForgePin.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProject();
|
||||
loadTasks();
|
||||
loadNotes();
|
||||
loadDesignSystems();
|
||||
loadCoverage();
|
||||
loadForgeOptions();
|
||||
});
|
||||
|
||||
/** Populate the design-system picker. Swallows failure on purpose: with no
|
||||
@@ -496,6 +542,7 @@ watch(projectId, async () => {
|
||||
loadTasks();
|
||||
loadNotes();
|
||||
loadCoverage();
|
||||
loadForgeOptions();
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -693,6 +740,23 @@ async function confirmDelete() {
|
||||
against recorded snippets.
|
||||
</p>
|
||||
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
|
||||
<!-- Forge pin (#2778): owner-only, because the eligible set is the
|
||||
owner's own keyring. Automatic = resolve by repo host. -->
|
||||
<div v-if="isProjectOwner && forgeOptions.length" class="coverage-forge-row">
|
||||
<label for="forge-pin" class="coverage-gaps-label">Forge</label>
|
||||
<select
|
||||
id="forge-pin"
|
||||
v-model="forgePin"
|
||||
class="input"
|
||||
:disabled="savingForgePin"
|
||||
@change="saveForgePin"
|
||||
>
|
||||
<option :value="null">Automatic (by repo host)</option>
|
||||
<option v-for="c in forgeOptions" :key="c.id" :value="c.id">
|
||||
{{ c.host }} ({{ c.kind }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-body">
|
||||
@@ -1202,6 +1266,18 @@ async function confirmDelete() {
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.coverage-forge-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.coverage-forge-row select {
|
||||
max-width: 20rem;
|
||||
}
|
||||
.coverage-gaps-label { color: var(--fs-text-tertiary); }
|
||||
.coverage-gap-chip {
|
||||
padding: 0.1rem 0.5rem;
|
||||
|
||||
@@ -420,15 +420,25 @@ const baseUrl = ref("");
|
||||
const savingBaseUrl = ref(false);
|
||||
const baseUrlSaved = ref(false);
|
||||
|
||||
// Git forge integration (admin only, #2689). The token round-trips masked;
|
||||
// the server treats the mask as "unchanged".
|
||||
const forge = ref({ kind: "", base_url: "", token: "", webhook_secret: "" });
|
||||
const forgeKinds = ref<string[]>(["gitea"]);
|
||||
const forgeConfigured = ref(false);
|
||||
const savingForge = ref(false);
|
||||
const forgeSaved = ref(false);
|
||||
const testingForge = ref(false);
|
||||
const forgeTestResult = ref<{ ok: boolean; message: string } | null>(null);
|
||||
// Git forge connections (#2778) — the user's keyring: one read-only
|
||||
// credential per forge host, used server-side for every forge read on
|
||||
// projects this user owns. The token round-trips masked; the server treats
|
||||
// the mask as "unchanged".
|
||||
interface ForgeConnectionEntry {
|
||||
id: number; kind: string; base_url: string; host: string;
|
||||
}
|
||||
const forgeConnections = ref<ForgeConnectionEntry[]>([]);
|
||||
const forgeKinds = ref<string[]>(["gitea", "github"]);
|
||||
const connForm = ref({ id: 0, kind: "gitea", base_url: "", token: "" });
|
||||
const connFormOpen = ref(false);
|
||||
const savingConn = ref(false);
|
||||
const testingConnId = ref(0);
|
||||
const connTestResult = ref<{ id: number; ok: boolean; message: string } | null>(null);
|
||||
// The webhook secret stays admin: the push endpoint is one URL per instance
|
||||
// and authenticates deliveries, not users.
|
||||
const forgeWebhookSecret = ref("");
|
||||
const savingForgeWebhook = ref(false);
|
||||
const forgeWebhookSaved = ref(false);
|
||||
|
||||
|
||||
// Search test (SearXNG)
|
||||
@@ -576,25 +586,30 @@ onMounted(async () => {
|
||||
// base URL not configured yet
|
||||
}
|
||||
try {
|
||||
await loadForgeSettings();
|
||||
await loadForgeWebhook();
|
||||
} catch {
|
||||
// forge not configured yet
|
||||
// webhook secret not configured yet
|
||||
}
|
||||
}
|
||||
try {
|
||||
await loadForgeConnections();
|
||||
} catch {
|
||||
// no keyring yet — the ordinary state
|
||||
}
|
||||
_loadTabContent(activeTab.value);
|
||||
});
|
||||
|
||||
async function loadForgeSettings() {
|
||||
const cfg = await apiGet<{
|
||||
kind: string; base_url: string; token: string; webhook_secret: string;
|
||||
configured: boolean; kinds: string[];
|
||||
}>("/api/admin/forge");
|
||||
forge.value = {
|
||||
kind: cfg.kind, base_url: cfg.base_url, token: cfg.token,
|
||||
webhook_secret: cfg.webhook_secret,
|
||||
};
|
||||
forgeConfigured.value = cfg.configured;
|
||||
if (cfg.kinds?.length) forgeKinds.value = cfg.kinds;
|
||||
async function loadForgeConnections() {
|
||||
const res = await apiGet<{
|
||||
connections: ForgeConnectionEntry[]; kinds: string[];
|
||||
}>("/api/settings/forge-connections");
|
||||
forgeConnections.value = res.connections;
|
||||
if (res.kinds?.length) forgeKinds.value = res.kinds;
|
||||
}
|
||||
|
||||
async function loadForgeWebhook() {
|
||||
const cfg = await apiGet<{ webhook_secret: string }>("/api/admin/forge-webhook");
|
||||
forgeWebhookSecret.value = cfg.webhook_secret;
|
||||
}
|
||||
|
||||
async function changeEmail() {
|
||||
@@ -762,42 +777,77 @@ async function sendTestEmail() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveForge() {
|
||||
savingForge.value = true;
|
||||
forgeSaved.value = false;
|
||||
forgeTestResult.value = null;
|
||||
function editConnection(c: ForgeConnectionEntry | null) {
|
||||
connTestResult.value = null;
|
||||
connFormOpen.value = true;
|
||||
connForm.value = c
|
||||
? { id: c.id, kind: c.kind, base_url: c.base_url, token: "********" }
|
||||
: { id: 0, kind: forgeKinds.value[0] || "gitea", base_url: "", token: "" };
|
||||
}
|
||||
|
||||
async function saveConnection() {
|
||||
savingConn.value = true;
|
||||
try {
|
||||
await apiPut("/api/admin/forge", forge.value);
|
||||
await loadForgeSettings();
|
||||
forgeSaved.value = true;
|
||||
setTimeout(() => (forgeSaved.value = false), 2000);
|
||||
const { id, ...values } = connForm.value;
|
||||
if (id) await apiPut(`/api/settings/forge-connections/${id}`, values);
|
||||
else await apiPost("/api/settings/forge-connections", values);
|
||||
connFormOpen.value = false;
|
||||
await loadForgeConnections();
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to save forge settings", "error");
|
||||
toastStore.show(body?.error || "Failed to save forge connection", "error");
|
||||
} finally {
|
||||
savingForge.value = false;
|
||||
savingConn.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testForge() {
|
||||
testingForge.value = true;
|
||||
forgeTestResult.value = null;
|
||||
async function removeConnection(id: number) {
|
||||
try {
|
||||
await apiDelete(`/api/settings/forge-connections/${id}`);
|
||||
connTestResult.value = null;
|
||||
await loadForgeConnections();
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to delete forge connection", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(id: number) {
|
||||
testingConnId.value = id;
|
||||
connTestResult.value = null;
|
||||
try {
|
||||
const res = await apiPost<{ version: string; username: string }>(
|
||||
"/api/admin/forge/test", {},
|
||||
`/api/settings/forge-connections/${id}/test`, {},
|
||||
);
|
||||
forgeTestResult.value = {
|
||||
ok: true,
|
||||
message: `Connected — Gitea ${res.version}, authenticated as ${res.username}`,
|
||||
connTestResult.value = {
|
||||
id, ok: true,
|
||||
message: `Connected — ${res.version}, authenticated as ${res.username}`,
|
||||
};
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
forgeTestResult.value = {
|
||||
ok: false,
|
||||
connTestResult.value = {
|
||||
id, ok: false,
|
||||
message: body?.error || "Connection test failed",
|
||||
};
|
||||
} finally {
|
||||
testingForge.value = false;
|
||||
testingConnId.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveForgeWebhook() {
|
||||
savingForgeWebhook.value = true;
|
||||
try {
|
||||
await apiPut("/api/admin/forge-webhook", {
|
||||
webhook_secret: forgeWebhookSecret.value,
|
||||
});
|
||||
await loadForgeWebhook();
|
||||
forgeWebhookSaved.value = true;
|
||||
setTimeout(() => (forgeWebhookSaved.value = false), 2000);
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to save webhook secret", "error");
|
||||
} finally {
|
||||
savingForgeWebhook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1710,6 +1760,70 @@ function formatUserDate(iso: string): string {
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Git Forges</h2>
|
||||
<p class="section-desc">
|
||||
Read-only connections to your git forges, one per host. Projects you
|
||||
own use them server-side — resolved by repo host — to fetch and
|
||||
drift-check recorded snippets and measure pattern coverage. A
|
||||
read-scope token is enough. Gitea: an access token with read scope
|
||||
on repositories. GitHub: a fine-grained PAT with Contents:
|
||||
Read-only, base URL <code>https://github.com</code> (or your GitHub
|
||||
Enterprise URL).
|
||||
</p>
|
||||
<table v-if="forgeConnections.length" class="api-keys-table">
|
||||
<thead>
|
||||
<tr><th>Host</th><th>Kind</th><th>Base URL</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in forgeConnections" :key="c.id">
|
||||
<td>{{ c.host }}</td>
|
||||
<td>{{ c.kind }}</td>
|
||||
<td><code>{{ c.base_url }}</code></td>
|
||||
<td>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="testingConnId === c.id" @click="testConnection(c.id)">
|
||||
{{ testingConnId === c.id ? "Testing…" : "Test" }}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="editConnection(c)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="removeConnection(c.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="section-desc not-configured">No forge connections yet.</p>
|
||||
<p
|
||||
v-if="connTestResult"
|
||||
:class="connTestResult.ok ? 'text-success' : 'text-error'"
|
||||
>
|
||||
{{ connTestResult.message }}
|
||||
</p>
|
||||
<div v-if="connFormOpen" class="smtp-grid">
|
||||
<div class="field">
|
||||
<label for="conn-kind">Forge</label>
|
||||
<select id="conn-kind" v-model="connForm.kind" class="input">
|
||||
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="conn-base-url">Base URL</label>
|
||||
<input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="conn-token">API Token (read scope)</label>
|
||||
<input id="conn-token" v-model="connForm.token" type="password" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<template v-if="connFormOpen">
|
||||
<button class="btn-primary" @click="saveConnection" :disabled="savingConn">
|
||||
{{ savingConn ? "Saving..." : connForm.id ? "Save Connection" : "Add Connection" }}
|
||||
</button>
|
||||
<button class="btn-ghost" @click="connFormOpen = false">Cancel</button>
|
||||
</template>
|
||||
<button v-else class="btn-primary" @click="editConnection(null)">Add Connection</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Data ── -->
|
||||
@@ -2161,61 +2275,26 @@ function formatUserDate(iso: string): string {
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Git Forge</h2>
|
||||
<h2>Forge Webhook</h2>
|
||||
<p class="section-desc">
|
||||
Optional read-only connection to your git forge (Gitea or GitHub) so
|
||||
snippet code can be fetched, drift-checked, and coverage-measured
|
||||
server-side. A read-scope token is enough. Leave the kind unset to
|
||||
keep the integration off.
|
||||
Forge connections are per-user (Settings → Integrations → Git
|
||||
Forges). What stays instance-wide is the push webhook: create one on
|
||||
the forge pointing at <code>/api/webhooks/forge</code> with this
|
||||
secret, and snippets whose recorded files change get flagged for
|
||||
re-verification.
|
||||
</p>
|
||||
<div class="smtp-grid">
|
||||
<div class="field">
|
||||
<label for="forge-kind">Forge</label>
|
||||
<select id="forge-kind" v-model="forge.kind" class="input">
|
||||
<option value="">Off</option>
|
||||
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="forge-base-url">Base URL</label>
|
||||
<input id="forge-base-url" v-model="forge.base_url" type="text" placeholder="https://git.example.com" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="forge-token">API Token (read scope)</label>
|
||||
<input id="forge-token" v-model="forge.token" type="password" class="input" />
|
||||
<p class="field-hint">
|
||||
Gitea: an access token with read scope on repositories. GitHub:
|
||||
a fine-grained PAT with Contents: Read-only (or a classic token
|
||||
with repo read). For GitHub, use
|
||||
<code>https://github.com</code> as the base URL — or your
|
||||
GitHub Enterprise instance's URL.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="forge-webhook-secret">Webhook Secret</label>
|
||||
<input id="forge-webhook-secret" v-model="forge.webhook_secret" type="password" class="input" />
|
||||
<p class="field-hint">
|
||||
Optional: create a push webhook on the forge pointing at
|
||||
<code>/api/webhooks/forge</code> with this secret, and snippets
|
||||
whose recorded files change get flagged for re-verification.
|
||||
</p>
|
||||
<input id="forge-webhook-secret" v-model="forgeWebhookSecret" type="password" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" style="margin-bottom: 1.25rem;">
|
||||
<button class="btn-primary" @click="saveForge" :disabled="savingForge">
|
||||
{{ savingForge ? "Saving..." : "Save Forge Settings" }}
|
||||
<div class="actions">
|
||||
<button class="btn-primary" @click="saveForgeWebhook" :disabled="savingForgeWebhook">
|
||||
{{ savingForgeWebhook ? "Saving..." : "Save Webhook Secret" }}
|
||||
</button>
|
||||
<button class="btn-ghost" @click="testForge" :disabled="testingForge || !forgeConfigured">
|
||||
{{ testingForge ? "Testing..." : "Test Connection" }}
|
||||
</button>
|
||||
<span v-if="forgeSaved" class="saved-msg">Saved!</span>
|
||||
<span v-if="forgeWebhookSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="forgeTestResult"
|
||||
:class="forgeTestResult.ok ? 'text-success' : 'text-error'"
|
||||
>
|
||||
{{ forgeTestResult.message }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user