From a6a2b113ee00ef93b174f9612b8c794e4cbf23d7 Mon Sep 17 00:00:00 2001 From: bbe Date: Tue, 23 Jun 2026 09:29:30 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + .../20260623120000_add_api_keys/migration.sql | 15 + backend/prisma/schema.prisma | 15 + backend/src/api/apiKeys.ts | 150 +++++++++ backend/src/api/auth.ts | 7 +- backend/src/api/users.ts | 90 +++++- backend/src/constants/permissions.ts | 10 +- backend/src/index.ts | 2 + backend/src/middleware/auth.ts | 29 +- backend/src/services/apiKeys.ts | 57 ++++ backend/src/services/pubkey.ts | 24 ++ frontend/app/admin/api-keys/page.tsx | 305 ++++++++++++++++++ frontend/app/admin/roles/page.tsx | 54 ++-- frontend/app/admin/users/page.tsx | 53 ++- frontend/app/dashboard/page.tsx | 103 +++--- frontend/components/admin/AdminSidebar.tsx | 12 +- frontend/lib/api.ts | 9 + 17 files changed, 836 insertions(+), 100 deletions(-) create mode 100644 backend/prisma/migrations/20260623120000_add_api_keys/migration.sql create mode 100644 backend/src/api/apiKeys.ts create mode 100644 backend/src/services/apiKeys.ts create mode 100644 backend/src/services/pubkey.ts create mode 100644 frontend/app/admin/api-keys/page.tsx diff --git a/.gitignore b/.gitignore index 33ecced..dec0ed3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ Thumbs.db # IDE .idea/ .vscode/ +.cursor/ *.swp *.swo diff --git a/backend/prisma/migrations/20260623120000_add_api_keys/migration.sql b/backend/prisma/migrations/20260623120000_add_api_keys/migration.sql new file mode 100644 index 0000000..230ae02 --- /dev/null +++ b/backend/prisma/migrations/20260623120000_add_api_keys/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 6fced5d..3dc5614 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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. diff --git a/backend/src/api/apiKeys.ts b/backend/src/api/apiKeys.ts new file mode 100644 index 0000000..4e221e2 --- /dev/null +++ b/backend/src/api/apiKeys.ts @@ -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; diff --git a/backend/src/api/auth.ts b/backend/src/api/auth.ts index 0d3511e..75b093c 100644 --- a/backend/src/api/auth.ts +++ b/backend/src/api/auth.ts @@ -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); diff --git a/backend/src/api/users.ts b/backend/src/api/users.ts index d8fd04e..b4c59bc 100644 --- a/backend/src/api/users.ts +++ b/backend/src/api/users.ts @@ -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(); + 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 }, }); diff --git a/backend/src/constants/permissions.ts b/backend/src/constants/permissions.ts index e1a253b..deb0cf6 100644 --- a/backend/src/constants/permissions.ts +++ b/backend/src/constants/permissions.ts @@ -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 = { - 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', diff --git a/backend/src/index.ts b/backend/src/index.ts index b650e23..0cb9285 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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) => { diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index c53b7fd..cf591e6 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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 { 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; diff --git a/backend/src/services/apiKeys.ts b/backend/src/services/apiKeys.ts new file mode 100644 index 0000000..f1e020a --- /dev/null +++ b/backend/src/services/apiKeys.ts @@ -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 { + 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), + }; +} diff --git a/backend/src/services/pubkey.ts b/backend/src/services/pubkey.ts new file mode 100644 index 0000000..2973ae1 --- /dev/null +++ b/backend/src/services/pubkey.ts @@ -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(); +} diff --git a/frontend/app/admin/api-keys/page.tsx b/frontend/app/admin/api-keys/page.tsx new file mode 100644 index 0000000..cbb80db --- /dev/null +++ b/frontend/app/admin/api-keys/page.tsx @@ -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([]); + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const [name, setName] = useState(""); + const [selected, setSelected] = useState>({}); + 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 = {}; + 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 ( +
+
+ You do not have permission to manage API keys. +
+
+ ); + } + + if (loading) { + return ( +
+
Loading API keys...
+
+ ); + } + + const labelFor = (key: string) => permissions.find((p) => p.key === key)?.label ?? key; + + return ( +
+
+

API Keys

+

+ Create scoped API keys for programmatic access. A key is shown in full + only once, right after you create it, so copy it immediately. +

+
+ + {error &&

{error}

} + + {revealedKey && ( +
+
+ + Copy your new key now — it won't be shown again +
+

+ Key for {revealedKey.name}: +

+
+ + {revealedKey.key} + + +
+ +
+ )} + + {/* Create form */} +
+

Create API Key

+ +
+ + 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" + /> +
+ +
+

+ Permissions +

+
+ {groups.map(({ group, perms }) => ( +
+

+ {group} +

+
+ {perms.map((perm) => ( + + ))} +
+
+ ))} +
+
+ + +
+ + {/* Existing keys */} +
+

Existing Keys

+ {keys.length === 0 ? ( +
+ +

No API keys yet.

+
+ ) : ( + keys.map((key) => ( +
+
+
+
+

{key.name}

+ {key.revokedAt && ( + + Revoked + + )} +
+

{key.prefix}…

+

+ Created {formatDate(key.createdAt)} + {key.lastUsedAt ? ` · Last used ${formatDate(key.lastUsedAt)}` : " · Never used"} +

+
+ {key.permissions.map((p) => ( + + {labelFor(p)} + + ))} +
+
+ {!key.revokedAt && ( + + )} +
+
+ )) + )} +
+
+ ); +} diff --git a/frontend/app/admin/roles/page.tsx b/frontend/app/admin/roles/page.tsx index 1003a1f..d2b29d4 100644 --- a/frontend/app/admin/roles/page.tsx +++ b/frontend/app/admin/roles/page.tsx @@ -45,7 +45,7 @@ export default function RolesPage() { const [original, setOriginal] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); - const [savingRole, setSavingRole] = useState(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() { -
- {roles.map((role) => { - const dirty = dirtyRoles.includes(role); - return ( - - ); - })} +
+ + {dirtyRoles.length > 0 && !saving && ( + + Unsaved changes to {dirtyRoles.map((r) => ROLE_LABELS[r] ?? r).join(", ")} + + )}
); diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx index 8f65fcf..87b062f 100644 --- a/frontend/app/admin/users/page.tsx +++ b/frontend/app/admin/users/page.tsx @@ -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 = { superadmin: 4, @@ -208,6 +208,9 @@ export default function UsersPage() { const [usernameDrafts, setUsernameDrafts] = useState>({}); const [savingPubkey, setSavingPubkey] = useState(null); const [copiedPubkey, setCopiedPubkey] = useState(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 (
@@ -300,6 +321,36 @@ export default function UsersPage() { {error &&

{error}

} +
+

Add User

+

+ Pre-register a user by their npub or hex pubkey so you can assign a role before they log in. +

+
+ 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" + /> + +
+ {addUserSuccess && ( +

{addUserSuccess}

+ )} +
+
{users.length === 0 ? (

No users found.

diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 8b8453a..a198da2 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -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([]); 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" >

- Submit a Nostr longform post for moderator review. Provide the - event ID or naddr of the article you'd like published on the - blog. + Submit a Nostr longform post for moderator review. Paste the + note ID or naddr of the article you'd like published on the + blog. The title is pulled automatically from the note.

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" - /> -
- -
- - 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" - /> -
- -
- - 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" />
@@ -438,7 +435,7 @@ export default function DashboardPage() { > - {submitting ? "Submitting..." : "Submit for Review"} + {submitting ? "Fetching & submitting..." : "Submit for Review"}