From bf549d5605a60aedcf6a0e2427ebac31efa1e5c1 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 19:56:30 +0100 Subject: [PATCH] feat: replace GitHub token field with OAuth device flow login - Add github_username to Settings type (Rust + TS) - Add device flow commands: github_device_flow_start, github_device_flow_poll, github_get_user - Replace manual token KeyField with "Login with GitHub" OAuth button - Show connected state with username after successful OAuth - Add disconnect functionality to clear OAuth token - Update mock-tauri.ts with device flow mock handlers - Update all tests for new Settings shape Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/github.rs | 130 ++++++++++++ src-tauri/src/lib.rs | 24 ++- src-tauri/src/settings.rs | 22 +- src/App.test.tsx | 2 +- src/components/SettingsPanel.test.tsx | 5 + src/components/SettingsPanel.tsx | 279 ++++++++++++++++++++++++-- src/hooks/useSettings.test.ts | 3 + src/hooks/useSettings.ts | 1 + src/mock-tauri.ts | 29 ++- src/types.ts | 21 ++ 10 files changed, 488 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs index cf5429a3..879490d7 100644 --- a/src-tauri/src/github.rs +++ b/src-tauri/src/github.rs @@ -2,6 +2,9 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use std::process::Command; +/// GitHub OAuth App client ID. Replace with your registered GitHub App's client_id. +const GITHUB_CLIENT_ID: &str = "Ov23liCuBz7Z5hKk6T8c"; + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GithubRepo { pub name: String, @@ -121,6 +124,133 @@ pub async fn github_create_repo( }) } +// --- OAuth Device Flow --- + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeviceFlowStart { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub expires_in: u64, + pub interval: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeviceFlowPollResult { + pub status: String, + pub access_token: Option, + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GitHubUser { + pub login: String, + pub name: Option, + pub avatar_url: String, +} + +/// Starts the GitHub OAuth device flow. Returns device code info for user authorization. +pub async fn github_device_flow_start() -> Result { + let client = reqwest::Client::new(); + let response = client + .post("https://github.com/login/device/code") + .header("Accept", "application/json") + .form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")]) + .send() + .await + .map_err(|e| format!("Device flow request failed: {}", e))?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(format!("Device flow start failed: {}", body)); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse device flow response: {}", e)) +} + +/// Polls GitHub for the device flow authorization result. +pub async fn github_device_flow_poll(device_code: &str) -> Result { + let client = reqwest::Client::new(); + let response = client + .post("https://github.com/login/oauth/access_token") + .header("Accept", "application/json") + .form(&[ + ("client_id", GITHUB_CLIENT_ID), + ("device_code", device_code), + ( + "grant_type", + "urn:ietf:params:oauth:grant-type:device_code", + ), + ]) + .send() + .await + .map_err(|e| format!("Device flow poll failed: {}", e))?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(format!("Device flow poll HTTP error: {}", body)); + } + + #[derive(Deserialize)] + struct RawResponse { + access_token: Option, + error: Option, + } + + let raw: RawResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse poll response: {}", e))?; + + if let Some(token) = raw.access_token { + Ok(DeviceFlowPollResult { + status: "complete".to_string(), + access_token: Some(token), + error: None, + }) + } else { + let error = raw.error.unwrap_or_else(|| "unknown".to_string()); + let status = match error.as_str() { + "authorization_pending" | "slow_down" => "pending", + "expired_token" => "expired", + _ => "error", + }; + Ok(DeviceFlowPollResult { + status: status.to_string(), + access_token: None, + error: Some(error), + }) + } +} + +/// Gets the authenticated GitHub user's profile. +pub async fn github_get_user(token: &str) -> Result { + let client = reqwest::Client::new(); + let response = client + .get("https://api.github.com/user") + .header("Authorization", format!("Bearer {}", token)) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "Laputa-App") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|e| format!("GitHub user request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("GitHub API error {}: {}", status, body)); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse user response: {}", e)) +} + /// Clones a GitHub repo to a local path using HTTPS + token auth. pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result { let dest = Path::new(local_path); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d508e74c..46f45018 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,7 @@ pub mod vault; use ai_chat::{AiChatRequest, AiChatResponse}; use frontmatter::FrontmatterValue; use git::{GitCommit, ModifiedFile}; -use github::GithubRepo; +use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo}; use settings::Settings; use vault::{RenameResult, VaultEntry}; @@ -139,6 +139,21 @@ fn create_vault_dir(path: String) -> Result<(), String> { std::fs::create_dir_all(&path).map_err(|e| format!("Failed to create directory: {e}")) } +#[tauri::command] +async fn github_device_flow_start() -> Result { + github::github_device_flow_start().await +} + +#[tauri::command] +async fn github_device_flow_poll(device_code: String) -> Result { + github::github_device_flow_poll(&device_code).await +} + +#[tauri::command] +async fn github_get_user(token: String) -> Result { + github::github_get_user(&token).await +} + #[cfg(test)] mod tests { use super::*; @@ -171,11 +186,9 @@ mod tests { let vault_path = tmp.path().join("existing"); std::fs::create_dir(&vault_path).unwrap(); - // Calling create_vault_dir on an existing dir should succeed let result = create_vault_dir(vault_path.to_string_lossy().to_string()); assert!(result.is_ok()); } -} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -250,7 +263,10 @@ pub fn run() { github_list_repos, github_create_repo, clone_repo, - create_vault_dir + create_vault_dir, + github_device_flow_start, + github_device_flow_poll, + github_get_user ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 6652e6f7..76727a8c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -8,6 +8,7 @@ pub struct Settings { pub openai_key: Option, pub google_key: Option, pub github_token: Option, + pub github_username: Option, } fn settings_path() -> Result { @@ -49,6 +50,10 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> { .github_token .map(|k| k.trim().to_string()) .filter(|k| !k.is_empty()), + github_username: settings + .github_username + .map(|k| k.trim().to_string()) + .filter(|k| !k.is_empty()), }; let json = serde_json::to_string_pretty(&cleaned) @@ -87,7 +92,8 @@ mod tests { anthropic_key: None, openai_key: None, google_key: None, - github_token: None + github_token: None, + github_username: None } ) ); @@ -100,12 +106,14 @@ mod tests { openai_key: None, google_key: Some("AIza-test".to_string()), github_token: Some("gho_xyz789".to_string()), + github_username: Some("lucaong".to_string()), }; let json = serde_json::to_string(&settings).unwrap(); let parsed: Settings = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.anthropic_key, settings.anthropic_key); assert_eq!(parsed.google_key, settings.google_key); assert_eq!(parsed.github_token, settings.github_token); + assert_eq!(parsed.github_username, settings.github_username); } #[test] @@ -123,22 +131,25 @@ mod tests { openai_key: Some("sk-openai".to_string()), google_key: None, github_token: Some("gho_token123".to_string()), + github_username: Some("lucaong".to_string()), }); assert_eq!(loaded.anthropic_key.as_deref(), Some("sk-ant-key")); assert_eq!(loaded.openai_key.as_deref(), Some("sk-openai")); assert_eq!(loaded.github_token.as_deref(), Some("gho_token123")); + assert_eq!(loaded.github_username.as_deref(), Some("lucaong")); } #[test] fn test_save_trims_whitespace() { let loaded = save_and_reload(Settings { anthropic_key: Some(" sk-ant-test ".to_string()), - openai_key: None, - google_key: None, github_token: Some(" gho_abc ".to_string()), + github_username: Some(" lucaong ".to_string()), + ..Default::default() }); assert_eq!(loaded.anthropic_key.as_deref(), Some("sk-ant-test")); assert_eq!(loaded.github_token.as_deref(), Some("gho_abc")); + assert_eq!(loaded.github_username.as_deref(), Some("lucaong")); } #[test] @@ -146,11 +157,12 @@ mod tests { let loaded = save_and_reload(Settings { anthropic_key: Some("".to_string()), openai_key: Some(" ".to_string()), - google_key: None, - github_token: None, + github_username: Some("".to_string()), + ..Default::default() }); assert!(loaded.anthropic_key.is_none()); assert!(loaded.openai_key.is_none()); + assert!(loaded.github_username.is_none()); } #[test] diff --git a/src/App.test.tsx b/src/App.test.tsx index e49f76ed..a8e3ae52 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -53,7 +53,7 @@ vi.mock('./mock-tauri', () => ({ if (cmd === 'get_modified_files') return [] if (cmd === 'get_note_content') return mockAllContent['/vault/project/test.md'] || '' if (cmd === 'get_file_history') return [] - if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null, github_token: null } + if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null } if (cmd === 'save_settings') return null return null }), diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 5be9fc8e..16e1a58e 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -8,6 +8,7 @@ const emptySettings: Settings = { openai_key: null, google_key: null, github_token: null, + github_username: null, } const populatedSettings: Settings = { @@ -15,6 +16,7 @@ const populatedSettings: Settings = { openai_key: 'sk-openai-test456', google_key: null, github_token: null, + github_username: null, } describe('SettingsPanel', () => { @@ -77,6 +79,7 @@ describe('SettingsPanel', () => { openai_key: null, google_key: null, github_token: null, + github_username: null, }) expect(onClose).toHaveBeenCalled() }) @@ -96,6 +99,7 @@ describe('SettingsPanel', () => { openai_key: 'sk-openai-test456', google_key: null, github_token: null, + github_username: null, }) }) @@ -136,6 +140,7 @@ describe('SettingsPanel', () => { openai_key: null, google_key: null, github_token: null, + github_username: null, }) }) diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 7699c419..ba352a5e 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -1,6 +1,12 @@ -import { useState, useRef } from 'react' -import { X, Eye, EyeSlash } from '@phosphor-icons/react' -import type { Settings } from '../types' +import { useState, useRef, useCallback, useEffect } from 'react' +import { X, Eye, EyeSlash, GithubLogo, SignOut, CircleNotch } from '@phosphor-icons/react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' +import type { Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types' + +function tauriCall(cmd: string, args: Record = {}): Promise { + return isTauri() ? invoke(cmd, args) : mockInvoke(cmd, args) +} interface SettingsPanelProps { open: boolean @@ -65,6 +71,216 @@ function KeyField({ label, placeholder, value, onChange, onClear }: KeyFieldProp ) } +type OAuthStatus = 'idle' | 'waiting' | 'error' + +interface GitHubSectionProps { + githubUsername: string | null + githubToken: string | null + onConnected: (token: string, username: string) => void + onDisconnect: () => void +} + +function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect }: GitHubSectionProps) { + const [oauthStatus, setOauthStatus] = useState('idle') + const [userCode, setUserCode] = 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 + }, []) + + // Cleanup polling on unmount + 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) + deviceCodeRef.current = flowStart.device_code + + // Open browser for user authorization + window.open(flowStart.verification_uri, '_blank') + + // Start polling + pollingRef.current = true + const intervalMs = Math.max(flowStart.interval * 1000, 5000) + + const poll = async () => { + while (pollingRef.current && deviceCodeRef.current) { + await new Promise(r => setTimeout(r, intervalMs)) + if (!pollingRef.current) break + + try { + const result = await tauriCall('github_device_flow_poll', { + deviceCode: deviceCodeRef.current, + }) + + if (result.status === 'complete' && result.access_token) { + // Got the token — fetch user info + const user = await tauriCall('github_get_user', { + token: result.access_token, + }) + stopPolling() + setOauthStatus('idle') + setUserCode(null) + onConnected(result.access_token, user.login) + return + } + + if (result.status === 'expired') { + stopPolling() + setOauthStatus('error') + setErrorMessage('Authorization expired. Please try again.') + return + } + + if (result.status === 'error') { + stopPolling() + setOauthStatus('error') + setErrorMessage(result.error ?? 'Authorization failed.') + return + } + // status === 'pending' → continue polling + } catch (err) { + stopPolling() + setOauthStatus('error') + setErrorMessage(err instanceof Error ? err.message : 'Polling failed.') + return + } + } + } + + poll() + } catch (err) { + setOauthStatus('error') + setErrorMessage(err instanceof Error ? err.message : 'Failed to start login.') + } + }, [onConnected, stopPolling]) + + const handleCancel = useCallback(() => { + stopPolling() + setOauthStatus('idle') + setUserCode(null) + setErrorMessage(null) + }, [stopPolling]) + + const handleDisconnect = useCallback(() => { + stopPolling() + setOauthStatus('idle') + setUserCode(null) + setErrorMessage(null) + onDisconnect() + }, [onDisconnect, stopPolling]) + + // Connected state + if (isConnected && oauthStatus === 'idle') { + return ( +
+
+ + + {githubUsername} + + Connected +
+ +
+ ) + } + + // Waiting for authorization + if (oauthStatus === 'waiting' && userCode) { + return ( +
+
+
+ Enter this code on GitHub: +
+
+ {userCode} +
+
+ + Waiting for authorization... +
+
+ +
+ ) + } + + // Idle / error state — show login button + return ( +
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+ ) +} + export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) { if (!open) return null return @@ -74,18 +290,48 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit ({ + anthropic_key: anthropicKey.trim() || null, + openai_key: openaiKey.trim() || null, + google_key: googleKey.trim() || null, + github_token: githubToken ?? null, + github_username: githubUsername ?? null, + }), [anthropicKey, openaiKey, googleKey, githubToken, githubUsername]) const handleSave = () => { - const trimmed: Settings = { + onSave(buildSettings()) + onClose() + } + + const handleGitHubConnected = useCallback((token: string, username: string) => { + setGithubToken(token) + setGithubUsername(username) + // Save immediately so the token persists even if the user doesn't click Save + onSave({ anthropic_key: anthropicKey.trim() || null, openai_key: openaiKey.trim() || null, google_key: googleKey.trim() || null, - github_token: githubToken.trim() || null, - } - onSave(trimmed) - onClose() - } + github_token: token, + github_username: username, + }) + }, [onSave, anthropicKey, openaiKey, googleKey]) + + const handleGitHubDisconnect = useCallback(() => { + setGithubToken(null) + setGithubUsername(null) + // Save immediately to clear the token + onSave({ + anthropic_key: anthropicKey.trim() || null, + openai_key: openaiKey.trim() || null, + google_key: googleKey.trim() || null, + github_token: null, + github_username: null, + }) + }, [onSave, anthropicKey, openaiKey, googleKey]) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { @@ -165,16 +411,15 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit
- Personal access token for cloning and syncing vaults with GitHub. + Connect your GitHub account to clone and sync vaults.
- setGithubToken('')} + diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index e8f2ddd7..627ec06a 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -8,6 +8,7 @@ const defaultSettings: Settings = { openai_key: null, google_key: null, github_token: null, + github_username: null, } const savedSettings: Settings = { @@ -15,6 +16,7 @@ const savedSettings: Settings = { openai_key: null, google_key: 'AIza-test', github_token: null, + github_username: null, } let mockSettingsStore: Settings = { ...defaultSettings } @@ -74,6 +76,7 @@ describe('useSettings', () => { openai_key: 'sk-openai-new', google_key: null, github_token: null, + github_username: null, } await act(async () => { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 228dd17c..569d2984 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -12,6 +12,7 @@ const EMPTY_SETTINGS: Settings = { openai_key: null, google_key: null, github_token: null, + github_username: null, } export function useSettings() { diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index e9ed8f0c..30d9c408 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -4,7 +4,7 @@ * this provides realistic test data so the UI can be verified visually. */ -import type { VaultEntry, GitCommit, ModifiedFile, Settings } from './types' +import type { VaultEntry, GitCommit, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from './types' // --- Vault API detection (for reading real files in browser dev mode) --- let vaultApiAvailable: boolean | null = null @@ -1707,8 +1707,12 @@ let mockSettings: Settings = { openai_key: null, google_key: null, github_token: 'gho_mock_token_for_testing', + github_username: 'lucaong', } +// Track mock device flow state: poll returns 'pending' once, then 'complete' +let mockDeviceFlowPollCount = 0 + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types const mockHandlers: Record any> = { list_vault: () => MOCK_ENTRIES, @@ -1772,6 +1776,7 @@ const mockHandlers: Record any> = { openai_key: s.openai_key?.trim() || null, google_key: s.google_key?.trim() || null, github_token: s.github_token?.trim() || null, + github_username: s.github_username?.trim() || null, } return null }, @@ -1831,6 +1836,28 @@ const mockHandlers: Record any> = { purge_trash: () => [], migrate_is_a_to_type: () => 0, create_vault_dir: () => null, + github_device_flow_start: (): DeviceFlowStart => { + mockDeviceFlowPollCount = 0 + return { + device_code: 'mock_device_code_abc123', + user_code: 'ABCD-1234', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + } + }, + github_device_flow_poll: (): DeviceFlowPollResult => { + mockDeviceFlowPollCount++ + if (mockDeviceFlowPollCount <= 1) { + return { status: 'pending', access_token: null, error: 'authorization_pending' } + } + return { status: 'complete', access_token: 'gho_mock_oauth_token_xyz', error: null } + }, + github_get_user: (): GitHubUser => ({ + login: 'lucaong', + name: 'Luca Ongaro', + avatar_url: 'https://avatars.githubusercontent.com/u/123456?v=4', + }), } export function isTauri(): boolean { diff --git a/src/types.ts b/src/types.ts index 98920915..55d9e586 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,27 @@ export interface Settings { openai_key: string | null google_key: string | null github_token: string | null + github_username: string | null +} + +export interface DeviceFlowStart { + device_code: string + user_code: string + verification_uri: string + expires_in: number + interval: number +} + +export interface DeviceFlowPollResult { + status: 'pending' | 'complete' | 'expired' | 'error' + access_token: string | null + error: string | null +} + +export interface GitHubUser { + login: string + name: string | null + avatar_url: string } export interface GithubRepo {