Skip to content

Commit bfca610

Browse files
ralyodioclaude
andcommitted
feat(admin): add autoblog integration manager with token generation
- Server action createIntegration: generates prefixed bearer token - Server action revokeIntegration: deletes integration row - IntegrationsManager: webhook URL + copy, generate form, reveal/copy/revoke - Admin page shows phone number for phone-auth users Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 474c5f8 commit bfca610

3 files changed

Lines changed: 235 additions & 31 deletions

File tree

‎src/app/actions/integrations.js‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"use server";
2+
3+
import { randomBytes } from "node:crypto";
4+
import { revalidatePath } from "next/cache";
5+
import { createSupabaseServerClient } from "@/lib/supabase";
6+
import { getServiceRoleClient } from "@/lib/supabase/service-role";
7+
8+
const ALLOWED_KINDS = ["outrank", "crawlproof"];
9+
10+
const KIND_PREFIX = {
11+
outrank: "otrk_",
12+
crawlproof: "cp_qc_",
13+
};
14+
15+
async function assertAdmin() {
16+
const supabase = await createSupabaseServerClient();
17+
const { data: { user } } = await supabase.auth.getUser();
18+
if (!user) return { ok: false, error: "Not authenticated." };
19+
20+
const svc = getServiceRoleClient();
21+
const { data } = await svc
22+
.from("admin_users")
23+
.select("user_id")
24+
.eq("user_id", user.id)
25+
.maybeSingle();
26+
if (!data) return { ok: false, error: "Admin only." };
27+
return { ok: true, userId: user.id };
28+
}
29+
30+
export async function createIntegration({ name, kind }) {
31+
const adminCheck = await assertAdmin();
32+
if (!adminCheck.ok) return adminCheck;
33+
34+
const safeKind = ALLOWED_KINDS.includes(kind) ? kind : "crawlproof";
35+
const safeName = (name || "").trim().slice(0, 100);
36+
if (!safeName) return { ok: false, error: "Name is required." };
37+
38+
const accessToken = `${KIND_PREFIX[safeKind]}${randomBytes(32).toString("base64url")}`;
39+
40+
const svc = getServiceRoleClient();
41+
const { error } = await svc.from("autoblog_integrations").insert({
42+
name: safeName,
43+
kind: safeKind,
44+
access_token: accessToken,
45+
});
46+
if (error) return { ok: false, error: error.message };
47+
48+
revalidatePath("/admin");
49+
return { ok: true, accessToken };
50+
}
51+
52+
export async function revokeIntegration({ id }) {
53+
const adminCheck = await assertAdmin();
54+
if (!adminCheck.ok) return adminCheck;
55+
56+
const svc = getServiceRoleClient();
57+
const { error } = await svc
58+
.from("autoblog_integrations")
59+
.delete()
60+
.eq("id", id);
61+
if (error) return { ok: false, error: error.message };
62+
63+
revalidatePath("/admin");
64+
return { ok: true };
65+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"use client";
2+
3+
import { useEffect, useState, useTransition } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { createIntegration, revokeIntegration } from "@/app/actions/integrations";
6+
7+
const KIND_LABELS = { crawlproof: "Crawlproof", outrank: "Outrank" };
8+
9+
export function IntegrationsManager({ initial }) {
10+
const router = useRouter();
11+
const [pending, start] = useTransition();
12+
const [items, setItems] = useState(initial);
13+
const [kind, setKind] = useState("crawlproof");
14+
const [name, setName] = useState("Crawlproof");
15+
const [origin, setOrigin] = useState("");
16+
const [revealed, setRevealed] = useState({});
17+
const [copied, setCopied] = useState(null);
18+
const [error, setError] = useState(null);
19+
const [justCreatedToken, setJustCreatedToken] = useState(null);
20+
21+
useEffect(() => { setOrigin(window.location.origin); }, []);
22+
23+
const onCreate = (e) => {
24+
e.preventDefault();
25+
setError(null);
26+
setJustCreatedToken(null);
27+
start(async () => {
28+
const res = await createIntegration({ name, kind });
29+
if (!res.ok) { setError(res.error); return; }
30+
setJustCreatedToken(res.accessToken);
31+
setName(kind === "crawlproof" ? "Crawlproof" : "Outrank");
32+
router.refresh();
33+
});
34+
};
35+
36+
const onRevoke = (it) => {
37+
if (!confirm(`Revoke "${it.name}"? The source will stop being able to publish.`)) return;
38+
start(async () => {
39+
const res = await revokeIntegration({ id: it.id });
40+
if (!res.ok) { setError(res.error); return; }
41+
setItems((prev) => prev.filter((i) => i.id !== it.id));
42+
router.refresh();
43+
});
44+
};
45+
46+
const copy = (key, text) => {
47+
navigator.clipboard?.writeText(text);
48+
setCopied(key);
49+
setTimeout(() => setCopied(null), 1500);
50+
};
51+
52+
const webhookUrl = `${origin}/api/autoblog`;
53+
const mutedColor = "var(--color-text-secondary)";
54+
const borderColor = "var(--color-border)";
55+
56+
return (
57+
<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
58+
<div>
59+
<p style={{ fontSize: "0.7rem", textTransform: "uppercase", letterSpacing: "0.05em", color: mutedColor, marginBottom: "0.5rem" }}>
60+
Webhook endpoint
61+
</p>
62+
<div style={{ display: "flex", gap: "0.5rem" }}>
63+
<code style={{ flex: 1, wordBreak: "break-all", borderRadius: "0.375rem", border: `1px solid ${borderColor}`, background: "var(--color-bg-secondary)", padding: "0.5rem 0.75rem", fontSize: "0.75rem" }}>
64+
{webhookUrl || "https://qrypt.chat/api/autoblog"}
65+
</code>
66+
<button type="button" onClick={() => copy("url", webhookUrl)}
67+
style={{ padding: "0.5rem 0.75rem", fontSize: "0.875rem", border: `1px solid ${borderColor}`, borderRadius: "0.375rem", cursor: "pointer" }}>
68+
{copied === "url" ? "Copied" : "Copy"}
69+
</button>
70+
</div>
71+
</div>
72+
73+
<form onSubmit={onCreate}>
74+
<p style={{ fontSize: "0.7rem", textTransform: "uppercase", letterSpacing: "0.05em", color: mutedColor, marginBottom: "0.5rem" }}>
75+
Generate token
76+
</p>
77+
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
78+
<select value={kind} onChange={(e) => { setKind(e.target.value); setName(e.target.value === "crawlproof" ? "Crawlproof" : "Outrank"); }}
79+
style={{ padding: "0.5rem 0.75rem", fontSize: "0.875rem", border: `1px solid ${borderColor}`, borderRadius: "0.375rem", background: "var(--color-bg)" }}>
80+
<option value="crawlproof">Crawlproof</option>
81+
<option value="outrank">Outrank</option>
82+
</select>
83+
<input style={{ flex: 1, minWidth: "160px", padding: "0.5rem 0.75rem", fontSize: "0.875rem", border: `1px solid ${borderColor}`, borderRadius: "0.375rem", background: "var(--color-bg)" }}
84+
type="text" value={name} onChange={(e) => setName(e.target.value)}
85+
placeholder="Integration name" maxLength={100} required />
86+
<button type="submit" disabled={pending}
87+
style={{ padding: "0.5rem 1rem", fontSize: "0.875rem", fontWeight: 500, background: "var(--color-accent)", color: "#fff", border: "none", borderRadius: "0.375rem", cursor: "pointer", opacity: pending ? 0.5 : 1 }}>
88+
{pending ? "…" : "Generate"}
89+
</button>
90+
</div>
91+
{error && <p style={{ marginTop: "0.5rem", fontSize: "0.875rem", color: "#ef4444" }}>{error}</p>}
92+
{justCreatedToken && (
93+
<div style={{ marginTop: "0.75rem", borderRadius: "0.375rem", border: "1px solid rgba(34,197,94,0.4)", background: "rgba(34,197,94,0.05)", padding: "0.75rem" }}>
94+
<p style={{ marginBottom: "0.5rem", fontSize: "0.75rem", fontWeight: 600, color: "#16a34a" }}>
95+
Token created — copy now and paste into CrawlProof autoblog webhook settings.
96+
</p>
97+
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
98+
<code style={{ flex: 1, wordBreak: "break-all", fontSize: "0.75rem" }}>{justCreatedToken}</code>
99+
<button type="button" onClick={() => copy("new", justCreatedToken)}
100+
style={{ padding: "0.25rem 0.5rem", border: `1px solid ${borderColor}`, borderRadius: "0.25rem", fontSize: "0.75rem", cursor: "pointer" }}>
101+
{copied === "new" ? "Copied" : "Copy"}
102+
</button>
103+
</div>
104+
</div>
105+
)}
106+
</form>
107+
108+
<div>
109+
<p style={{ fontSize: "0.7rem", textTransform: "uppercase", letterSpacing: "0.05em", color: mutedColor, marginBottom: "0.5rem" }}>
110+
Access tokens ({items.length})
111+
</p>
112+
{items.length === 0 ? (
113+
<p style={{ fontSize: "0.875rem", color: mutedColor }}>None yet — generate one above.</p>
114+
) : (
115+
<ul style={{ listStyle: "none", padding: 0, display: "flex", flexDirection: "column", gap: "0.5rem" }}>
116+
{items.map((it) => {
117+
const show = !!revealed[it.id];
118+
const masked = `${it.access_token.slice(0, 8)}…${it.access_token.slice(-4)}`;
119+
return (
120+
<li key={it.id} style={{ borderRadius: "0.375rem", border: `1px solid ${borderColor}`, padding: "0.75rem", fontSize: "0.875rem" }}>
121+
<div style={{ display: "flex", justifyContent: "space-between", gap: "0.75rem", alignItems: "flex-start" }}>
122+
<div style={{ flex: 1, minWidth: 0 }}>
123+
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "baseline", gap: "0.5rem" }}>
124+
<span style={{ fontWeight: 500 }}>{it.name}</span>
125+
<span style={{ fontSize: "0.7rem", padding: "0.1rem 0.4rem", borderRadius: "0.25rem", background: "var(--color-bg-secondary)" }}>
126+
{KIND_LABELS[it.kind] ?? it.kind}
127+
</span>
128+
<span style={{ fontSize: "0.75rem", color: mutedColor }}>
129+
{it.request_count} requests
130+
{it.last_used_at && ` · last ${new Date(it.last_used_at).toLocaleString()}`}
131+
</span>
132+
</div>
133+
<div style={{ marginTop: "0.5rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
134+
<code style={{ flex: 1, wordBreak: "break-all", borderRadius: "0.25rem", border: `1px solid ${borderColor}`, background: "var(--color-bg-secondary)", padding: "0.25rem 0.5rem", fontSize: "0.75rem" }}>
135+
{show ? it.access_token : masked}
136+
</code>
137+
<button type="button" onClick={() => setRevealed((p) => ({ ...p, [it.id]: !p[it.id] }))}
138+
style={{ fontSize: "0.75rem", color: mutedColor, background: "none", border: "none", cursor: "pointer" }}>
139+
{show ? "Hide" : "Reveal"}
140+
</button>
141+
<button type="button" onClick={() => copy(it.id, it.access_token)}
142+
style={{ fontSize: "0.75rem", color: mutedColor, background: "none", border: "none", cursor: "pointer" }}>
143+
{copied === it.id ? "Copied" : "Copy"}
144+
</button>
145+
</div>
146+
</div>
147+
<button type="button" onClick={() => onRevoke(it)} disabled={pending}
148+
style={{ fontSize: "0.75rem", color: "#ef4444", background: "none", border: "none", cursor: "pointer" }}>
149+
Revoke
150+
</button>
151+
</div>
152+
</li>
153+
);
154+
})}
155+
</ul>
156+
)}
157+
</div>
158+
</div>
159+
);
160+
}

‎src/app/admin/page.jsx‎

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { notFound, redirect } from 'next/navigation';
22
import Link from 'next/link';
33
import { createSupabaseServerClient } from '@/lib/supabase';
44
import { getServiceRoleClient } from '@/lib/supabase/service-role';
5+
import { IntegrationsManager } from './integrations-form';
56

67
export const metadata = {
78
title: 'Admin — QryptChat',
@@ -25,7 +26,7 @@ export default async function AdminPage() {
2526

2627
const [{ data: integrations }, { data: posts }] = await Promise.all([
2728
svc.from('autoblog_integrations')
28-
.select('id, name, kind, request_count, last_used_at, created_at')
29+
.select('id, name, kind, access_token, request_count, last_used_at, created_at')
2930
.order('created_at', { ascending: false }),
3031
svc.from('blog_posts')
3132
.select('id, slug, title, source, published_at')
@@ -41,44 +42,22 @@ export default async function AdminPage() {
4142
<div>
4243
<h1 style={{ fontSize: '2rem', fontWeight: 700, margin: 0 }}>Admin</h1>
4344
<p style={{ marginTop: '0.25rem', fontSize: '0.875rem', color: mutedColor }}>
44-
Logged in as {user.email}
45+
Logged in as {user.phone ?? user.email}
4546
</p>
4647
</div>
4748

4849
<section style={cardStyle}>
49-
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, margin: '0 0 0.5rem' }}>Autoblog integrations</h2>
50-
<p style={{ fontSize: '0.875rem', color: mutedColor, marginBottom: '1rem' }}>
51-
Bearer tokens for inbound autoblog webhooks. Add via direct insert into{' '}
52-
<code style={{ background: 'var(--color-bg-secondary)', padding: '0.1rem 0.35rem', borderRadius: '0.25rem', fontSize: '0.8em' }}>
53-
autoblog_integrations
54-
</code>.
50+
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, margin: '0 0 0.25rem' }}>Autoblog integrations</h2>
51+
<p style={{ fontSize: '0.875rem', color: mutedColor, marginBottom: '1.25rem' }}>
52+
Generate a bearer token, then paste it into{' '}
53+
<a href="https://crawlproof.com" style={{ textDecoration: 'underline' }}>CrawlProof</a>{' '}
54+
as the webhook secret. The token also doubles as the HMAC signing secret for request verification.
5555
</p>
56-
{(integrations ?? []).length === 0 ? (
57-
<p style={{ fontSize: '0.875rem', color: mutedColor }}>No integrations configured.</p>
58-
) : (
59-
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
60-
<thead>
61-
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
62-
<th style={{ textAlign: 'left', padding: '0.5rem 1rem 0.5rem 0', fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: mutedColor }}>Name</th>
63-
<th style={{ textAlign: 'left', padding: '0.5rem 1rem 0.5rem 0', fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: mutedColor }}>Kind</th>
64-
<th style={{ textAlign: 'right', padding: '0.5rem 0', fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: mutedColor }}>Requests</th>
65-
</tr>
66-
</thead>
67-
<tbody>
68-
{(integrations ?? []).map((i) => (
69-
<tr key={i.id} style={{ borderBottom: '1px solid var(--color-border)' }}>
70-
<td style={{ padding: '0.5rem 1rem 0.5rem 0', fontWeight: 500 }}>{i.name}</td>
71-
<td style={{ padding: '0.5rem 1rem 0.5rem 0', color: mutedColor }}>{i.kind}</td>
72-
<td style={{ padding: '0.5rem 0', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{i.request_count}</td>
73-
</tr>
74-
))}
75-
</tbody>
76-
</table>
77-
)}
56+
<IntegrationsManager initial={integrations ?? []} />
7857
</section>
7958

8059
<section style={cardStyle}>
81-
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, margin: '0 0 0.5rem' }}>Recent blog posts</h2>
60+
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, margin: '0 0 0.75rem' }}>Recent blog posts</h2>
8261
{(posts ?? []).length === 0 ? (
8362
<p style={{ fontSize: '0.875rem', color: mutedColor }}>No posts ingested yet.</p>
8463
) : (

0 commit comments

Comments
 (0)