Skip to content

Commit b2a523f

Browse files
authored
feat: prompt for API key when server requires authentication (#48)
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
1 parent 651a551 commit b2a523f

4 files changed

Lines changed: 218 additions & 11 deletions

File tree

apps/reasondb-client/src/App.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,24 @@ function App() {
3434
useSsl: activeConnection.ssl,
3535
})
3636

37-
client.testConnection().then((result) => {
38-
if (result.success) {
39-
setClient(activeConnectionId, client)
40-
} else {
37+
client.testConnection().then(async (result) => {
38+
if (!result.success) {
4139
setActiveConnection(null)
40+
return
41+
}
42+
43+
// If the persisted connection has no API key, check whether auth is required.
44+
// If so, drop the active connection so the user is brought back to the
45+
// connection list where clicking the row will show the API key prompt.
46+
if (!activeConnection.apiKey) {
47+
const { authRequired } = await client.checkAuth()
48+
if (authRequired) {
49+
setActiveConnection(null)
50+
return
51+
}
4252
}
53+
54+
setClient(activeConnectionId, client)
4355
}).catch(() => {
4456
setActiveConnection(null)
4557
})
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { useState, useEffect } from 'react'
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogHeader,
6+
DialogTitle,
7+
DialogDescription,
8+
DialogFooter,
9+
} from '@/components/ui/Dialog'
10+
import { Input } from '@/components/ui/Input'
11+
import { Button } from '@/components/ui/Button'
12+
import { Label } from '@/components/ui/Label'
13+
import { Key, CircleNotch, WarningCircle } from '@phosphor-icons/react'
14+
15+
interface ApiKeyPromptDialogProps {
16+
open: boolean
17+
connectionName: string
18+
/** Called with the submitted key; return false to show a validation error */
19+
onSubmit: (apiKey: string) => Promise<boolean>
20+
onCancel: () => void
21+
}
22+
23+
export function ApiKeyPromptDialog({
24+
open,
25+
connectionName,
26+
onSubmit,
27+
onCancel,
28+
}: ApiKeyPromptDialogProps) {
29+
const [apiKey, setApiKey] = useState('')
30+
const [loading, setLoading] = useState(false)
31+
const [error, setError] = useState<string | null>(null)
32+
33+
// Reset state when dialog opens
34+
useEffect(() => {
35+
if (open) {
36+
setApiKey('')
37+
setError(null)
38+
setLoading(false)
39+
}
40+
}, [open])
41+
42+
const handleSubmit = async (e: React.FormEvent) => {
43+
e.preventDefault()
44+
if (!apiKey.trim()) {
45+
setError('API key is required')
46+
return
47+
}
48+
49+
setLoading(true)
50+
setError(null)
51+
52+
try {
53+
const ok = await onSubmit(apiKey.trim())
54+
if (!ok) {
55+
setError('Invalid API key — authentication failed')
56+
}
57+
} catch {
58+
setError('Connection error — please check the key and try again')
59+
} finally {
60+
setLoading(false)
61+
}
62+
}
63+
64+
return (
65+
<Dialog open={open} onOpenChange={(v) => { if (!v) onCancel() }}>
66+
<DialogContent className="sm:max-w-md">
67+
<DialogHeader>
68+
<DialogTitle className="flex items-center gap-2">
69+
<Key size={18} weight="duotone" className="text-blue" />
70+
Authentication Required
71+
</DialogTitle>
72+
<DialogDescription>
73+
<strong>{connectionName}</strong> requires an API key. Enter your key to connect.
74+
</DialogDescription>
75+
</DialogHeader>
76+
77+
<form onSubmit={handleSubmit} className="space-y-4 py-2">
78+
<div className="space-y-1.5">
79+
<Label htmlFor="api-key-input">API Key</Label>
80+
<Input
81+
id="api-key-input"
82+
type="password"
83+
placeholder="Enter your API key…"
84+
value={apiKey}
85+
onChange={(e) => setApiKey(e.target.value)}
86+
autoFocus
87+
disabled={loading}
88+
/>
89+
</div>
90+
91+
{error && (
92+
<div className="flex items-center gap-2 text-sm text-red rounded-md bg-red/10 px-3 py-2">
93+
<WarningCircle size={16} weight="fill" className="shrink-0" />
94+
<span>{error}</span>
95+
</div>
96+
)}
97+
98+
<DialogFooter>
99+
<Button type="button" variant="ghost" onClick={onCancel} disabled={loading}>
100+
Cancel
101+
</Button>
102+
<Button type="submit" disabled={loading || !apiKey.trim()}>
103+
{loading && <CircleNotch size={14} className="mr-1.5 animate-spin" />}
104+
Connect
105+
</Button>
106+
</DialogFooter>
107+
</form>
108+
</DialogContent>
109+
</Dialog>
110+
)
111+
}

apps/reasondb-client/src/components/layout/Sidebar.tsx

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from 'react'
1+
import { useState, useRef } from 'react'
22
import {
33
Clock,
44
Star,
@@ -17,8 +17,9 @@ import { useLlmHealthStore } from '@/stores/llmHealthStore'
1717
import { useQueryStore } from '@/stores/queryStore'
1818
import { ConnectionList } from '@/components/connection/ConnectionList'
1919
import { ConnectionForm } from '@/components/connection/ConnectionForm'
20+
import { ApiKeyPromptDialog } from '@/components/connection/ApiKeyPromptDialog'
2021
import { TableBrowser } from '@/components/table/TableBrowser'
21-
import { createClient, setClient, removeClient } from '@/lib/api'
22+
import { createClient, setClient, removeClient, type ReasonDBClient } from '@/lib/api'
2223

2324
export function Sidebar() {
2425
const {
@@ -31,6 +32,10 @@ export function Sidebar() {
3132
const { history, savedQueries } = useQueryStore()
3233
const [editingConnection, setEditingConnection] = useState<Connection | undefined>()
3334

35+
// Pending auth — set when a server requires auth but the connection has no key
36+
const [pendingAuthConnection, setPendingAuthConnection] = useState<Connection | null>(null)
37+
const pendingClientRef = useRef<ReasonDBClient | null>(null)
38+
3439
const handleConnect = async (connection: Connection) => {
3540
setConnecting(true)
3641
setConnectionError(null)
@@ -44,20 +49,60 @@ export function Sidebar() {
4449
})
4550

4651
const result = await client.testConnection()
47-
48-
if (result.success) {
49-
setClient(connection.id, client)
50-
setActiveConnection(connection.id)
51-
} else {
52+
53+
if (!result.success) {
5254
setConnectionError(result.error || 'Connection failed')
55+
return
56+
}
57+
58+
// If no apiKey is already configured, probe to see if the server requires one
59+
if (!connection.apiKey) {
60+
const { authRequired } = await client.checkAuth()
61+
if (authRequired) {
62+
// Park the client and surface the API key prompt
63+
pendingClientRef.current = client
64+
setPendingAuthConnection(connection)
65+
return
66+
}
5367
}
68+
69+
setClient(connection.id, client)
70+
setActiveConnection(connection.id)
5471
} catch (error) {
5572
setConnectionError(error instanceof Error ? error.message : 'Connection failed')
5673
} finally {
5774
setConnecting(false)
5875
}
5976
}
6077

78+
const handleApiKeySubmit = async (apiKey: string): Promise<boolean> => {
79+
if (!pendingAuthConnection || !pendingClientRef.current) return false
80+
81+
const client = pendingClientRef.current
82+
client.updateApiKey(apiKey)
83+
84+
// Validate the key by making a real authenticated request
85+
try {
86+
await client.listTables()
87+
} catch {
88+
// 401/403 or other error — key is invalid or request failed
89+
return false
90+
}
91+
92+
// Key is valid — complete the connection
93+
setClient(pendingAuthConnection.id, client)
94+
setActiveConnection(pendingAuthConnection.id)
95+
setPendingAuthConnection(null)
96+
pendingClientRef.current = null
97+
return true
98+
}
99+
100+
const handleApiKeyCancel = () => {
101+
setPendingAuthConnection(null)
102+
pendingClientRef.current = null
103+
setConnecting(false)
104+
}
105+
61106
const handleDisconnect = () => {
62107
if (activeConnectionId) {
63108
removeClient(activeConnectionId)
@@ -218,6 +263,13 @@ export function Sidebar() {
218263
onOpenChange={setShowConnectionForm}
219264
editConnection={editingConnection}
220265
/>
266+
267+
<ApiKeyPromptDialog
268+
open={pendingAuthConnection !== null}
269+
connectionName={pendingAuthConnection?.name ?? ''}
270+
onSubmit={handleApiKeySubmit}
271+
onCancel={handleApiKeyCancel}
272+
/>
221273
</nav>
222274
)
223275
}

apps/reasondb-client/src/lib/api.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,38 @@ class ReasonDBClient {
770770
requestCache.clear()
771771
}
772772

773+
// ==================== Auth ====================
774+
775+
/**
776+
* Update the API key on this client instance (in-memory only).
777+
* Called after a successful auth prompt so the client can make authenticated requests.
778+
*/
779+
updateApiKey(apiKey: string): void {
780+
this.apiKey = apiKey
781+
}
782+
783+
/**
784+
* Detect whether the server requires an API key by probing /v1/tables
785+
* without any auth header.
786+
*
787+
* Returns `{ authRequired: true }` on 401/403, `{ authRequired: false }` on
788+
* any successful response, and `{ authRequired: false, error }` on network
789+
* errors so callers don't block the connect flow for transient failures.
790+
*/
791+
async checkAuth(): Promise<{ authRequired: boolean; error?: string }> {
792+
const url = `${this.baseUrl}/v1/tables`
793+
try {
794+
const response = await fetch(url, { method: 'GET' })
795+
if (response.status === 401 || response.status === 403) {
796+
return { authRequired: true }
797+
}
798+
return { authRequired: false }
799+
} catch {
800+
// Network error — treat as no auth required so we don't block the flow
801+
return { authRequired: false }
802+
}
803+
}
804+
773805
// ==================== Health ====================
774806

775807
/**

0 commit comments

Comments
 (0)