feat(admin): repository linking, webhook setup and backfill
This commit is contained in:
parent
0236a0b1eb
commit
8c60c1dc1b
3 changed files with 206 additions and 1 deletions
57
src/app/admin/projects/[projectId]/page.tsx
Normal file
57
src/app/admin/projects/[projectId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
142
src/components/admin/ingest-panel.tsx
Normal file
142
src/components/admin/ingest-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue