Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions apps/reasondb-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
111 changes: 111 additions & 0 deletions apps/reasondb-client/src/components/connection/ApiKeyPromptDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>
onCancel: () => void
}

export function ApiKeyPromptDialog({
open,
connectionName,
onSubmit,
onCancel,
}: ApiKeyPromptDialogProps) {
const [apiKey, setApiKey] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={(v) => { if (!v) onCancel() }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key size={18} weight="duotone" className="text-blue" />
Authentication Required
</DialogTitle>
<DialogDescription>
<strong>{connectionName}</strong> requires an API key. Enter your key to connect.
</DialogDescription>
</DialogHeader>

<form onSubmit={handleSubmit} className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="api-key-input">API Key</Label>
<Input
id="api-key-input"
type="password"
placeholder="Enter your API key…"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoFocus
disabled={loading}
/>
</div>

{error && (
<div className="flex items-center gap-2 text-sm text-red rounded-md bg-red/10 px-3 py-2">
<WarningCircle size={16} weight="fill" className="shrink-0" />
<span>{error}</span>
</div>
)}

<DialogFooter>
<Button type="button" variant="ghost" onClick={onCancel} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading || !apiKey.trim()}>
{loading && <CircleNotch size={14} className="mr-1.5 animate-spin" />}
Connect
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
66 changes: 59 additions & 7 deletions apps/reasondb-client/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import {
Clock,
Star,
Expand All @@ -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 {
Expand All @@ -31,6 +32,10 @@ export function Sidebar() {
const { history, savedQueries } = useQueryStore()
const [editingConnection, setEditingConnection] = useState<Connection | undefined>()

// Pending auth — set when a server requires auth but the connection has no key
const [pendingAuthConnection, setPendingAuthConnection] = useState<Connection | null>(null)
const pendingClientRef = useRef<ReasonDBClient | null>(null)

const handleConnect = async (connection: Connection) => {
setConnecting(true)
setConnectionError(null)
Expand All @@ -44,20 +49,60 @@ 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 {
setConnecting(false)
}
}

const handleApiKeySubmit = async (apiKey: string): Promise<boolean> => {
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)
Expand Down Expand Up @@ -218,6 +263,13 @@ export function Sidebar() {
onOpenChange={setShowConnectionForm}
editConnection={editingConnection}
/>

<ApiKeyPromptDialog
open={pendingAuthConnection !== null}
connectionName={pendingAuthConnection?.name ?? ''}
onSubmit={handleApiKeySubmit}
onCancel={handleApiKeyCancel}
/>
</nav>
)
}
32 changes: 32 additions & 0 deletions apps/reasondb-client/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ====================

/**
Expand Down
Loading