From e51c7be9baa25bd78c7c5cf549f69202eecbf9a9 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Wed, 18 Mar 2026 22:33:57 +0000 Subject: [PATCH] feat(dbal): add login gate + CLI mode to Query Console - Login screen with admin token (default pre-configured for local dev) - CLI mode: type dbal commands directly (list, read, create, update, delete, rest, ping) - Clickable example commands for quick start - CLI/GUI mode toggle - Token persisted in localStorage, forwarded as Bearer auth - History shows CLI commands with $ prefix - Disconnect button to logout Co-Authored-By: Claude Opus 4.6 (1M context) --- frontends/dbal/app/api/query/route.ts | 12 +- frontends/dbal/src/QueryConsole.module.scss | 188 ++++++++ frontends/dbal/src/QueryConsole.tsx | 505 +++++++++++++------- 3 files changed, 533 insertions(+), 172 deletions(-) diff --git a/frontends/dbal/app/api/query/route.ts b/frontends/dbal/app/api/query/route.ts index ef5d2cfe1..a5ee083ab 100644 --- a/frontends/dbal/app/api/query/route.ts +++ b/frontends/dbal/app/api/query/route.ts @@ -3,14 +3,22 @@ import { NextRequest, NextResponse } from 'next/server' const DBAL_DAEMON_URL = process.env.DBAL_DAEMON_URL ?? 'http://localhost:8080' export async function POST(request: NextRequest) { - const { method, path, body } = await request.json() + const { method, path, body, token } = await request.json() const url = `${DBAL_DAEMON_URL}${path}` try { + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + const fetchOptions: RequestInit = { method: method, - headers: { 'Content-Type': 'application/json' }, + headers, signal: AbortSignal.timeout(10000), } diff --git a/frontends/dbal/src/QueryConsole.module.scss b/frontends/dbal/src/QueryConsole.module.scss index 469416a85..4e84f0e81 100644 --- a/frontends/dbal/src/QueryConsole.module.scss +++ b/frontends/dbal/src/QueryConsole.module.scss @@ -344,6 +344,194 @@ padding: 2rem 0; } +/* Login */ +.loginContainer { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(100vh - 100px); +} + +.loginCard { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 1rem; + padding: 2.5rem; + width: 100%; + max-width: 400px; + text-align: center; +} + +.loginIcon { + width: 56px; + height: 56px; + border-radius: 14px; + background: #3b82f6; + color: #fff; + font-size: 1.25rem; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 1.25rem; +} + +.loginTitle { + font-size: 1.25rem; + font-weight: 700; + margin: 0 0 0.5rem; +} + +.loginSub { + font-size: 0.875rem; + color: #888; + margin: 0 0 1.5rem; + line-height: 1.4; +} + +.loginBtn { + display: block; + width: 100%; + padding: 0.75rem; + margin-top: 1rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 0.5rem; + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; + + &:hover { + background: #2563eb; + } +} + +.loginHint { + font-size: 0.75rem; + color: #555; + margin-top: 1rem; +} + +/* Mode bar */ +.modeBar { + display: flex; + align-items: center; + justify-content: space-between; +} + +.modeTabs { + display: flex; + gap: 0; + background: #1a1a1a; + border: 1px solid #333; + border-radius: 0.5rem; + overflow: hidden; +} + +.modeTab { + padding: 0.5rem 1.25rem; + background: transparent; + border: none; + color: #888; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + + &:hover { + color: #ccc; + } +} + +.modeTabActive { + background: #333; + color: #fff; +} + +.logoutBtn { + padding: 0.375rem 0.75rem; + background: transparent; + border: 1px solid #333; + border-radius: 0.375rem; + color: #888; + font-size: 0.75rem; + cursor: pointer; + + &:hover { + border-color: #f87171; + color: #f87171; + } +} + +/* CLI */ +.cliRow { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.cliPrompt { + color: #4ade80; + font-family: 'IBM Plex Mono', monospace; + font-size: 1.125rem; + font-weight: 700; + flex-shrink: 0; +} + +.cliInput { + flex: 1; + padding: 0.625rem 0.75rem; + background: #111; + border: 1px solid #333; + border-radius: 0.375rem; + color: var(--foreground); + font-size: 0.9375rem; + font-family: 'IBM Plex Mono', monospace; + outline: none; + + &:focus { + border-color: #4ade80; + } + + &::placeholder { + color: #555; + } +} + +.cliHelp { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.75rem; + align-items: center; +} + +.cliHelpLabel { + font-size: 0.6875rem; + color: #555; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; +} + +.cliExample { + padding: 0.25rem 0.5rem; + background: #111; + border: 1px solid #262626; + border-radius: 0.25rem; + color: #888; + font-size: 0.6875rem; + font-family: 'IBM Plex Mono', monospace; + cursor: pointer; + transition: all 0.15s; + + &:hover { + border-color: #4ade80; + color: #4ade80; + } +} + .clearBtn { padding: 0.375rem 0.75rem; background: transparent; diff --git a/frontends/dbal/src/QueryConsole.tsx b/frontends/dbal/src/QueryConsole.tsx index 66e134495..fdf8122f1 100644 --- a/frontends/dbal/src/QueryConsole.tsx +++ b/frontends/dbal/src/QueryConsole.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useCallback, useEffect } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import styles from './QueryConsole.module.scss' type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' @@ -14,6 +14,7 @@ interface QueryHistoryEntry { timestamp: string body?: unknown queryParams?: string + cli?: string } interface QueryResponse { @@ -32,7 +33,88 @@ const METHODS: { value: HttpMethod; className: string }[] = [ ] const HISTORY_KEY = 'dbal-query-history' +const TOKEN_KEY = 'dbal-admin-token' const MAX_HISTORY = 20 +const DEFAULT_TOKEN = '069e6487a710300381cd52120eab95d56d7f53beee21479cbeba9128217cbea9' + +const CLI_EXAMPLES = [ + 'dbal list Snippet', + 'dbal read Snippet ', + 'dbal create Snippet title="Hello" content="world"', + 'dbal delete Snippet ', + 'dbal rest pastebin pastebin Snippet', + 'dbal rest pastebin pastebin User', + 'dbal ping', +] + +function parseCli(input: string): { method: HttpMethod; path: string; body?: Record } | null { + const parts = input.trim().split(/\s+/) + if (parts.length < 2) return null + + const cmd = parts[0].toLowerCase() + if (cmd !== 'dbal') return null + + const sub = parts[1].toLowerCase() + + if (sub === 'ping') { + return { method: 'GET', path: '/health' } + } + + if (sub === 'rest' && parts.length >= 5) { + const [, , tenant, pkg, entity, ...rest] = parts + let path = `/${tenant}/${pkg}/${entity}` + const id = rest.find(r => !r.includes('=') && !['GET', 'POST', 'PUT', 'DELETE'].includes(r.toUpperCase())) + const methodOverride = rest.find(r => ['GET', 'POST', 'PUT', 'DELETE'].includes(r.toUpperCase())) + if (id) path += `/${id}` + const method = (methodOverride?.toUpperCase() as HttpMethod) || 'GET' + const bodyParts = rest.filter(r => r.includes('=') && !['GET', 'POST', 'PUT', 'DELETE'].includes(r.toUpperCase())) + const body: Record = {} + for (const bp of bodyParts) { + const [k, ...v] = bp.split('=') + const val = v.join('=').replace(/^"|"$/g, '') + body[k] = val === 'true' ? true : val === 'false' ? false : isNaN(Number(val)) ? val : Number(val) + } + return { method, path, body: Object.keys(body).length > 0 ? body : undefined } + } + + // dbal list/read/create/update/delete [id] [field=value...] + const entity = parts[2] || 'Snippet' + const tenant = 'pastebin' + const pkg = 'pastebin' + + switch (sub) { + case 'list': + return { method: 'GET', path: `/${tenant}/${pkg}/${entity}` } + case 'read': + return { method: 'GET', path: `/${tenant}/${pkg}/${entity}/${parts[3] || ''}` } + case 'create': { + const body: Record = {} + for (const p of parts.slice(3)) { + if (p.includes('=')) { + const [k, ...v] = p.split('=') + const val = v.join('=').replace(/^"|"$/g, '') + body[k] = val === 'true' ? true : val === 'false' ? false : isNaN(Number(val)) ? val : Number(val) + } + } + return { method: 'POST', path: `/${tenant}/${pkg}/${entity}`, body } + } + case 'update': { + const body: Record = {} + for (const p of parts.slice(4)) { + if (p.includes('=')) { + const [k, ...v] = p.split('=') + const val = v.join('=').replace(/^"|"$/g, '') + body[k] = val === 'true' ? true : val === 'false' ? false : isNaN(Number(val)) ? val : Number(val) + } + } + return { method: 'PUT', path: `/${tenant}/${pkg}/${entity}/${parts[3] || ''}`, body } + } + case 'delete': + return { method: 'DELETE', path: `/${tenant}/${pkg}/${entity}/${parts[3] || ''}` } + default: + return null + } +} function syntaxHighlight(json: string): string { return json.replace( @@ -64,12 +146,29 @@ function loadHistory(): QueryHistoryEntry[] { function saveHistory(entries: QueryHistoryEntry[]) { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(entries)) - } catch { - /* quota exceeded — silently ignore */ - } + } catch { /* quota exceeded */ } +} + +function loadToken(): string { + if (typeof window === 'undefined') return '' + return localStorage.getItem(TOKEN_KEY) || '' +} + +function saveToken(token: string) { + try { + localStorage.setItem(TOKEN_KEY, token) + } catch { /* ignore */ } } export function QueryConsole() { + const [token, setToken] = useState('') + const [authed, setAuthed] = useState(false) + const [tokenInput, setTokenInput] = useState('') + + const [mode, setMode] = useState<'gui' | 'cli'>('cli') + const [cliInput, setCliInput] = useState('') + const cliRef = useRef(null) + const [method, setMethod] = useState('GET') const [tenant, setTenant] = useState('pastebin') const [pkg, setPkg] = useState('pastebin') @@ -83,60 +182,56 @@ export function QueryConsole() { useEffect(() => { setHistory(loadHistory()) + const saved = loadToken() + if (saved) { + setToken(saved) + setAuthed(true) + } + }, []) + + const handleLogin = useCallback(() => { + const t = tokenInput.trim() || DEFAULT_TOKEN + setToken(t) + saveToken(t) + setAuthed(true) + }, [tokenInput]) + + const handleLogout = useCallback(() => { + setToken('') + setAuthed(false) + localStorage.removeItem(TOKEN_KEY) }, []) const buildPath = useCallback(() => { let path = `/${tenant}/${pkg}/${entity}` - if (entityId.trim()) { - path += `/${entityId.trim()}` - } - if (queryParams.trim() && method === 'GET') { - path += `?${queryParams.trim()}` - } + if (entityId.trim()) path += `/${entityId.trim()}` + if (queryParams.trim() && method === 'GET') path += `?${queryParams.trim()}` return path }, [tenant, pkg, entity, entityId, queryParams, method]) - const execute = useCallback(async () => { + const executeQuery = useCallback(async (m: HttpMethod, path: string, reqBody?: unknown, cliCmd?: string) => { setLoading(true) setResponse(null) - const path = buildPath() - let parsedBody: unknown = undefined - if (body.trim() && (method === 'POST' || method === 'PUT')) { - try { - parsedBody = JSON.parse(body) - } catch { - setResponse({ - status: 0, - statusText: 'Client Error', - data: { error: 'Invalid JSON in request body' }, - url: path, - timestamp: new Date().toISOString(), - }) - setLoading(false) - return - } - } - try { const basePath = process.env.__NEXT_ROUTER_BASEPATH || '/dbal' const res = await fetch(`${basePath}/api/query`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ method, path, body: parsedBody }), + body: JSON.stringify({ method: m, path, body: reqBody, token }), }) const data: QueryResponse = await res.json() setResponse(data) const entry: QueryHistoryEntry = { id: crypto.randomUUID(), - method, + method: m, path, status: data.status, statusText: data.statusText, timestamp: data.timestamp, - body: parsedBody, - queryParams: queryParams.trim() || undefined, + body: reqBody, + cli: cliCmd, } const updated = [entry, ...history].slice(0, MAX_HISTORY) setHistory(updated) @@ -152,20 +247,53 @@ export function QueryConsole() { } finally { setLoading(false) } - }, [method, body, buildPath, history, queryParams]) + }, [token, history]) + + const executeGui = useCallback(async () => { + const path = buildPath() + let parsedBody: unknown = undefined + if (body.trim() && (method === 'POST' || method === 'PUT')) { + try { + parsedBody = JSON.parse(body) + } catch { + setResponse({ + status: 0, statusText: 'Client Error', + data: { error: 'Invalid JSON in request body' }, + url: path, timestamp: new Date().toISOString(), + }) + return + } + } + executeQuery(method, path, parsedBody) + }, [method, body, buildPath, executeQuery]) + + const executeCli = useCallback(() => { + const parsed = parseCli(cliInput) + if (!parsed) { + setResponse({ + status: 0, statusText: 'Parse Error', + data: { error: `Unknown command. Try: ${CLI_EXAMPLES[0]}` }, + url: '', timestamp: new Date().toISOString(), + }) + return + } + executeQuery(parsed.method, parsed.path, parsed.body, cliInput) + }, [cliInput, executeQuery]) const restoreFromHistory = useCallback((entry: QueryHistoryEntry) => { - setMethod(entry.method) - const parts = entry.path.replace(/\?.*$/, '').split('/').filter(Boolean) - if (parts.length >= 1) setTenant(parts[0]) - if (parts.length >= 2) setPkg(parts[1]) - if (parts.length >= 3) setEntity(parts[2]) - if (parts.length >= 4) setEntityId(parts[3]) - else setEntityId('') - if (entry.queryParams) setQueryParams(entry.queryParams) - else setQueryParams('') - if (entry.body) setBody(JSON.stringify(entry.body, null, 2)) - else setBody('') + if (entry.cli) { + setMode('cli') + setCliInput(entry.cli) + } else { + setMode('gui') + setMethod(entry.method) + const parts = entry.path.replace(/\?.*$/, '').split('/').filter(Boolean) + if (parts.length >= 1) setTenant(parts[0]) + if (parts.length >= 2) setPkg(parts[1]) + if (parts.length >= 3) setEntity(parts[2]) + if (parts.length >= 4) setEntityId(parts[3]) + else setEntityId('') + } }, []) const clearHistory = useCallback(() => { @@ -182,116 +310,163 @@ export function QueryConsole() { } } - const formatTimestamp = (ts: string) => { - try { - return new Date(ts).toLocaleTimeString() - } catch { - return ts - } + // Login screen + if (!authed) { + return ( +
+
+
+
DB
+

DBAL Query Console

+

Enter your admin token to connect to the DBAL daemon.

+
+ + setTokenInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleLogin()} + placeholder="Leave blank for default token" + autoFocus + /> +
+ +

Default token is pre-configured for local development

+
+
+
+ ) } return (
-
-

Query Console

- -
- {METHODS.map(m => ( - - ))} -
- -
-
- - setTenant(e.target.value)} - placeholder="pastebin" - /> -
-
- - setPkg(e.target.value)} - placeholder="pastebin" - /> -
-
- - setEntity(e.target.value)} - placeholder="Snippet" - /> -
-
- - setEntityId(e.target.value)} - placeholder="abc-123" - /> -
- {method === 'GET' && ( -
- - setQueryParams(e.target.value)} - placeholder="limit=10&offset=0" - /> -
- )} - {(method === 'POST' || method === 'PUT') && ( -
- -