From 18cef0b002abd3b3b3c66814943adca180667890 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Sat, 28 Feb 2026 14:02:38 +0100 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Test Co-authored-by: Claude Opus 4.6 --- design/github-oauth-fix.pen | 565 ++++++++--------------- src/App.tsx | 1 + src/components/GitHubDeviceFlow.tsx | 226 +++++++++ src/components/GitHubVaultModal.test.tsx | 37 +- src/components/GitHubVaultModal.tsx | 28 +- src/components/SettingsPanel.tsx | 230 +-------- src/mock-tauri/mock-handlers.ts | 4 +- 7 files changed, 485 insertions(+), 606 deletions(-) create mode 100644 src/components/GitHubDeviceFlow.tsx diff --git a/design/github-oauth-fix.pen b/design/github-oauth-fix.pen index b27f0f31..7dbabdc3 100644 --- a/design/github-oauth-fix.pen +++ b/design/github-oauth-fix.pen @@ -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": {} -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index 33be115e..74887052 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 })} /> ) diff --git a/src/components/GitHubDeviceFlow.tsx b/src/components/GitHubDeviceFlow.tsx new file mode 100644 index 00000000..9bc13813 --- /dev/null +++ b/src/components/GitHubDeviceFlow.tsx @@ -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(cmd: string, args: Record = {}): Promise { + return isTauri() ? invoke(cmd, args) : mockInvoke(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 + 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('idle') + const [userCode, setUserCode] = useState(null) + const [verificationUri, setVerificationUri] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) + const pollingRef = useRef(false) + const deviceCodeRef = useRef(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('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('github_device_flow_poll', { + deviceCode: deviceCodeRef.current, + }) + const outcome = processPollResult(result, { + onComplete: async (token) => { + const user = await tauriCall('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 + } + + return { 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 ( +
+
+
Enter this code on GitHub:
+
+
+ {userCode} +
+ +
+ {verificationUri && ( + + )} +
+ + Waiting for authorization... +
+
+ +
+ ) +} + +function LoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) { + return ( +
+ + {errorMessage && ( +
+ {errorMessage} + {onRetry && ( + + )} +
+ )} +
+ ) +} diff --git a/src/components/GitHubVaultModal.test.tsx b/src/components/GitHubVaultModal.test.tsx index 5d9da6e7..a904a70a 100644 --- a/src/components/GitHubVaultModal.test.tsx +++ b/src/components/GitHubVaultModal.test.tsx @@ -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( ) @@ -54,6 +58,37 @@ describe('GitHubVaultModal', () => { expect(onOpenSettings).toHaveBeenCalled() }) + it('shows inline device flow when no token and onGitHubConnected is provided', () => { + render( + + ) + 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( + + ) + + 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( diff --git a/src/components/GitHubVaultModal.tsx b/src/components/GitHubVaultModal.tsx index 9ebb2330..187ad836 100644 --- a/src/components/GitHubVaultModal.tsx +++ b/src/components/GitHubVaultModal.tsx @@ -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(cmd: string, args: Record): Promise { @@ -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([]) const [loading, setLoading] = useState(false) @@ -365,15 +367,21 @@ export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, on Connect your GitHub account to clone or create vaults backed by GitHub repos.
-

- You need to connect your GitHub account first. Add your GitHub token in Settings. -

- + {onGitHubConnected ? ( + + ) : ( + <> +

+ You need to connect your GitHub account first. Add your GitHub token in Settings. +

+ + + )}
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 30021554..1fcdd002 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -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(cmd: string, args: Record = {}): Promise { - return isTauri() ? invoke(cmd, args) : mockInvoke(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 - 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('idle') - const [userCode, setUserCode] = useState(null) - const [verificationUri, setVerificationUri] = useState(null) - const [errorMessage, setErrorMessage] = useState(null) - const pollingRef = useRef(false) - const deviceCodeRef = useRef(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('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('github_device_flow_poll', { - deviceCode: deviceCodeRef.current, - }) - const outcome = processPollResult(result, { - onComplete: async (token) => { - const user = await tauriCall('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 { resetOAuth(); onDisconnect() }} /> + if (isConnected) { + return } - if (oauthStatus === 'waiting' && userCode) { - return - } - - return { resetOAuth(); handleLogin() } : undefined} /> + return } 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 ( -
-
-
Enter this code on GitHub:
-
-
- {userCode} -
- -
- {verificationUri && ( - - )} -
- - Waiting for authorization... -
-
- -
- ) -} - -function GitHubLoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) { - return ( -
- - {errorMessage && ( -
- {errorMessage} - {onRetry && ( - - )} -
- )} -
- ) -} - // --- Settings Panel --- export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) { diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 6048dcfd..6e05bc6b 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -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, }