Files
tolaria/src/components/SettingsPanel.tsx
Test ce12e3cd6f fix: use Tauri opener plugin for GitHub OAuth browser launch
Replace window.open() with openExternalUrl() which uses
@tauri-apps/plugin-opener in native mode. Also show the verification
URL in the waiting UI so users can navigate manually, add a copy-code
button, and display a retry button on error/expiry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:13:42 +01:00

524 lines
20 KiB
TypeScript

import { useState, useRef, useCallback, useEffect } from 'react'
import { X, Eye, EyeSlash, GithubLogo, SignOut, CircleNotch, ArrowClockwise, Copy, Check } from '@phosphor-icons/react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { openExternalUrl } from '../utils/url'
import type { Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types'
function tauriCall<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
}
interface SettingsPanelProps {
open: boolean
settings: Settings
onSave: (settings: Settings) => void
onClose: () => void
}
interface KeyFieldProps {
label: string
placeholder: string
value: string
onChange: (value: string) => void
onClear: () => void
}
function KeyField({ label, placeholder, value, onChange, onClear }: KeyFieldProps) {
const [revealed, setRevealed] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>{label}</label>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<input
ref={inputRef}
type={revealed ? 'text' : 'password'}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="w-full border border-border bg-transparent text-foreground rounded"
style={{ fontSize: 13, padding: '8px 60px 8px 10px', outline: 'none', fontFamily: 'inherit' }}
autoComplete="off"
data-testid={`settings-key-${label.toLowerCase().replace(/\s+/g, '-')}`}
/>
<div style={{ position: 'absolute', right: 8, display: 'flex', gap: 4, alignItems: 'center' }}>
{value && (
<>
<button
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => setRevealed(r => !r)}
title={revealed ? 'Hide key' : 'Reveal key'}
type="button"
>
{revealed ? <EyeSlash size={14} /> : <Eye size={14} />}
</button>
<button
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => { onClear(); setRevealed(false) }}
title="Clear key"
type="button"
data-testid={`clear-${label.toLowerCase().replace(/\s+/g, '-')}`}
>
<X size={14} />
</button>
</>
)}
</div>
</div>
</div>
)
}
// --- GitHub OAuth Section ---
type OAuthStatus = 'idle' | 'waiting' | 'error'
interface GitHubSectionProps {
githubUsername: string | null
githubToken: string | null
onConnected: (token: string, username: string) => void
onDisconnect: () => void
}
/** Process a device flow poll result. Returns 'done' if auth complete, 'continue' to keep polling. */
function processPollResult(
result: DeviceFlowPollResult,
callbacks: {
onComplete: (token: string) => Promise<void>
onExpired: () => void
onError: (msg: string) => void
},
): 'done' | 'continue' {
if (result.status === 'complete' && result.access_token) {
callbacks.onComplete(result.access_token)
return 'done'
}
if (result.status === 'expired') {
callbacks.onExpired()
return 'done'
}
if (result.status === 'error') {
callbacks.onError(result.error ?? 'Authorization failed.')
return 'done'
}
return 'continue' // status === 'pending'
}
function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect }: GitHubSectionProps) {
const [oauthStatus, setOauthStatus] = useState<OAuthStatus>('idle')
const [userCode, setUserCode] = useState<string | null>(null)
const [verificationUri, setVerificationUri] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const pollingRef = useRef(false)
const deviceCodeRef = useRef<string | null>(null)
const isConnected = !!githubToken && !!githubUsername
const stopPolling = useCallback(() => {
pollingRef.current = false
deviceCodeRef.current = null
}, [])
useEffect(() => {
return () => { pollingRef.current = false }
}, [])
const handleLogin = useCallback(async () => {
setOauthStatus('waiting')
setErrorMessage(null)
setUserCode(null)
try {
const flowStart = await tauriCall<DeviceFlowStart>('github_device_flow_start')
setUserCode(flowStart.user_code)
setVerificationUri(flowStart.verification_uri)
deviceCodeRef.current = flowStart.device_code
openExternalUrl(flowStart.verification_uri).catch(() => {
// Browser failed to open — URL is shown in UI so user can navigate manually
})
pollingRef.current = true
const intervalMs = Math.max(flowStart.interval * 1000, 5000)
const pollLoop = async () => {
while (pollingRef.current && deviceCodeRef.current) {
await new Promise(r => setTimeout(r, intervalMs))
if (!pollingRef.current) break
const result = await tauriCall<DeviceFlowPollResult>('github_device_flow_poll', {
deviceCode: deviceCodeRef.current,
})
const outcome = processPollResult(result, {
onComplete: async (token) => {
const user = await tauriCall<GitHubUser>('github_get_user', { token })
stopPolling()
setOauthStatus('idle')
setUserCode(null)
onConnected(token, user.login)
},
onExpired: () => {
stopPolling()
setOauthStatus('error')
setErrorMessage('Authorization expired. Please try again.')
},
onError: (msg) => {
stopPolling()
setOauthStatus('error')
setErrorMessage(msg)
},
})
if (outcome === 'done') return
}
}
pollLoop().catch(err => {
stopPolling()
setOauthStatus('error')
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.')
})
} catch (err) {
setOauthStatus('error')
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.')
}
}, [onConnected, stopPolling])
const resetOAuth = useCallback(() => {
stopPolling()
setOauthStatus('idle')
setUserCode(null)
setVerificationUri(null)
setErrorMessage(null)
}, [stopPolling])
if (isConnected && oauthStatus === 'idle') {
return <GitHubConnectedRow username={githubUsername!} onDisconnect={() => { resetOAuth(); onDisconnect() }} />
}
if (oauthStatus === 'waiting' && userCode) {
return <GitHubWaitingView userCode={userCode} verificationUri={verificationUri} onCancel={resetOAuth} />
}
return <GitHubLoginButton onLogin={handleLogin} disabled={oauthStatus === 'waiting'} errorMessage={errorMessage} onRetry={errorMessage ? () => { resetOAuth(); handleLogin() } : undefined} />
}
function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDisconnect: () => void }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div
className="flex items-center gap-2 border border-border rounded px-3 py-2 flex-1"
style={{ minHeight: 36 }}
data-testid="github-connected"
>
<GithubLogo size={16} weight="fill" style={{ color: 'var(--foreground)' }} />
<span style={{ fontSize: 13, color: 'var(--foreground)', fontWeight: 500 }}>{username}</span>
<span style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Connected</span>
</div>
<button
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground hover:border-foreground"
style={{ fontSize: 12, padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 4 }}
onClick={onDisconnect}
title="Disconnect GitHub account"
data-testid="github-disconnect"
>
<SignOut size={14} />
Disconnect
</button>
</div>
)
}
function GitHubWaitingView({ userCode, verificationUri, onCancel }: { userCode: string; verificationUri: string | null; onCancel: () => void }) {
const [copied, setCopied] = useState(false)
const handleCopyCode = useCallback(() => {
navigator.clipboard.writeText(userCode).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}).catch(() => {})
}, [userCode])
const handleOpenUrl = useCallback(() => {
if (verificationUri) openExternalUrl(verificationUri).catch(() => {})
}, [verificationUri])
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }} data-testid="github-waiting">
<div
className="border border-border rounded px-4 py-3"
style={{ display: 'flex', flexDirection: 'column', gap: 8, textAlign: 'center' }}
>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Enter this code on GitHub:</div>
<div className="flex items-center justify-center gap-2">
<div
style={{ fontSize: 24, fontWeight: 700, letterSpacing: 4, color: 'var(--foreground)', fontFamily: 'monospace' }}
data-testid="github-user-code"
>
{userCode}
</div>
<button
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={handleCopyCode}
title="Copy code"
data-testid="github-copy-code"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
{verificationUri && (
<button
className="border-none bg-transparent text-muted-foreground cursor-pointer hover:text-foreground underline"
style={{ fontSize: 12 }}
onClick={handleOpenUrl}
data-testid="github-open-url"
>
{verificationUri}
</button>
)}
<div className="flex items-center justify-center gap-2" style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>
<CircleNotch size={14} className="animate-spin" />
Waiting for authorization...
</div>
</div>
<button
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground"
style={{ fontSize: 12, padding: '6px 12px', alignSelf: 'center' }}
onClick={onCancel}
data-testid="github-cancel"
>
Cancel
</button>
</div>
)
}
function GitHubLoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
className="border-none rounded cursor-pointer flex items-center justify-center gap-2"
style={{ fontSize: 13, fontWeight: 500, padding: '8px 16px', background: 'var(--foreground)', color: 'var(--background)', height: 36 }}
onClick={onLogin}
disabled={disabled}
data-testid="github-login"
>
<GithubLogo size={16} weight="fill" />
Login with GitHub
</button>
{errorMessage && (
<div className="flex items-center gap-2" style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)' }}>
<span data-testid="github-error">{errorMessage}</span>
{onRetry && (
<button
className="border-none bg-transparent cursor-pointer hover:text-foreground flex items-center gap-1"
style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)', padding: 0 }}
onClick={onRetry}
data-testid="github-retry"
>
<ArrowClockwise size={12} />
Retry
</button>
)}
</div>
)}
</div>
)
}
// --- Settings Panel ---
export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) {
if (!open) return null
return <SettingsPanelInner settings={settings} onSave={onSave} onClose={onClose} />
}
function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelProps, 'open'>) {
const [anthropicKey, setAnthropicKey] = useState(settings.anthropic_key ?? '')
const [openaiKey, setOpenaiKey] = useState(settings.openai_key ?? '')
const [googleKey, setGoogleKey] = useState(settings.google_key ?? '')
const [githubToken, setGithubToken] = useState(settings.github_token)
const [githubUsername, setGithubUsername] = useState(settings.github_username)
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
const buildSettings = useCallback((ghOverride?: { token: string | null; username: string | null }): Settings => ({
anthropic_key: anthropicKey.trim() || null,
openai_key: openaiKey.trim() || null,
google_key: googleKey.trim() || null,
github_token: ghOverride ? ghOverride.token : (githubToken ?? null),
github_username: ghOverride ? ghOverride.username : (githubUsername ?? null),
auto_pull_interval_minutes: pullInterval,
}), [anthropicKey, openaiKey, googleKey, githubToken, githubUsername, pullInterval])
const handleSave = () => {
onSave(buildSettings())
onClose()
}
const handleGitHubConnected = useCallback((token: string, username: string) => {
setGithubToken(token)
setGithubUsername(username)
onSave(buildSettings({ token, username }))
}, [onSave, buildSettings])
const handleGitHubDisconnect = useCallback(() => {
setGithubToken(null)
setGithubUsername(null)
onSave(buildSettings({ token: null, username: null }))
}, [onSave, buildSettings])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation()
onClose()
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSave()
}
}
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ background: 'rgba(0,0,0,0.4)' }}
onClick={e => { if (e.target === e.currentTarget) onClose() }}
onKeyDown={handleKeyDown}
data-testid="settings-panel"
>
<div
className="bg-background border border-border rounded-lg shadow-xl"
style={{ width: 520, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
>
<SettingsHeader onClose={onClose} />
<SettingsBody
anthropicKey={anthropicKey} setAnthropicKey={setAnthropicKey}
openaiKey={openaiKey} setOpenaiKey={setOpenaiKey}
googleKey={googleKey} setGoogleKey={setGoogleKey}
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
pullInterval={pullInterval} setPullInterval={setPullInterval}
/>
<SettingsFooter onClose={onClose} onSave={handleSave} />
</div>
</div>
)
}
function SettingsHeader({ onClose }: { onClose: () => void }) {
return (
<div
className="flex items-center justify-between shrink-0"
style={{ height: 56, padding: '0 24px', borderBottom: '1px solid var(--border)' }}
>
<span style={{ fontSize: 16, fontWeight: 600, color: 'var(--foreground)' }}>Settings</span>
<button
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onClose}
title="Close settings"
>
<X size={16} />
</button>
</div>
)
}
interface SettingsBodyProps {
anthropicKey: string; setAnthropicKey: (v: string) => void
openaiKey: string; setOpenaiKey: (v: string) => void
googleKey: string; setGoogleKey: (v: string) => void
githubToken: string | null; githubUsername: string | null
onGitHubConnected: (token: string, username: string) => void
onGitHubDisconnect: () => void
pullInterval: number; setPullInterval: (v: number) => void
}
function SettingsBody(props: SettingsBodyProps) {
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20, overflow: 'auto' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>AI Provider Keys</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
API keys are stored locally on your device. Never sent to our servers.
</div>
</div>
<KeyField label="Anthropic" placeholder="sk-ant-..." value={props.anthropicKey} onChange={props.setAnthropicKey} onClear={() => props.setAnthropicKey('')} />
<KeyField label="OpenAI" placeholder="sk-..." value={props.openaiKey} onChange={props.setOpenaiKey} onClear={() => props.setOpenaiKey('')} />
<KeyField label="Google AI" placeholder="AIza..." value={props.googleKey} onChange={props.setGoogleKey} onClear={() => props.setGoogleKey('')} />
<div style={{ height: 1, background: 'var(--border)' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>GitHub</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
Connect your GitHub account to clone and sync vaults.
</div>
</div>
<GitHubSection
githubUsername={props.githubUsername}
githubToken={props.githubToken}
onConnected={props.onGitHubConnected}
onDisconnect={props.onGitHubDisconnect}
/>
<div style={{ height: 1, background: 'var(--border)' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Sync</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
Automatically pull vault changes from Git in the background.
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>Pull interval (minutes)</label>
<select
value={props.pullInterval}
onChange={(e) => props.setPullInterval(Number(e.target.value))}
className="border border-border bg-transparent text-foreground rounded"
style={{ fontSize: 13, padding: '8px 10px', outline: 'none', fontFamily: 'inherit' }}
data-testid="settings-pull-interval"
>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={15}>15</option>
<option value={30}>30</option>
</select>
</div>
</div>
)
}
function SettingsFooter({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
return (
<div
className="flex items-center justify-between shrink-0"
style={{ height: 56, padding: '0 24px', borderTop: '1px solid var(--border)' }}
>
<span style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>{'\u2318'}, to open settings</span>
<div className="flex gap-2">
<button
className="border border-border bg-transparent text-foreground rounded cursor-pointer hover:bg-accent"
style={{ fontSize: 13, padding: '6px 16px' }}
onClick={onClose}
>
Cancel
</button>
<button
className="border-none rounded cursor-pointer"
style={{ fontSize: 13, padding: '6px 16px', background: 'var(--primary)', color: 'white' }}
onClick={onSave}
data-testid="settings-save"
>
Save
</button>
</div>
</div>
)
}