From 95a7e4c2cbcb4884efd96d782b95e0db96f956f8 Mon Sep 17 00:00:00 2001 From: Pedro Enrique Date: Wed, 11 Feb 2026 12:03:18 +0100 Subject: [PATCH 1/8] feat: add public Tasks API with API key auth and webhook support --- src/api/index.ts | 8 +- src/lib/webhook.ts | 80 ++++++++ src/routes/chat.ts | 105 +++++++++-- src/routes/tasks.ts | 440 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 615 insertions(+), 18 deletions(-) create mode 100644 src/lib/webhook.ts create mode 100644 src/routes/tasks.ts diff --git a/src/api/index.ts b/src/api/index.ts index b34360e..2cc9313 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,8 +3,9 @@ * Mounts auth, workspace, session, chat, skills, and sources routes. */ import { createRouter } from '@agentuity/runtime'; -import { auth, authMiddleware, authRoutes } from '../auth'; +import { auth, authMiddleware, apiKeyMiddleware, authRoutes } from '../auth'; import workspaceRoutes from '../routes/workspaces'; +import taskRoutes from '../routes/tasks'; import sessionRoutes from '../routes/sessions'; import sessionDetailRoutes from '../routes/session-detail'; import chatRoutes from '../routes/chat'; @@ -33,6 +34,11 @@ api.get('/auth-methods', (c) => { // Shared session routes (public — no authentication required) api.route('/shared', sharedRoutes); +// Public Tasks API (API key authentication) +api.use('/v1/tasks/*', apiKeyMiddleware); +api.use('/v1/tasks', apiKeyMiddleware); +api.route('/v1/tasks', taskRoutes); + // All other routes require authentication api.use('/*', authMiddleware); diff --git a/src/lib/webhook.ts b/src/lib/webhook.ts new file mode 100644 index 0000000..1caef41 --- /dev/null +++ b/src/lib/webhook.ts @@ -0,0 +1,80 @@ +/** + * Webhook invocation utility with retry logic. + * + * Fires a POST to the caller-supplied webhook URL when a task + * reaches a terminal state (completed, error, terminated). + */ + +export interface WebhookPayload { + taskId: string; + status: 'completed' | 'error' | 'terminated'; + repoUrl?: string; + branch?: string; + summary?: string; + prUrl?: string; + error?: string; + completedAt: string; +} + +interface WebhookOptions { + /** Maximum number of delivery attempts (default: 3). */ + maxAttempts?: number; + /** Initial backoff in ms before the first retry (default: 1000). */ + initialBackoffMs?: number; +} + +/** + * Deliver a webhook payload via POST with exponential-backoff retry. + * + * Returns `true` if the webhook was delivered (2xx response), + * `false` if all attempts failed. + */ +export async function deliverWebhook( + url: string, + payload: WebhookPayload, + options: WebhookOptions = {}, +): Promise { + const maxAttempts = options.maxAttempts ?? 3; + const initialBackoffMs = options.initialBackoffMs ?? 1_000; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Agentuity-Coder/1.0', + 'X-Webhook-Attempt': String(attempt), + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10_000), // 10s timeout per attempt + }); + + if (response.ok) { + return true; + } + + // Non-retryable client errors (4xx except 429) + if (response.status >= 400 && response.status < 500 && response.status !== 429) { + console.warn( + `[webhook] Non-retryable ${response.status} from ${url} (attempt ${attempt}/${maxAttempts})`, + ); + return false; + } + } catch (err) { + console.warn( + `[webhook] Delivery attempt ${attempt}/${maxAttempts} to ${url} failed:`, + err instanceof Error ? err.message : err, + ); + } + + // Exponential backoff before next retry + if (attempt < maxAttempts) { + const backoff = initialBackoffMs * Math.pow(2, attempt - 1); + await new Promise((r) => setTimeout(r, backoff)); + } + } + + console.error(`[webhook] All ${maxAttempts} attempts to ${url} failed`); + return false; +} diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 87e4462..2316558 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -11,6 +11,9 @@ import { eq } from 'drizzle-orm'; import { getOpencodeClient } from '../opencode'; import { sandboxListFiles, sandboxReadFile, sandboxExecute, sandboxWriteFiles } from '@agentuity/server'; import { normalizeSandboxPath } from '../lib/path-utils'; +import { parseMetadata } from '../lib/parse-metadata'; +import { deliverWebhook } from '../lib/webhook'; +import type { WebhookPayload } from '../lib/webhook'; const SANDBOX_HOME = '/home/agentuity'; const UPLOADS_DIR = `${SANDBOX_HOME}/uploads`; @@ -72,6 +75,69 @@ function isAllowedFilename(filename: string) { const api = createRouter(); +// --------------------------------------------------------------------------- +// Session completion detection & webhook delivery +// --------------------------------------------------------------------------- + +/** + * Detect whether an SSE event signals session completion (session.idle). + * When detected, update DB status to 'completed' and fire webhook if configured. + */ +async function handleSessionCompletionEvent( + event: any, + sessionId: string, + opencodeSessionId: string, +): Promise { + // OpenCode emits "session.idle" when the AI finishes its work + const eventType = event?.type; + if (eventType !== 'session.idle') return; + + // Check if this event is for our session + const props = event?.properties; + const eventSessionId = + props?.sessionID || props?.info?.sessionID || props?.info?.id || props?.part?.sessionID; + if (eventSessionId && eventSessionId !== opencodeSessionId) return; + + // Only handle completion for API-created tasks — leave web UI sessions untouched + const [current] = await db + .select() + .from(chatSessions) + .where(eq(chatSessions.id, sessionId)) + .limit(1); + if (!current) return; + + const metadata = parseMetadata(current); + if (metadata.source !== 'api') return; + + // Update session status to 'completed' + const [updated] = await db + .update(chatSessions) + .set({ status: 'completed', updatedAt: new Date() }) + .where(eq(chatSessions.id, sessionId)) + .returning(); + + if (!updated) return; + + // Check for webhook URL in metadata + const webhookUrl = typeof metadata.webhookUrl === 'string' ? metadata.webhookUrl : null; + if (!webhookUrl) return; + + // Build webhook payload + const payload: WebhookPayload = { + taskId: sessionId, + status: 'completed', + repoUrl: typeof metadata.repoUrl === 'string' ? metadata.repoUrl : undefined, + branch: typeof metadata.branch === 'string' ? metadata.branch : undefined, + prUrl: (metadata.pullRequest as any)?.url ?? undefined, + completedAt: new Date().toISOString(), + }; + + // Fire-and-forget webhook delivery (don't block SSE) + deliverWebhook(webhookUrl, payload).catch((err) => { + console.error(`[webhook] Failed to deliver for session ${sessionId}:`, err); + }); +} + // --------------------------------------------------------------------------- // GET /api/sessions/:id/messages — fetch existing messages for page load // --------------------------------------------------------------------------- @@ -265,24 +331,29 @@ api.get( const jsonStr = line.slice(6).trim(); if (!jsonStr) continue; - try { - const event = JSON.parse(jsonStr); - // Filter by session - const props = (event as any)?.properties; - const eventSessionId = - props?.sessionID || - props?.info?.sessionID || - props?.info?.id || - props?.part?.sessionID; - - if (eventSessionId && eventSessionId !== session.opencodeSessionId) { - continue; - } - - await stream.writeSSE({ data: JSON.stringify(event) }); - } catch { - // Skip malformed events + try { + const event = JSON.parse(jsonStr); + // Filter by session + const props = (event as any)?.properties; + const eventSessionId = + props?.sessionID || + props?.info?.sessionID || + props?.info?.id || + props?.part?.sessionID; + + if (eventSessionId && eventSessionId !== session.opencodeSessionId) { + continue; } + + // Detect session completion and trigger webhook (fire-and-forget) + handleSessionCompletionEvent(event, session.id, session.opencodeSessionId!).catch( + () => {}, + ); + + await stream.writeSSE({ data: JSON.stringify(event) }); + } catch { + // Skip malformed events + } } } } catch { diff --git a/src/routes/tasks.ts b/src/routes/tasks.ts new file mode 100644 index 0000000..67c41dc --- /dev/null +++ b/src/routes/tasks.ts @@ -0,0 +1,440 @@ +/** + * Public Tasks API — programmatic access to coding sessions. + * + * All routes are authenticated via API key (Bearer token). + * This is the external-facing API for triggering coding sessions, + * sending follow-up messages, streaming events, and cleaning up. + * + * Endpoints: + * POST /api/v1/tasks — Create a new coding task + * GET /api/v1/tasks/:id — Get task status & details + * POST /api/v1/tasks/:id/messages — Send a follow-up message + * GET /api/v1/tasks/:id/events — SSE event stream + * DELETE /api/v1/tasks/:id — Delete task & destroy sandbox + */ +import { createRouter, sse } from '@agentuity/runtime'; +import { db } from '../db'; +import { chatSessions, workspaces, skills, sources, userSettings } from '../db/schema'; +import { eq, and } from 'drizzle-orm'; +import { randomUUID } from 'node:crypto'; +import { + createSandbox, + generateOpenCodeConfig, + serializeOpenCodeConfig, + getOpencodeClient, + removeOpencodeClient, + destroySandbox, +} from '../opencode'; +import type { SandboxContext } from '../opencode'; +import { decrypt } from '../lib/encryption'; +import { parseMetadata } from '../lib/parse-metadata'; + +const api = createRouter(); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const API_WORKSPACE_NAME = 'API Tasks'; + +/** + * Get or create the default "API Tasks" workspace for a user. + * API-created sessions are grouped under a single auto-managed workspace + * so external callers don't need to know about workspaces. + */ +async function getOrCreateApiWorkspace(userId: string) { + const existing = await db + .select() + .from(workspaces) + .where(and(eq(workspaces.organizationId, userId), eq(workspaces.name, API_WORKSPACE_NAME))) + .limit(1); + + if (existing[0]) return existing[0]; + + const [workspace] = await db + .insert(workspaces) + .values({ + organizationId: userId, + name: API_WORKSPACE_NAME, + description: 'Auto-created workspace for API-initiated tasks', + }) + .returning(); + + return workspace!; +} + +/** + * Look up a task (session) and verify the requesting user owns it. + */ +async function getOwnedTask(taskId: string, userId: string) { + const [session] = await db + .select() + .from(chatSessions) + .where(eq(chatSessions.id, taskId)); + + if (!session) return { error: 'Task not found', status: 404 as const }; + if (session.createdBy !== userId) return { error: 'Task not found', status: 404 as const }; + + return { session }; +} + +// --------------------------------------------------------------------------- +// POST /api/v1/tasks — create a new coding task +// --------------------------------------------------------------------------- +api.post('/', async (c) => { + const user = c.get('user')!; + + const body = await c.req + .json<{ + repoUrl?: string; + branch?: string; + prompt: string; + agent?: string; + model?: string; + webhookUrl?: string; + }>() + .catch(() => null); + + if (!body || !body.prompt?.trim()) { + return c.json({ error: 'A "prompt" field is required' }, 400); + } + + // Validate webhookUrl if provided + if (body.webhookUrl) { + try { + const url = new URL(body.webhookUrl); + if (!['http:', 'https:'].includes(url.protocol)) { + return c.json({ error: 'webhookUrl must use http or https' }, 400); + } + } catch { + return c.json({ error: 'webhookUrl is not a valid URL' }, 400); + } + } + + const workspace = await getOrCreateApiWorkspace(user.id); + const workspaceId = workspace.id; + + // Fetch workspace skills and sources for config + const [workspaceSkills, workspaceSources] = await Promise.all([ + db.select().from(skills).where(eq(skills.workspaceId, workspaceId)), + db.select().from(sources).where(eq(sources.workspaceId, workspaceId)), + ]); + + const opencodeConfig = generateOpenCodeConfig( + { model: body.model }, + workspaceSources.map((s) => ({ + name: s.name, + type: s.type, + config: (s.config || {}) as Record, + enabled: s.enabled ?? true, + })), + ); + + const enabledSkills = workspaceSkills.filter((s) => s.enabled ?? true); + const customSkills = enabledSkills + .filter((s) => s.type !== 'registry') + .map((s) => ({ name: s.name, content: s.content })); + const registrySkills = enabledSkills + .filter((s) => s.type === 'registry' && s.repo) + .map((s) => ({ repo: s.repo as string, skillName: s.name })); + + const title = body.prompt.length > 60 ? body.prompt.slice(0, 57) + '...' : body.prompt; + + const sessionId = randomUUID(); + const metadata: Record = { + repoUrl: body.repoUrl, + branch: body.branch, + source: 'api', + }; + if (body.webhookUrl) { + metadata.webhookUrl = body.webhookUrl; + } + + const insertedRows = await db + .insert(chatSessions) + .values({ + id: sessionId, + workspaceId, + createdBy: user.id, + status: 'creating', + title, + agent: body.agent ?? null, + model: body.model ?? null, + metadata, + }) + .onConflictDoNothing() + .returning(); + + let session = insertedRows[0]; + if (!session) { + const [existing] = await db + .select() + .from(chatSessions) + .where(eq(chatSessions.id, sessionId)) + .limit(1); + session = existing; + } + + // Capture context variables before async block + const sandbox = c.var.sandbox; + const logger = c.var.logger; + + // Background: create sandbox, establish OpenCode session, send prompt + (async () => { + try { + const sandboxCtx: SandboxContext = { sandbox, logger }; + let githubToken: string | undefined; + try { + const [settings] = await db + .select() + .from(userSettings) + .where(eq(userSettings.userId, user.id)); + if (settings?.githubPat) { + githubToken = decrypt(settings.githubPat); + } + } catch { + logger.warn('Failed to load GitHub token for sandbox', { userId: user.id }); + } + + const { sandboxId, sandboxUrl } = await createSandbox(sandboxCtx, { + repoUrl: body.repoUrl, + branch: body.branch, + opencodeConfigJson: serializeOpenCodeConfig(opencodeConfig), + customSkills, + registrySkills, + githubToken, + }); + + const client = getOpencodeClient(sandboxId, sandboxUrl); + let opencodeSessionId: string | null = null; + for (let attempt = 1; attempt <= 5; attempt++) { + try { + const opencodeSession = await client.session.create({ body: {} }); + opencodeSessionId = + (opencodeSession as any)?.data?.id || (opencodeSession as any)?.id || null; + if (opencodeSessionId) break; + logger.warn(`session.create attempt ${attempt}: no session ID returned`); + } catch (err) { + logger.warn(`session.create attempt ${attempt} failed`, { error: err }); + } + if (attempt < 5) await new Promise((r) => setTimeout(r, 2000)); + } + + const newStatus = opencodeSessionId ? 'active' : 'creating'; + + await db + .update(chatSessions) + .set({ + sandboxId, + sandboxUrl, + opencodeSessionId, + status: newStatus, + updatedAt: new Date(), + }) + .where(eq(chatSessions.id, session!.id)); + + // Send prompt + if (body.prompt && opencodeSessionId) { + try { + await client.session.promptAsync({ + path: { id: opencodeSessionId }, + body: { parts: [{ type: 'text', text: body.prompt }] }, + }); + } catch (err) { + logger.warn('Failed to send initial prompt', { error: err }); + } + } + } catch (error) { + await db + .update(chatSessions) + .set({ + status: 'error', + metadata: { ...metadata, error: String(error) }, + updatedAt: new Date(), + }) + .where(eq(chatSessions.id, session!.id)); + } + })(); + + return c.json( + { + taskId: session!.id, + status: 'creating', + message: 'Task created. Poll GET /api/v1/tasks/:id or listen to SSE at GET /api/v1/tasks/:id/events.', + }, + 201, + ); +}); + +// --------------------------------------------------------------------------- +// GET /api/v1/tasks/:id — get task status & details +// --------------------------------------------------------------------------- +api.get('/:id', async (c) => { + const user = c.get('user')!; + const result = await getOwnedTask(c.req.param('id')!, user.id); + if ('error' in result) return c.json({ error: result.error }, result.status); + + const { session } = result; + const metadata = parseMetadata(session); + + return c.json({ + taskId: session.id, + status: session.status, + title: session.title, + repoUrl: metadata.repoUrl ?? null, + branch: metadata.branch ?? null, + prUrl: (metadata.pullRequest as any)?.url ?? null, + webhookUrl: metadata.webhookUrl ?? null, + agent: session.agent, + model: session.model, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/v1/tasks/:id/messages — send follow-up message +// --------------------------------------------------------------------------- +api.post('/:id/messages', async (c) => { + const user = c.get('user')!; + const result = await getOwnedTask(c.req.param('id')!, user.id); + if ('error' in result) return c.json({ error: result.error }, result.status); + + const { session } = result; + if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { + return c.json({ error: 'Task sandbox not ready' }, 503); + } + + const body = await c.req.json<{ text: string; model?: string }>().catch(() => null); + if (!body || !body.text?.trim()) { + return c.json({ error: 'A "text" field is required' }, 400); + } + + const client = getOpencodeClient(session.sandboxId, session.sandboxUrl); + + try { + const [providerID, modelID] = body.model ? body.model.split('/') : []; + await client.session.promptAsync({ + path: { id: session.opencodeSessionId }, + body: { + parts: [{ type: 'text' as const, text: body.text }], + ...(providerID && modelID ? { model: { providerID, modelID } } : {}), + }, + }); + return c.json({ success: true }); + } catch (error) { + return c.json({ error: 'Failed to send message', details: String(error) }, 500); + } +}); + +// --------------------------------------------------------------------------- +// GET /api/v1/tasks/:id/events — SSE event stream +// --------------------------------------------------------------------------- +api.get( + '/:id/events', + sse(async (c, stream) => { + const user = c.get('user')!; + const result = await getOwnedTask(c.req.param('id')!, user.id); + if ('error' in result) { + await stream.writeSSE({ + data: JSON.stringify({ type: 'error', message: result.error }), + }); + stream.close(); + return; + } + + const { session } = result; + if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { + await stream.writeSSE({ + data: JSON.stringify({ type: 'error', message: 'Task sandbox not ready' }), + }); + stream.close(); + return; + } + + try { + const eventResponse = await fetch(`${session.sandboxUrl}/event`); + if (!eventResponse.ok || !eventResponse.body) { + await stream.writeSSE({ + data: JSON.stringify({ type: 'error', message: 'No event stream' }), + }); + stream.close(); + return; + } + + const reader = eventResponse.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const jsonStr = line.slice(6).trim(); + if (!jsonStr) continue; + + try { + const event = JSON.parse(jsonStr); + const props = (event as any)?.properties; + const eventSessionId = + props?.sessionID || + props?.info?.sessionID || + props?.info?.id || + props?.part?.sessionID; + + if (eventSessionId && eventSessionId !== session.opencodeSessionId) { + continue; + } + + await stream.writeSSE({ data: JSON.stringify(event) }); + } catch { + // Skip malformed events + } + } + } + } catch { + // Stream ended + } finally { + reader.releaseLock(); + } + } catch (error) { + await stream.writeSSE({ + data: JSON.stringify({ type: 'error', message: String(error) }), + }); + } + + stream.close(); + }), +); + +// --------------------------------------------------------------------------- +// DELETE /api/v1/tasks/:id — delete task & destroy sandbox +// --------------------------------------------------------------------------- +api.delete('/:id', async (c) => { + const user = c.get('user')!; + const result = await getOwnedTask(c.req.param('id')!, user.id); + if ('error' in result) return c.json({ error: result.error }, result.status); + + const { session } = result; + + if (session.sandboxId) { + const sandboxCtx: SandboxContext = { + sandbox: c.var.sandbox, + logger: c.var.logger, + }; + await destroySandbox(sandboxCtx, session.sandboxId); + removeOpencodeClient(session.sandboxId); + } + + await db.delete(chatSessions).where(eq(chatSessions.id, session.id)); + return c.json({ success: true, message: 'Task deleted and sandbox destroyed' }); +}); + +export default api; From a3f8a74e89c37cacc6da92bdbb0b70cc77184724 Mon Sep 17 00:00:00 2001 From: Pedro Enrique Date: Wed, 11 Feb 2026 12:13:15 +0100 Subject: [PATCH 2/8] feat: add API key management UI to settings page --- src/web/components/pages/SettingsPage.tsx | 10 + .../components/settings/ApiKeySettings.tsx | 391 ++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 src/web/components/settings/ApiKeySettings.tsx diff --git a/src/web/components/pages/SettingsPage.tsx b/src/web/components/pages/SettingsPage.tsx index c4e66ae..36b8b5d 100644 --- a/src/web/components/pages/SettingsPage.tsx +++ b/src/web/components/pages/SettingsPage.tsx @@ -3,6 +3,7 @@ import { Settings, Save, AlertTriangle, Plus, Trash2 } from 'lucide-react'; import { Button } from '../ui/button'; import { Card } from '../ui/card'; import { GitHubSettings } from '../settings/GitHubSettings'; +import { ApiKeySettings } from '../settings/ApiKeySettings'; interface Workspace { id: string; @@ -327,6 +328,15 @@ export function SettingsPage({ workspaceId, onWorkspaceChange }: SettingsPagePro + {/* API Keys */} + +

API Keys

+

+ Manage API keys for programmatic access to the Tasks API. +

+ +
+ {/* Danger Zone */}

diff --git a/src/web/components/settings/ApiKeySettings.tsx b/src/web/components/settings/ApiKeySettings.tsx new file mode 100644 index 0000000..f6c866d --- /dev/null +++ b/src/web/components/settings/ApiKeySettings.tsx @@ -0,0 +1,391 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Copy, Check, Plus, Trash2, Key, Eye, EyeOff } from 'lucide-react'; +import { Button } from '../ui/button'; +import { Badge } from '../ui/badge'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '../ui/dialog'; + +/** Better Auth API key endpoints live under /api/auth/api-key/* */ +const API_BASE = '/api/auth/api-key'; + +interface ApiKey { + id: string; + name: string | null; + start: string | null; + prefix: string | null; + createdAt: string; + expiresAt: string | null; + lastUsedAt: string | null; + enabled: boolean; +} + +function formatDate(date: Date | string | null) { + if (!date) return 'Never'; + const d = typeof date === 'string' ? new Date(date) : date; + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function formatRelative(date: Date | string | null) { + if (!date) return null; + const d = typeof date === 'string' ? new Date(date) : date; + const now = Date.now(); + const diff = now - d.getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return formatDate(date); +} + +export function ApiKeySettings() { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Create dialog state + const [showCreate, setShowCreate] = useState(false); + const [newKeyName, setNewKeyName] = useState(''); + const [creating, setCreating] = useState(false); + + // Newly created key (shown once) + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [copied, setCopied] = useState(false); + + // Delete state + const [deletingId, setDeletingId] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + // Visibility toggle for key prefixes + const [showPrefixes, setShowPrefixes] = useState(false); + + const fetchKeys = useCallback(async () => { + try { + const res = await fetch(`${API_BASE}/list-api-keys`, { + method: 'GET', + credentials: 'include', + }); + if (!res.ok) { + setError('Failed to load API keys'); + return; + } + const data = await res.json(); + setKeys(Array.isArray(data) ? data : []); + } catch { + setError('Failed to load API keys'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchKeys(); + }, [fetchKeys]); + + const handleCreate = async () => { + if (!newKeyName.trim()) return; + setCreating(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/create-api-key`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newKeyName.trim() }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.message || data.error || 'Failed to create API key'); + setCreating(false); + return; + } + const data = await res.json(); + const key = data?.key; + if (key) { + setNewlyCreatedKey(key); + } + setNewKeyName(''); + setShowCreate(false); + await fetchKeys(); + } catch { + setError('Failed to create API key'); + } finally { + setCreating(false); + } + }; + + const handleDelete = async (id: string) => { + setDeletingId(id); + setError(null); + try { + const res = await fetch(`${API_BASE}/delete-api-key`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keyId: id }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.message || data.error || 'Failed to delete API key'); + } else { + setConfirmDeleteId(null); + await fetchKeys(); + } + } catch { + setError('Failed to delete API key'); + } finally { + setDeletingId(null); + } + }; + + const handleCopy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback: select text + } + }; + + const handleDismissNewKey = () => { + setNewlyCreatedKey(null); + setCopied(false); + }; + + if (loading) { + return ( +
Loading API keys...
+ ); + } + + return ( +
+ {/* Newly created key banner */} + {newlyCreatedKey && ( +
+
+
+

+ API key created +

+

+ Copy this key now. You won't be able to see it again. +

+
+
+
+ + {newlyCreatedKey} + + +
+ +
+ )} + + {/* Key list */} + {keys.length === 0 ? ( +
+ +

No API keys yet

+

+ Create an API key to use the Tasks API programmatically. +

+ +
+ ) : ( + <> +
+
+ +
+ +
+ +
+ {keys.map((key) => { + const isConfirming = confirmDeleteId === key.id; + const isDeleting = deletingId === key.id; + + return ( +
+
+
+
+ + {key.name || 'Unnamed key'} + + {!key.enabled && ( + + Disabled + + )} +
+
+ {showPrefixes && (key.start || key.prefix) && ( + + {key.start || key.prefix}... + + )} + Created {formatDate(key.createdAt)} + {key.expiresAt && ( + + Expires {formatDate(key.expiresAt)} + + )} + {key.lastUsedAt && ( + + Last used {formatRelative(key.lastUsedAt)} + + )} +
+
+ +
+ {isConfirming ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ ); + })} +
+ + )} + + {error && ( +
+ {error} +
+ )} + + {/* Create dialog */} + + + + Create API Key + + Create a new key to authenticate with the Tasks API. The key will only be shown once after creation. + + +
+
+ + setNewKeyName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && newKeyName.trim()) handleCreate(); + }} + className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] focus:ring-offset-1" + placeholder="e.g. CI Pipeline, Slack Bot, My Script" + autoFocus + /> +
+
+ + + + +
+
+ +

+ Use API keys to authenticate with the{' '} + + POST /api/v1/tasks + {' '} + endpoint. Pass the key as{' '} + + Authorization: Bearer <key> + +

+
+ ); +} From 46c0c80af6de1b5b93f54700fa09162f2a5f71af Mon Sep 17 00:00:00 2001 From: Pedro Enrique Date: Wed, 11 Feb 2026 12:19:34 +0100 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20race=20conditions,=20error=20handling,=20webhook=20?= =?UTF-8?q?in=20tasks=20SSE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/chat.ts | 11 +++---- src/routes/tasks.ts | 29 +++++++++++++++++-- .../components/settings/ApiKeySettings.tsx | 10 +++++-- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 2316558..7e83443 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -7,7 +7,7 @@ import { createRouter, sse } from '@agentuity/runtime'; import { db } from '../db'; import { chatSessions } from '../db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { getOpencodeClient } from '../opencode'; import { sandboxListFiles, sandboxReadFile, sandboxExecute, sandboxWriteFiles } from '@agentuity/server'; import { normalizeSandboxPath } from '../lib/path-utils'; @@ -83,7 +83,7 @@ const api = createRouter(); * Detect whether an SSE event signals session completion (session.idle). * When detected, update DB status to 'completed' and fire webhook if configured. */ -async function handleSessionCompletionEvent( +export async function handleSessionCompletionEvent( event: any, sessionId: string, opencodeSessionId: string, @@ -108,12 +108,13 @@ async function handleSessionCompletionEvent( const metadata = parseMetadata(current); if (metadata.source !== 'api') return; + if (current.status === 'completed') return; // Already processed — prevent duplicate webhooks - // Update session status to 'completed' + // Update session status to 'completed' (only if still active, to win the race) const [updated] = await db .update(chatSessions) .set({ status: 'completed', updatedAt: new Date() }) - .where(eq(chatSessions.id, sessionId)) + .where(and(eq(chatSessions.id, sessionId), eq(chatSessions.status, 'active'))) .returning(); if (!updated) return; @@ -347,7 +348,7 @@ api.get( // Detect session completion and trigger webhook (fire-and-forget) handleSessionCompletionEvent(event, session.id, session.opencodeSessionId!).catch( - () => {}, + (err) => console.error('[webhook] Completion event handling failed:', err), ); await stream.writeSSE({ data: JSON.stringify(event) }); diff --git a/src/routes/tasks.ts b/src/routes/tasks.ts index 67c41dc..a1ea571 100644 --- a/src/routes/tasks.ts +++ b/src/routes/tasks.ts @@ -28,6 +28,7 @@ import { import type { SandboxContext } from '../opencode'; import { decrypt } from '../lib/encryption'; import { parseMetadata } from '../lib/parse-metadata'; +import { handleSessionCompletionEvent } from './chat'; const api = createRouter(); @@ -58,9 +59,18 @@ async function getOrCreateApiWorkspace(userId: string) { name: API_WORKSPACE_NAME, description: 'Auto-created workspace for API-initiated tasks', }) + .onConflictDoNothing() .returning(); - return workspace!; + if (workspace) return workspace; + + // Re-fetch if conflict occurred (concurrent creation race) + const [created] = await db + .select() + .from(workspaces) + .where(and(eq(workspaces.organizationId, userId), eq(workspaces.name, API_WORKSPACE_NAME))) + .limit(1); + return created!; } /** @@ -174,6 +184,9 @@ api.post('/', async (c) => { .limit(1); session = existing; } + if (!session) { + return c.json({ error: 'Failed to create task' }, 500); + } // Capture context variables before async block const sandbox = c.var.sandbox; @@ -393,6 +406,11 @@ api.get( continue; } + // Detect session completion and trigger webhook (fire-and-forget) + handleSessionCompletionEvent(event, session.id, session.opencodeSessionId!).catch( + (err) => console.error('[webhook] Completion event handling failed:', err), + ); + await stream.writeSSE({ data: JSON.stringify(event) }); } catch { // Skip malformed events @@ -429,7 +447,14 @@ api.delete('/:id', async (c) => { sandbox: c.var.sandbox, logger: c.var.logger, }; - await destroySandbox(sandboxCtx, session.sandboxId); + try { + await destroySandbox(sandboxCtx, session.sandboxId); + } catch (err) { + c.var.logger.warn('Failed to destroy sandbox, proceeding with task deletion', { + sandboxId: session.sandboxId, + error: err, + }); + } removeOpencodeClient(session.sandboxId); } diff --git a/src/web/components/settings/ApiKeySettings.tsx b/src/web/components/settings/ApiKeySettings.tsx index f6c866d..5bd7181 100644 --- a/src/web/components/settings/ApiKeySettings.tsx +++ b/src/web/components/settings/ApiKeySettings.tsx @@ -111,6 +111,8 @@ export function ApiKeySettings() { const key = data?.key; if (key) { setNewlyCreatedKey(key); + } else { + setError('Key was created but could not be retrieved. Check your keys list.'); } setNewKeyName(''); setShowCreate(false); @@ -152,7 +154,9 @@ export function ApiKeySettings() { setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { - // Fallback: select text + // Clipboard API unavailable — the code element has select-all styling + // so users can manually select and copy + console.warn('Clipboard access denied, user can manually select and copy'); } }; @@ -206,8 +210,8 @@ export function ApiKeySettings() { )} {/* Key list */} - {keys.length === 0 ? ( -
+ {keys.length === 0 && !error ? ( +

No API keys yet

From 48ec784a74e49e882bd79dc2d35f1302d5ed8064 Mon Sep 17 00:00:00 2001 From: Pedro Enrique Date: Wed, 11 Feb 2026 12:31:23 +0100 Subject: [PATCH 4/8] fix: mark failed task creation as error, clean up copy timeout on unmount --- src/routes/tasks.ts | 6 +++++- src/web/components/settings/ApiKeySettings.tsx | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/routes/tasks.ts b/src/routes/tasks.ts index a1ea571..510dd5f 100644 --- a/src/routes/tasks.ts +++ b/src/routes/tasks.ts @@ -233,7 +233,10 @@ api.post('/', async (c) => { if (attempt < 5) await new Promise((r) => setTimeout(r, 2000)); } - const newStatus = opencodeSessionId ? 'active' : 'creating'; + const newStatus = opencodeSessionId ? 'active' : 'error'; + const nextMetadata = opencodeSessionId + ? metadata + : { ...metadata, error: 'Failed to create OpenCode session after 5 attempts' }; await db .update(chatSessions) @@ -242,6 +245,7 @@ api.post('/', async (c) => { sandboxUrl, opencodeSessionId, status: newStatus, + metadata: nextMetadata, updatedAt: new Date(), }) .where(eq(chatSessions.id, session!.id)); diff --git a/src/web/components/settings/ApiKeySettings.tsx b/src/web/components/settings/ApiKeySettings.tsx index 5bd7181..dd224f8 100644 --- a/src/web/components/settings/ApiKeySettings.tsx +++ b/src/web/components/settings/ApiKeySettings.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Copy, Check, Plus, Trash2, Key, Eye, EyeOff } from 'lucide-react'; import { Button } from '../ui/button'; import { Badge } from '../ui/badge'; @@ -67,6 +67,14 @@ export function ApiKeySettings() { // Visibility toggle for key prefixes const [showPrefixes, setShowPrefixes] = useState(false); + // Cleanup timeout on unmount to avoid React state update warnings + const copyTimeoutRef = useRef | null>(null); + useEffect(() => { + return () => { + if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); + }; + }, []); + const fetchKeys = useCallback(async () => { try { const res = await fetch(`${API_BASE}/list-api-keys`, { @@ -152,7 +160,7 @@ export function ApiKeySettings() { try { await navigator.clipboard.writeText(text); setCopied(true); - setTimeout(() => setCopied(false), 2000); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); } catch { // Clipboard API unavailable — the code element has select-all styling // so users can manually select and copy From ab544020232720b558047df7ce2041dc332433ad Mon Sep 17 00:00:00 2001 From: Pedro Enrique Date: Wed, 11 Feb 2026 12:36:43 +0100 Subject: [PATCH 5/8] fix: SSE fetch timeout, clear stale errors, validate model format --- src/routes/tasks.ts | 13 +++++++++++-- src/web/components/settings/ApiKeySettings.tsx | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/routes/tasks.ts b/src/routes/tasks.ts index 510dd5f..757c995 100644 --- a/src/routes/tasks.ts +++ b/src/routes/tasks.ts @@ -330,7 +330,14 @@ api.post('/:id/messages', async (c) => { const client = getOpencodeClient(session.sandboxId, session.sandboxUrl); try { - const [providerID, modelID] = body.model ? body.model.split('/') : []; + let providerID: string | undefined; + let modelID: string | undefined; + if (body.model) { + if (!body.model.includes('/')) { + return c.json({ error: 'model must be in format "provider/model"' }, 400); + } + [providerID, modelID] = body.model.split('/'); + } await client.session.promptAsync({ path: { id: session.opencodeSessionId }, body: { @@ -370,7 +377,9 @@ api.get( } try { - const eventResponse = await fetch(`${session.sandboxUrl}/event`); + const eventResponse = await fetch(`${session.sandboxUrl}/event`, { + signal: AbortSignal.timeout(30_000), // 30s connection timeout + }); if (!eventResponse.ok || !eventResponse.body) { await stream.writeSSE({ data: JSON.stringify({ type: 'error', message: 'No event stream' }), diff --git a/src/web/components/settings/ApiKeySettings.tsx b/src/web/components/settings/ApiKeySettings.tsx index dd224f8..1b522cd 100644 --- a/src/web/components/settings/ApiKeySettings.tsx +++ b/src/web/components/settings/ApiKeySettings.tsx @@ -87,6 +87,7 @@ export function ApiKeySettings() { } const data = await res.json(); setKeys(Array.isArray(data) ? data : []); + setError(null); } catch { setError('Failed to load API keys'); } finally { From 69d9ecafc31bd4c330c7814d4604306853ccc3ae Mon Sep 17 00:00:00 2001 From: Pedro Enrique Date: Wed, 11 Feb 2026 12:43:02 +0100 Subject: [PATCH 6/8] fix: SSRF protection for webhookUrl, connection-only SSE timeout, preserve model path segments --- src/routes/tasks.ts | 105 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/src/routes/tasks.ts b/src/routes/tasks.ts index 757c995..663639c 100644 --- a/src/routes/tasks.ts +++ b/src/routes/tasks.ts @@ -17,6 +17,7 @@ import { db } from '../db'; import { chatSessions, workspaces, skills, sources, userSettings } from '../db/schema'; import { eq, and } from 'drizzle-orm'; import { randomUUID } from 'node:crypto'; +import { lookup } from 'node:dns/promises'; import { createSandbox, generateOpenCodeConfig, @@ -38,6 +39,36 @@ const api = createRouter(); const API_WORKSPACE_NAME = 'API Tasks'; +/** + * Check whether an IP address is private, loopback, or link-local. + * Covers RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8, ::1), + * link-local (169.254/16, fe80::/10), and ULA (fc00::/7). + */ +function isPrivateIp(ip: string): boolean { + // IPv4 + const v4Parts = ip.split('.').map(Number); + if (v4Parts.length === 4 && v4Parts.every((n) => n >= 0 && n <= 255)) { + const a = v4Parts[0]!; + const b = v4Parts[1]!; + if (a === 10) return true; // 10.0.0.0/8 + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 169 && b === 254) return true; // 169.254.0.0/16 + if (a === 127) return true; // 127.0.0.0/8 + if (a === 0) return true; // 0.0.0.0/8 + return false; + } + + // IPv6 + const normalized = ip.toLowerCase().replace(/^\[|\]$/g, ''); + if (normalized === '::1') return true; // loopback + if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; // fc00::/7 ULA + if (normalized.startsWith('fe80')) return true; // fe80::/10 link-local + if (normalized === '::') return true; // unspecified + + return false; +} + /** * Get or create the default "API Tasks" workspace for a user. * API-created sessions are grouped under a single auto-managed workspace @@ -109,16 +140,48 @@ api.post('/', async (c) => { return c.json({ error: 'A "prompt" field is required' }, 400); } - // Validate webhookUrl if provided + // Validate webhookUrl if provided (with SSRF protection) if (body.webhookUrl) { + let parsedUrl: URL; try { - const url = new URL(body.webhookUrl); - if (!['http:', 'https:'].includes(url.protocol)) { - return c.json({ error: 'webhookUrl must use http or https' }, 400); - } + parsedUrl = new URL(body.webhookUrl); } catch { return c.json({ error: 'webhookUrl is not a valid URL' }, 400); } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return c.json({ error: 'webhookUrl must use http or https' }, 400); + } + + // Block obvious private/loopback hostnames + const hostname = parsedUrl.hostname.toLowerCase(); + const blockedHosts = ['localhost', '127.0.0.1', '::1', '0.0.0.0', '[::1]']; + if (blockedHosts.includes(hostname)) { + return c.json({ error: 'webhookUrl must not point to a loopback address' }, 400); + } + + // Check if hostname is an IP literal and validate it directly + const isIpV4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); + const isIpV6 = hostname.startsWith('[') && hostname.endsWith(']'); + const ipToCheck = isIpV4 ? hostname : isIpV6 ? hostname.slice(1, -1) : null; + + if (ipToCheck) { + if (isPrivateIp(ipToCheck)) { + return c.json({ error: 'webhookUrl must not point to a private or loopback address' }, 400); + } + } else { + // Resolve hostname and check all resulting IPs + try { + const results = await lookup(hostname, { all: true }); + for (const result of results) { + if (isPrivateIp(result.address)) { + return c.json({ error: 'webhookUrl resolves to a private or loopback address' }, 400); + } + } + } catch { + return c.json({ error: 'webhookUrl hostname could not be resolved' }, 400); + } + } } const workspace = await getOrCreateApiWorkspace(user.id); @@ -333,10 +396,12 @@ api.post('/:id/messages', async (c) => { let providerID: string | undefined; let modelID: string | undefined; if (body.model) { - if (!body.model.includes('/')) { + const slashIdx = body.model.indexOf('/'); + if (slashIdx === -1) { return c.json({ error: 'model must be in format "provider/model"' }, 400); } - [providerID, modelID] = body.model.split('/'); + providerID = body.model.slice(0, slashIdx); + modelID = body.model.slice(slashIdx + 1); } await client.session.promptAsync({ path: { id: session.opencodeSessionId }, @@ -377,9 +442,29 @@ api.get( } try { - const eventResponse = await fetch(`${session.sandboxUrl}/event`, { - signal: AbortSignal.timeout(30_000), // 30s connection timeout - }); + // Use an AbortController for the initial connection only — + // clear the timeout once connected so the long-lived SSE stream isn't killed. + const connectController = new AbortController(); + const connectTimeout = setTimeout(() => connectController.abort(), 30_000); + + let eventResponse: Response; + try { + eventResponse = await fetch(`${session.sandboxUrl}/event`, { + signal: connectController.signal, + }); + } catch (err) { + clearTimeout(connectTimeout); + const message = connectController.signal.aborted + ? 'Sandbox event stream connection timed out' + : String(err); + await stream.writeSSE({ + data: JSON.stringify({ type: 'error', message }), + }); + stream.close(); + return; + } + clearTimeout(connectTimeout); + if (!eventResponse.ok || !eventResponse.body) { await stream.writeSSE({ data: JSON.stringify({ type: 'error', message: 'No event stream' }), From e02cb2c9f02fa64258a8ec6ec58a4ddd891c94c0 Mon Sep 17 00:00:00 2001 From: Pedro Enrique Date: Wed, 11 Feb 2026 13:01:16 +0100 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20remove=20redundant=20ApiKeySetting?= =?UTF-8?q?s=20=E2=80=94=20Better=20Auth=20profile=20already=20has=20API?= =?UTF-8?q?=20key=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web/components/pages/SettingsPage.tsx | 10 - .../components/settings/ApiKeySettings.tsx | 404 ------------------ 2 files changed, 414 deletions(-) delete mode 100644 src/web/components/settings/ApiKeySettings.tsx diff --git a/src/web/components/pages/SettingsPage.tsx b/src/web/components/pages/SettingsPage.tsx index 36b8b5d..c4e66ae 100644 --- a/src/web/components/pages/SettingsPage.tsx +++ b/src/web/components/pages/SettingsPage.tsx @@ -3,7 +3,6 @@ import { Settings, Save, AlertTriangle, Plus, Trash2 } from 'lucide-react'; import { Button } from '../ui/button'; import { Card } from '../ui/card'; import { GitHubSettings } from '../settings/GitHubSettings'; -import { ApiKeySettings } from '../settings/ApiKeySettings'; interface Workspace { id: string; @@ -328,15 +327,6 @@ export function SettingsPage({ workspaceId, onWorkspaceChange }: SettingsPagePro - {/* API Keys */} - -

API Keys

-

- Manage API keys for programmatic access to the Tasks API. -

- - - {/* Danger Zone */}

diff --git a/src/web/components/settings/ApiKeySettings.tsx b/src/web/components/settings/ApiKeySettings.tsx deleted file mode 100644 index 1b522cd..0000000 --- a/src/web/components/settings/ApiKeySettings.tsx +++ /dev/null @@ -1,404 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Copy, Check, Plus, Trash2, Key, Eye, EyeOff } from 'lucide-react'; -import { Button } from '../ui/button'; -import { Badge } from '../ui/badge'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from '../ui/dialog'; - -/** Better Auth API key endpoints live under /api/auth/api-key/* */ -const API_BASE = '/api/auth/api-key'; - -interface ApiKey { - id: string; - name: string | null; - start: string | null; - prefix: string | null; - createdAt: string; - expiresAt: string | null; - lastUsedAt: string | null; - enabled: boolean; -} - -function formatDate(date: Date | string | null) { - if (!date) return 'Never'; - const d = typeof date === 'string' ? new Date(date) : date; - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); -} - -function formatRelative(date: Date | string | null) { - if (!date) return null; - const d = typeof date === 'string' ? new Date(date) : date; - const now = Date.now(); - const diff = now - d.getTime(); - const minutes = Math.floor(diff / 60000); - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - return formatDate(date); -} - -export function ApiKeySettings() { - const [keys, setKeys] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Create dialog state - const [showCreate, setShowCreate] = useState(false); - const [newKeyName, setNewKeyName] = useState(''); - const [creating, setCreating] = useState(false); - - // Newly created key (shown once) - const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); - const [copied, setCopied] = useState(false); - - // Delete state - const [deletingId, setDeletingId] = useState(null); - const [confirmDeleteId, setConfirmDeleteId] = useState(null); - - // Visibility toggle for key prefixes - const [showPrefixes, setShowPrefixes] = useState(false); - - // Cleanup timeout on unmount to avoid React state update warnings - const copyTimeoutRef = useRef | null>(null); - useEffect(() => { - return () => { - if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); - }; - }, []); - - const fetchKeys = useCallback(async () => { - try { - const res = await fetch(`${API_BASE}/list-api-keys`, { - method: 'GET', - credentials: 'include', - }); - if (!res.ok) { - setError('Failed to load API keys'); - return; - } - const data = await res.json(); - setKeys(Array.isArray(data) ? data : []); - setError(null); - } catch { - setError('Failed to load API keys'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchKeys(); - }, [fetchKeys]); - - const handleCreate = async () => { - if (!newKeyName.trim()) return; - setCreating(true); - setError(null); - try { - const res = await fetch(`${API_BASE}/create-api-key`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newKeyName.trim() }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - setError(data.message || data.error || 'Failed to create API key'); - setCreating(false); - return; - } - const data = await res.json(); - const key = data?.key; - if (key) { - setNewlyCreatedKey(key); - } else { - setError('Key was created but could not be retrieved. Check your keys list.'); - } - setNewKeyName(''); - setShowCreate(false); - await fetchKeys(); - } catch { - setError('Failed to create API key'); - } finally { - setCreating(false); - } - }; - - const handleDelete = async (id: string) => { - setDeletingId(id); - setError(null); - try { - const res = await fetch(`${API_BASE}/delete-api-key`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ keyId: id }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - setError(data.message || data.error || 'Failed to delete API key'); - } else { - setConfirmDeleteId(null); - await fetchKeys(); - } - } catch { - setError('Failed to delete API key'); - } finally { - setDeletingId(null); - } - }; - - const handleCopy = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - setCopied(true); - copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); - } catch { - // Clipboard API unavailable — the code element has select-all styling - // so users can manually select and copy - console.warn('Clipboard access denied, user can manually select and copy'); - } - }; - - const handleDismissNewKey = () => { - setNewlyCreatedKey(null); - setCopied(false); - }; - - if (loading) { - return ( -
Loading API keys...
- ); - } - - return ( -
- {/* Newly created key banner */} - {newlyCreatedKey && ( -
-
-
-

- API key created -

-

- Copy this key now. You won't be able to see it again. -

-
-
-
- - {newlyCreatedKey} - - -
- -
- )} - - {/* Key list */} - {keys.length === 0 && !error ? ( -
- -

No API keys yet

-

- Create an API key to use the Tasks API programmatically. -

- -
- ) : ( - <> -
-
- -
- -
- -
- {keys.map((key) => { - const isConfirming = confirmDeleteId === key.id; - const isDeleting = deletingId === key.id; - - return ( -
-
-
-
- - {key.name || 'Unnamed key'} - - {!key.enabled && ( - - Disabled - - )} -
-
- {showPrefixes && (key.start || key.prefix) && ( - - {key.start || key.prefix}... - - )} - Created {formatDate(key.createdAt)} - {key.expiresAt && ( - - Expires {formatDate(key.expiresAt)} - - )} - {key.lastUsedAt && ( - - Last used {formatRelative(key.lastUsedAt)} - - )} -
-
- -
- {isConfirming ? ( -
- - -
- ) : ( - - )} -
-
-
- ); - })} -
- - )} - - {error && ( -
- {error} -
- )} - - {/* Create dialog */} - - - - Create API Key - - Create a new key to authenticate with the Tasks API. The key will only be shown once after creation. - - -
-
- - setNewKeyName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && newKeyName.trim()) handleCreate(); - }} - className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] focus:ring-offset-1" - placeholder="e.g. CI Pipeline, Slack Bot, My Script" - autoFocus - /> -
-
- - - - -
-
- -

- Use API keys to authenticate with the{' '} - - POST /api/v1/tasks - {' '} - endpoint. Pass the key as{' '} - - Authorization: Bearer <key> - -

-
- ); -} From 01374781bdf41d1f9bbf6b2a3f3731afd2b48122 Mon Sep 17 00:00:00 2001 From: Pedro Enrique Date: Wed, 11 Feb 2026 13:04:14 +0100 Subject: [PATCH 8/8] fix: add background color to dialog in light mode --- src/web/styles.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/styles.css b/src/web/styles.css index fd3236d..b784c17 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -121,8 +121,8 @@ h1, h2, #logo { color-scheme: dark; } -/* Solid background for dialog content in dark mode */ -.dark [role="dialog"] { +/* Solid background for dialog content */ +[role="dialog"] { background-color: var(--card); color: var(--card-foreground); }