feat: add scoped API keys for programmatic site access

Introduce ApiKey model, CRUD endpoints, and admin UI so agents can
authenticate with permission-scoped keys. Normalize pubkeys to hex on login,
dedupe legacy npub/hex user rows, and ignore .cursor in git.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bbe
2026-06-23 09:29:30 +02:00
co-authored by Cursor
parent 70e3e0633d
commit a6a2b113ee
17 changed files with 836 additions and 100 deletions
+1
View File
@@ -21,6 +21,7 @@ Thumbs.db
# IDE
.idea/
.vscode/
.cursor/
*.swp
*.swo
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"permissions" TEXT NOT NULL,
"createdByPubkey" TEXT NOT NULL,
"lastUsedAt" DATETIME,
"revokedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
+15
View File
@@ -17,6 +17,21 @@ model User {
updatedAt DateTime @updatedAt
}
// Scoped API key for programmatic access. The raw key is shown to the creator
// only once; only its SHA-256 hash is stored. `permissions` is a JSON array of
// permission keys that gate what the key may do.
model ApiKey {
id String @id @default(uuid())
name String
prefix String // leading characters of the raw key, for display
keyHash String @unique // SHA-256 hex of the full key
permissions String // JSON array of permission keys
createdByPubkey String
lastUsedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
}
// Maps an assignable role to a granted permission key. Editable at runtime.
// One row per (role, permission). SuperAdmin is never stored here; it bypasses
// all checks via the env list.
+150
View File
@@ -0,0 +1,150 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import { generateApiKey } from '../services/apiKeys';
import { isValidPermissionKey } from '../constants/permissions';
const router = Router();
function serialize(key: {
id: string;
name: string;
prefix: string;
permissions: string;
createdByPubkey: string;
lastUsedAt: Date | null;
revokedAt: Date | null;
createdAt: Date;
}) {
let permissions: string[] = [];
try {
const parsed = JSON.parse(key.permissions);
if (Array.isArray(parsed)) permissions = parsed.filter((p): p is string => typeof p === 'string');
} catch {
permissions = [];
}
return {
id: key.id,
name: key.name,
prefix: key.prefix,
permissions,
createdByPubkey: key.createdByPubkey,
lastUsedAt: key.lastUsedAt,
revokedAt: key.revokedAt,
createdAt: key.createdAt,
};
}
router.get(
'/',
requireAuth,
requires('api_keys.manage'),
async (_req: Request, res: Response) => {
try {
const keys = await prisma.apiKey.findMany({ orderBy: { createdAt: 'desc' } });
res.json(keys.map(serialize));
} catch (err) {
console.error('List API keys error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/',
requireAuth,
requires('api_keys.manage'),
async (req: Request, res: Response) => {
try {
const { name, permissions } = req.body as { name?: string; permissions?: unknown };
const trimmedName = typeof name === 'string' ? name.trim() : '';
if (!trimmedName) {
res.status(400).json({ error: 'name is required' });
return;
}
if (trimmedName.length > 100) {
res.status(400).json({ error: 'name must be 100 characters or fewer' });
return;
}
if (!Array.isArray(permissions) || permissions.length === 0) {
res.status(400).json({ error: 'At least one permission is required' });
return;
}
const requested = [...new Set(permissions.filter((p): p is string => typeof p === 'string'))];
const invalid = requested.filter((p) => !isValidPermissionKey(p));
if (invalid.length > 0) {
res.status(400).json({ error: `Unknown permissions: ${invalid.join(', ')}` });
return;
}
// A key can never grant more than its creator holds. SuperAdmins hold all.
const caller = req.access!;
if (!caller.isSuperAdmin) {
const exceeded = requested.filter((p) => !caller.permissions.has(p));
if (exceeded.length > 0) {
res
.status(403)
.json({ error: `You cannot grant permissions you do not hold: ${exceeded.join(', ')}` });
return;
}
}
const { rawKey, prefix, keyHash } = generateApiKey();
const created = await prisma.apiKey.create({
data: {
name: trimmedName,
prefix,
keyHash,
permissions: JSON.stringify(requested),
createdByPubkey: req.user!.pubkey,
},
});
// The raw key is returned exactly once and never stored in plaintext.
res.status(201).json({ ...serialize(created), key: rawKey });
} catch (err) {
console.error('Create API key error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requires('api_keys.manage'),
async (req: Request, res: Response) => {
try {
const idRaw = req.params.id;
const id = typeof idRaw === 'string' ? idRaw : Array.isArray(idRaw) ? idRaw[0] : '';
if (!id) {
res.status(400).json({ error: 'id is required' });
return;
}
const existing = await prisma.apiKey.findUnique({ where: { id } });
if (!existing) {
res.status(404).json({ error: 'API key not found' });
return;
}
if (existing.revokedAt) {
res.json(serialize(existing));
return;
}
const revoked = await prisma.apiKey.update({
where: { id },
data: { revokedAt: new Date() },
});
res.json(serialize(revoked));
} catch (err) {
console.error('Revoke API key error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;
+5 -2
View File
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
import { authService } from '../services/auth';
import { prisma } from '../db/prisma';
import { requireAuth } from '../middleware/auth';
import { normalizePubkey } from '../services/pubkey';
const router = Router();
@@ -37,10 +38,12 @@ router.post('/verify', async (req: Request, res: Response) => {
// Ensure a user row exists, but never overwrite an existing role on login.
// The role is managed only through role assignment, and SuperAdmin is env-sourced.
// Store the pubkey as hex so the same identity is never duplicated as npub.
const hexPubkey = normalizePubkey(pubkey);
const dbUser = await prisma.user.upsert({
where: { pubkey },
where: { pubkey: hexPubkey },
update: {},
create: { pubkey },
create: { pubkey: hexPubkey },
});
const access = await authService.resolveAccess(pubkey);
+82 -8
View File
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import { isSuperadmin } from '../services/auth';
import { toHexPubkey, normalizePubkey } from '../services/pubkey';
import {
ROLE_RANK,
isAssignableRole,
@@ -42,6 +43,12 @@ function validateUsername(
return null;
}
function pickHigherRole(a: string | null, b: string | null): string | null {
const rankOf = (r: string | null): number =>
r && isAssignableRole(r) ? ROLE_RANK[r as EffectiveRole] : 0;
return rankOf(a) >= rankOf(b) ? a : b;
}
const router = Router();
router.get(
@@ -53,9 +60,36 @@ router.get(
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
});
// The same person can have been stored both as hex and as npub (no
// normalization existed historically). Collapse those into a single
// identity keyed by hex so the list never shows a duplicate row.
const byHex = new Map<string, (typeof users)[number]>();
for (const u of users) {
const hex = toHexPubkey(u.pubkey) ?? u.pubkey;
const existing = byHex.get(hex);
if (!existing) {
byHex.set(hex, { ...u, pubkey: hex });
continue;
}
// Merge: prefer a stored role over none, the higher-ranked role on
// conflict, and the first available username. Keep the earliest join.
const mergedRole = pickHigherRole(existing.role, u.role);
const mergedUsername = existing.username ?? u.username;
const earliest = existing.createdAt <= u.createdAt ? existing : u;
byHex.set(hex, {
...existing,
role: mergedRole,
username: mergedUsername,
displayName: existing.displayName ?? u.displayName,
createdAt: earliest.createdAt,
pubkey: hex,
});
}
// Flag env-sourced SuperAdmins so the UI can render them locked. Their
// effective role is reported as superadmin regardless of any stored value.
const result = users.map((u) => {
const result = [...byHex.values()].map((u) => {
const superAdmin = isSuperadmin(u.pubkey);
return {
...u,
@@ -71,6 +105,41 @@ router.get(
}
);
// Creates a user row from an npub or hex pubkey so admins can pre-register
// people before they have logged in. The pubkey is normalized to hex.
router.post(
'/',
requireAuth,
requires('users.assign_role'),
async (req: Request, res: Response) => {
try {
const { pubkey: rawPubkey } = req.body as { pubkey?: string };
if (!rawPubkey || typeof rawPubkey !== 'string') {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const hex = toHexPubkey(rawPubkey);
if (!hex) {
res.status(400).json({ error: 'Invalid pubkey. Provide an npub or 64-char hex key.' });
return;
}
const existing = await prisma.user.findUnique({ where: { pubkey: hex } });
if (existing) {
res.status(409).json({ error: 'User already exists' });
return;
}
const user = await prisma.user.create({ data: { pubkey: hex } });
res.status(201).json({ ...user, role: user.role ?? null, isSuperAdmin: isSuperadmin(hex) });
} catch (err) {
console.error('Create user error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Assigns a role to a user, replacing the old promote/demote toggle. Enforces the
// hierarchy: SuperAdmins are never modifiable here, and a non-SuperAdmin caller
// cannot assign a role at or above their own or modify anyone at or above them.
@@ -81,12 +150,13 @@ router.put(
async (req: Request, res: Response) => {
try {
const pubkeyRaw = req.params.pubkey;
const pubkey =
const pubkeyParam =
typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : '';
if (!pubkey) {
if (!pubkeyParam) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const pubkey = normalizePubkey(pubkeyParam);
const { role } = req.body as { role?: string | null };
@@ -222,12 +292,13 @@ router.patch(
async (req: Request, res: Response) => {
try {
const pubkeyRaw = req.params.pubkey;
const pubkey =
const pubkeyParam =
typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : '';
if (!pubkey) {
if (!pubkeyParam) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const hex = normalizePubkey(pubkeyParam);
const { username } = req.body;
const normalized = (username as string || '').trim().toLowerCase();
@@ -238,7 +309,10 @@ router.patch(
return;
}
const target = await prisma.user.findUnique({ where: { pubkey } });
// Tolerate legacy rows still stored as npub by matching either form.
const target = await prisma.user.findFirst({
where: { OR: [{ pubkey: hex }, { pubkey: pubkeyParam }] },
});
if (!target) {
res.status(404).json({ error: 'User not found' });
return;
@@ -247,7 +321,7 @@ router.patch(
const existing = await prisma.user.findFirst({
where: {
username: { equals: normalized },
NOT: { pubkey },
NOT: { pubkey: target.pubkey },
},
});
@@ -257,7 +331,7 @@ router.patch(
}
const user = await prisma.user.update({
where: { pubkey },
where: { pubkey: target.pubkey },
data: { username: normalized },
});
+7 -3
View File
@@ -46,6 +46,8 @@ export const PERMISSIONS: PermissionDef[] = [
{ key: 'roles.edit_permissions', label: 'Edit roles and permissions', group: 'Roles' },
{ key: 'api_keys.manage', label: 'Create and manage API keys', group: 'API Keys' },
{ key: 'nostr_tools.use', label: 'Use Nostr tools', group: 'Nostr Tools' },
];
@@ -78,10 +80,12 @@ export function isAssignableRole(role: unknown): role is AssignableRole {
}
// Default permission sets seeded per role. Admin gets everything except the
// roles editor, which stays SuperAdmin-only by default. Guest gets nothing and
// is therefore not represented here.
// roles editor and API key management, which stay SuperAdmin-only by default.
// Guest gets nothing and is therefore not represented here.
export const DEFAULT_ROLE_PERMISSIONS: Record<AssignableRole, PermissionKey[]> = {
admin: PERMISSIONS.map((p) => p.key).filter((k) => k !== 'roles.edit_permissions'),
admin: PERMISSIONS.map((p) => p.key).filter(
(k) => k !== 'roles.edit_permissions' && k !== 'api_keys.manage'
),
moderator: [
'events.create',
'events.edit',
+2
View File
@@ -26,6 +26,7 @@ import messagesRouter from './api/messages';
import adminMessagesRouter from './api/adminMessages';
import userRelaysRouter from './api/userRelays';
import rolesRouter from './api/roles';
import apiKeysRouter from './api/apiKeys';
const app = express();
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
@@ -57,6 +58,7 @@ app.use('/api/nip05', nip05Router);
app.use('/api/messages', messagesRouter);
app.use('/api/admin/messages', adminMessagesRouter);
app.use('/api/admin', rolesRouter);
app.use('/api/api-keys', apiKeysRouter);
app.use('/api/user-relays', userRelaysRouter);
app.get('/api/health', (_req, res) => {
+26 -3
View File
@@ -1,6 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { authService, type ResolvedAccess } from '../services/auth';
import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
@@ -14,11 +15,12 @@ declare global {
interface Request {
user?: AuthPayload;
access?: ResolvedAccess;
isApiKey?: boolean;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
export async function requireAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid authorization header' });
@@ -26,6 +28,22 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
}
const token = header.slice(7);
// API keys authenticate programmatic clients. They carry their own scoped
// permission set rather than a role, so we pre-resolve access here.
if (looksLikeApiKey(token)) {
const access = await resolveApiKey(token);
if (!access) {
res.status(401).json({ error: 'Invalid or revoked API key' });
return;
}
req.user = { pubkey: access.pubkey, role: 'apikey' };
req.access = access;
req.isApiKey = true;
next();
return;
}
try {
const payload = jwt.verify(token, JWT_SECRET) as AuthPayload;
req.user = payload;
@@ -45,8 +63,13 @@ export function requires(permission: string) {
return;
}
try {
const access = await authService.resolveAccess(req.user.pubkey);
req.access = access;
// API key access is already resolved (and scoped) in requireAuth; for JWT
// callers, resolve their live role-based access here.
let access = req.access;
if (!req.isApiKey || !access) {
access = await authService.resolveAccess(req.user.pubkey);
req.access = access;
}
if (access.isSuperAdmin || access.permissions.has(permission)) {
next();
return;
+57
View File
@@ -0,0 +1,57 @@
import crypto from 'crypto';
import { prisma } from '../db/prisma';
import type { ResolvedAccess } from './auth';
// Raw keys are prefixed so the auth middleware can distinguish them from JWTs
// (which always start with "eyJ").
export const API_KEY_PREFIX = 'bbe_';
const DISPLAY_PREFIX_LENGTH = API_KEY_PREFIX.length + 8;
export function hashApiKey(rawKey: string): string {
return crypto.createHash('sha256').update(rawKey).digest('hex');
}
// Generates a new random key. Returns the raw key (shown once), its display
// prefix, and the hash to persist.
export function generateApiKey(): { rawKey: string; prefix: string; keyHash: string } {
const rawKey = API_KEY_PREFIX + crypto.randomBytes(32).toString('hex');
return {
rawKey,
prefix: rawKey.slice(0, DISPLAY_PREFIX_LENGTH),
keyHash: hashApiKey(rawKey),
};
}
export function looksLikeApiKey(token: string): boolean {
return token.startsWith(API_KEY_PREFIX);
}
// Resolves a raw API key into an access object scoped to the key's permissions.
// Returns null when the key is unknown or revoked. Identity is attributed to the
// pubkey that created the key, but the permissions come from the key, not the
// creator's role.
export async function resolveApiKey(rawKey: string): Promise<ResolvedAccess | null> {
const keyHash = hashApiKey(rawKey);
const key = await prisma.apiKey.findUnique({ where: { keyHash } });
if (!key || key.revokedAt) return null;
let permissions: string[] = [];
try {
const parsed = JSON.parse(key.permissions);
if (Array.isArray(parsed)) permissions = parsed.filter((p): p is string => typeof p === 'string');
} catch {
permissions = [];
}
// Best-effort usage tracking; never block the request on it.
prisma.apiKey
.update({ where: { id: key.id }, data: { lastUsedAt: new Date() } })
.catch(() => undefined);
return {
pubkey: key.createdByPubkey,
role: 'guest',
isSuperAdmin: false,
permissions: new Set(permissions),
};
}
+24
View File
@@ -0,0 +1,24 @@
import { nip19 } from 'nostr-tools';
// Relays and the rest of the app key identities by lowercase hex pubkeys.
// Pubkeys may arrive as npub/nprofile, so normalize them to hex. Returns null
// when the input cannot be interpreted as a pubkey.
export function toHexPubkey(pubkey: string | null | undefined): string | null {
if (!pubkey) return null;
const trimmed = pubkey.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') return decoded.data as string;
if (decoded.type === 'nprofile') return (decoded.data as { pubkey: string }).pubkey;
} catch {
// fall through
}
return null;
}
// Normalizes to hex when possible, otherwise returns the trimmed original so we
// never silently drop an identity we cannot decode.
export function normalizePubkey(pubkey: string): string {
return toHexPubkey(pubkey) ?? pubkey.trim();
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { formatDate } from "@/lib/utils";
import { Copy, Check, KeyRound, Plus, Trash2, ShieldAlert } from "lucide-react";
interface PermissionDef {
key: string;
label: string;
group: string;
}
interface ApiKey {
id: string;
name: string;
prefix: string;
permissions: string[];
createdByPubkey: string;
lastUsedAt: string | null;
revokedAt: string | null;
createdAt: string;
}
export default function ApiKeysPage() {
const { can } = useAuth();
const allowed = can("api_keys.manage");
const [permissions, setPermissions] = useState<PermissionDef[]>([]);
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [name, setName] = useState("");
const [selected, setSelected] = useState<Record<string, boolean>>({});
const [creating, setCreating] = useState(false);
const [revealedKey, setRevealedKey] = useState<{ name: string; key: string } | null>(null);
const [copied, setCopied] = useState(false);
const load = async () => {
setLoading(true);
try {
const [registry, list] = await Promise.all([
api.getPermissionRegistry(),
api.getApiKeys(),
]);
setPermissions(registry.permissions);
setKeys(list);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (allowed) load();
else setLoading(false);
}, [allowed]);
const groups = useMemo(() => {
const order: string[] = [];
const byGroup: Record<string, PermissionDef[]> = {};
for (const perm of permissions) {
if (!byGroup[perm.group]) {
byGroup[perm.group] = [];
order.push(perm.group);
}
byGroup[perm.group].push(perm);
}
return order.map((group) => ({ group, perms: byGroup[group] }));
}, [permissions]);
const selectedKeys = useMemo(
() => Object.entries(selected).filter(([, v]) => v).map(([k]) => k),
[selected]
);
const toggle = (key: string) => {
setSelected((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handleCreate = async () => {
if (!name.trim() || selectedKeys.length === 0) return;
setCreating(true);
setError("");
try {
const created = await api.createApiKey({ name: name.trim(), permissions: selectedKeys });
setRevealedKey({ name: created.name, key: created.key });
setCopied(false);
setName("");
setSelected({});
await load();
} catch (err: any) {
setError(err.message);
} finally {
setCreating(false);
}
};
const handleRevoke = async (id: string) => {
setError("");
try {
await api.revokeApiKey(id);
await load();
} catch (err: any) {
setError(err.message);
}
};
const handleCopy = async () => {
if (!revealedKey) return;
try {
await navigator.clipboard.writeText(revealedKey.key);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
};
if (!allowed) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">
You do not have permission to manage API keys.
</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading API keys...</div>
</div>
);
}
const labelFor = (key: string) => permissions.find((p) => p.key === key)?.label ?? key;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-on-surface">API Keys</h1>
<p className="text-on-surface/60 text-sm mt-1">
Create scoped API keys for programmatic access. A key is shown in full
only once, right after you create it, so copy it immediately.
</p>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{revealedKey && (
<div className="bg-primary-container/10 border border-primary/30 rounded-xl p-6 space-y-3">
<div className="flex items-center gap-2 text-primary font-semibold">
<ShieldAlert size={18} />
Copy your new key now it won&apos;t be shown again
</div>
<p className="text-on-surface/70 text-sm">
Key for <span className="font-semibold">{revealedKey.name}</span>:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm break-all">
{revealedKey.key}
</code>
<button
onClick={handleCopy}
className="flex items-center gap-2 px-4 py-3 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity whitespace-nowrap"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
<button
onClick={() => setRevealedKey(null)}
className="text-on-surface/50 text-sm hover:text-on-surface transition-colors"
>
I&apos;ve saved it, dismiss
</button>
</div>
)}
{/* Create form */}
<div className="bg-surface-container-low rounded-xl p-6 space-y-5">
<h2 className="text-sm font-semibold text-on-surface/70">Create API Key</h2>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Blog publishing bot"
maxLength={100}
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<p className="text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-3">
Permissions
</p>
<div className="space-y-4">
{groups.map(({ group, perms }) => (
<div key={group}>
<p className="text-xs font-bold uppercase tracking-wide text-on-surface/40 mb-2">
{group}
</p>
<div className="grid sm:grid-cols-2 gap-2">
{perms.map((perm) => (
<label
key={perm.key}
className="flex items-start gap-2 cursor-pointer text-sm text-on-surface"
>
<input
type="checkbox"
checked={!!selected[perm.key]}
onChange={() => toggle(perm.key)}
className="mt-0.5 h-4 w-4 accent-primary cursor-pointer"
/>
<span>
{perm.label}
<span className="block text-on-surface/40 text-xs font-mono">
{perm.key}
</span>
</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<button
onClick={handleCreate}
disabled={!name.trim() || selectedKeys.length === 0 || creating}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Plus size={16} />
{creating ? "Creating..." : "Create API Key"}
</button>
</div>
{/* Existing keys */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-on-surface/70">Existing Keys</h2>
{keys.length === 0 ? (
<div className="bg-surface-container-low rounded-xl p-8 text-center">
<KeyRound size={32} className="text-on-surface-variant/30 mx-auto mb-3" />
<p className="text-on-surface-variant/60 text-sm">No API keys yet.</p>
</div>
) : (
keys.map((key) => (
<div
key={key.id}
className={`bg-surface-container-low rounded-xl p-6 ${key.revokedAt ? "opacity-60" : ""}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-on-surface">{key.name}</h3>
{key.revokedAt && (
<span className="text-xs font-bold text-error bg-error/10 px-2 py-0.5 rounded-full">
Revoked
</span>
)}
</div>
<p className="font-mono text-sm text-on-surface/60 mt-1">{key.prefix}</p>
<p className="text-on-surface/40 text-xs mt-1">
Created {formatDate(key.createdAt)}
{key.lastUsedAt ? ` · Last used ${formatDate(key.lastUsedAt)}` : " · Never used"}
</p>
<div className="flex flex-wrap gap-1.5 mt-3">
{key.permissions.map((p) => (
<span
key={p}
className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded-full"
title={p}
>
{labelFor(p)}
</span>
))}
</div>
</div>
{!key.revokedAt && (
<button
onClick={() => handleRevoke(key.id)}
className="text-on-surface-variant/50 hover:text-error transition-colors p-2 rounded-lg hover:bg-error/10 shrink-0"
title="Revoke key"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
))
)}
</div>
</div>
);
}
+29 -25
View File
@@ -45,7 +45,7 @@ export default function RolesPage() {
const [original, setOriginal] = useState<Matrix>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [savingRole, setSavingRole] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState("");
const load = async () => {
@@ -101,19 +101,26 @@ export default function RolesPage() {
}));
};
const saveRole = async (role: string) => {
setSavingRole(role);
const saveAll = async () => {
if (dirtyRoles.length === 0) return;
setSaving(true);
setError("");
setNotice("");
try {
const keys = permissions.filter((p) => matrix[role]?.[p.key]).map((p) => p.key);
await api.updateRolePermissions(role, keys);
setOriginal((prev) => ({ ...prev, [role]: { ...matrix[role] } }));
setNotice(`${ROLE_LABELS[role] ?? role} permissions saved.`);
for (const role of dirtyRoles) {
const keys = permissions.filter((p) => matrix[role]?.[p.key]).map((p) => p.key);
await api.updateRolePermissions(role, keys);
}
setOriginal(() => {
const next: Matrix = {};
for (const role of roles) next[role] = { ...matrix[role] };
return next;
});
setNotice("Permissions saved.");
} catch (err: any) {
setError(err.message);
} finally {
setSavingRole(null);
setSaving(false);
}
};
@@ -207,23 +214,20 @@ export default function RolesPage() {
</table>
</div>
<div className="flex flex-wrap gap-3">
{roles.map((role) => {
const dirty = dirtyRoles.includes(role);
return (
<button
key={role}
onClick={() => saveRole(role)}
disabled={!dirty || savingRole === role}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={16} />
{savingRole === role
? "Saving..."
: `Save ${ROLE_LABELS[role] ?? role}${dirty ? " *" : ""}`}
</button>
);
})}
<div className="flex flex-wrap items-center gap-3">
<button
onClick={saveAll}
disabled={dirtyRoles.length === 0 || saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={16} />
{saving ? "Saving..." : "Save changes"}
</button>
{dirtyRoles.length > 0 && !saving && (
<span className="text-on-surface/50 text-sm">
Unsaved changes to {dirtyRoles.map((r) => ROLE_LABELS[r] ?? r).join(", ")}
</span>
)}
</div>
</div>
);
+52 -1
View File
@@ -7,7 +7,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useNostrProfile } from "@/hooks/useNostrProfile";
import { NostrAvatar } from "@/components/nostr/NostrAvatar";
import { formatDate } from "@/lib/utils";
import { Lock } from "lucide-react";
import { Lock, Plus } from "lucide-react";
const ROLE_RANK: Record<string, number> = {
superadmin: 4,
@@ -208,6 +208,9 @@ export default function UsersPage() {
const [usernameDrafts, setUsernameDrafts] = useState<Record<string, string>>({});
const [savingPubkey, setSavingPubkey] = useState<string | null>(null);
const [copiedPubkey, setCopiedPubkey] = useState<string | null>(null);
const [newUserPubkey, setNewUserPubkey] = useState("");
const [addingUser, setAddingUser] = useState(false);
const [addUserSuccess, setAddUserSuccess] = useState("");
const loadUsers = async () => {
try {
@@ -286,6 +289,24 @@ export default function UsersPage() {
setUsernameDrafts((prev) => ({ ...prev, [pubkey]: value }));
};
const handleAddUser = async () => {
const value = newUserPubkey.trim();
if (!value) return;
setAddingUser(true);
setError("");
setAddUserSuccess("");
try {
await api.createUser(value);
setNewUserPubkey("");
setAddUserSuccess("User added.");
await loadUsers();
} catch (err: any) {
setError(err.message);
} finally {
setAddingUser(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
@@ -300,6 +321,36 @@ export default function UsersPage() {
{error && <p className="text-error text-sm">{error}</p>}
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Add User</h2>
<p className="text-on-surface-variant text-xs mb-3">
Pre-register a user by their npub or hex pubkey so you can assign a role before they log in.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<input
placeholder="npub1... or hex pubkey"
value={newUserPubkey}
onChange={(e) => setNewUserPubkey(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddUser();
}}
disabled={addingUser}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full font-mono text-sm focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1 disabled:opacity-50"
/>
<button
onClick={handleAddUser}
disabled={!newUserPubkey.trim() || addingUser}
className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
<Plus size={16} />
{addingUser ? "Adding…" : "Add User"}
</button>
</div>
{addUserSuccess && (
<p className="text-green-400 text-sm mt-3">{addUserSuccess}</p>
)}
</div>
<div className="space-y-3">
{users.length === 0 ? (
<p className="text-on-surface/50 text-sm">No users found.</p>
+50 -53
View File
@@ -5,7 +5,7 @@ import Image from "next/image";
import { Send, FileText, Clock, CheckCircle, XCircle, Plus, User, Loader2, AtSign, Radio, Trash2, Download, Eye, Pencil } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import { shortenPubkey } from "@/lib/nostr";
import { shortenPubkey, fetchEventFromRelays, fetchLongformFromRelays } from "@/lib/nostr";
import { formatDate } from "@/lib/utils";
import { Button } from "@/components/ui/Button";
@@ -61,9 +61,7 @@ export default function DashboardPage() {
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [loadingSubs, setLoadingSubs] = useState(true);
const [showForm, setShowForm] = useState(false);
const [title, setTitle] = useState("");
const [eventId, setEventId] = useState("");
const [naddr, setNaddr] = useState("");
const [noteInput, setNoteInput] = useState("");
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState("");
const [formSuccess, setFormSuccess] = useState("");
@@ -125,31 +123,56 @@ export default function DashboardPage() {
loadRelays();
}, [loadSubmissions, loadRelays]);
const extractTitle = (event: any): string => {
const titleTag = event?.tags?.find((t: string[]) => t[0] === "title");
return titleTag?.[1]?.trim() || "Untitled";
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError("");
setFormSuccess("");
if (!title.trim()) {
setFormError("Title is required");
return;
}
if (!eventId.trim() && !naddr.trim()) {
setFormError("Either an Event ID or naddr is required");
const input = noteInput.trim();
if (!input) {
setFormError("A Note ID or naddr is required");
return;
}
setSubmitting(true);
try {
await api.createSubmission({
title: title.trim(),
eventId: eventId.trim() || undefined,
naddr: naddr.trim() || undefined,
});
setFormSuccess("Submission sent for review!");
setTitle("");
setEventId("");
setNaddr("");
let payload: { title: string; eventId?: string; naddr?: string };
if (input.startsWith("naddr1")) {
const event = await fetchLongformFromRelays(input);
if (!event) {
throw new Error("Could not find that article on the relays");
}
payload = { title: extractTitle(event), naddr: input };
} else {
let hexId = input;
if (input.startsWith("note1")) {
try {
const { nip19 } = await import("nostr-tools");
const decoded = nip19.decode(input);
if (decoded.type !== "note") throw new Error();
hexId = decoded.data as string;
} catch {
throw new Error("Invalid note id");
}
} else if (!/^[0-9a-f]{64}$/i.test(input)) {
throw new Error("Enter a valid note id, naddr, or hex event id");
}
const event = await fetchEventFromRelays(hexId);
if (!event) {
throw new Error("Could not find that note on the relays");
}
payload = { title: extractTitle(event), eventId: hexId };
}
await api.createSubmission(payload);
setFormSuccess(`Submission "${payload.title}" sent for review!`);
setNoteInput("");
setShowForm(false);
await loadSubmissions();
} catch (err: any) {
@@ -381,46 +404,20 @@ export default function DashboardPage() {
className="bg-surface-container-low rounded-xl p-6 mb-8 space-y-4"
>
<p className="text-on-surface-variant text-sm mb-2">
Submit a Nostr longform post for moderator review. Provide the
event ID or naddr of the article you&apos;d like published on the
blog.
Submit a Nostr longform post for moderator review. Paste the
note ID or naddr of the article you&apos;d like published on the
blog. The title is pulled automatically from the note.
</p>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Title
Note ID or naddr
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Bitcoin Article"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Nostr Event ID
</label>
<input
type="text"
value={eventId}
onChange={(e) => setEventId(e.target.value)}
placeholder="note1... or hex event id"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Or naddr
</label>
<input
type="text"
value={naddr}
onChange={(e) => setNaddr(e.target.value)}
placeholder="naddr1..."
value={noteInput}
onChange={(e) => setNoteInput(e.target.value)}
placeholder="naddr1... or note1... or hex event id"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
@@ -438,7 +435,7 @@ export default function DashboardPage() {
>
<span className="flex items-center gap-2">
<Send size={16} />
{submitting ? "Submitting..." : "Submit for Review"}
{submitting ? "Fetching & submitting..." : "Submit for Review"}
</span>
</Button>
<Button
+7 -5
View File
@@ -22,6 +22,7 @@ import {
MessageSquare,
Building2,
KeyRound,
Key,
} from "lucide-react";
// Each item is shown when the user holds any of its permissions. Items with no
@@ -44,6 +45,7 @@ const navItems: {
{ href: "/admin/categories", label: "Categories", icon: Tag, permissions: ["categories.manage"] },
{ href: "/admin/users", label: "Users", icon: Users, permissions: ["users.assign_role", "nip05.assign"] },
{ href: "/admin/roles", label: "Roles", icon: KeyRound, permissions: ["roles.edit_permissions"] },
{ href: "/admin/api-keys", label: "API Keys", icon: Key, permissions: ["api_keys.manage"] },
{ href: "/admin/relays", label: "Relays", icon: Radio, permissions: ["relays.manage"] },
{ href: "/admin/settings", label: "Settings", icon: Settings, permissions: ["settings.edit"] },
{ href: "/admin/nostr", label: "Nostr Tools", icon: Wrench, permissions: ["nostr_tools.use"] },
@@ -58,8 +60,8 @@ export function AdminSidebar() {
: "";
return (
<aside className="w-64 bg-surface-container-lowest min-h-screen p-6 flex flex-col shrink-0">
<div className="mb-8">
<aside className="w-64 bg-surface-container-lowest h-screen sticky top-0 p-6 flex flex-col shrink-0">
<div className="mb-8 shrink-0">
<Link href="/" className="text-primary-container font-bold text-xl">
BBE Admin
</Link>
@@ -73,7 +75,7 @@ export function AdminSidebar() {
</div>
) : (
<>
<div className="mb-6">
<div className="mb-6 shrink-0">
<p className="text-on-surface/70 text-sm font-mono truncate">{shortPubkey}</p>
<span
className={cn(
@@ -87,7 +89,7 @@ export function AdminSidebar() {
</span>
</div>
<nav className="flex-1 space-y-1">
<nav className="flex-1 overflow-y-auto min-h-0 space-y-1">
{navItems
.filter(
(item) =>
@@ -114,7 +116,7 @@ export function AdminSidebar() {
})}
</nav>
<div className="mt-auto space-y-2 pt-6">
<div className="shrink-0 space-y-2 pt-6">
<button
onClick={logout}
className="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-on-surface/70 hover:text-on-surface hover:bg-surface-container w-full"
+9
View File
@@ -110,6 +110,8 @@ export const api = {
// Users
getUsers: () => request<any[]>("/users"),
createUser: (pubkey: string) =>
request<any>("/users", { method: "POST", body: JSON.stringify({ pubkey }) }),
setUserRole: (pubkey: string, role: string | null) =>
request<any>(`/users/${encodeURIComponent(pubkey)}/role`, {
method: "PUT",
@@ -134,6 +136,13 @@ export const api = {
{ method: "PUT", body: JSON.stringify({ permissions }) }
),
// API keys
getApiKeys: () => request<any[]>("/api-keys"),
createApiKey: (data: { name: string; permissions: string[] }) =>
request<any>("/api-keys", { method: "POST", body: JSON.stringify(data) }),
revokeApiKey: (id: string) =>
request<any>(`/api-keys/${encodeURIComponent(id)}`, { method: "DELETE" }),
// Categories
getCategories: () => request<any[]>("/categories"),
createCategory: (data: { name: string; slug: string }) =>