From db422151bd2de8d13112e7019e0c05b665e17816 Mon Sep 17 00:00:00 2001 From: Ajain Date: Mon, 16 Mar 2026 16:32:29 +1100 Subject: [PATCH] feat: prompt for API key when server requires authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Servers with auth enabled return 401/403 on unauthenticated requests. Previously the client would silently fail or show a generic error. - Add `checkAuth()` to ReasonDBClient — probes /v1/tables without auth and returns `{ authRequired: true }` on 401/403 - Add `updateApiKey()` to apply a key to an existing client instance - Add ApiKeyPromptDialog — modal that validates the key by calling listTables() before completing the connection - Wire into Sidebar.handleConnect: if the server requires auth, park the client and show the prompt instead of failing - Wire into App startup: if a persisted connection has no stored key and the server now requires auth, drop it back to the connection list --- apps/reasondb-client/src/App.tsx | 20 +++- .../connection/ApiKeyPromptDialog.tsx | 111 ++++++++++++++++++ .../src/components/layout/Sidebar.tsx | 66 +++++++++-- apps/reasondb-client/src/lib/api.ts | 32 +++++ 4 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 apps/reasondb-client/src/components/connection/ApiKeyPromptDialog.tsx diff --git a/apps/reasondb-client/src/App.tsx b/apps/reasondb-client/src/App.tsx index ea14ec1..3c4e1bf 100644 --- a/apps/reasondb-client/src/App.tsx +++ b/apps/reasondb-client/src/App.tsx @@ -34,12 +34,24 @@ function App() { useSsl: activeConnection.ssl, }) - client.testConnection().then((result) => { - if (result.success) { - setClient(activeConnectionId, client) - } else { + client.testConnection().then(async (result) => { + if (!result.success) { setActiveConnection(null) + return + } + + // If the persisted connection has no API key, check whether auth is required. + // If so, drop the active connection so the user is brought back to the + // connection list where clicking the row will show the API key prompt. + if (!activeConnection.apiKey) { + const { authRequired } = await client.checkAuth() + if (authRequired) { + setActiveConnection(null) + return + } } + + setClient(activeConnectionId, client) }).catch(() => { setActiveConnection(null) }) diff --git a/apps/reasondb-client/src/components/connection/ApiKeyPromptDialog.tsx b/apps/reasondb-client/src/components/connection/ApiKeyPromptDialog.tsx new file mode 100644 index 0000000..b08f596 --- /dev/null +++ b/apps/reasondb-client/src/components/connection/ApiKeyPromptDialog.tsx @@ -0,0 +1,111 @@ +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/Dialog' +import { Input } from '@/components/ui/Input' +import { Button } from '@/components/ui/Button' +import { Label } from '@/components/ui/Label' +import { Key, CircleNotch, WarningCircle } from '@phosphor-icons/react' + +interface ApiKeyPromptDialogProps { + open: boolean + connectionName: string + /** Called with the submitted key; return false to show a validation error */ + onSubmit: (apiKey: string) => Promise + onCancel: () => void +} + +export function ApiKeyPromptDialog({ + open, + connectionName, + onSubmit, + onCancel, +}: ApiKeyPromptDialogProps) { + const [apiKey, setApiKey] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setApiKey('') + setError(null) + setLoading(false) + } + }, [open]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!apiKey.trim()) { + setError('API key is required') + return + } + + setLoading(true) + setError(null) + + try { + const ok = await onSubmit(apiKey.trim()) + if (!ok) { + setError('Invalid API key — authentication failed') + } + } catch { + setError('Connection error — please check the key and try again') + } finally { + setLoading(false) + } + } + + return ( + { if (!v) onCancel() }}> + + + + + Authentication Required + + + {connectionName} requires an API key. Enter your key to connect. + + + +
+
+ + setApiKey(e.target.value)} + autoFocus + disabled={loading} + /> +
+ + {error && ( +
+ + {error} +
+ )} + + + + + +
+
+
+ ) +} diff --git a/apps/reasondb-client/src/components/layout/Sidebar.tsx b/apps/reasondb-client/src/components/layout/Sidebar.tsx index 1da3f34..d88a130 100644 --- a/apps/reasondb-client/src/components/layout/Sidebar.tsx +++ b/apps/reasondb-client/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useRef } from 'react' import { Clock, Star, @@ -17,8 +17,9 @@ import { useLlmHealthStore } from '@/stores/llmHealthStore' import { useQueryStore } from '@/stores/queryStore' import { ConnectionList } from '@/components/connection/ConnectionList' import { ConnectionForm } from '@/components/connection/ConnectionForm' +import { ApiKeyPromptDialog } from '@/components/connection/ApiKeyPromptDialog' import { TableBrowser } from '@/components/table/TableBrowser' -import { createClient, setClient, removeClient } from '@/lib/api' +import { createClient, setClient, removeClient, type ReasonDBClient } from '@/lib/api' export function Sidebar() { const { @@ -31,6 +32,10 @@ export function Sidebar() { const { history, savedQueries } = useQueryStore() const [editingConnection, setEditingConnection] = useState() + // Pending auth — set when a server requires auth but the connection has no key + const [pendingAuthConnection, setPendingAuthConnection] = useState(null) + const pendingClientRef = useRef(null) + const handleConnect = async (connection: Connection) => { setConnecting(true) setConnectionError(null) @@ -44,13 +49,25 @@ export function Sidebar() { }) const result = await client.testConnection() - - if (result.success) { - setClient(connection.id, client) - setActiveConnection(connection.id) - } else { + + if (!result.success) { setConnectionError(result.error || 'Connection failed') + return + } + + // If no apiKey is already configured, probe to see if the server requires one + if (!connection.apiKey) { + const { authRequired } = await client.checkAuth() + if (authRequired) { + // Park the client and surface the API key prompt + pendingClientRef.current = client + setPendingAuthConnection(connection) + return + } } + + setClient(connection.id, client) + setActiveConnection(connection.id) } catch (error) { setConnectionError(error instanceof Error ? error.message : 'Connection failed') } finally { @@ -58,6 +75,34 @@ export function Sidebar() { } } + const handleApiKeySubmit = async (apiKey: string): Promise => { + if (!pendingAuthConnection || !pendingClientRef.current) return false + + const client = pendingClientRef.current + client.updateApiKey(apiKey) + + // Validate the key by making a real authenticated request + try { + await client.listTables() + } catch { + // 401/403 or other error — key is invalid or request failed + return false + } + + // Key is valid — complete the connection + setClient(pendingAuthConnection.id, client) + setActiveConnection(pendingAuthConnection.id) + setPendingAuthConnection(null) + pendingClientRef.current = null + return true + } + + const handleApiKeyCancel = () => { + setPendingAuthConnection(null) + pendingClientRef.current = null + setConnecting(false) + } + const handleDisconnect = () => { if (activeConnectionId) { removeClient(activeConnectionId) @@ -218,6 +263,13 @@ export function Sidebar() { onOpenChange={setShowConnectionForm} editConnection={editingConnection} /> + + ) } diff --git a/apps/reasondb-client/src/lib/api.ts b/apps/reasondb-client/src/lib/api.ts index 723552c..20f5a25 100644 --- a/apps/reasondb-client/src/lib/api.ts +++ b/apps/reasondb-client/src/lib/api.ts @@ -770,6 +770,38 @@ class ReasonDBClient { requestCache.clear() } + // ==================== Auth ==================== + + /** + * Update the API key on this client instance (in-memory only). + * Called after a successful auth prompt so the client can make authenticated requests. + */ + updateApiKey(apiKey: string): void { + this.apiKey = apiKey + } + + /** + * Detect whether the server requires an API key by probing /v1/tables + * without any auth header. + * + * Returns `{ authRequired: true }` on 401/403, `{ authRequired: false }` on + * any successful response, and `{ authRequired: false, error }` on network + * errors so callers don't block the connect flow for transient failures. + */ + async checkAuth(): Promise<{ authRequired: boolean; error?: string }> { + const url = `${this.baseUrl}/v1/tables` + try { + const response = await fetch(url, { method: 'GET' }) + if (response.status === 401 || response.status === 403) { + return { authRequired: true } + } + return { authRequired: false } + } catch { + // Network error — treat as no auth required so we don't block the flow + return { authRequired: false } + } + } + // ==================== Health ==================== /**