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}>
|
||||
<AdminCell>
|
||||
<Link
|
||||
href={`/p/${client.slug}/${project.slug}`}
|
||||
href={`/admin/projects/${project.id}`}
|
||||
className="font-medium text-text-primary hover:text-brand"
|
||||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/p/${client.slug}/${project.slug}`}
|
||||
className="ml-3 text-xs text-text-muted hover:text-text-secondary"
|
||||
>
|
||||
Board
|
||||
</Link>
|
||||
</AdminCell>
|
||||
<AdminCell>{client.name}</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