Files
tolaria/src/components/SettingsPanel.tsx
Luca Rossi 4e2fa0d374 fix: inline GitHub OAuth device flow in Connect GitHub modal (#136)
* fix: inline GitHub OAuth device flow in Connect GitHub modal

Previously, clicking "Connect GitHub repo" without a token would redirect
to Settings, forcing the user to authenticate there and then navigate back.
Now the device flow runs directly inside the GitHubVaultModal, showing the
user code and opening the browser inline.

- Extract GitHubDeviceFlow component from SettingsPanel (shared by both)
- Add onGitHubConnected prop to GitHubVaultModal for inline auth
- Update mock defaults to no-token state for testable device flow
- Add tests for inline device flow in modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* design: add GitHub OAuth fix wireframes

Three frames showing the fixed modal states:
1. Login button (inline device flow start)
2. Device code waiting (code + URL + spinner)
3. Error/expired state with retry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:02:38 +01:00

308 lines
12 KiB
TypeScript

import { useState, useRef, useCallback } from 'react'
import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react'
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
import type { Settings } from '../types'
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 ---
interface GitHubSectionProps {
githubUsername: string | null
githubToken: string | null
onConnected: (token: string, username: string) => void
onDisconnect: () => void
}
function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect }: GitHubSectionProps) {
const isConnected = !!githubToken && !!githubUsername
if (isConnected) {
return <GitHubConnectedRow username={githubUsername!} onDisconnect={onDisconnect} />
}
return <GitHubDeviceFlow onConnected={onConnected} />
}
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>
)
}
// --- 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>
)
}