feat(admin): repository linking, webhook setup and backfill

This commit is contained in:
Dave 2026-09-16 11:49:31 +02:00
parent 0236a0b1eb
commit 8c60c1dc1b
3 changed files with 206 additions and 1 deletions

View file

@ -0,0 +1,57 @@
import { notFound } from "next/navigation";
import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { IngestPanel } from "@/components/admin/ingest-panel";
import { getAdminPb } from "@/lib/pb/admin";
import type { ProjectRecord } from "@/lib/pb/types";
/**
* The admin layout has already refused anyone who is not the owner.
*
* `webhook_secret` is read here but only its presence crosses into the client
* component — never the value. PocketBase rules are record-level and cannot
* hide one field, so stripping it is this page's job.
*/
export default async function ProjectAdminPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const { projectId } = await params;
const pb = await getAdminPb();
let project: ProjectRecord;
try {
project = await pb.collection("projects").getOne<ProjectRecord>(projectId);
} catch {
notFound();
}
const commits = await pb
.collection("commits")
.getList(1, 1, { filter: `project = "${projectId}"`, fields: "id" });
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
return (
<AppShell role="owner" currentPath="/admin/projects">
<TopBar title={project.name} subtitle="Project settings" />
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<IngestPanel
projectId={project.id}
repoOwner={project.repo_owner}
repoName={project.repo_name}
hasSecret={Boolean(project.webhook_secret)}
webhookUrl={`${appUrl}/api/webhooks/forgejo/${project.id}`}
lastIngestAt={project.last_ingest_at}
commitCount={commits.totalItems}
/>
</div>
</div>
</AppShell>
);
}

View file

@ -28,11 +28,17 @@ export default async function AdminProjectsPage() {
<tr key={project.id}> <tr key={project.id}>
<AdminCell> <AdminCell>
<Link <Link
href={`/p/${client.slug}/${project.slug}`} href={`/admin/projects/${project.id}`}
className="font-medium text-text-primary hover:text-brand" className="font-medium text-text-primary hover:text-brand"
> >
{project.name} {project.name}
</Link> </Link>
<Link
href={`/p/${client.slug}/${project.slug}`}
className="ml-3 text-xs text-text-muted hover:text-text-secondary"
>
Board
</Link>
</AdminCell> </AdminCell>
<AdminCell>{client.name}</AdminCell> <AdminCell>{client.name}</AdminCell>
<AdminCell> <AdminCell>

View file

@ -0,0 +1,142 @@
"use client";
import { useState, useTransition } from "react";
import { Copy, RefreshCw, Download } from "lucide-react";
import { backfillProject, rotateWebhookSecret } from "@/app/actions/ingest";
export function IngestPanel({
projectId,
repoOwner,
repoName,
hasSecret,
webhookUrl,
lastIngestAt,
commitCount,
}: {
projectId: string;
repoOwner: string;
repoName: string;
hasSecret: boolean;
webhookUrl: string;
lastIngestAt: string;
commitCount: number;
}) {
const [pending, startTransition] = useTransition();
const [secret, setSecret] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const linked = Boolean(repoOwner && repoName);
const field =
"w-full rounded-lg border border-border-subtle bg-surface px-3 py-2 text-xs text-text-secondary";
return (
<section className="space-y-4 rounded-[var(--radius-tile)] border border-border-subtle p-4">
<div>
<h2 className="text-sm font-semibold text-text-primary">
Commit ingestion
</h2>
<p className="mt-0.5 text-xs text-text-muted">
{linked ? (
<>
Linked to <code>{repoOwner}/{repoName}</code> ·{" "}
<span className="tabular">{commitCount}</span> commits stored
{lastIngestAt && <> · last synced {lastIngestAt.slice(0, 16).replace("T", " ")}</>}
</>
) : (
"No repository linked. Set the owner and name on this project first."
)}
</p>
</div>
{linked && (
<>
<div className="space-y-1.5">
<span className="text-xs text-text-muted">Webhook URL</span>
<div className="flex items-center gap-2">
<input readOnly value={webhookUrl} className={field} />
<button
type="button"
onClick={() => navigator.clipboard.writeText(webhookUrl)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-2 text-xs text-text-secondary hover:bg-surface-raised"
>
<Copy size={13} strokeWidth={2} />
Copy
</button>
</div>
<p className="text-[11px] text-text-muted">
In Forgejo: Settings → Webhooks → Add Webhook → Gitea. Method POST,
content type <code>application/json</code>, trigger on Push.
</p>
</div>
<div className="space-y-1.5">
<span className="text-xs text-text-muted">
Webhook secret {hasSecret && !secret && "(set)"}
</span>
<div className="flex items-center gap-2">
{secret ? (
<input readOnly value={secret} className={`${field} font-mono`} />
) : (
<span className="flex-1 text-xs text-text-muted">
{hasSecret
? "A secret is configured. Rotating it requires updating Forgejo."
: "No secret yet. The webhook will reject every delivery until one is set."}
</span>
)}
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await rotateWebhookSecret({ projectId });
setSecret(result.secret);
})
}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-2 text-xs text-text-secondary hover:bg-surface-raised disabled:opacity-50"
>
<RefreshCw size={13} strokeWidth={2} />
{hasSecret ? "Rotate" : "Generate"}
</button>
</div>
</div>
<div className="flex items-center gap-3 border-t border-border-subtle pt-3">
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
setError(null);
setStatus("Reading history from Forgejo…");
try {
const result = await backfillProject({ projectId });
setStatus(
`Done — ${result.created} new, ${result.skipped} already stored.`
);
} catch (e) {
setStatus(null);
setError(e instanceof Error ? e.message : "Backfill failed");
}
})
}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg transition-colors hover:bg-brand-hover disabled:opacity-50"
>
<Download size={14} strokeWidth={2} />
{pending ? "Working…" : "Backfill history"}
</button>
{status && <span className="text-xs text-text-secondary">{status}</span>}
{error && (
<span role="alert" className="text-xs text-accent">
{error}
</span>
)}
</div>
</>
)}
</section>
);
}