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>
This commit is contained in:
Luca Rossi
2026-02-28 14:02:38 +01:00
committed by GitHub
parent 074c22286c
commit 18cef0b002
7 changed files with 485 additions and 606 deletions

View File

@@ -2,302 +2,68 @@
"children": [
{
"type": "frame",
"id": "gh_oauth_frame",
"name": "GitHub OAuth — Device Code Waiting",
"id": "gofix01",
"x": 0,
"y": 0,
"width": 520,
"height": "fit_content(600)",
"fill": "#ffffff",
"name": "GitHub OAuth Fix — Modal with inline device flow",
"clip": true,
"width": 560,
"height": 420,
"fill": "$--background",
"cornerRadius": 12,
"layout": "vertical",
"gap": 0,
"cornerRadius": [8, 8, 8, 8],
"stroke": "#e5e5e5",
"strokeWidth": 1,
"theme": {
"Mode": "Light"
},
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"children": [
{
"type": "frame",
"id": "gh_header",
"id": "gofix01-hdr",
"name": "Header",
"width": "fill_container",
"height": 56,
"layout": "horizontal",
"fill": "$--background",
"padding": [0, 24],
"gap": 0,
"alignItems": "center",
"justifyContent": "space-between",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{
"type": "text",
"id": "gh_title",
"content": "Settings",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600",
"fill": "#1a1a1a"
},
{
"type": "text",
"id": "gh_close",
"content": "✕",
"fontFamily": "Inter",
"fontSize": 14,
"fill": "#888888"
}
{ "type": "text", "id": "gofix01-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
{ "type": "frame", "id": "gofix01-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix01-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
]
},
{
"type": "rectangle",
"id": "gh_div1",
"type": "frame",
"id": "gofix01-desc",
"name": "Description",
"width": "fill_container",
"height": 1,
"fill": "#e5e5e5"
"padding": [16, 24],
"children": [
{ "type": "text", "id": "gofix01-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
]
},
{
"type": "frame",
"id": "gh_body",
"name": "Body",
"id": "gofix01-body",
"name": "DeviceFlowLogin",
"width": "fill_container",
"layout": "vertical",
"padding": 24,
"gap": 20,
"gap": 16,
"padding": [24, 24],
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "gh_section_header",
"name": "GitHub Section Header",
"layout": "vertical",
"width": "fill_container",
"gap": 4,
"children": [
{
"type": "text",
"id": "gh_section_title",
"content": "GitHub",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600",
"fill": "#1a1a1a"
},
{
"type": "text",
"id": "gh_section_desc",
"content": "Connect your GitHub account to clone and sync vaults.",
"fontFamily": "Inter",
"fontSize": 12,
"fill": "#888888",
"lineHeight": 1.5
}
]
},
{
"type": "frame",
"id": "gh_waiting_card",
"name": "Device Code Card",
"width": "fill_container",
"layout": "vertical",
"padding": [16, 20],
"gap": 10,
"alignItems": "center",
"cornerRadius": [6, 6, 6, 6],
"stroke": "#e5e5e5",
"strokeWidth": 1,
"children": [
{
"type": "text",
"id": "gh_prompt_text",
"content": "Enter this code on GitHub:",
"fontFamily": "Inter",
"fontSize": 12,
"fill": "#888888"
},
{
"type": "frame",
"id": "gh_code_row",
"name": "Code + Copy",
"layout": "horizontal",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "gh_user_code",
"content": "ABCD-1234",
"fontFamily": "JetBrains Mono, monospace",
"fontSize": 24,
"fontWeight": "700",
"letterSpacing": 4,
"fill": "#1a1a1a"
},
{
"type": "frame",
"id": "gh_copy_btn",
"name": "Copy Button",
"width": 24,
"height": 24,
"alignItems": "center",
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "gh_copy_icon",
"content": "⧉",
"fontFamily": "Inter",
"fontSize": 16,
"fill": "#888888"
}
]
}
]
},
{
"type": "text",
"id": "gh_url_link",
"content": "https://github.com/login/device",
"fontFamily": "Inter",
"fontSize": 12,
"fill": "#888888",
"textDecoration": "underline"
},
{
"type": "frame",
"id": "gh_spinner_row",
"name": "Waiting Spinner",
"layout": "horizontal",
"gap": 6,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "gh_spinner_icon",
"content": "◌",
"fontFamily": "Inter",
"fontSize": 14,
"fill": "#888888"
},
{
"type": "text",
"id": "gh_spinner_text",
"content": "Waiting for authorization...",
"fontFamily": "Inter",
"fontSize": 12,
"fill": "#888888"
}
]
}
]
},
{
"type": "frame",
"id": "gh_cancel_row",
"name": "Cancel Row",
"width": "fill_container",
"id": "gofix01-login-btn",
"name": "LoginButton",
"width": 200,
"height": 36,
"fill": "$--foreground",
"cornerRadius": 6,
"alignItems": "center",
"justifyContent": "center",
"children": [
{
"type": "frame",
"id": "gh_cancel_btn",
"name": "Cancel Button",
"layout": "horizontal",
"padding": [6, 12],
"cornerRadius": [4, 4, 4, 4],
"stroke": "#e5e5e5",
"strokeWidth": 1,
"alignItems": "center",
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "gh_cancel_text",
"content": "Cancel",
"fontFamily": "Inter",
"fontSize": 12,
"fill": "#888888"
}
]
}
]
}
]
},
{
"type": "rectangle",
"id": "gh_div2",
"width": "fill_container",
"height": 1,
"fill": "#e5e5e5"
},
{
"type": "frame",
"id": "gh_footer",
"name": "Footer",
"width": "fill_container",
"height": 56,
"layout": "horizontal",
"padding": [0, 24],
"alignItems": "center",
"justifyContent": "space-between",
"children": [
{
"type": "text",
"id": "gh_shortcut",
"content": "⌘, to open settings",
"fontFamily": "Inter",
"fontSize": 11,
"fill": "#888888"
},
{
"type": "frame",
"id": "gh_footer_buttons",
"name": "Footer Buttons",
"layout": "horizontal",
"gap": 8,
"children": [
{
"type": "frame",
"id": "gh_cancel_footer",
"name": "Cancel",
"padding": [6, 16],
"cornerRadius": [4, 4, 4, 4],
"stroke": "#e5e5e5",
"strokeWidth": 1,
"alignItems": "center",
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "gh_cancel_f_text",
"content": "Cancel",
"fontFamily": "Inter",
"fontSize": 13,
"fill": "#1a1a1a"
}
]
},
{
"type": "frame",
"id": "gh_save_btn",
"name": "Save",
"padding": [6, 16],
"cornerRadius": [4, 4, 4, 4],
"fill": "#3b82f6",
"alignItems": "center",
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "gh_save_text",
"content": "Save",
"fontFamily": "Inter",
"fontSize": 13,
"fill": "#ffffff"
}
]
}
{ "type": "text", "id": "gofix01-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" },
{ "type": "text", "id": "gofix01-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" }
]
}
]
@@ -306,122 +72,181 @@
},
{
"type": "frame",
"id": "gh_error_frame",
"name": "GitHub OAuth — Error with Retry",
"x": 620,
"id": "gofix02",
"x": 660,
"y": 0,
"width": 520,
"height": "fit_content(300)",
"fill": "#ffffff",
"name": "GitHub OAuth Fix — Device code waiting state",
"clip": true,
"width": 560,
"height": 420,
"fill": "$--background",
"cornerRadius": 12,
"layout": "vertical",
"padding": 24,
"gap": 16,
"cornerRadius": [8, 8, 8, 8],
"stroke": "#e5e5e5",
"strokeWidth": 1,
"theme": {
"Mode": "Light"
},
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"children": [
{
"type": "frame",
"id": "gh_err_header",
"id": "gofix02-hdr",
"name": "Header",
"width": "fill_container",
"height": 56,
"fill": "$--background",
"padding": [0, 24],
"alignItems": "center",
"justifyContent": "space-between",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "text", "id": "gofix02-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
{ "type": "frame", "id": "gofix02-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix02-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
]
},
{
"type": "frame",
"id": "gofix02-desc",
"name": "Description",
"width": "fill_container",
"padding": [16, 24],
"children": [
{ "type": "text", "id": "gofix02-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
]
},
{
"type": "frame",
"id": "gofix02-body",
"name": "DeviceCodeCard",
"width": "fill_container",
"layout": "vertical",
"width": "fill_container",
"gap": 4,
"children": [
{
"type": "text",
"id": "gh_err_title",
"content": "GitHub",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600",
"fill": "#1a1a1a"
},
{
"type": "text",
"id": "gh_err_desc",
"content": "Connect your GitHub account to clone and sync vaults.",
"fontFamily": "Inter",
"fontSize": 12,
"fill": "#888888",
"lineHeight": 1.5
}
]
},
{
"type": "frame",
"id": "gh_login_btn",
"name": "Login Button",
"width": "fill_container",
"height": 36,
"fill": "#1a1a1a",
"cornerRadius": [6, 6, 6, 6],
"layout": "horizontal",
"gap": 8,
"alignItems": "center",
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "gh_login_icon",
"content": "⬤",
"fontFamily": "Inter",
"fontSize": 14,
"fill": "#ffffff"
},
{
"type": "text",
"id": "gh_login_text",
"content": "Login with GitHub",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500",
"fill": "#ffffff"
}
]
},
{
"type": "frame",
"id": "gh_error_row",
"name": "Error + Retry",
"layout": "horizontal",
"gap": 8,
"gap": 12,
"padding": [24, 24],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "gh_error_msg",
"content": "Authorization expired. Please try again.",
"fontFamily": "Inter",
"fontSize": 12,
"fill": "#e03e3e"
"type": "frame",
"id": "gofix02-card",
"name": "CodeCard",
"width": "fill_container",
"layout": "vertical",
"gap": 8,
"padding": [16, 24],
"cornerRadius": 8,
"alignItems": "center",
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"children": [
{ "type": "text", "id": "gofix02-hint", "content": "Enter this code on GitHub:", "fontSize": 12, "fill": "$--muted-foreground" },
{
"type": "frame",
"id": "gofix02-code-row",
"gap": 8,
"alignItems": "center",
"children": [
{ "type": "text", "id": "gofix02-code", "content": "ABCD-1234", "fontSize": 24, "fontWeight": 700, "letterSpacing": 4, "fontFamily": "monospace", "fill": "$--foreground" },
{ "type": "text", "id": "gofix02-copy", "content": "[copy]", "fontSize": 12, "fill": "$--muted-foreground" }
]
},
{ "type": "text", "id": "gofix02-url", "content": "https://github.com/login/device", "fontSize": 12, "fill": "$--muted-foreground", "textDecoration": "underline" },
{
"type": "frame",
"id": "gofix02-spinner-row",
"gap": 6,
"alignItems": "center",
"children": [
{ "type": "text", "id": "gofix02-waiting", "content": "Waiting for authorization...", "fontSize": 12, "fill": "$--muted-foreground" }
]
}
]
},
{
"type": "frame",
"id": "gh_retry_btn",
"name": "Retry Button",
"layout": "horizontal",
"gap": 4,
"id": "gofix02-cancel-btn",
"name": "CancelButton",
"width": 80,
"height": 32,
"cornerRadius": 6,
"alignItems": "center",
"justifyContent": "center",
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"children": [
{ "type": "text", "id": "gofix02-cancel-label", "content": "Cancel", "fontSize": 12, "fill": "$--muted-foreground" }
]
}
]
}
]
},
{
"type": "frame",
"id": "gofix03",
"x": 1320,
"y": 0,
"name": "GitHub OAuth Fix — Expired/Error state with retry",
"clip": true,
"width": 560,
"height": 420,
"fill": "$--background",
"cornerRadius": 12,
"layout": "vertical",
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"children": [
{
"type": "frame",
"id": "gofix03-hdr",
"name": "Header",
"width": "fill_container",
"height": 56,
"fill": "$--background",
"padding": [0, 24],
"alignItems": "center",
"justifyContent": "space-between",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "text", "id": "gofix03-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
{ "type": "frame", "id": "gofix03-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix03-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
]
},
{
"type": "frame",
"id": "gofix03-desc",
"name": "Description",
"width": "fill_container",
"padding": [16, 24],
"children": [
{ "type": "text", "id": "gofix03-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
]
},
{
"type": "frame",
"id": "gofix03-body",
"name": "ErrorState",
"width": "fill_container",
"layout": "vertical",
"gap": 16,
"padding": [24, 24],
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "gofix03-login-btn",
"name": "LoginButton",
"width": 200,
"height": 36,
"fill": "$--foreground",
"cornerRadius": 6,
"alignItems": "center",
"justifyContent": "center",
"gap": 8,
"children": [
{ "type": "text", "id": "gofix03-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" },
{ "type": "text", "id": "gofix03-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" }
]
},
{
"type": "frame",
"id": "gofix03-error-row",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "gh_retry_icon",
"content": "↻",
"fontFamily": "Inter",
"fontSize": 12,
"fill": "#e03e3e"
},
{
"type": "text",
"id": "gh_retry_text",
"content": "Retry",
"fontFamily": "Inter",
"fontSize": 12,
"fill": "#e03e3e"
}
{ "type": "text", "id": "gofix03-error-msg", "content": "Authorization expired. Please try again.", "fontSize": 12, "fill": "$--destructive" },
{ "type": "text", "id": "gofix03-retry-label", "content": "Retry", "fontSize": 12, "fill": "$--destructive" }
]
}
]
@@ -430,4 +255,4 @@
}
],
"variables": {}
}
}

View File

@@ -389,6 +389,7 @@ function App() {
onClose={dialogs.closeGitHubVault}
onVaultCloned={vaultSwitcher.handleVaultCloned}
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
/>
</div>
)

View File

@@ -0,0 +1,226 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { GithubLogo, 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 { 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)
}
type OAuthStatus = 'idle' | 'waiting' | 'error'
/** 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'
}
interface GitHubDeviceFlowProps {
onConnected: (token: string, username: string) => void
}
export function GitHubDeviceFlow({ onConnected }: GitHubDeviceFlowProps) {
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 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(() => {})
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 (oauthStatus === 'waiting' && userCode) {
return <DeviceCodeView userCode={userCode} verificationUri={verificationUri} onCancel={resetOAuth} />
}
return <LoginButton onLogin={handleLogin} disabled={oauthStatus === 'waiting'} errorMessage={errorMessage} onRetry={errorMessage ? () => { resetOAuth(); handleLogin() } : undefined} />
}
function DeviceCodeView({ 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 LoginButton({ 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>
)
}

View File

@@ -7,6 +7,9 @@ vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(),
}))
vi.mock('../utils/url', () => ({
openExternalUrl: vi.fn().mockResolvedValue(undefined),
}))
import { mockInvoke } from '../mock-tauri'
const mockInvokeFn = vi.mocked(mockInvoke)
@@ -20,6 +23,7 @@ describe('GitHubVaultModal', () => {
const onClose = vi.fn()
const onVaultCloned = vi.fn()
const onOpenSettings = vi.fn()
const onGitHubConnected = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
@@ -38,7 +42,7 @@ describe('GitHubVaultModal', () => {
expect(container.querySelector('[data-testid="github-vault-modal"]')).not.toBeInTheDocument()
})
it('shows connect prompt when no GitHub token', () => {
it('shows settings fallback when no token and no onGitHubConnected', () => {
render(
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
@@ -54,6 +58,37 @@ describe('GitHubVaultModal', () => {
expect(onOpenSettings).toHaveBeenCalled()
})
it('shows inline device flow when no token and onGitHubConnected is provided', () => {
render(
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} onGitHubConnected={onGitHubConnected} />
)
expect(screen.getByTestId('github-login')).toBeInTheDocument()
expect(screen.getByText('Login with GitHub')).toBeInTheDocument()
expect(screen.queryByTestId('github-open-settings')).not.toBeInTheDocument()
})
it('shows device code after starting inline OAuth flow', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'github_device_flow_start') {
return { device_code: 'dc_modal', user_code: 'MODAL-5678', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 }
}
if (cmd === 'github_device_flow_poll') {
return { status: 'pending', access_token: null, error: 'authorization_pending' }
}
return null
})
render(
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} onGitHubConnected={onGitHubConnected} />
)
fireEvent.click(screen.getByTestId('github-login'))
await waitFor(() => {
expect(screen.getByTestId('github-user-code')).toHaveTextContent('MODAL-5678')
})
})
it('shows clone and create tabs when token is present', async () => {
render(
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />

View File

@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
import type { GithubRepo } from '../types'
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
@@ -18,6 +19,7 @@ interface GitHubVaultModalProps {
onClose: () => void
onVaultCloned: (path: string, label: string) => void
onOpenSettings: () => void
onGitHubConnected?: (token: string, username: string) => void
}
type CloneStatus = 'idle' | 'cloning' | 'success' | 'error'
@@ -244,7 +246,7 @@ function CloningProgress({ repoName }: { repoName: string }) {
)
}
export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings }: GitHubVaultModalProps) {
export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings, onGitHubConnected }: GitHubVaultModalProps) {
const [tab, setTab] = useState('clone')
const [repos, setRepos] = useState<GithubRepo[]>([])
const [loading, setLoading] = useState(false)
@@ -365,15 +367,21 @@ export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, on
<DialogDescription>Connect your GitHub account to clone or create vaults backed by GitHub repos.</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-6">
<p className="text-sm text-muted-foreground text-center">
You need to connect your GitHub account first. Add your GitHub token in Settings.
</p>
<Button
onClick={() => { handleClose(); onOpenSettings() }}
data-testid="github-open-settings"
>
Open Settings
</Button>
{onGitHubConnected ? (
<GitHubDeviceFlow onConnected={onGitHubConnected} />
) : (
<>
<p className="text-sm text-muted-foreground text-center">
You need to connect your GitHub account first. Add your GitHub token in Settings.
</p>
<Button
onClick={() => { handleClose(); onOpenSettings() }}
data-testid="github-open-settings"
>
Open Settings
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>

View File

@@ -1,13 +1,7 @@
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)
}
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
@@ -74,8 +68,6 @@ function KeyField({ label, placeholder, value, onChange, onClear }: KeyFieldProp
// --- GitHub OAuth Section ---
type OAuthStatus = 'idle' | 'waiting' | 'error'
interface GitHubSectionProps {
githubUsername: string | null
githubToken: string | null
@@ -83,125 +75,14 @@ interface GitHubSectionProps {
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 (isConnected) {
return <GitHubConnectedRow username={githubUsername!} onDisconnect={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} />
return <GitHubDeviceFlow onConnected={onConnected} />
}
function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDisconnect: () => void }) {
@@ -230,103 +111,6 @@ function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDi
)
}
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) {

View File

@@ -77,8 +77,8 @@ let mockSettings: Settings = {
anthropic_key: null,
openai_key: null,
google_key: null,
github_token: 'gho_mock_token_for_testing',
github_username: 'lucaong',
github_token: null,
github_username: null,
auto_pull_interval_minutes: 5,
}