diff --git a/.env.example b/.env.example index 9fa4d43..ecd4ba3 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,4 @@ VITE_API_BASE_URL=http://localhost:8001 #production #VITE_API_BASE_URL=http://api.thehumanpatternlab.com VITE_ADMIN_DEV_BYPASS=true +TOKEN_PEPPER=your-super-secret-random-string-here \ No newline at end of file diff --git a/src/pages/admin/admin.routes.tsx b/src/pages/admin/admin.routes.tsx index ec277e5..8a30a90 100644 --- a/src/pages/admin/admin.routes.tsx +++ b/src/pages/admin/admin.routes.tsx @@ -11,6 +11,7 @@ import { AdminDashboardPage } from "@/pages/admin/pages/AdminDashboardPage"; import { AdminNotesPage } from "@/pages/admin/pages/AdminNotesPage"; import AdminApiDocsPage from "@/pages/admin/AdminApiDocsPage"; import { AdminTokensPage } from "@/pages/admin/pages/AdminTokensPage"; +import { AdminRelaysPage } from "@/pages/admin/pages/AdminRelaysPage"; function PageLoader() { return
Loading…
; @@ -50,6 +51,7 @@ export const adminRoutes = [ { path: "dashboard", element: }, { path: "notes", element: }, { path: "tokens", element: }, + { path: "relays", element: }, { path: "docs", element: }, { path: "*", element: }, ], diff --git a/src/pages/admin/layout/AdminNav.tsx b/src/pages/admin/layout/AdminNav.tsx index c61a1c5..87592d9 100644 --- a/src/pages/admin/layout/AdminNav.tsx +++ b/src/pages/admin/layout/AdminNav.tsx @@ -4,6 +4,7 @@ const NAV_ITEMS = [ { label: "Dashboard", path: "/admin/dashboard" }, { label: "Lab Notes", path: "/admin/notes" }, { label: "API Tokens", path: "/admin/tokens" }, + { label: "Relays", path: "/admin/relays" }, { label: "API Docs", path: "/admin/docs" }, // Future controls diff --git a/src/pages/admin/pages/AdminRelaysPage.tsx b/src/pages/admin/pages/AdminRelaysPage.tsx new file mode 100644 index 0000000..5893e58 --- /dev/null +++ b/src/pages/admin/pages/AdminRelaysPage.tsx @@ -0,0 +1,336 @@ +// src/pages/admin/pages/AdminRelaysPage.tsx +import { useEffect, useMemo, useState } from "react"; +import { apiBaseUrl } from "@/api/api"; + +type RelaySession = { + id: string; + voice: string; + created_at: string; + expires_at: string; + used: boolean; + used_at: string | null; +}; + +type LoadState = + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ready" }; + +const VOICES = ["lyric", "coda", "sage", "vesper"] as const; +const EXPIRY_OPTIONS = [ + { label: "1 hour", value: "1h" }, + { label: "2 hours", value: "2h" }, + { label: "6 hours", value: "6h" }, + { label: "24 hours", value: "24h" }, +] as const; + +function formatDate(iso: string | null) { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(); +} + +function isExpired(expiresAt: string): boolean { + return new Date(expiresAt) < new Date(); +} + +export function AdminRelaysPage() { + const API = apiBaseUrl; + const [relays, setRelays] = useState([]); + const [state, setState] = useState({ status: "loading" }); + + // Generation form + const [selectedVoice, setSelectedVoice] = useState("sage"); + const [selectedExpiry, setSelectedExpiry] = useState("1h"); + const [isGenerating, setIsGenerating] = useState(false); + const [generatedUrl, setGeneratedUrl] = useState(null); + + const listUrl = useMemo(() => `${API}/admin/relay/list`, [API]); + const generateUrl = useMemo(() => `${API}/admin/relay/generate`, [API]); + + const loadRelays = async () => { + setState({ status: "loading" }); + try { + const res = await fetch(listUrl, { + credentials: "include", + headers: { Accept: "application/json" }, + }); + + if (!res.ok) { + throw new Error(`Failed to load relays: ${res.status}`); + } + + const json = await res.json(); + setRelays(json.relays || []); + setState({ status: "ready" }); + } catch (err: any) { + setState({ status: "error", message: err.message }); + } + }; + + const handleGenerate = async () => { + setIsGenerating(true); + setGeneratedUrl(null); + + try { + const res = await fetch(generateUrl, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + voice: selectedVoice, + expires: selectedExpiry, + }), + }); + + if (!res.ok) { + throw new Error(`Failed to generate relay: ${res.status}`); + } + + const json = await res.json(); + const fullUrl = json.url.replace("http://localhost:3001", API); + setGeneratedUrl(fullUrl); + + // Reload the list + await loadRelays(); + } catch (err: any) { + alert(`Error: ${err.message}`); + } finally { + setIsGenerating(false); + } + }; + + const handleCopyUrl = () => { + if (generatedUrl) { + navigator.clipboard.writeText(generatedUrl); + alert("Relay URL copied to clipboard!"); + } + }; + + const handleRevoke = async (relayId: string) => { + if (!confirm(`Revoke relay ${relayId}?`)) return; + + try { + const res = await fetch(`${API}/admin/relay/revoke`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ relay_id: relayId }), + }); + + if (!res.ok) { + throw new Error(`Failed to revoke: ${res.status}`); + } + + await loadRelays(); + } catch (err: any) { + alert(`Error: ${err.message}`); + } + }; + + useEffect(() => { + loadRelays(); + }, [listUrl]); + + return ( +
+ {/* Header */} +
+

Relay Management

+

+ Generate temporary, single-use relay URLs for AI agents. +

+
+ + {/* Generate Section */} +
+

+ Generate New Relay +

+ +
+ {/* Voice Selection */} +
+ + +
+ + {/* Expiry Selection */} +
+ + +
+ + {/* Generate Button */} +
+ +
+
+ + {/* Generated URL Display */} + {generatedUrl && ( +
+
+ + ✓ Relay Generated + + +
+ + {generatedUrl} + +
+ )} +
+ + {/* Active Relays List */} +
+
+

Active Relays

+ +
+ + {state.status === "loading" && ( +
Loading relays...
+ )} + + {state.status === "error" && ( +
Error: {state.message}
+ )} + + {state.status === "ready" && relays.length === 0 && ( +
No active relays
+ )} + + {state.status === "ready" && relays.length > 0 && ( +
+ + + + + + + + + + + + {relays.map((relay) => { + const expired = isExpired(relay.expires_at); + const statusColor = relay.used + ? "text-slate-500" + : expired + ? "text-orange-400" + : "text-green-400"; + + return ( + + + + + + + + ); + })} + +
+ Relay ID + + Voice + + Expires + + Status + + Actions +
+ {relay.id.replace("relay_", "")} + + {relay.voice} + + {formatDate(relay.expires_at)} + + {relay.used + ? "Used" + : expired + ? "Expired" + : "Active"} + + {!relay.used && !expired && ( + + )} +
+
+ )} +
+ + {/* Info Box */} +
+

+ 🏛️ About Relay URLs +

+
    +
  • • Each relay URL is single-use and time-limited
  • +
  • • Hand the URL to an AI agent (like ChatGPT)
  • +
  • • The agent can POST to create a Lab Note without managing bearer tokens
  • +
  • • After use, the relay automatically closes
  • +
+
+
+ ); +}