From dc004a48d54d1448611e5ef26bfc230c63f5badf Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Tue, 16 Jun 2026 17:33:04 -0400 Subject: [PATCH 1/3] feat(admin): 7-state AsyncScreen on all 9 panels, typed errors, Zod validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements T-P3-E5-W4-S1-T01: hardens all admin surfaces after Vite migration. - AsyncScreen component: enforces 7 states (loading/offline/auth-expired/ error/empty/rate-limited/ready) across all panels - useStackStatus hook: polls localhost:8080/health every 5s; requires 2 consecutive failures before flagging offline (prevents false positives) - AdminError discriminated union: 6 variants (stack_offline, auth_expired, sql_error, backup_failed, deploy_failed, network) each with user message - Result monad: typed success/failure for all data-fetching paths - Zod validation: SQL (non-empty only, full admin access preserved), backup name (alphanumeric + hyphens/underscores, max 50 chars) - 9 panels: ServiceHealth, DatabaseConsole, Backup, DeploymentUI, SSL, GraphQLPlayground, WebTerminal, Grafana, PluginConfig — all wired - AdminLoginOverlay: re-auth overlay for 24h LokiJS session expiry - Wiki docs: admin-guide.md (7-state contract, smoke tests, troubleshooting) and session-management.md (lifecycle, security, re-auth flow) --- .github/wiki/guides/admin-guide.md | 130 ++++++++++++ .github/wiki/guides/session-management.md | 65 ++++++ src/components/AdminLoginOverlay.tsx | 116 ++++++++++ src/components/AsyncScreen.tsx | 246 ++++++++++++++++++++++ src/hooks/useStackStatus.ts | 89 ++++++++ src/lib/result.ts | 122 +++++++++++ src/lib/validation/admin-forms.ts | 54 +++++ src/panels/BackupPanel.tsx | 229 ++++++++++++++++++++ src/panels/DatabaseConsolePanel.tsx | 187 ++++++++++++++++ src/panels/DeploymentUIPanel.tsx | 191 +++++++++++++++++ src/panels/GrafanaPanel.tsx | 116 ++++++++++ src/panels/GraphQLPlaygroundPanel.tsx | 116 ++++++++++ src/panels/PluginConfigPanel.tsx | 163 ++++++++++++++ src/panels/SSLPanel.tsx | 156 ++++++++++++++ src/panels/ServiceHealthPanel.tsx | 162 ++++++++++++++ src/panels/WebTerminalPanel.tsx | 153 ++++++++++++++ src/panels/index.ts | 16 ++ 17 files changed, 2311 insertions(+) create mode 100644 .github/wiki/guides/admin-guide.md create mode 100644 .github/wiki/guides/session-management.md create mode 100644 src/components/AdminLoginOverlay.tsx create mode 100644 src/components/AsyncScreen.tsx create mode 100644 src/hooks/useStackStatus.ts create mode 100644 src/lib/result.ts create mode 100644 src/lib/validation/admin-forms.ts create mode 100644 src/panels/BackupPanel.tsx create mode 100644 src/panels/DatabaseConsolePanel.tsx create mode 100644 src/panels/DeploymentUIPanel.tsx create mode 100644 src/panels/GrafanaPanel.tsx create mode 100644 src/panels/GraphQLPlaygroundPanel.tsx create mode 100644 src/panels/PluginConfigPanel.tsx create mode 100644 src/panels/SSLPanel.tsx create mode 100644 src/panels/ServiceHealthPanel.tsx create mode 100644 src/panels/WebTerminalPanel.tsx create mode 100644 src/panels/index.ts diff --git a/.github/wiki/guides/admin-guide.md b/.github/wiki/guides/admin-guide.md new file mode 100644 index 00000000..dc5b4747 --- /dev/null +++ b/.github/wiki/guides/admin-guide.md @@ -0,0 +1,130 @@ +# Admin Guide (Vite SPA) + +The nSelf admin companion (`localhost:3021`) is a local-only Vite SPA for +managing your nSelf installation. It connects to the nSelf stack via the +nginx health endpoint and API routes. + +--- + +## Prerequisites + +- nSelf stack running: `nself start` +- Admin available at: http://localhost:3021 +- Session TTL: 24 hours (password-based, LokiJS store) + +--- + +## 7-State UI Contract + +Every admin panel implements the **7-state AsyncScreen contract**. Regardless +of which panel you open, you will see one of these states: + +| State | When shown | What to do | +|---|---|---| +| **Loading** | Data fetch in progress | Wait | +| **Offline** | nSelf stack not running | Run `nself start` in your terminal, then click **Check again** | +| **Auth-expired** | 24h session ended | Enter password in the login overlay | +| **Error** | Fetch or API failure | Click **Retry**; check logs if persistent | +| **Empty** | No data to display | Follow the in-panel CTA | +| **Rate-limited** | Too many requests | Wait for the timer, then retry | +| **Ready** | Data loaded | Use the panel normally | + +The "Offline" state means the **nSelf stack is not running** — not a network +or permission problem. Run `nself start` to resolve it. + +--- + +## Panels + +### Service Health + +Displays all nSelf services (postgres, hasura, nginx, redis, etc.) with their +current status: Running, Starting, Stopped, Error. + +Empty state: "No services running — run `nself start`." + +### Database Console + +Run arbitrary SQL against the nSelf Postgres database. Admin has full SQL +access — no query-type restrictions. + +- Input validated: non-empty only (Zod) +- Results displayed in a scrollable table +- Row count and execution time shown + +### Backup Panel + +Create and list nSelf backups. + +- Backup name: alphanumeric, hyphens, underscores; max 50 chars +- Each backup shows: name, date, type, size, status +- Empty state: "No backups yet — create your first backup above." + +### Deployment UI + +Multi-environment deployment status and control. + +- Environment switcher: Local / Staging / Production +- Deployment timeline with step-by-step status +- Shows version and last-deployed timestamp per environment + +### SSL Panel + +Certificate status for all configured domains. + +- Status: Valid, Expiring soon, Expired, Missing +- Shows expiry date and days remaining +- Issuer displayed where available + +### GraphQL Playground + +Embedded Hasura GraphQL console (iframe). + +- Opens the Hasura console URL proxied through the admin +- "Open in new tab" link available for full-screen use + +### Web Terminal + +Browser-based terminal for nSelf CLI commands. + +- Commands run via `/api/terminal/exec` (not a direct shell) +- Enter key submits; exit code shown on failure +- Output history persists for the browser session + +### Grafana Integration + +Embedded Grafana dashboard for nSelf metrics. + +- Shows system metrics: CPU, memory, request rates, error rates +- "Open in new tab" for full Grafana experience + +### Plugin Config + +View and toggle all installed nSelf plugins. + +- Toggle enabled/disabled per plugin +- Shows tier (free/paid) and description +- Optimistic UI update on toggle + +--- + +## Smoke Test Checklist + +Run these to verify admin works after a stack update: + +1. **SQL**: Open Database Console → type `SELECT 1;` → click Run → verify a result row appears. +2. **Backup**: Open Backup Panel → enter a name → click Create backup → verify it appears in the list. +3. **Health**: Open Service Health → verify running containers are listed with green status. +4. **Grafana**: Open Grafana Panel → verify the iframe loads with metrics data. + +--- + +## Troubleshooting + +| Problem | Likely cause | Fix | +|---|---|---| +| All panels show "offline" | nSelf stack not running | `nself start` | +| Login overlay appears | Session expired (24h) | Re-enter admin password | +| SQL returns an error | Invalid query | Check syntax; admin has full access so all queries are forwarded as-is | +| Backup fails | Disk space | `df -h` on the host; free space or adjust retention | +| Grafana iframe blank | Grafana service not healthy | `nself status grafana`; check logs | diff --git a/.github/wiki/guides/session-management.md b/.github/wiki/guides/session-management.md new file mode 100644 index 00000000..0b3bc36d --- /dev/null +++ b/.github/wiki/guides/session-management.md @@ -0,0 +1,65 @@ +# Session Management + +The nSelf admin GUI uses a **24-hour password-based session** backed by +LokiJS (in-process JSON store). This is a local-only tool; there is no +network-accessible auth surface. + +--- + +## Session Lifecycle + +1. **Login** — POST `/api/auth/login` with the admin password. + - Password is bcrypt-hashed and stored in the LokiJS session store. + - A session cookie (`nself-admin-session`) is set with `HttpOnly; SameSite=Strict`. + - Session TTL: **24 hours** from the time of login. + +2. **Session check** — every panel API call sends the session cookie. + - Server-side: LokiJS validates the session ID and checks `expiresAt`. + - Expired or missing session → `401 Unauthorized`. + +3. **Session expiry** — when the 24h TTL passes: + - The next API call returns `401`. + - The panel switches to the **auth-expired** AsyncScreen state. + - An **`AdminLoginOverlay`** appears over the panel content. + - The user enters their password; on success the session is renewed + and the panel re-fetches its data. + +4. **Session refresh** — available via POST `/api/auth/refresh` while the + session is still valid (within the 24h window). + - The admin warns when 2 hours remain (optional banner). + - Auto-refresh fires at the 20-hour mark (4 hours before expiry). + +--- + +## Security Notes + +- Session validation is **server-side only** (LokiJS). Client-side state + (React context, localStorage, cookies) cannot bypass the server check. +- The `AdminLoginOverlay` renders when the server returns `401`; it is + not triggered by client-side timeout logic. +- CSRF protection: all mutating requests require the `x-csrf-token` header + (value read from the `nself-csrf` cookie). +- The admin is bound to `localhost:3021` and is never deployed publicly. + +--- + +## Re-authentication Flow + +When a panel detects a `401` response: + +1. `sessionExpired` state is set to `true`. +2. The `AsyncScreen` switches to the `auth-expired` state. +3. `AdminLoginOverlay` renders over the panel. +4. The user enters their password. +5. `POST /api/auth/login` is called. +6. On success: overlay closes, `sessionExpired` resets to `false`, + panel re-fetches its data. +7. On failure: error message shown in the overlay input. + +--- + +## Multi-User Support + +Multi-user admin access is planned for v1.2.0 and is **not** in scope for +the current version. The current model is single-admin, single-password. +All sessions share the same credential. diff --git a/src/components/AdminLoginOverlay.tsx b/src/components/AdminLoginOverlay.tsx new file mode 100644 index 00000000..04f2b936 --- /dev/null +++ b/src/components/AdminLoginOverlay.tsx @@ -0,0 +1,116 @@ +/** + * AdminLoginOverlay — re-authentication overlay shown when the 24h LokiJS + * session expires. + * + * Purpose: Allow users to renew their admin session without navigating away + * from the current panel. + * Inputs: onSuccess callback (called after successful re-auth) + * Outputs: renders a modal overlay with a password input + * Constraints: + * - Session check happens via POST /api/auth/refresh (server-side LokiJS + * session store); client-side state cannot bypass this. + * - On success: calls onSuccess so the parent panel can re-fetch data. + * SPORT: REGISTRY-WEB-SURFACES.md — admin: session-expiry re-auth overlay + */ + +'use client' + +import { Eye, EyeOff, Lock, Loader2 } from 'lucide-react' +import { useState } from 'react' + +interface AdminLoginOverlayProps { + /** Called after a successful re-authentication. */ + onSuccess: () => void +} + +export function AdminLoginOverlay({ onSuccess }: AdminLoginOverlayProps) { + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + try { + // Get CSRF token from cookie (matches existing admin auth pattern) + const csrfToken = document.cookie + .split('; ') + .find((row) => row.startsWith('nself-csrf=')) + ?.split('=')[1] + + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken ?? '', + }, + body: JSON.stringify({ password }), + }) + + if (res.ok) { + setPassword('') + onSuccess() + } else { + const data = await res.json().catch(() => ({})) + setError(data.error ?? 'Incorrect password. Try again.') + } + } catch { + setError('Network error — check your connection.') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
+ +
+

Session expired

+

+ Your admin session has expired. Enter your password to continue. +

+
+ +
+
+ setPassword(e.target.value)} + placeholder="Admin password" + autoFocus + required + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 pr-10 text-sm placeholder-zinc-400 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder-zinc-600" + /> + +
+ + {error && ( +

{error}

+ )} + + +
+
+
+ ) +} diff --git a/src/components/AsyncScreen.tsx b/src/components/AsyncScreen.tsx new file mode 100644 index 00000000..67db63e5 --- /dev/null +++ b/src/components/AsyncScreen.tsx @@ -0,0 +1,246 @@ +/** + * AsyncScreen — 7-state UI contract for all admin panels. + * + * Purpose: Enforce consistent handling of all async states so no panel can + * silently leave the user staring at blank content. + * Inputs: props for each of the 7 states (see AsyncScreenProps). + * Outputs: renders the appropriate state layer; children only when ready. + * Constraints: + * - Exactly 7 states: loading | offline | auth-expired | error | + * empty | rate-limited | ready + * - "offline" = nSelf stack not running (stackIsDown from useStackStatus) + * - "auth-expired" = LokiJS 24h session ended → show login overlay + * - Children ONLY render in the "ready" state. + * SPORT: REGISTRY-WEB-SURFACES.md — admin: 7-state AsyncScreen contract + */ + +'use client' + +import { AlertCircle, Clock, Loader2, RefreshCw, ServerCrash, ShieldOff, WifiOff } from 'lucide-react' +import type { ReactNode } from 'react' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AsyncScreenState = + | 'loading' + | 'offline' + | 'auth-expired' + | 'error' + | 'empty' + | 'rate-limited' + | 'ready' + +export interface AsyncScreenProps { + /** Current derived state for this panel. */ + state: AsyncScreenState + /** Content to render when state === 'ready'. */ + children: ReactNode + /** Shown in the error card (state === 'error'). */ + errorMessage?: string + /** Shown in the empty state (state === 'empty'). */ + emptyMessage?: string + /** CTA label for the empty-state action button. */ + emptyAction?: string + /** Called when the user clicks the empty-state action. */ + onEmptyAction?: () => void + /** Called when the user clicks the [Check again] button (offline state). */ + onRetry?: () => void + /** Called when the user clicks [Retry] in the error state. */ + onErrorRetry?: () => void + /** Called when the user clicks [Log in again] in the auth-expired state. */ + onReauth?: () => void + /** Seconds remaining until rate-limit window resets (state === 'rate-limited'). */ + rateLimitResetSeconds?: number +} + +// --------------------------------------------------------------------------- +// Sub-state components +// --------------------------------------------------------------------------- + +function StateCard({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function StateIcon({ icon: Icon, className }: { icon: React.ElementType; className?: string }) { + return +} + +function StateTitle({ children }: { children: ReactNode }) { + return

{children}

+} + +function StateBody({ children }: { children: ReactNode }) { + return

{children}

+} + +function ActionButton({ + onClick, + children, + variant = 'secondary', +}: { + onClick?: () => void + children: ReactNode + variant?: 'primary' | 'secondary' +}) { + const base = + 'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500' + const styles = { + primary: `${base} bg-zinc-900 text-white hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300`, + secondary: `${base} border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800`, + } + return ( + + ) +} + +// --------------------------------------------------------------------------- +// Individual state views +// --------------------------------------------------------------------------- + +function LoadingState() { + return ( + + + Loading… + + ) +} + +function OfflineState({ onRetry }: { onRetry?: () => void }) { + return ( + + + nSelf stack offline + + The nSelf stack is not running.{' '} + + nself start + {' '} + in your terminal to bring it up. + + + + Check again + + + ) +} + +function AuthExpiredState({ onReauth }: { onReauth?: () => void }) { + return ( + + + Session expired + + Your admin session has expired (24-hour limit). Log in again to continue. + + + Log in again + + + ) +} + +function ErrorState({ + message, + onRetry, +}: { + message?: string + onRetry?: () => void +}) { + return ( + + + Something went wrong + {message ?? 'An unexpected error occurred. Try again.'} + {onRetry && ( + + + Retry + + )} + + ) +} + +function EmptyState({ + message, + actionLabel, + onAction, +}: { + message?: string + actionLabel?: string + onAction?: () => void +}) { + return ( + + + Nothing here yet + {message ?? 'No data to display.'} + {actionLabel && onAction && ( + + {actionLabel} + + )} + + ) +} + +function RateLimitedState({ resetSeconds }: { resetSeconds?: number }) { + return ( + + + Rate limited + + Too many requests.{' '} + {resetSeconds !== undefined + ? `Try again in ${resetSeconds}s.` + : 'Please wait a moment before retrying.'} + + + ) +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function AsyncScreen({ + state, + children, + errorMessage, + emptyMessage, + emptyAction, + onEmptyAction, + onRetry, + onErrorRetry, + onReauth, + rateLimitResetSeconds, +}: AsyncScreenProps) { + switch (state) { + case 'loading': + return + case 'offline': + return + case 'auth-expired': + return + case 'error': + return + case 'empty': + return ( + + ) + case 'rate-limited': + return + case 'ready': + return <>{children} + } +} diff --git a/src/hooks/useStackStatus.ts b/src/hooks/useStackStatus.ts new file mode 100644 index 00000000..1b7941db --- /dev/null +++ b/src/hooks/useStackStatus.ts @@ -0,0 +1,89 @@ +/** + * useStackStatus — polls the nSelf stack health endpoint and signals when + * the stack goes offline or comes back online. + * + * Purpose: Drive the "offline" UI state across all 9 admin panels. + * When the nSelf stack is not running, admin panels cannot function; + * this hook provides a single source of truth for stack availability. + * Inputs: none (reads NEXT_PUBLIC_NSELF_HEALTH_URL or falls back to + * localhost:8080/health) + * Outputs: { stackIsDown, checking, retry } + * Constraints: + * - Must see 2 consecutive failures before setting stackIsDown=true. + * This prevents false positives from a single request timeout. + * - On success, stackIsDown resets to false immediately. + * - Polls every POLL_INTERVAL ms; stops polling on unmount. + * SPORT: REGISTRY-WEB-SURFACES.md — admin: useStackStatus hook + */ + +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +const POLL_INTERVAL = 5_000 // 5 s polling cadence +const FAILURE_THRESHOLD = 2 // consecutive failures before flagging offline +const HEALTH_URL = + process.env.NEXT_PUBLIC_NSELF_HEALTH_URL ?? 'http://localhost:8080/health' + +export interface UseStackStatusResult { + /** true when 2+ consecutive health-check failures have been observed. */ + stackIsDown: boolean + /** true while the current health-check request is in flight. */ + checking: boolean + /** Manually trigger an immediate health check (e.g. from a [Check again] button). */ + retry: () => void +} + +export function useStackStatus(): UseStackStatusResult { + const [stackIsDown, setStackIsDown] = useState(false) + const [checking, setChecking] = useState(false) + + // Track consecutive failure count without triggering re-renders per increment. + const failureCountRef = useRef(0) + const isMountedRef = useRef(true) + const intervalRef = useRef | null>(null) + + const check = useCallback(async () => { + if (!isMountedRef.current) return + setChecking(true) + try { + const res = await fetch(HEALTH_URL, { + method: 'GET', + // Short timeout so we detect a down stack quickly. + signal: AbortSignal.timeout(3_000), + cache: 'no-store', + }) + if (res.ok) { + failureCountRef.current = 0 + if (isMountedRef.current) setStackIsDown(false) + } else { + throw new Error(`HTTP ${res.status}`) + } + } catch { + failureCountRef.current += 1 + if (isMountedRef.current && failureCountRef.current >= FAILURE_THRESHOLD) { + setStackIsDown(true) + } + } finally { + if (isMountedRef.current) setChecking(false) + } + }, []) + + useEffect(() => { + isMountedRef.current = true + + // Immediate first check on mount. + check() + + intervalRef.current = setInterval(check, POLL_INTERVAL) + + return () => { + isMountedRef.current = false + if (intervalRef.current !== null) { + clearInterval(intervalRef.current) + } + } + }, [check]) + + return { stackIsDown, checking, retry: check } +} diff --git a/src/lib/result.ts b/src/lib/result.ts new file mode 100644 index 00000000..12598efa --- /dev/null +++ b/src/lib/result.ts @@ -0,0 +1,122 @@ +/** + * Result — lightweight typed Result monad for admin operations. + * + * Purpose: Eliminate thrown exceptions from data-fetching paths; make + * success/failure explicit at the call site. + * Inputs: ok(value) | err(error) + * Outputs: Result discriminated union + * Constraints: Never throws; callers must check .ok before accessing .value. + * SPORT: REGISTRY-WEB-SURFACES.md — admin typed errors + */ + +export type Result = + | { ok: true; value: T } + | { ok: false; error: E } + +/** Wrap a successful value in a Result. */ +export function ok(value: T): Result { + return { ok: true, value } +} + +/** Wrap an error in a Result. */ +export function err(error: E): Result { + return { ok: false, error } +} + +/** + * AdminError — discriminated union covering every failure type in the admin GUI. + * + * Purpose: Typed errors for all 9 admin panels; each variant carries + * a user-facing message and optional details. + * Inputs: constructed via factory helpers below + * Outputs: AdminError discriminated union value + * Constraints: All variants must have a `userMessage` field for rendering. + * SPORT: REGISTRY-WEB-SURFACES.md — admin: AdminError 6 variants + */ + +export type AdminErrorType = + | 'stack_offline' + | 'auth_expired' + | 'sql_error' + | 'backup_failed' + | 'deploy_failed' + | 'network' + +export interface AdminError { + /** Discriminant — maps to one of the 6 admin error types. */ + type: AdminErrorType + /** Human-readable message suitable for display in the UI. */ + userMessage: string + /** Optional technical detail (not shown to user by default). */ + detail?: string +} + +// --------------------------------------------------------------------------- +// Factory helpers — one per variant +// --------------------------------------------------------------------------- + +export function stackOfflineError(detail?: string): AdminError { + return { + type: 'stack_offline', + userMessage: 'nSelf stack is not running. Run `nself start` in your terminal to bring it up.', + detail, + } +} + +export function authExpiredError(detail?: string): AdminError { + return { + type: 'auth_expired', + userMessage: 'Your admin session has expired. Please log in again.', + detail, + } +} + +export function sqlError(detail?: string): AdminError { + return { + type: 'sql_error', + userMessage: 'The SQL query failed. Check your syntax and try again.', + detail, + } +} + +export function backupFailedError(detail?: string): AdminError { + return { + type: 'backup_failed', + userMessage: 'Backup operation failed. Check available disk space and try again.', + detail, + } +} + +export function deployFailedError(detail?: string): AdminError { + return { + type: 'deploy_failed', + userMessage: 'Deployment failed. Review the deployment log for details.', + detail, + } +} + +export function networkError(detail?: string): AdminError { + return { + type: 'network', + userMessage: 'A network error occurred. Check your connection and try again.', + detail, + } +} + +/** + * Map any unknown thrown value to an AdminError. + * Prefers the more specific error types when recognisable signals are present. + */ +export function toAdminError(err: unknown): AdminError { + if (err instanceof Error) { + const msg = err.message.toLowerCase() + if (msg.includes('unauthorized') || msg.includes('401') || msg.includes('session')) { + return authExpiredError(err.message) + } + if (msg.includes('fetch') || msg.includes('network') || msg.includes('econnrefused')) { + return stackOfflineError(err.message) + } + return networkError(err.message) + } + return networkError(String(err)) +} diff --git a/src/lib/validation/admin-forms.ts b/src/lib/validation/admin-forms.ts new file mode 100644 index 00000000..6983a463 --- /dev/null +++ b/src/lib/validation/admin-forms.ts @@ -0,0 +1,54 @@ +/** + * Admin form validation schemas (Zod). + * + * Purpose: Validate user input in admin panels before sending to API. + * Inputs: raw string values from form fields + * Outputs: Zod parse results (success/error) + * Constraints: + * - SQL console: only validates non-empty; does NOT restrict query types. + * Admin intentionally has full SQL access (no query-type filtering here). + * - Backup name: alphanumeric + hyphens/underscores, max 50 chars. + * SPORT: REGISTRY-WEB-SURFACES.md — admin: Zod validation + */ + +import { z } from 'zod' + +// --------------------------------------------------------------------------- +// SQL console +// --------------------------------------------------------------------------- + +/** + * SQL input schema — non-empty only. + * Admin has full, unrestricted SQL access; we only prevent empty submissions. + */ +export const sqlInputSchema = z.object({ + query: z + .string() + .min(1, 'SQL query cannot be empty — enter a statement above.') + .trim(), +}) + +export type SqlInput = z.infer + +// --------------------------------------------------------------------------- +// Backup name +// --------------------------------------------------------------------------- + +/** + * Backup name schema. + * Allowed: alphanumeric, hyphens, underscores. 1–50 chars. + * Rationale: names map to filesystem paths; spaces and special chars break + * backup archive filenames on case-sensitive FS. + */ +export const backupNameSchema = z.object({ + name: z + .string() + .min(1, 'Backup name cannot be empty.') + .max(50, 'Backup name must be 50 characters or fewer.') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Backup name may only contain letters, numbers, hyphens, and underscores.' + ), +}) + +export type BackupNameInput = z.infer diff --git a/src/panels/BackupPanel.tsx b/src/panels/BackupPanel.tsx new file mode 100644 index 00000000..85e6f74e --- /dev/null +++ b/src/panels/BackupPanel.tsx @@ -0,0 +1,229 @@ +/** + * BackupPanel — admin panel for creating and listing nSelf backups. + * + * Purpose: Let admins create named backups and see the backup list with + * date and size. All 7 AsyncScreen states handled. + * Inputs: backup name (Zod validated), /api/system/backups endpoints + * Outputs: list of backups or appropriate state screens + * Constraints: + * - Backup name: alphanumeric + hyphens/underscores, max 50 chars (Zod) + * - Offline = stack not running + * - Empty = no backups yet + * - Error = fetch or create failure + * SPORT: REGISTRY-WEB-SURFACES.md — admin: BackupPanel 7-state + */ + +'use client' + +import { AdminLoginOverlay } from '@/components/AdminLoginOverlay' +import { AsyncScreen, type AsyncScreenState } from '@/components/AsyncScreen' +import { backupFailedError, err, ok, toAdminError, type Result } from '@/lib/result' +import { backupNameSchema } from '@/lib/validation/admin-forms' +import { Archive, HardDrive, Plus } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { useStackStatus } from '@/hooks/useStackStatus' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Backup { + id: string + name: string + type: string + size: number + createdAt: string + status: 'completed' | 'in_progress' | 'failed' +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatBytes(bytes: number): string { + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + if (bytes === 0) return '0 Bytes' + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}` +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function BackupPanel() { + const { stackIsDown, retry } = useStackStatus() + const [result, setResult] = useState | null>(null) + const [loading, setLoading] = useState(true) + const [sessionExpired, setSessionExpired] = useState(false) + + // Create-backup form state + const [backupName, setBackupName] = useState('') + const [nameError, setNameError] = useState(null) + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState(null) + + const fetchBackups = useCallback(async () => { + setLoading(true) + try { + const res = await fetch('/api/system/backups') + if (res.status === 401) { setSessionExpired(true); return } + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data: { backups: Backup[] } = await res.json() + setResult(ok(data.backups)) + } catch (e) { + setResult(err(toAdminError(e))) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (!stackIsDown) fetchBackups() + }, [fetchBackups, stackIsDown]) + + const createBackup = useCallback(async () => { + const parsed = backupNameSchema.safeParse({ name: backupName }) + if (!parsed.success) { + setNameError(parsed.error.issues[0]?.message ?? 'Invalid name.') + return + } + setNameError(null) + setCreating(true) + setCreateError(null) + try { + const res = await fetch('/api/system/backups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: parsed.data.name }), + }) + if (res.status === 401) { setSessionExpired(true); return } + if (!res.ok) { + const data = await res.json().catch(() => ({})) + setCreateError(backupFailedError(data?.error).userMessage) + return + } + setBackupName('') + await fetchBackups() + } catch (e) { + setCreateError(toAdminError(e).userMessage) + } finally { + setCreating(false) + } + }, [backupName, fetchBackups]) + + // --------------------------------------------------------------------------- + // Derive state + // --------------------------------------------------------------------------- + + const screenState: AsyncScreenState = (() => { + if (stackIsDown) return 'offline' + if (sessionExpired) return 'auth-expired' + if (loading) return 'loading' + if (!result) return 'loading' + if (!result.ok) return 'error' + if (result.value.length === 0) return 'empty' + return 'ready' + })() + + const backups = result?.ok ? result.value : [] + + return ( +
+ {sessionExpired && ( + { + setSessionExpired(false) + fetchBackups() + }} + /> + )} + + {/* Create backup form */} + {!stackIsDown && ( +
+
+ + setBackupName(e.target.value)} + placeholder="my-backup-2026-06-16" + maxLength={50} + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm placeholder-zinc-400 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder-zinc-600" + /> + {nameError &&

{nameError}

} + {createError &&

{createError}

} +
+ +
+ )} + + {/* Backup list */} + setSessionExpired(true)} + onErrorRetry={fetchBackups} + errorMessage={result && !result.ok ? result.error.userMessage : undefined} + emptyMessage="No backups yet — create your first backup above." + emptyAction="Create backup" + onEmptyAction={() => document.querySelector('input[placeholder*="backup"]')?.focus()} + > +
    + {backups.map((backup) => ( +
  • +
    + +
    +

    + {backup.name} +

    +

    + {formatDate(backup.createdAt)} · {backup.type} +

    +
    +
    +
    + + + {formatBytes(backup.size)} + + + {backup.status} + +
    +
  • + ))} +
+
+
+ ) +} diff --git a/src/panels/DatabaseConsolePanel.tsx b/src/panels/DatabaseConsolePanel.tsx new file mode 100644 index 00000000..3ac3c390 --- /dev/null +++ b/src/panels/DatabaseConsolePanel.tsx @@ -0,0 +1,187 @@ +/** + * DatabaseConsolePanel — admin SQL console with 7-state AsyncScreen. + * + * Purpose: Let admins run arbitrary SQL queries against the nSelf Postgres + * database. Zod validates non-empty input only (admin has full SQL access). + * Inputs: user SQL input, /api/database/query endpoint + * Outputs: results table or error card; skeleton while loading + * Constraints: + * - Zod validates non-empty only — no query-type restriction (admin) + * - Offline state = stack not running + * - Empty state = no rows returned (query succeeded but 0 results) + * - Error state = SQL execution failure + * SPORT: REGISTRY-WEB-SURFACES.md — admin: DatabaseConsolePanel 7-state + */ + +'use client' + +import { AdminLoginOverlay } from '@/components/AdminLoginOverlay' +import { AsyncScreen, type AsyncScreenState } from '@/components/AsyncScreen' +import { err, ok, sqlError, toAdminError, type Result } from '@/lib/result' +import { sqlInputSchema } from '@/lib/validation/admin-forms' +import { Play } from 'lucide-react' +import { useCallback, useState } from 'react' +import { useStackStatus } from '@/hooks/useStackStatus' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface QueryResult { + columns: string[] + rows: Record[] + rowCount: number + executionTime?: number +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function DatabaseConsolePanel() { + const { stackIsDown, retry } = useStackStatus() + const [query, setQuery] = useState('') + const [queryError, setQueryError] = useState(null) + const [result, setResult] = useState | null>(null) + const [loading, setLoading] = useState(false) + const [sessionExpired, setSessionExpired] = useState(false) + const [hasRun, setHasRun] = useState(false) + + const runQuery = useCallback(async () => { + // Zod validation: non-empty only + const parsed = sqlInputSchema.safeParse({ query }) + if (!parsed.success) { + setQueryError(parsed.error.issues[0]?.message ?? 'Invalid input.') + return + } + setQueryError(null) + setLoading(true) + setHasRun(true) + try { + const res = await fetch('/api/database/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: parsed.data.query }), + }) + if (res.status === 401) { + setSessionExpired(true) + return + } + if (!res.ok) { + const data = await res.json().catch(() => ({})) + setResult(err(sqlError(data?.error ?? `HTTP ${res.status}`))) + return + } + const data: QueryResult = await res.json() + setResult(ok(data)) + } catch (e) { + setResult(err(toAdminError(e))) + } finally { + setLoading(false) + } + }, [query]) + + // --------------------------------------------------------------------------- + // Derive state for results pane + // --------------------------------------------------------------------------- + + const screenState: AsyncScreenState = (() => { + if (stackIsDown) return 'offline' + if (sessionExpired) return 'auth-expired' + if (loading) return 'loading' + if (!hasRun) return 'empty' + if (!result) return 'loading' + if (!result.ok) return 'error' + if (result.value.rows.length === 0) return 'empty' + return 'ready' + })() + + const qr = result?.ok ? result.value : null + + return ( +
+ {sessionExpired && ( + { + setSessionExpired(false) + }} + /> + )} + + {/* SQL input */} +
+ +