From 80c8e26fd1e47038193f9856107c30e392e53aea Mon Sep 17 00:00:00 2001 From: ZenaBot Date: Sat, 28 Feb 2026 00:28:02 +1100 Subject: [PATCH 01/18] docs: add developer portal design document Co-Authored-By: Claude Opus 4.6 --- .../2026-02-28-developer-portal-design.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/plans/2026-02-28-developer-portal-design.md diff --git a/docs/plans/2026-02-28-developer-portal-design.md b/docs/plans/2026-02-28-developer-portal-design.md new file mode 100644 index 0000000..ee5cacd --- /dev/null +++ b/docs/plans/2026-02-28-developer-portal-design.md @@ -0,0 +1,185 @@ +# Developer Portal Design + +**Date:** 2026-02-28 +**Status:** Approved + +## Overview + +Add a Developer Portal to the existing FlowScan frontend (`frontend/`). Developers can sign up via Email Magic Link, manage API keys, webhook endpoints, subscriptions, and view delivery logs — all within the Nothing Phone aesthetic. + +## Architecture + +Integrated into `frontend/` using TanStack Router + Shadcn/UI. New routes under `/developer/*`. Auth state managed via `AuthContext` with JWT in localStorage. + +``` +frontend/ + app/ + routes/ + developer/ + login.tsx — Magic Link login (email input → send link) + callback.tsx — Magic Link callback (extract token from URL) + index.tsx — Dashboard overview + keys.tsx — API Key management + endpoints.tsx — Webhook endpoint management + subscriptions.tsx — Subscription management + logs.tsx — Delivery log viewer + contexts/ + AuthContext.tsx — JWT state, login/logout, auto-refresh + lib/ + webhookApi.ts — All webhook + GoTrue API calls + components/ + developer/ + ProtectedRoute.tsx — Redirect to login if unauthenticated + DeveloperLayout.tsx— Sidebar nav + user header +``` + +## Pages + +### /developer/login +- Email input field + "Send Magic Link" button +- On submit: POST to GoTrue `/magiclink` endpoint +- Show "Check your email" confirmation message +- Fallback: email + password form (while SMTP not configured) + +### /developer/callback +- Extracts `access_token` and `refresh_token` from URL hash fragment +- Stores in localStorage via AuthContext +- Redirects to `/developer` + +### /developer (Dashboard) +- Overview cards: API Keys count, Endpoints count, Active Subscriptions count +- Recent delivery logs (last 5) +- Quick actions: "Create API Key", "Add Endpoint", "New Subscription" + +### /developer/keys +- Table: key_prefix, name, created_at, last_used, status +- "Create Key" button → modal with name input → shows plaintext key once +- Delete with confirmation + +### /developer/endpoints +- Table: URL, description, status, created_at +- "Add Endpoint" button → form with URL + description +- Edit/delete actions + +### /developer/subscriptions +- Table: event_type, endpoint URL, conditions (JSON), enabled status +- "New Subscription" button → form: + - Select event_type from dropdown (10 types) + - Select endpoint from existing endpoints + - Conditions editor (key-value pairs, type-specific) +- Toggle enable/disable +- Delete with confirmation + +### /developer/logs +- Table: timestamp, event_type, endpoint, status_code, payload preview +- Pagination (50 per page) +- Filter by event_type and status_code range + +## Auth Flow + +``` +Email input → POST GoTrue /magiclink + → User receives email with link + → Link: https://flowindex.io/developer/callback#access_token=xxx&refresh_token=yyy + → callback.tsx extracts tokens, stores in AuthContext + → Redirects to /developer + +Token refresh: + → JWT expires (1hr) → AuthContext intercepts 401 + → POST GoTrue /token?grant_type=refresh_token + → Updates stored tokens +``` + +## API Client (webhookApi.ts) + +```typescript +// GoTrue endpoints (direct to GoTrue service) +signUp(email, password) +signIn(email, password) +sendMagicLink(email) +refreshToken(refreshToken) +signOut() + +// Webhook API endpoints (backend /api/v1/*) +listEventTypes() +createAPIKey(name) +listAPIKeys() +deleteAPIKey(id) +createEndpoint(url, description) +listEndpoints() +updateEndpoint(id, data) +deleteEndpoint(id) +createSubscription(endpointId, eventType, conditions) +listSubscriptions() +updateSubscription(id, data) +deleteSubscription(id) +listDeliveryLogs(params) +``` + +## Auth Configuration + +### GoTrue (when SMTP is ready) +```env +GOTRUE_SMTP_HOST=smtp.resend.com +GOTRUE_SMTP_PORT=465 +GOTRUE_SMTP_USER=resend +GOTRUE_SMTP_PASS=re_xxxx # TODO: Resend API Key +GOTRUE_SMTP_SENDER_NAME=FlowIndex +GOTRUE_MAILER_AUTOCONFIRM=false +GOTRUE_MAILER_URLPATHS_CONFIRMATION=/developer/callback +GOTRUE_SITE_URL=https://testnet.flowindex.io +``` + +### Fallback (current, no SMTP) +```env +GOTRUE_MAILER_AUTOCONFIRM=true +``` +Email + password login works immediately. Magic Link requires SMTP. + +## GoTrue URL Configuration + +The frontend needs to know the GoTrue URL: +- **Railway:** `https://supabase-auth-production-073d.up.railway.app` +- **GCP:** Proxied through backend or exposed directly +- **Local:** `http://localhost:9999` + +Environment variable: `VITE_GOTRUE_URL` + +## UI Design + +Nothing Phone aesthetic — dark-first, monochrome, geometric: +- Background: `#0a0a0a` (nothing-dark) +- Accent: `#00ef8b` (nothing-green / Flow green) +- Font: Geist Sans / Geist Mono +- Cards: Shadcn Card with subtle borders +- Tables: minimal, with hover states +- Forms: clean inputs with green focus rings +- Sidebar: compact, icon + label, green active indicator + +## Event Types & Condition Fields + +| Event Type | Condition Fields | +|---|---| +| `ft.transfer` | addresses[], direction, token_contract, min_amount | +| `ft.large_transfer` | token_contract, min_amount (required) | +| `nft.transfer` | addresses[], collection, token_ids[], direction | +| `contract.event` | contract_address, event_names[] | +| `address.activity` | addresses[], roles[] | +| `staking.event` | event_types[], node_id, min_amount | +| `defi.swap` | pair_id, min_amount, addresses[] | +| `defi.liquidity` | pair_id, event_type | +| `account.key_change` | addresses[] | +| `evm.transaction` | from, to, min_value | + +## Dependencies + +No new npm packages needed beyond what's already in the frontend: +- Shadcn/UI components (Card, Button, Dialog, etc.) +- TanStack Router (already configured) +- Tailwind CSS (already configured) + +## References + +- [Supabase GoTrue API](https://supabase.com/docs/reference/auth) +- [Resend SMTP](https://resend.com/docs/send-with-smtp) +- Webhook API: `backend/internal/webhooks/handlers.go` From 9991461e2b7bb14e5f120ca11a488bf5e0690d81 Mon Sep 17 00:00:00 2001 From: ZenaBot Date: Sat, 28 Feb 2026 00:36:04 +1100 Subject: [PATCH 02/18] docs: add developer portal implementation plan 12-task plan covering AuthContext, webhookApi, login/callback pages, DeveloperLayout, API keys, endpoints, subscriptions, delivery logs, sidebar link, Resend SMTP config, and build verification. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-02-28-developer-portal.md | 1719 +++++++++++++++++++++ 1 file changed, 1719 insertions(+) create mode 100644 docs/plans/2026-02-28-developer-portal.md diff --git a/docs/plans/2026-02-28-developer-portal.md b/docs/plans/2026-02-28-developer-portal.md new file mode 100644 index 0000000..a2cbb3c --- /dev/null +++ b/docs/plans/2026-02-28-developer-portal.md @@ -0,0 +1,1719 @@ +# Developer Portal Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a Developer Portal to the FlowScan frontend where developers can sign up, manage API keys, webhook endpoints, subscriptions, and view delivery logs. + +**Architecture:** Integrated into `frontend/app/routes/developer/*` using TanStack Router file-based routing. Auth via GoTrue (Supabase self-hosted) with JWT stored in localStorage via AuthContext. API calls to backend `/api/v1/*` endpoints with Bearer token auth. + +**Tech Stack:** React 19, TanStack Router, Shadcn/UI, Tailwind CSS, GoTrue API, existing backend webhook API + +**Design Reference:** `docs/plans/2026-02-28-developer-portal-design.md` + +--- + +### Task 1: Install Missing Shadcn/UI Components + +We need table, input, label, select, tabs, badge, dropdown-menu, separator, switch, and textarea components that aren't in the project yet. + +**Files:** +- Modify: `frontend/app/components/ui/` (new component files added by shadcn CLI) + +**Step 1: Check which components already exist** + +```bash +cd frontend && ls app/components/ui/ +``` + +**Step 2: Install missing components via shadcn CLI** + +```bash +cd frontend +npx shadcn@latest add table input label select tabs badge dropdown-menu separator switch textarea -y +``` + +**Step 3: Verify installation** + +```bash +ls app/components/ui/table* app/components/ui/input* app/components/ui/label* app/components/ui/select* app/components/ui/tabs* app/components/ui/badge* app/components/ui/dropdown-menu* app/components/ui/separator* app/components/ui/switch* app/components/ui/textarea* +``` + +**Step 4: Commit** + +```bash +git add app/components/ui/ +git commit -m "feat(developer): add shadcn UI components for developer portal" +``` + +--- + +### Task 2: Create AuthContext and webhookApi + +Auth foundation: AuthContext manages JWT lifecycle, webhookApi wraps all API calls. + +**Files:** +- Create: `frontend/app/contexts/AuthContext.tsx` +- Create: `frontend/app/lib/webhookApi.ts` + +**Step 1: Create AuthContext.tsx** + +```tsx +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' + +interface AuthState { + accessToken: string | null + refreshToken: string | null + user: { id: string; email: string } | null + loading: boolean +} + +interface AuthContextType extends AuthState { + signUp: (email: string, password: string) => Promise + signIn: (email: string, password: string) => Promise + sendMagicLink: (email: string) => Promise + handleCallback: (hash: string) => void + signOut: () => void +} + +const STORAGE_KEY = 'flowindex_dev_auth' +const GOTRUE_URL = import.meta.env.VITE_GOTRUE_URL || 'http://localhost:9999' + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + accessToken: null, + refreshToken: null, + user: null, + loading: true, + }) + + // Load stored tokens on mount + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const { accessToken, refreshToken } = JSON.parse(stored) + if (accessToken) { + const payload = JSON.parse(atob(accessToken.split('.')[1])) + if (payload.exp * 1000 > Date.now()) { + setState({ + accessToken, + refreshToken, + user: { id: payload.sub, email: payload.email }, + loading: false, + }) + return + } + // Token expired — try refresh + if (refreshToken) { + doRefresh(refreshToken) + return + } + } + } + } catch {} + setState(s => ({ ...s, loading: false })) + }, []) + + async function gotruePost(path: string, body: Record) { + const res = await fetch(`${GOTRUE_URL}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ msg: res.statusText })) + throw new Error(err.msg || err.error_description || 'Auth error') + } + return res.json() + } + + function setTokens(data: { access_token: string; refresh_token: string }) { + const payload = JSON.parse(atob(data.access_token.split('.')[1])) + const auth = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + user: { id: payload.sub, email: payload.email }, + loading: false, + } + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + })) + setState(auth) + } + + async function doRefresh(token: string) { + try { + const data = await gotruePost('/token?grant_type=refresh_token', { refresh_token: token }) + setTokens(data) + } catch { + localStorage.removeItem(STORAGE_KEY) + setState({ accessToken: null, refreshToken: null, user: null, loading: false }) + } + } + + const signUp = useCallback(async (email: string, password: string) => { + const data = await gotruePost('/signup', { email, password }) + if (data.access_token) setTokens(data) + }, []) + + const signIn = useCallback(async (email: string, password: string) => { + const data = await gotruePost('/token?grant_type=password', { email, password }) + setTokens(data) + }, []) + + const sendMagicLink = useCallback(async (email: string) => { + await gotruePost('/magiclink', { email }) + }, []) + + const handleCallback = useCallback((hash: string) => { + const params = new URLSearchParams(hash.replace('#', '')) + const accessToken = params.get('access_token') + const refreshToken = params.get('refresh_token') + if (accessToken && refreshToken) { + setTokens({ access_token: accessToken, refresh_token: refreshToken }) + } + }, []) + + const signOut = useCallback(() => { + localStorage.removeItem(STORAGE_KEY) + setState({ accessToken: null, refreshToken: null, user: null, loading: false }) + }, []) + + // Auto-refresh before expiry + useEffect(() => { + if (!state.accessToken) return + try { + const payload = JSON.parse(atob(state.accessToken.split('.')[1])) + const expiresIn = payload.exp * 1000 - Date.now() + const refreshAt = expiresIn - 60_000 // 1 minute before expiry + if (refreshAt <= 0) { + if (state.refreshToken) doRefresh(state.refreshToken) + return + } + const timer = setTimeout(() => { + if (state.refreshToken) doRefresh(state.refreshToken) + }, refreshAt) + return () => clearTimeout(timer) + } catch {} + }, [state.accessToken, state.refreshToken]) + + return ( + + {children} + + ) +} + +export function useAuth() { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} +``` + +**Step 2: Create webhookApi.ts** + +```typescript +const API_BASE = (import.meta.env.VITE_API_URL || '/api') + '/v1' + +function getToken(): string | null { + try { + const stored = localStorage.getItem('flowindex_dev_auth') + if (stored) return JSON.parse(stored).accessToken + } catch {} + return null +} + +async function apiFetch(path: string, options?: RequestInit): Promise { + const token = getToken() + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options?.headers, + }, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || err.message || `API error ${res.status}`) + } + if (res.status === 204) return undefined as T + return res.json() +} + +// Event Types (public, no auth) +export function listEventTypes() { + return apiFetch<{ event_types: { name: string; description: string; condition_fields: string[] }[] }>('/event-types') +} + +// API Keys +export function listAPIKeys() { + return apiFetch<{ api_keys: { id: string; key_prefix: string; name: string; created_at: string; last_used: string | null; is_active: boolean }[] }>('/api-keys') +} + +export function createAPIKey(name: string) { + return apiFetch<{ id: string; key: string; key_prefix: string; name: string }>('/api-keys', { + method: 'POST', + body: JSON.stringify({ name }), + }) +} + +export function deleteAPIKey(id: string) { + return apiFetch(`/api-keys/${id}`, { method: 'DELETE' }) +} + +// Endpoints +export function listEndpoints() { + return apiFetch<{ endpoints: { id: string; url: string; description: string; is_active: boolean; created_at: string }[] }>('/endpoints') +} + +export function createEndpoint(url: string, description: string) { + return apiFetch<{ id: string; url: string; description: string }>('/endpoints', { + method: 'POST', + body: JSON.stringify({ url, description }), + }) +} + +export function updateEndpoint(id: string, data: { url?: string; description?: string; is_active?: boolean }) { + return apiFetch(`/endpoints/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) +} + +export function deleteEndpoint(id: string) { + return apiFetch(`/endpoints/${id}`, { method: 'DELETE' }) +} + +// Subscriptions +export function listSubscriptions() { + return apiFetch<{ subscriptions: { id: string; endpoint_id: string; event_type: string; conditions: Record; is_enabled: boolean; created_at: string }[] }>('/subscriptions') +} + +export function createSubscription(endpointId: string, eventType: string, conditions: Record) { + return apiFetch<{ id: string }>('/subscriptions', { + method: 'POST', + body: JSON.stringify({ endpoint_id: endpointId, event_type: eventType, conditions }), + }) +} + +export function updateSubscription(id: string, data: { is_enabled?: boolean; conditions?: Record }) { + return apiFetch(`/subscriptions/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) +} + +export function deleteSubscription(id: string) { + return apiFetch(`/subscriptions/${id}`, { method: 'DELETE' }) +} + +// Delivery Logs +export function listDeliveryLogs(params: { page?: number; per_page?: number; event_type?: string; status_min?: number; status_max?: number }) { + const qs = new URLSearchParams() + if (params.page) qs.set('page', String(params.page)) + if (params.per_page) qs.set('per_page', String(params.per_page)) + if (params.event_type) qs.set('event_type', params.event_type) + if (params.status_min) qs.set('status_min', String(params.status_min)) + if (params.status_max) qs.set('status_max', String(params.status_max)) + return apiFetch<{ logs: { id: string; event_type: string; endpoint_id: string; status_code: number; payload: unknown; delivered_at: string }[]; total: number }>(`/logs?${qs}`) +} + +// Dashboard stats +export function getDashboardStats() { + return Promise.all([listAPIKeys(), listEndpoints(), listSubscriptions(), listDeliveryLogs({ per_page: 5 })]) +} +``` + +**Step 3: Verify TypeScript compiles** + +```bash +cd frontend && npx tsc --noEmit app/contexts/AuthContext.tsx app/lib/webhookApi.ts 2>&1 | head -20 +``` + +**Step 4: Commit** + +```bash +git add app/contexts/AuthContext.tsx app/lib/webhookApi.ts +git commit -m "feat(developer): add AuthContext and webhook API client" +``` + +--- + +### Task 3: Create Login and Callback Pages + +Login page with email/password form (fallback) and magic link option. Callback page extracts tokens from URL hash. + +**Files:** +- Create: `frontend/app/routes/developer/login.tsx` +- Create: `frontend/app/routes/developer/callback.tsx` + +**Step 1: Create the developer routes directory** + +```bash +mkdir -p frontend/app/routes/developer +``` + +**Step 2: Create login.tsx** + +```tsx +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useState } from 'react' +import { useAuth } from '../../contexts/AuthContext' +import { motion } from 'framer-motion' + +export const Route = createFileRoute('/developer/login')({ + component: DeveloperLogin, +}) + +function DeveloperLogin() { + const { signIn, signUp, sendMagicLink, user } = useAuth() + const navigate = useNavigate() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [mode, setMode] = useState<'login' | 'register' | 'magic'>('login') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const [magicSent, setMagicSent] = useState(false) + + // Redirect if already logged in + if (user) { + navigate({ to: '/developer' }) + return null + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError('') + setLoading(true) + try { + if (mode === 'magic') { + await sendMagicLink(email) + setMagicSent(true) + } else if (mode === 'register') { + await signUp(email, password) + navigate({ to: '/developer' }) + } else { + await signIn(email, password) + navigate({ to: '/developer' }) + } + } catch (err: any) { + setError(err.message || 'Something went wrong') + } finally { + setLoading(false) + } + } + + return ( +
+ +

Developer Portal

+

+ {mode === 'magic' ? 'Sign in with a magic link sent to your email' : + mode === 'register' ? 'Create a developer account' : 'Sign in to your account'} +

+ + {magicSent ? ( +
+
+

Check your email

+

We sent a magic link to {email}

+ +
+ ) : ( +
+
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 bg-neutral-900 border border-neutral-700 rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-nothing-green" + placeholder="dev@example.com" + /> +
+ + {mode !== 'magic' && ( +
+ + setPassword(e.target.value)} + required + minLength={6} + className="w-full px-3 py-2 bg-neutral-900 border border-neutral-700 rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-nothing-green" + placeholder="Min 6 characters" + /> +
+ )} + + {error &&

{error}

} + + + +
+ {mode === 'login' && ( + <> + + + + )} + {mode === 'register' && ( + + )} + {mode === 'magic' && ( + + )} +
+
+ )} +
+
+ ) +} +``` + +**Step 3: Create callback.tsx** + +```tsx +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useEffect } from 'react' +import { useAuth } from '../../contexts/AuthContext' + +export const Route = createFileRoute('/developer/callback')({ + component: DeveloperCallback, +}) + +function DeveloperCallback() { + const { handleCallback } = useAuth() + const navigate = useNavigate() + + useEffect(() => { + if (typeof window !== 'undefined') { + handleCallback(window.location.hash) + navigate({ to: '/developer' }) + } + }, []) + + return ( +
+

Signing you in...

+
+ ) +} +``` + +**Step 4: Regenerate route tree** + +```bash +cd frontend && npx tsr generate +``` + +**Step 5: Verify build** + +```bash +cd frontend && npx tsc --noEmit 2>&1 | head -20 +``` + +**Step 6: Commit** + +```bash +git add app/routes/developer/ +git commit -m "feat(developer): add login and callback pages" +``` + +--- + +### Task 4: Create DeveloperLayout and ProtectedRoute + +Shared layout with sidebar navigation for all `/developer/*` pages. Redirects to login if not authenticated. + +**Files:** +- Create: `frontend/app/components/developer/DeveloperLayout.tsx` +- Create: `frontend/app/routes/developer/index.tsx` (dashboard) + +**Step 1: Create DeveloperLayout.tsx** + +```tsx +import { Link, useLocation } from '@tanstack/react-router' +import { useAuth } from '../../contexts/AuthContext' +import { useNavigate } from '@tanstack/react-router' +import { useEffect, type ReactNode } from 'react' +import { Key, Globe, Bell, FileText, LayoutDashboard, LogOut } from 'lucide-react' + +const NAV_ITEMS = [ + { to: '/developer', label: 'Dashboard', icon: LayoutDashboard }, + { to: '/developer/keys', label: 'API Keys', icon: Key }, + { to: '/developer/endpoints', label: 'Endpoints', icon: Globe }, + { to: '/developer/subscriptions', label: 'Subscriptions', icon: Bell }, + { to: '/developer/logs', label: 'Delivery Logs', icon: FileText }, +] + +export function DeveloperLayout({ children }: { children: ReactNode }) { + const { user, loading, signOut } = useAuth() + const navigate = useNavigate() + const location = useLocation() + + useEffect(() => { + if (!loading && !user) { + navigate({ to: '/developer/login' }) + } + }, [loading, user]) + + if (loading) { + return ( +
+

Loading...

+
+ ) + } + + if (!user) return null + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {children} +
+
+ ) +} +``` + +**Step 2: Create developer dashboard (index.tsx)** + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { useState, useEffect } from 'react' +import { DeveloperLayout } from '../../components/developer/DeveloperLayout' +import { listAPIKeys, listEndpoints, listSubscriptions, listDeliveryLogs } from '../../lib/webhookApi' +import { Key, Globe, Bell, FileText } from 'lucide-react' +import { Link } from '@tanstack/react-router' + +export const Route = createFileRoute('/developer/')({ + component: DeveloperDashboard, +}) + +function DeveloperDashboard() { + const [stats, setStats] = useState({ keys: 0, endpoints: 0, subscriptions: 0, logs: [] as any[] }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + Promise.all([ + listAPIKeys().catch(() => ({ api_keys: [] })), + listEndpoints().catch(() => ({ endpoints: [] })), + listSubscriptions().catch(() => ({ subscriptions: [] })), + listDeliveryLogs({ per_page: 5 }).catch(() => ({ logs: [] })), + ]).then(([keys, eps, subs, logs]) => { + setStats({ + keys: keys.api_keys?.length || 0, + endpoints: eps.endpoints?.length || 0, + subscriptions: subs.subscriptions?.length || 0, + logs: logs.logs || [], + }) + setLoading(false) + }) + }, []) + + const cards = [ + { label: 'API Keys', value: stats.keys, icon: Key, to: '/developer/keys', color: 'text-blue-400' }, + { label: 'Endpoints', value: stats.endpoints, icon: Globe, to: '/developer/endpoints', color: 'text-purple-400' }, + { label: 'Subscriptions', value: stats.subscriptions, icon: Bell, to: '/developer/subscriptions', color: 'text-nothing-green' }, + ] + + return ( + +

Dashboard

+ + {loading ? ( +
Loading...
+ ) : ( + <> +
+ {cards.map(c => ( + +
+ + {c.label} +
+

{c.value}

+ + ))} +
+ +
+
+

Recent Deliveries

+ View all +
+ {stats.logs.length === 0 ? ( +

No deliveries yet

+ ) : ( +
+ {stats.logs.map((log: any) => ( +
+ {log.event_type} + + {log.status_code || 'pending'} + + {new Date(log.delivered_at).toLocaleString()} +
+ ))} +
+ )} +
+ + )} +
+ ) +} +``` + +**Step 3: Regenerate route tree and verify** + +```bash +cd frontend && npx tsr generate && npx tsc --noEmit 2>&1 | head -20 +``` + +**Step 4: Commit** + +```bash +git add app/components/developer/ app/routes/developer/index.tsx +git commit -m "feat(developer): add DeveloperLayout and dashboard page" +``` + +--- + +### Task 5: Add AuthProvider to Root Layout + +Wrap the app in AuthProvider so all developer routes can access auth state. + +**Files:** +- Modify: `frontend/app/routes/__root.tsx` + +**Step 1: Read current __root.tsx** + +Read `frontend/app/routes/__root.tsx` to understand current provider structure. + +**Step 2: Add AuthProvider import and wrap** + +Add import: +```tsx +import { AuthProvider } from '../contexts/AuthContext' +``` + +Wrap inside the existing provider chain (inside ThemeProvider, alongside WebSocketProvider): +```tsx + + + + ...existing content... + + + +``` + +**Step 3: Verify build** + +```bash +cd frontend && npx tsc --noEmit 2>&1 | head -20 +``` + +**Step 4: Commit** + +```bash +git add app/routes/__root.tsx +git commit -m "feat(developer): add AuthProvider to root layout" +``` + +--- + +### Task 6: API Keys Page + +Manage API keys: list, create (modal shows key once), delete with confirmation. + +**Files:** +- Create: `frontend/app/routes/developer/keys.tsx` + +**Step 1: Create keys.tsx** + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { useState, useEffect } from 'react' +import { DeveloperLayout } from '../../components/developer/DeveloperLayout' +import { listAPIKeys, createAPIKey, deleteAPIKey } from '../../lib/webhookApi' +import { Plus, Trash2, Copy, Check } from 'lucide-react' + +export const Route = createFileRoute('/developer/keys')({ + component: DeveloperKeys, +}) + +function DeveloperKeys() { + const [keys, setKeys] = useState([]) + const [loading, setLoading] = useState(true) + const [showCreate, setShowCreate] = useState(false) + const [newKeyName, setNewKeyName] = useState('') + const [createdKey, setCreatedKey] = useState(null) + const [creating, setCreating] = useState(false) + const [copied, setCopied] = useState(false) + const [error, setError] = useState('') + + async function loadKeys() { + try { + const data = await listAPIKeys() + setKeys(data.api_keys || []) + } catch {} + setLoading(false) + } + + useEffect(() => { loadKeys() }, []) + + async function handleCreate(e: React.FormEvent) { + e.preventDefault() + setCreating(true) + setError('') + try { + const data = await createAPIKey(newKeyName) + setCreatedKey(data.key) + setNewKeyName('') + loadKeys() + } catch (err: any) { + setError(err.message) + } + setCreating(false) + } + + async function handleDelete(id: string) { + if (!confirm('Delete this API key? This cannot be undone.')) return + try { + await deleteAPIKey(id) + loadKeys() + } catch {} + } + + function copyKey() { + if (createdKey) { + navigator.clipboard.writeText(createdKey) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + return ( + +
+

API Keys

+ +
+ + {/* Create modal */} + {showCreate && ( +
!createdKey && setShowCreate(false)}> +
e.stopPropagation()}> + {createdKey ? ( +
+

API Key Created

+

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

+
+ {createdKey} + +
+ +
+ ) : ( +
+

Create API Key

+ setNewKeyName(e.target.value)} + placeholder="Key name (e.g. Production)" required + className="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-nothing-green mb-3" /> + {error &&

{error}

} +
+ + +
+
+ )} +
+
+ )} + + {/* Keys table */} + {loading ? ( +
Loading...
+ ) : keys.length === 0 ? ( +
No API keys yet. Create one to get started.
+ ) : ( +
+ + + + + + + + + + + + {keys.map(k => ( + + + + + + + + ))} + +
NameKey PrefixCreatedLast Used
{k.name}{k.key_prefix}...{new Date(k.created_at).toLocaleDateString()}{k.last_used ? new Date(k.last_used).toLocaleDateString() : 'Never'} + +
+
+ )} +
+ ) +} +``` + +**Step 2: Regenerate route tree and verify** + +```bash +cd frontend && npx tsr generate && npx tsc --noEmit 2>&1 | head -20 +``` + +**Step 3: Commit** + +```bash +git add app/routes/developer/keys.tsx +git commit -m "feat(developer): add API keys management page" +``` + +--- + +### Task 7: Endpoints Page + +Manage webhook endpoints: list, create, edit, delete. + +**Files:** +- Create: `frontend/app/routes/developer/endpoints.tsx` + +**Step 1: Create endpoints.tsx** + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { useState, useEffect } from 'react' +import { DeveloperLayout } from '../../components/developer/DeveloperLayout' +import { listEndpoints, createEndpoint, updateEndpoint, deleteEndpoint } from '../../lib/webhookApi' +import { Plus, Trash2, Edit2, Globe } from 'lucide-react' + +export const Route = createFileRoute('/developer/endpoints')({ + component: DeveloperEndpoints, +}) + +function DeveloperEndpoints() { + const [endpoints, setEndpoints] = useState([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [editId, setEditId] = useState(null) + const [url, setUrl] = useState('') + const [description, setDescription] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + async function load() { + try { + const data = await listEndpoints() + setEndpoints(data.endpoints || []) + } catch {} + setLoading(false) + } + + useEffect(() => { load() }, []) + + function openCreate() { + setEditId(null); setUrl(''); setDescription(''); setError(''); setShowForm(true) + } + + function openEdit(ep: any) { + setEditId(ep.id); setUrl(ep.url); setDescription(ep.description || ''); setError(''); setShowForm(true) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setSaving(true); setError('') + try { + if (editId) { + await updateEndpoint(editId, { url, description }) + } else { + await createEndpoint(url, description) + } + setShowForm(false) + load() + } catch (err: any) { + setError(err.message) + } + setSaving(false) + } + + async function handleDelete(id: string) { + if (!confirm('Delete this endpoint? Active subscriptions will also be removed.')) return + try { await deleteEndpoint(id); load() } catch {} + } + + return ( + +
+

Webhook Endpoints

+ +
+ + {/* Form modal */} + {showForm && ( +
setShowForm(false)}> +
e.stopPropagation()}> +

{editId ? 'Edit Endpoint' : 'Add Endpoint'}

+
+ setUrl(e.target.value)} placeholder="https://your-app.com/webhook" + required className="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-nothing-green" /> + setDescription(e.target.value)} placeholder="Description (optional)" + className="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-nothing-green" /> +
+ {error &&

{error}

} +
+ + +
+
+
+ )} + + {loading ? ( +
Loading...
+ ) : endpoints.length === 0 ? ( +
No endpoints yet. Add one to start receiving webhooks.
+ ) : ( +
+ + + + + + + + + + + + {endpoints.map(ep => ( + + + + + + + + ))} + +
URLDescriptionStatusCreated
+ {ep.url} + {ep.description || '-'} + + {ep.is_active ? 'Active' : 'Inactive'} + + {new Date(ep.created_at).toLocaleDateString()} + + +
+
+ )} +
+ ) +} +``` + +**Step 2: Regenerate route tree and verify** + +```bash +cd frontend && npx tsr generate && npx tsc --noEmit 2>&1 | head -20 +``` + +**Step 3: Commit** + +```bash +git add app/routes/developer/endpoints.tsx +git commit -m "feat(developer): add webhook endpoints management page" +``` + +--- + +### Task 8: Subscriptions Page + +Manage subscriptions: list, create (with event type dropdown + endpoint selector + conditions editor), toggle enable/disable, delete. + +**Files:** +- Create: `frontend/app/routes/developer/subscriptions.tsx` + +**Step 1: Create subscriptions.tsx** + +This is the most complex page. It needs: +- List subscriptions in a table +- Create subscription form with: + - Event type dropdown (from `/api/v1/event-types`) + - Endpoint selector (from user's endpoints) + - Conditions editor (key-value pairs based on event type) +- Toggle enable/disable +- Delete with confirmation + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { useState, useEffect } from 'react' +import { DeveloperLayout } from '../../components/developer/DeveloperLayout' +import { listSubscriptions, createSubscription, updateSubscription, deleteSubscription, listEndpoints, listEventTypes } from '../../lib/webhookApi' +import { Plus, Trash2, ToggleLeft, ToggleRight } from 'lucide-react' + +export const Route = createFileRoute('/developer/subscriptions')({ + component: DeveloperSubscriptions, +}) + +// Condition field definitions per event type +const CONDITION_FIELDS: Record = { + 'ft.transfer': [ + { key: 'addresses', label: 'Addresses (comma-separated)', type: 'array' }, + { key: 'direction', label: 'Direction', type: 'select', options: ['in', 'out', 'both'] }, + { key: 'token_contract', label: 'Token Contract', type: 'text' }, + { key: 'min_amount', label: 'Min Amount', type: 'number' }, + ], + 'ft.large_transfer': [ + { key: 'token_contract', label: 'Token Contract', type: 'text' }, + { key: 'min_amount', label: 'Min Amount (required)', type: 'number' }, + ], + 'nft.transfer': [ + { key: 'addresses', label: 'Addresses (comma-separated)', type: 'array' }, + { key: 'collection', label: 'Collection', type: 'text' }, + { key: 'direction', label: 'Direction', type: 'select', options: ['in', 'out', 'both'] }, + ], + 'contract.event': [ + { key: 'contract_address', label: 'Contract Address', type: 'text' }, + { key: 'event_names', label: 'Event Names (comma-separated)', type: 'array' }, + ], + 'address.activity': [ + { key: 'addresses', label: 'Addresses (comma-separated)', type: 'array' }, + { key: 'roles', label: 'Roles (comma-separated: PROPOSER,PAYER,AUTHORIZER)', type: 'array' }, + ], + 'staking.event': [ + { key: 'event_types', label: 'Event Types (comma-separated)', type: 'array' }, + { key: 'node_id', label: 'Node ID', type: 'text' }, + { key: 'min_amount', label: 'Min Amount', type: 'number' }, + ], + 'defi.swap': [ + { key: 'pair_id', label: 'Pair ID', type: 'text' }, + { key: 'min_amount', label: 'Min Amount', type: 'number' }, + { key: 'addresses', label: 'Addresses (comma-separated)', type: 'array' }, + ], + 'defi.liquidity': [ + { key: 'pair_id', label: 'Pair ID', type: 'text' }, + { key: 'event_type', label: 'Event Type', type: 'select', options: ['add', 'remove'] }, + ], + 'account.key_change': [ + { key: 'addresses', label: 'Addresses (comma-separated)', type: 'array' }, + ], + 'evm.transaction': [ + { key: 'from', label: 'From Address', type: 'text' }, + { key: 'to', label: 'To Address', type: 'text' }, + { key: 'min_value', label: 'Min Value', type: 'number' }, + ], +} + +function DeveloperSubscriptions() { + const [subs, setSubs] = useState([]) + const [endpoints, setEndpoints] = useState([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [eventType, setEventType] = useState('') + const [endpointId, setEndpointId] = useState('') + const [conditions, setConditions] = useState>({}) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + async function load() { + try { + const [subsData, epsData] = await Promise.all([listSubscriptions(), listEndpoints()]) + setSubs(subsData.subscriptions || []) + setEndpoints(epsData.endpoints || []) + } catch {} + setLoading(false) + } + + useEffect(() => { load() }, []) + + function openCreate() { + setEventType(''); setEndpointId(''); setConditions({}); setError(''); setShowForm(true) + } + + function handleConditionChange(key: string, value: string) { + setConditions(prev => ({ ...prev, [key]: value })) + } + + function buildConditions() { + const result: Record = {} + const fields = CONDITION_FIELDS[eventType] || [] + for (const field of fields) { + const val = conditions[field.key] + if (!val) continue + if (field.type === 'array') { + result[field.key] = val.split(',').map(s => s.trim()).filter(Boolean) + } else if (field.type === 'number') { + result[field.key] = val + } else { + result[field.key] = val + } + } + return result + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault() + setSaving(true); setError('') + try { + await createSubscription(endpointId, eventType, buildConditions()) + setShowForm(false) + load() + } catch (err: any) { + setError(err.message) + } + setSaving(false) + } + + async function handleToggle(sub: any) { + try { + await updateSubscription(sub.id, { is_enabled: !sub.is_enabled }) + load() + } catch {} + } + + async function handleDelete(id: string) { + if (!confirm('Delete this subscription?')) return + try { await deleteSubscription(id); load() } catch {} + } + + const fields = CONDITION_FIELDS[eventType] || [] + + return ( + +
+

Subscriptions

+ +
+ + {endpoints.length === 0 && !loading && ( +
+ You need at least one endpoint before creating subscriptions. +
+ )} + + {/* Create form modal */} + {showForm && ( +
setShowForm(false)}> +
e.stopPropagation()}> +

New Subscription

+
+ {/* Event type */} +
+ + +
+ {/* Endpoint */} +
+ + +
+ {/* Condition fields */} + {fields.length > 0 && ( +
+ + {fields.map(f => ( +
+ + {f.type === 'select' ? ( + + ) : ( + handleConditionChange(f.key, e.target.value)} + className="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-nothing-green" + step={f.type === 'number' ? 'any' : undefined} /> + )} +
+ ))} +
+ )} +
+ {error &&

{error}

} +
+ + +
+
+
+ )} + + {/* Subscriptions table */} + {loading ? ( +
Loading...
+ ) : subs.length === 0 ? ( +
No subscriptions yet.
+ ) : ( +
+ + + + + + + + + + + + {subs.map(sub => { + const ep = endpoints.find(e => e.id === sub.endpoint_id) + return ( + + + + + + + + ) + })} + +
Event TypeEndpointConditionsStatus
{sub.event_type}{ep?.url || sub.endpoint_id} + {Object.keys(sub.conditions || {}).length > 0 ? JSON.stringify(sub.conditions) : '-'} + + + + +
+
+ )} +
+ ) +} +``` + +**Step 2: Regenerate route tree and verify** + +```bash +cd frontend && npx tsr generate && npx tsc --noEmit 2>&1 | head -20 +``` + +**Step 3: Commit** + +```bash +git add app/routes/developer/subscriptions.tsx +git commit -m "feat(developer): add subscriptions management page" +``` + +--- + +### Task 9: Delivery Logs Page + +View delivery logs with pagination and filtering. + +**Files:** +- Create: `frontend/app/routes/developer/logs.tsx` + +**Step 1: Create logs.tsx** + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { useState, useEffect } from 'react' +import { DeveloperLayout } from '../../components/developer/DeveloperLayout' +import { listDeliveryLogs } from '../../lib/webhookApi' +import { ChevronLeft, ChevronRight, Filter } from 'lucide-react' + +export const Route = createFileRoute('/developer/logs')({ + component: DeveloperLogs, +}) + +function DeveloperLogs() { + const [logs, setLogs] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [eventFilter, setEventFilter] = useState('') + const [statusFilter, setStatusFilter] = useState<'' | 'success' | 'error'>('') + const perPage = 50 + + async function load() { + setLoading(true) + try { + const params: any = { page, per_page: perPage } + if (eventFilter) params.event_type = eventFilter + if (statusFilter === 'success') { params.status_min = 200; params.status_max = 299 } + if (statusFilter === 'error') { params.status_min = 400; params.status_max = 599 } + const data = await listDeliveryLogs(params) + setLogs(data.logs || []) + setTotal(data.total || 0) + } catch {} + setLoading(false) + } + + useEffect(() => { load() }, [page, eventFilter, statusFilter]) + + const totalPages = Math.ceil(total / perPage) || 1 + + return ( + +
+

Delivery Logs

+
+ + { setEventFilter(e.target.value); setPage(1) }} + placeholder="Filter by event type" className="px-2 py-1 bg-neutral-800 border border-neutral-700 rounded text-white text-xs w-40 focus:outline-none focus:ring-1 focus:ring-nothing-green" /> + +
+
+ + {loading ? ( +
Loading...
+ ) : logs.length === 0 ? ( +
No delivery logs yet.
+ ) : ( + <> +
+ + + + + + + + + + + {logs.map(log => ( + + + + + + + ))} + +
TimestampEvent TypeStatusPayload
{new Date(log.delivered_at).toLocaleString()}{log.event_type} + + {log.status_code || 'pending'} + + + {typeof log.payload === 'object' ? JSON.stringify(log.payload) : String(log.payload)} +
+
+ + {/* Pagination */} +
+ {total} total logs +
+ + Page {page} of {totalPages} + +
+
+ + )} +
+ ) +} +``` + +**Step 2: Regenerate route tree and verify** + +```bash +cd frontend && npx tsr generate && npx tsc --noEmit 2>&1 | head -20 +``` + +**Step 3: Commit** + +```bash +git add app/routes/developer/logs.tsx +git commit -m "feat(developer): add delivery logs page" +``` + +--- + +### Task 10: Add Developer Portal Link to Sidebar + +Add a link to the developer portal in the main site sidebar/header. + +**Files:** +- Modify: `frontend/app/components/Sidebar.tsx` + +**Step 1: Read Sidebar.tsx** + +Read the file to understand the current navigation structure. + +**Step 2: Add Developer Portal link** + +Add a new nav item linking to `/developer`: +```tsx +{ to: '/developer', label: 'Developer', icon: Code2 } +``` + +Import `Code2` from lucide-react. + +**Step 3: Verify build** + +```bash +cd frontend && npx tsc --noEmit 2>&1 | head -20 +``` + +**Step 4: Commit** + +```bash +git add app/components/Sidebar.tsx +git commit -m "feat(developer): add developer portal link to sidebar" +``` + +--- + +### Task 11: Configure Resend SMTP on GoTrue + +Enable magic link emails by configuring Resend SMTP on both GCP and Railway GoTrue instances. + +**Files:** +- Modify: GCP GoTrue container environment +- Modify: Railway supabase-auth service environment + +**Step 1: Update GCP GoTrue** + +SSH into GCP VM and recreate GoTrue container with SMTP env vars: + +```bash +# On GCP VM +docker stop supabase-auth && docker rm supabase-auth + +docker run -d --name supabase-auth \ + --network host \ + -e GOTRUE_API_HOST=0.0.0.0 \ + -e GOTRUE_API_PORT=9999 \ + -e API_EXTERNAL_URL=http://localhost:9999 \ + -e GOTRUE_DB_DRIVER=postgres \ + -e GOTRUE_DB_DATABASE_URL="postgres://supabase_auth_admin:supabase-secret-prod-2026@localhost:5433/supabase" \ + -e GOTRUE_SITE_URL=https://flowindex.io \ + -e GOTRUE_JWT_SECRET=FYO8sf7LzurUbgjlMVqUwgHwD6ex76bGE597AkcWucRdgRu6eQ3N/rJJbn3QU9bJ \ + -e GOTRUE_JWT_EXP=3600 \ + -e GOTRUE_DISABLE_SIGNUP=false \ + -e GOTRUE_EXTERNAL_EMAIL_ENABLED=true \ + -e GOTRUE_MAILER_AUTOCONFIRM=false \ + -e GOTRUE_SMTP_HOST=smtp.resend.com \ + -e GOTRUE_SMTP_PORT=465 \ + -e GOTRUE_SMTP_USER=resend \ + -e GOTRUE_SMTP_PASS=re_D8V7i1NZ_K9fC2gCociLMWpnznmKZkQ19 \ + -e GOTRUE_SMTP_SENDER_NAME=FlowIndex \ + -e GOTRUE_SMTP_ADMIN_EMAIL=noreply@flowindex.io \ + -e GOTRUE_MAILER_URLPATHS_CONFIRMATION=/developer/callback \ + supabase/gotrue:v2.170.0 +``` + +Verify: `docker logs supabase-auth 2>&1 | tail -5` + +**Step 2: Update Railway GoTrue** + +```bash +railway variables set \ + GOTRUE_MAILER_AUTOCONFIRM=false \ + GOTRUE_SMTP_HOST=smtp.resend.com \ + GOTRUE_SMTP_PORT=465 \ + GOTRUE_SMTP_USER=resend \ + GOTRUE_SMTP_PASS=re_D8V7i1NZ_K9fC2gCociLMWpnznmKZkQ19 \ + GOTRUE_SMTP_SENDER_NAME=FlowIndex \ + GOTRUE_SMTP_ADMIN_EMAIL=noreply@flowindex.io \ + GOTRUE_MAILER_URLPATHS_CONFIRMATION=/developer/callback \ + --service supabase-auth +``` + +**Step 3: Verify magic link works** + +```bash +curl -s -X POST https://supabase-auth-production-073d.up.railway.app/magiclink \ + -H 'Content-Type: application/json' \ + -d '{"email":"test@example.com"}' +``` + +**Step 4: Update docker-compose.yml for local development** + +Add SMTP env vars to the `supabase-auth` service in docker-compose.yml: + +```yaml +GOTRUE_SMTP_HOST: smtp.resend.com +GOTRUE_SMTP_PORT: "465" +GOTRUE_SMTP_USER: resend +GOTRUE_SMTP_PASS: ${RESEND_API_KEY:-} +GOTRUE_SMTP_SENDER_NAME: FlowIndex +GOTRUE_SMTP_ADMIN_EMAIL: noreply@flowindex.io +GOTRUE_MAILER_URLPATHS_CONFIRMATION: /developer/callback +``` + +**Step 5: Add VITE_GOTRUE_URL to docker-compose frontend** + +```yaml +frontend: + environment: + - VITE_GOTRUE_URL=http://supabase-auth:9999 +``` + +**Step 6: Commit** + +```bash +git add docker-compose.yml +git commit -m "feat(developer): configure GoTrue SMTP for magic link emails" +``` + +--- + +### Task 12: Verify Frontend Build + +Final verification that everything compiles and the dev server starts. + +**Step 1: Install dependencies** + +```bash +cd frontend && npm ci +``` + +**Step 2: Type check** + +```bash +npx tsc --noEmit +``` + +**Step 3: Build** + +```bash +npm run build +``` + +**Step 4: Verify all routes registered** + +Check `app/routeTree.gen.ts` contains developer routes: +- `/developer/` +- `/developer/login` +- `/developer/callback` +- `/developer/keys` +- `/developer/endpoints` +- `/developer/subscriptions` +- `/developer/logs` + +**Step 5: Commit any remaining changes** + +```bash +git add -A && git status +git commit -m "feat(developer): developer portal complete" +``` From dd8861bc24697c56f3c0e54d6e4cf7a59f3259c8 Mon Sep 17 00:00:00 2001 From: ZenaBot Date: Sat, 28 Feb 2026 00:41:59 +1100 Subject: [PATCH 03/18] feat(developer): add shadcn UI components for developer portal Co-Authored-By: Claude Opus 4.6 --- frontend/app/components/ui/badge.jsx | 34 ++++ frontend/app/components/ui/dropdown-menu.jsx | 157 +++++++++++++++++++ frontend/app/components/ui/input.jsx | 19 +++ frontend/app/components/ui/label.jsx | 16 ++ frontend/app/components/ui/select.jsx | 122 ++++++++++++++ frontend/app/components/ui/separator.jsx | 23 +++ frontend/app/components/ui/switch.jsx | 24 +++ frontend/app/components/ui/table.jsx | 83 ++++++++++ frontend/app/components/ui/tabs.jsx | 41 +++++ frontend/app/components/ui/textarea.jsx | 18 +++ 10 files changed, 537 insertions(+) create mode 100644 frontend/app/components/ui/badge.jsx create mode 100644 frontend/app/components/ui/dropdown-menu.jsx create mode 100644 frontend/app/components/ui/input.jsx create mode 100644 frontend/app/components/ui/label.jsx create mode 100644 frontend/app/components/ui/select.jsx create mode 100644 frontend/app/components/ui/separator.jsx create mode 100644 frontend/app/components/ui/switch.jsx create mode 100644 frontend/app/components/ui/table.jsx create mode 100644 frontend/app/components/ui/tabs.jsx create mode 100644 frontend/app/components/ui/textarea.jsx diff --git a/frontend/app/components/ui/badge.jsx b/frontend/app/components/ui/badge.jsx new file mode 100644 index 0000000..deae51c --- /dev/null +++ b/frontend/app/components/ui/badge.jsx @@ -0,0 +1,34 @@ +import * as React from "react" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + ...props +}) { + return (
); +} + +export { Badge, badgeVariants } diff --git a/frontend/app/components/ui/dropdown-menu.jsx b/frontend/app/components/ui/dropdown-menu.jsx new file mode 100644 index 0000000..0b47c35 --- /dev/null +++ b/frontend/app/components/ui/dropdown-menu.jsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}) => { + return ( + + ); +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/frontend/app/components/ui/input.jsx b/frontend/app/components/ui/input.jsx new file mode 100644 index 0000000..879e612 --- /dev/null +++ b/frontend/app/components/ui/input.jsx @@ -0,0 +1,19 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef(({ className, type, ...props }, ref) => { + return ( + + ); +}) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/app/components/ui/label.jsx b/frontend/app/components/ui/label.jsx new file mode 100644 index 0000000..a1f4099 --- /dev/null +++ b/frontend/app/components/ui/label.jsx @@ -0,0 +1,16 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/frontend/app/components/ui/select.jsx b/frontend/app/components/ui/select.jsx new file mode 100644 index 0000000..47b319f --- /dev/null +++ b/frontend/app/components/ui/select.jsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props}> + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/frontend/app/components/ui/separator.jsx b/frontend/app/components/ui/separator.jsx new file mode 100644 index 0000000..c40b888 --- /dev/null +++ b/frontend/app/components/ui/separator.jsx @@ -0,0 +1,23 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef(( + { className, orientation = "horizontal", decorative = true, ...props }, + ref +) => ( + +)) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/frontend/app/components/ui/switch.jsx b/frontend/app/components/ui/switch.jsx new file mode 100644 index 0000000..e0b4e8e --- /dev/null +++ b/frontend/app/components/ui/switch.jsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/frontend/app/components/ui/table.jsx b/frontend/app/components/ui/table.jsx new file mode 100644 index 0000000..31b58ed --- /dev/null +++ b/frontend/app/components/ui/table.jsx @@ -0,0 +1,83 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef(({ className, ...props }, ref) => ( + tr]:last:border-b-0", className)} + {...props} /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/frontend/app/components/ui/tabs.jsx b/frontend/app/components/ui/tabs.jsx new file mode 100644 index 0000000..baa3422 --- /dev/null +++ b/frontend/app/components/ui/tabs.jsx @@ -0,0 +1,41 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/frontend/app/components/ui/textarea.jsx b/frontend/app/components/ui/textarea.jsx new file mode 100644 index 0000000..9be8b11 --- /dev/null +++ b/frontend/app/components/ui/textarea.jsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Textarea = React.forwardRef(({ className, ...props }, ref) => { + return ( +