From dedee0f2980b92d9381e27f330b46079832a2dd1 Mon Sep 17 00:00:00 2001 From: Anastasiia Solop <35258279+anastasiya1155@users.noreply.github.com> Date: Tue, 8 Jul 2025 19:32:23 +0200 Subject: [PATCH] feat: Implement GitHub OAuth (#72) * implement GitHub OAuth * fmt and clippy * add secrets for GitHub App in workflow * fix env vars * use device flow for login instead of callback for better security, add email and username to posthog analytics * cleanup * add user details to sentry context * fixes after rebase * feedback fixes * do not allow to press esc to hide github popup * use oauth app to get user token with full repo access * use PAT token as a backup for creating PRs * update github signin box text * update sign in box styling * fmt --------- Co-authored-by: Gabriel Gordon-Hall --- backend/Cargo.toml | 1 + backend/build.rs | 6 + backend/src/main.rs | 7 +- backend/src/models/config.rs | 6 + backend/src/routes/auth.rs | 286 ++++++++++++++++++ backend/src/routes/mod.rs | 1 + backend/src/routes/task_attempts.rs | 20 +- backend/src/services/analytics.rs | 29 +- backend/src/services/pr_monitor.rs | 6 +- frontend/src/App.tsx | 9 + frontend/src/components/GitHubLoginDialog.tsx | 213 +++++++++++++ frontend/src/components/ProvidePatDialog.tsx | 98 ++++++ frontend/src/components/config-provider.tsx | 49 ++- .../components/tasks/TaskDetailsToolbar.tsx | 61 +++- frontend/src/components/ui/dialog.tsx | 23 +- frontend/src/pages/Settings.tsx | 57 +++- shared/types.ts | 259 ++++++++++++++-- 17 files changed, 1041 insertions(+), 90 deletions(-) create mode 100644 backend/src/routes/auth.rs create mode 100644 frontend/src/components/GitHubLoginDialog.tsx create mode 100644 frontend/src/components/ProvidePatDialog.tsx diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7463f318..070b4149 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -47,6 +47,7 @@ sentry-tower = "0.41.0" sentry-tracing = { version = "0.41.0", features = ["backtrace"] } reqwest = { version = "0.11", features = ["json"] } strip-ansi-escapes = "0.2.1" +urlencoding = "2.1.3" [build-dependencies] dotenv = "0.15" diff --git a/backend/build.rs b/backend/build.rs index c041a1f2..d1ca3cc4 100644 --- a/backend/build.rs +++ b/backend/build.rs @@ -9,6 +9,12 @@ fn main() { if let Ok(api_endpoint) = std::env::var("POSTHOG_API_ENDPOINT") { println!("cargo:rustc-env=POSTHOG_API_ENDPOINT={}", api_endpoint); } + if let Ok(api_key) = std::env::var("GITHUB_APP_ID") { + println!("cargo:rustc-env=GITHUB_APP_ID={}", api_key); + } + if let Ok(api_endpoint) = std::env::var("GITHUB_APP_CLIENT_ID") { + println!("cargo:rustc-env=GITHUB_APP_CLIENT_ID={}", api_endpoint); + } // Create frontend/dist directory if it doesn't exist let dist_path = Path::new("../frontend/dist"); diff --git a/backend/src/main.rs b/backend/src/main.rs index 11c7dd48..2159a842 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -3,6 +3,7 @@ use std::{str::FromStr, sync::Arc}; use axum::{ body::Body, http::{header, HeaderValue, StatusCode}, + middleware::from_fn_with_state, response::{IntoResponse, Json as ResponseJson, Response}, routing::{get, post}, Json, Router, @@ -28,7 +29,7 @@ mod utils; use app_state::AppState; use execution_monitor::execution_monitor; use models::{ApiResponse, Config}; -use routes::{config, filesystem, health, projects, task_attempts, tasks}; +use routes::{auth, config, filesystem, health, projects, task_attempts, tasks}; use services::PrMonitorService; async fn echo_handler( @@ -197,7 +198,9 @@ fn main() -> anyhow::Result<()> { .merge(task_attempts::task_attempts_router()) .merge(filesystem::filesystem_router()) .merge(config::config_router()) - .route("/sounds/:filename", get(serve_sound_file)), + .merge(auth::auth_router()) + .route("/sounds/:filename", get(serve_sound_file)) + .layer(from_fn_with_state(app_state.clone(), auth::sentry_user_context_middleware)), ); let app = Router::new() diff --git a/backend/src/models/config.rs b/backend/src/models/config.rs index 9650f93c..1ba999b6 100644 --- a/backend/src/models/config.rs +++ b/backend/src/models/config.rs @@ -44,7 +44,10 @@ pub struct EditorConfig { #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] pub struct GitHubConfig { + pub pat: Option, pub token: Option, + pub username: Option, + pub primary_email: Option, pub default_pr_base: Option, } @@ -177,7 +180,10 @@ impl Default for EditorConfig { impl Default for GitHubConfig { fn default() -> Self { Self { + pat: None, token: None, + username: None, + primary_email: None, default_pr_base: Some("main".to_string()), } } diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs new file mode 100644 index 00000000..8d44a516 --- /dev/null +++ b/backend/src/routes/auth.rs @@ -0,0 +1,286 @@ +use axum::{ + extract::{Request, State}, + middleware::Next, + response::{Json as ResponseJson, Response}, + routing::post, + Json, Router, +}; + +use crate::{app_state::AppState, models::ApiResponse}; + +pub fn auth_router() -> Router { + Router::new() + .route("/auth/github/device/start", post(device_start)) + .route("/auth/github/device/poll", post(device_poll)) +} + +#[derive(serde::Deserialize)] +struct DeviceStartRequest {} + +#[derive(serde::Serialize)] +struct DeviceStartResponse { + device_code: String, + user_code: String, + verification_uri: String, + expires_in: u64, + interval: u64, +} + +#[derive(serde::Deserialize)] +struct DevicePollRequest { + device_code: String, +} + +/// POST /auth/github/device/start +async fn device_start() -> ResponseJson> { + let params = [ + ("client_id", "Ov23li9bxz3kKfPOIsGm"), + ("scope", "user:email,repo"), + ]; + let client = reqwest::Client::new(); + let res = client + .post("https://github.com/login/device/code") + .header("Accept", "application/json") + .form(¶ms) + .send() + .await; + let res = match res { + Ok(r) => r, + Err(e) => { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to contact GitHub: {e}")), + }); + } + }; + let json: serde_json::Value = match res.json().await { + Ok(j) => j, + Err(e) => { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to parse GitHub response: {e}")), + }); + } + }; + if let ( + Some(device_code), + Some(user_code), + Some(verification_uri), + Some(expires_in), + Some(interval), + ) = ( + json.get("device_code").and_then(|v| v.as_str()), + json.get("user_code").and_then(|v| v.as_str()), + json.get("verification_uri").and_then(|v| v.as_str()), + json.get("expires_in").and_then(|v| v.as_u64()), + json.get("interval").and_then(|v| v.as_u64()), + ) { + ResponseJson(ApiResponse { + success: true, + data: Some(DeviceStartResponse { + device_code: device_code.to_string(), + user_code: user_code.to_string(), + verification_uri: verification_uri.to_string(), + expires_in, + interval, + }), + message: None, + }) + } else { + ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("GitHub error: {}", json)), + }) + } +} + +/// POST /auth/github/device/poll +async fn device_poll( + State(app_state): State, + Json(payload): Json, +) -> ResponseJson> { + let params = [ + ("client_id", "Ov23li9bxz3kKfPOIsGm"), + ("device_code", payload.device_code.as_str()), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]; + let client = reqwest::Client::new(); + let res = client + .post("https://github.com/login/oauth/access_token") + .header("Accept", "application/json") + .form(¶ms) + .send() + .await; + let res = match res { + Ok(r) => r, + Err(e) => { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to contact GitHub: {e}")), + }); + } + }; + let json: serde_json::Value = match res.json().await { + Ok(j) => j, + Err(e) => { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to parse GitHub response: {e}")), + }); + } + }; + if let Some(error) = json.get("error").and_then(|v| v.as_str()) { + // Not authorized yet, or other error + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(error.to_string()), + }); + } + let access_token = json.get("access_token").and_then(|v| v.as_str()); + if let Some(access_token) = access_token { + // Fetch user info + let user_res = client + .get("https://api.github.com/user") + .bearer_auth(access_token) + .header("User-Agent", "vibe-kanban-app") + .send() + .await; + let user_json: serde_json::Value = match user_res { + Ok(res) => match res.json().await { + Ok(json) => json, + Err(e) => { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to parse GitHub user response: {e}")), + }); + } + }, + Err(e) => { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to fetch user info: {e}")), + }); + } + }; + let username = user_json + .get("login") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + // Fetch user emails + let emails_res = client + .get("https://api.github.com/user/emails") + .bearer_auth(access_token) + .header("User-Agent", "vibe-kanban-app") + .send() + .await; + let emails_json: serde_json::Value = match emails_res { + Ok(res) => match res.json().await { + Ok(json) => json, + Err(e) => { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to parse GitHub emails response: {e}")), + }); + } + }, + Err(e) => { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to fetch user emails: {e}")), + }); + } + }; + let primary_email = emails_json + .as_array() + .and_then(|arr| { + arr.iter() + .find(|email| { + email + .get("primary") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + }) + .and_then(|email| email.get("email").and_then(|v| v.as_str())) + }) + .map(|s| s.to_string()); + // Save to config + { + let mut config = app_state.get_config().write().await; + config.github.username = username.clone(); + config.github.primary_email = primary_email.clone(); + config.github.token = Some(access_token.to_string()); + let config_path = crate::utils::config_path(); + if config.save(&config_path).is_err() { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("Failed to save config".to_string()), + }); + } + } + // Identify user in PostHog + let mut props = serde_json::Map::new(); + if let Some(ref username) = username { + props.insert( + "username".to_string(), + serde_json::Value::String(username.clone()), + ); + } + if let Some(ref email) = primary_email { + props.insert( + "email".to_string(), + serde_json::Value::String(email.clone()), + ); + } + { + let props = serde_json::Value::Object(props); + app_state + .track_analytics_event("$identify", Some(props)) + .await; + } + + ResponseJson(ApiResponse { + success: true, + data: Some("GitHub login successful".to_string()), + message: None, + }) + } else { + ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("No access token yet".to_string()), + }) + } +} + +/// Middleware to set Sentry user context for every request +pub async fn sentry_user_context_middleware( + State(app_state): State, + req: Request, + next: Next, +) -> Response { + let config = app_state.get_config().read().await; + let username = config.github.username.clone(); + let email = config.github.primary_email.clone(); + drop(config); + if username.is_some() || email.is_some() { + sentry::configure_scope(|scope| { + scope.set_user(Some(sentry::User { + username, + email, + ..Default::default() + })); + }); + } + next.run(req).await +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index a83e55d7..92b93d94 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod config; pub mod filesystem; pub mod health; diff --git a/backend/src/routes/task_attempts.rs b/backend/src/routes/task_attempts.rs index 44404236..1014098a 100644 --- a/backend/src/routes/task_attempts.rs +++ b/backend/src/routes/task_attempts.rs @@ -322,8 +322,7 @@ pub async fn create_github_pr( success: false, data: None, message: Some( - "GitHub token not configured. Please set your GitHub token in settings." - .to_string(), + "GitHub authentication not configured. Please sign in with GitHub.".to_string(), ), })); } @@ -358,7 +357,7 @@ pub async fn create_github_pr( attempt_id, task_id, project_id, - github_token: &github_token, + github_token: &config.github.pat.unwrap_or(github_token), title: &request.title, body: request.body.as_deref(), base_branch: Some(&base_branch), @@ -390,10 +389,23 @@ pub async fn create_github_pr( attempt_id, e ); + let message = match &e { + crate::models::task_attempt::TaskAttemptError::Git(err) + if err.message().contains("status code: 403") => + { + Some("insufficient_github_permissions".to_string()) + } + crate::models::task_attempt::TaskAttemptError::Git(err) + if err.message().contains("status code: 404") => + { + Some("github_repo_not_found_or_no_access".to_string()) + } + _ => Some(format!("Failed to create PR: {}", e)), + }; Ok(ResponseJson(ApiResponse { success: false, data: None, - message: Some(format!("Failed to create PR: {}", e)), + message, })) } } diff --git a/backend/src/services/analytics.rs b/backend/src/services/analytics.rs index beed1ce2..bf617c09 100644 --- a/backend/src/services/analytics.rs +++ b/backend/src/services/analytics.rs @@ -56,21 +56,28 @@ impl AnalyticsService { self.config.posthog_api_endpoint.trim_end_matches('/') ); - let mut event_properties = properties.unwrap_or_else(|| json!({})); - if let Some(props) = event_properties.as_object_mut() { - props.insert( - "timestamp".to_string(), - json!(chrono::Utc::now().to_rfc3339()), - ); - props.insert("version".to_string(), json!(env!("CARGO_PKG_VERSION"))); - } - - let payload = json!({ + let mut payload = json!({ "api_key": self.config.posthog_api_key, "event": event_name, "distinct_id": user_id, - "properties": event_properties }); + if event_name == "$identify" { + // For $identify, set person properties in $set + if let Some(props) = properties { + payload["$set"] = props; + } + } else { + // For other events, use properties as before + let mut event_properties = properties.unwrap_or_else(|| json!({})); + if let Some(props) = event_properties.as_object_mut() { + props.insert( + "timestamp".to_string(), + json!(chrono::Utc::now().to_rfc3339()), + ); + props.insert("version".to_string(), json!(env!("CARGO_PKG_VERSION"))); + } + payload["properties"] = event_properties; + } let client = self.client.clone(); let event_name = event_name.to_string(); diff --git a/backend/src/services/pr_monitor.rs b/backend/src/services/pr_monitor.rs index 5b6be0ac..17a0f7c1 100644 --- a/backend/src/services/pr_monitor.rs +++ b/backend/src/services/pr_monitor.rs @@ -53,7 +53,11 @@ impl PrMonitorService { // Get GitHub token from config let github_token = { let config_read = config.read().await; - config_read.github.token.clone() + if config_read.github.pat.is_some() { + config_read.github.pat.clone() + } else { + config_read.github.token.clone() + } }; match github_token { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e16dfec2..b8181730 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import type { EditorType, } from 'shared/types'; import * as Sentry from '@sentry/react'; +import { GitHubLoginDialog } from '@/components/GitHubLoginDialog'; const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); @@ -24,6 +25,7 @@ function AppContent() { const { config, updateConfig, loading } = useConfig(); const [showDisclaimer, setShowDisclaimer] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false); + const [showGitHubLogin, setShowGitHubLogin] = useState(false); const showNavbar = true; useEffect(() => { @@ -32,6 +34,9 @@ function AppContent() { if (config.disclaimer_acknowledged) { setShowOnboarding(!config.onboarding_acknowledged); } + const notAuthenticated = + !config.github?.username || !config.github?.token; + setShowGitHubLogin(notAuthenticated); } }, [config]); @@ -108,6 +113,10 @@ function AppContent() { return (
+ void; +}) { + const { config, loading } = useConfig(); + const [fetching, setFetching] = useState(false); + const [error, setError] = useState(null); + const [deviceState, setDeviceState] = useState(null); + const [polling, setPolling] = useState(false); + const [copied, setCopied] = useState(false); + + const isAuthenticated = !!(config?.github?.username && config?.github?.token); + + const handleLogin = async () => { + setFetching(true); + setError(null); + setDeviceState(null); + try { + const res = await fetch('/api/auth/github/device/start', { + method: 'POST', + }); + const data = await res.json(); + if (data.success && data.data) { + setDeviceState(data.data); + setPolling(true); + } else { + setError(data.message || 'Failed to start GitHub login.'); + } + } catch (e) { + console.error(e); + setError('Network error'); + } finally { + setFetching(false); + } + }; + + // Poll for completion + useEffect(() => { + let timer: number; + if (polling && deviceState) { + const poll = async () => { + try { + const res = await fetch('/api/auth/github/device/poll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_code: deviceState.device_code }), + }); + const data = await res.json(); + if (data.success) { + setPolling(false); + setDeviceState(null); + setError(null); + window.location.reload(); // reload config + } else if (data.message === 'authorization_pending') { + // keep polling + timer = setTimeout(poll, (deviceState.interval || 5) * 1000); + } else if (data.message === 'slow_down') { + // increase interval + timer = setTimeout(poll, (deviceState.interval + 5) * 1000); + } else if (data.message === 'expired_token') { + setPolling(false); + setError('Device code expired. Please try again.'); + setDeviceState(null); + } else { + setPolling(false); + setError(data.message || 'Login failed.'); + setDeviceState(null); + } + } catch (e) { + setPolling(false); + setError('Network error'); + } + }; + timer = setTimeout(poll, deviceState.interval * 1000); + } + return () => { + if (timer) clearTimeout(timer); + }; + }, [polling, deviceState]); + + // Automatically copy code to clipboard when deviceState is set + useEffect(() => { + if (deviceState?.user_code) { + navigator.clipboard.writeText(deviceState.user_code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }, [deviceState?.user_code]); + + return ( + + + + Sign in with GitHub + + Connect your GitHub account to use Vibe Kanban. + + + {loading ? ( +
Loading…
+ ) : isAuthenticated ? ( +
+
+ You are signed in as {config?.github?.username ?? ''}. +
+ +
+ ) : deviceState ? ( +
+
+
+ + 1 + +
+

+ Go to GitHub Device Authorization +

+ + {deviceState.verification_uri} + +
+
+ +
+ + 2 + +
+

+ Enter this code: +

+
+ + {deviceState.user_code} + + +
+
+
+
+ +
+
+ {copied + ? 'Code copied to clipboard!' + : 'Waiting for you to authorize…'} +
+
+ {error &&
{error}
} +
+ ) : ( + <> + {error &&
{error}
} + + + + + )} +
+
+ ); +} diff --git a/frontend/src/components/ProvidePatDialog.tsx b/frontend/src/components/ProvidePatDialog.tsx new file mode 100644 index 00000000..58775cdb --- /dev/null +++ b/frontend/src/components/ProvidePatDialog.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from './ui/dialog'; +import { Input } from './ui/input'; +import { Button } from './ui/button'; +import { useConfig } from './config-provider'; +import { Alert, AlertDescription } from './ui/alert'; + +export function ProvidePatDialog({ + open, + onOpenChange, + errorMessage, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + errorMessage?: string; +}) { + const { config, updateAndSaveConfig } = useConfig(); + const [pat, setPat] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const handleSave = async () => { + if (!config) return; + setSaving(true); + setError(null); + try { + await updateAndSaveConfig({ + github: { + ...config.github, + pat, + }, + }); + onOpenChange(false); + } catch (err) { + setError('Failed to save Personal Access Token'); + } finally { + setSaving(false); + } + }; + + return ( + + + + Provide GitHub Personal Access Token + +
+

+ {errorMessage || + 'Your GitHub OAuth token does not have sufficient permissions to open a PR in this repository.'} +
+
+ Please provide a Personal Access Token with repo permissions. +

+ setPat(e.target.value)} + autoFocus + /> +

+ + Create a token here + +

+ {error && ( + + {error} + + )} +
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/config-provider.tsx b/frontend/src/components/config-provider.tsx index 1917a87b..ae1ad4d1 100644 --- a/frontend/src/components/config-provider.tsx +++ b/frontend/src/components/config-provider.tsx @@ -1,15 +1,17 @@ import { createContext, - useContext, - useState, - useEffect, ReactNode, + useCallback, + useContext, + useEffect, + useState, } from 'react'; -import type { Config, ApiResponse } from 'shared/types'; +import type { ApiResponse, Config } from 'shared/types'; interface ConfigContextType { config: Config | null; updateConfig: (updates: Partial) => void; + updateAndSaveConfig: (updates: Partial) => void; saveConfig: () => Promise; loading: boolean; } @@ -43,11 +45,11 @@ export function ConfigProvider({ children }: ConfigProviderProps) { loadConfig(); }, []); - const updateConfig = (updates: Partial) => { + const updateConfig = useCallback((updates: Partial) => { setConfig((prev) => (prev ? { ...prev, ...updates } : null)); - }; + }, []); - const saveConfig = async (): Promise => { + const saveConfig = useCallback(async (): Promise => { if (!config) return false; try { @@ -65,11 +67,40 @@ export function ConfigProvider({ children }: ConfigProviderProps) { console.error('Error saving config:', err); return false; } - }; + }, [config]); + + const updateAndSaveConfig = useCallback( + async (updates: Partial) => { + setLoading(true); + const newConfig: Config | null = config + ? { ...config, ...updates } + : null; + + try { + const response = await fetch('/api/config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(newConfig), + }); + + const data: ApiResponse = await response.json(); + setConfig(data.data); + return data.success; + } catch (err) { + console.error('Error saving config:', err); + return false; + } finally { + setLoading(false); + } + }, + [config] + ); return ( {children} diff --git a/frontend/src/components/tasks/TaskDetailsToolbar.tsx b/frontend/src/components/tasks/TaskDetailsToolbar.tsx index 86eb77a8..449e2d97 100644 --- a/frontend/src/components/tasks/TaskDetailsToolbar.tsx +++ b/frontend/src/components/tasks/TaskDetailsToolbar.tsx @@ -1,17 +1,17 @@ -import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { - History, - Settings2, - StopCircle, - Play, + ArrowDown, ExternalLink, GitBranch as GitBranchIcon, - Search, - X, - ArrowDown, + GitPullRequest, + History, + Play, Plus, RefreshCw, - GitPullRequest, + Search, + Settings2, + StopCircle, + X, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -28,8 +28,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuTrigger, DropdownMenuSeparator, + DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Dialog, @@ -48,14 +48,15 @@ import { import { useConfig } from '@/components/config-provider'; import { makeRequest } from '@/lib/api'; import type { + BranchStatus, + ExecutionProcess, + ExecutionProcessSummary, + GitBranch, + Project, TaskAttempt, TaskWithAttemptStatus, - ExecutionProcessSummary, - ExecutionProcess, - Project, - GitBranch, - BranchStatus, } from 'shared/types'; +import { ProvidePatDialog } from '@/components/ProvidePatDialog'; interface ApiResponse { success: boolean; @@ -142,6 +143,8 @@ export function TaskDetailsToolbar({ selectedAttempt?.base_branch || 'main' ); const [error, setError] = useState(null); + const [showPatDialog, setShowPatDialog] = useState(false); + const [patDialogError, setPatDialogError] = useState(null); // Set create attempt mode when there are no attempts useEffect(() => { @@ -330,9 +333,29 @@ export function TaskDetailsToolbar({ setPrTitle(''); setPrBody(''); setPrBaseBranch(selectedAttempt?.base_branch || 'main'); + } else if (result.message === 'insufficient_github_permissions') { + setShowCreatePRDialog(false); + setPatDialogError(null); + setShowPatDialog(true); + } else if (result.message === 'github_repo_not_found_or_no_access') { + setShowCreatePRDialog(false); + setPatDialogError( + 'Your token does not have access to this repository, or the repository does not exist. Please check the repository URL and/or provide a Personal Access Token with access.' + ); + setShowPatDialog(true); } else { setError(result.message || 'Failed to create GitHub PR'); } + } else if (response.status === 403) { + setShowCreatePRDialog(false); + setPatDialogError(null); + setShowPatDialog(true); + } else if (response.status === 404) { + setShowCreatePRDialog(false); + setPatDialogError( + 'Your token does not have access to this repository, or the repository does not exist. Please check the repository URL and/or provide a Personal Access Token with access.' + ); + setShowPatDialog(true); } else { setError('Failed to create GitHub PR'); } @@ -590,6 +613,14 @@ export function TaskDetailsToolbar({ return ( <> + { + setShowPatDialog(open); + if (!open) setPatDialogError(null); + }} + errorMessage={patDialogError || undefined} + />
{/* Error Display */} {error && ( diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index dcd04644..aec85760 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -9,11 +9,12 @@ const Dialog = React.forwardRef< React.HTMLAttributes & { open?: boolean; onOpenChange?: (open: boolean) => void; + uncloseable?: boolean; } ->(({ className, open, onOpenChange, children, ...props }, ref) => { +>(({ className, open, onOpenChange, children, uncloseable, ...props }, ref) => { // Add keyboard shortcut support for closing dialog with Esc useDialogKeyboardShortcuts(() => { - if (open && onOpenChange) { + if (open && onOpenChange && !uncloseable) { onOpenChange(false); } }); @@ -24,7 +25,7 @@ const Dialog = React.forwardRef<
onOpenChange?.(false)} + onClick={() => (uncloseable ? {} : onOpenChange?.(false))} />
- + {!uncloseable && ( + + )} {children}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 7a896168..7493cc1d 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { Card, CardContent, @@ -18,25 +18,28 @@ import { Label } from '@/components/ui/label'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; -import { Loader2, Volume2, Key } from 'lucide-react'; -import type { ThemeMode, EditorType, SoundFile } from 'shared/types'; +import { Key, Loader2, Volume2 } from 'lucide-react'; +import type { EditorType, SoundFile, ThemeMode } from 'shared/types'; import { - EXECUTOR_TYPES, + EDITOR_LABELS, EDITOR_TYPES, EXECUTOR_LABELS, - EDITOR_LABELS, + EXECUTOR_TYPES, SOUND_FILES, SOUND_LABELS, } from 'shared/types'; import { useTheme } from '@/components/theme-provider'; import { useConfig } from '@/components/config-provider'; +import { GitHubLoginDialog } from '@/components/GitHubLoginDialog'; export function Settings() { - const { config, updateConfig, saveConfig, loading } = useConfig(); + const { config, updateConfig, saveConfig, loading, updateAndSaveConfig } = + useConfig(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const { setTheme } = useTheme(); + const [showGitHubLogin, setShowGitHubLogin] = useState(false); const playSound = async (soundFile: SoundFile) => { const audio = new Audio(`/api/sounds/${soundFile}.wav`); @@ -87,6 +90,20 @@ export function Settings() { updateConfig({ onboarding_acknowledged: false }); }; + const isAuthenticated = !!(config?.github?.username && config?.github?.token); + + const handleLogout = useCallback(async () => { + if (!config) return; + updateAndSaveConfig({ + github: { + ...config.github, + token: null, + username: null, + primary_email: null, + }, + }); + }, [config, updateAndSaveConfig]); + if (loading) { return (
@@ -289,12 +306,12 @@ export function Settings() { id="github-token" type="password" placeholder="ghp_xxxxxxxxxxxxxxxxxxxx" - value={config.github.token || ''} + value={config.github.pat || ''} onChange={(e) => updateConfig({ github: { ...config.github, - token: e.target.value || null, + pat: e.target.value || null, }, }) } @@ -312,8 +329,28 @@ export function Settings() {

- -
+ {config && isAuthenticated ? ( +
+
+ +
+ {config.github.username} +
+
+ +
+ ) : ( + + )} + +
= { success: boolean, data: T | null, message: string | null, }; -export type Config = { theme: ThemeMode, executor: ExecutorConfig, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, sound_alerts: boolean, sound_file: SoundFile, push_notifications: boolean, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean | null, }; +export type Config = { + theme: ThemeMode, + executor: ExecutorConfig, + disclaimer_acknowledged: boolean, + onboarding_acknowledged: boolean, + sound_alerts: boolean, + sound_file: SoundFile, + push_notifications: boolean, + editor: EditorConfig, + github: GitHubConfig, + analytics_enabled: boolean | null, +}; export type ThemeMode = "light" | "dark" | "system" | "purple" | "green" | "blue" | "orange" | "red"; export type EditorConfig = { editor_type: EditorType, custom_command: string | null, }; -export type GitHubConfig = { token: string | null, default_pr_base: string | null, }; +export type GitHubConfig = { + token?: string | null, + default_pr_base: string | null, + username?: string | null, + primary_email?: string | null, + pat?: string | null +}; export type EditorType = "vscode" | "cursor" | "windsurf" | "intellij" | "zed" | "custom"; export type EditorConstants = { editor_types: Array, editor_labels: Array, }; -export type SoundFile = "abstract-sound1" | "abstract-sound2" | "abstract-sound3" | "abstract-sound4" | "cow-mooing" | "phone-vibration" | "rooster"; +export type SoundFile = + "abstract-sound1" + | "abstract-sound2" + | "abstract-sound3" + | "abstract-sound4" + | "cow-mooing" + | "phone-vibration" + | "rooster"; export type SoundConstants = { sound_files: Array, sound_labels: Array, }; export type ConfigConstants = { editor: EditorConstants, sound: SoundConstants, }; -export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" } | { "type": "gemini" } | { "type": "opencode" }; +export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" } | { "type": "gemini" } | { + "type": "opencode" +}; export type ExecutorConstants = { executor_types: Array, executor_labels: Array, }; -export type CreateProject = { name: string, git_repo_path: string, use_existing_repo: boolean, setup_script: string | null, dev_script: string | null, }; +export type CreateProject = { + name: string, + git_repo_path: string, + use_existing_repo: boolean, + setup_script: string | null, + dev_script: string | null, +}; -export type Project = { id: string, name: string, git_repo_path: string, setup_script: string | null, dev_script: string | null, created_at: Date, updated_at: Date, }; +export type Project = { + id: string, + name: string, + git_repo_path: string, + setup_script: string | null, + dev_script: string | null, + created_at: Date, + updated_at: Date, +}; -export type ProjectWithBranch = { id: string, name: string, git_repo_path: string, setup_script: string | null, dev_script: string | null, current_branch: string | null, created_at: Date, updated_at: Date, }; +export type ProjectWithBranch = { + id: string, + name: string, + git_repo_path: string, + setup_script: string | null, + dev_script: string | null, + current_branch: string | null, + created_at: Date, + updated_at: Date, +}; -export type UpdateProject = { name: string | null, git_repo_path: string | null, setup_script: string | null, dev_script: string | null, }; +export type UpdateProject = { + name: string | null, + git_repo_path: string | null, + setup_script: string | null, + dev_script: string | null, +}; export type SearchResult = { path: string, is_file: boolean, match_type: SearchMatchType, }; @@ -44,19 +98,63 @@ export type CreateBranch = { name: string, base_branch: string | null, }; export type CreateTask = { project_id: string, title: string, description: string | null, }; -export type CreateTaskAndStart = { project_id: string, title: string, description: string | null, executor: ExecutorConfig | null, }; +export type CreateTaskAndStart = { + project_id: string, + title: string, + description: string | null, + executor: ExecutorConfig | null, +}; export type TaskStatus = "todo" | "inprogress" | "inreview" | "done" | "cancelled"; -export type Task = { id: string, project_id: string, title: string, description: string | null, status: TaskStatus, created_at: string, updated_at: string, }; +export type Task = { + id: string, + project_id: string, + title: string, + description: string | null, + status: TaskStatus, + created_at: string, + updated_at: string, +}; -export type TaskWithAttemptStatus = { id: string, project_id: string, title: string, description: string | null, status: TaskStatus, created_at: string, updated_at: string, has_in_progress_attempt: boolean, has_merged_attempt: boolean, has_failed_attempt: boolean, }; +export type TaskWithAttemptStatus = { + id: string, + project_id: string, + title: string, + description: string | null, + status: TaskStatus, + created_at: string, + updated_at: string, + has_in_progress_attempt: boolean, + has_merged_attempt: boolean, + has_failed_attempt: boolean, +}; export type UpdateTask = { title: string | null, description: string | null, status: TaskStatus | null, }; -export type TaskAttemptStatus = "setuprunning" | "setupcomplete" | "setupfailed" | "executorrunning" | "executorcomplete" | "executorfailed"; +export type TaskAttemptStatus = + "setuprunning" + | "setupcomplete" + | "setupfailed" + | "executorrunning" + | "executorcomplete" + | "executorfailed"; -export type TaskAttempt = { id: string, task_id: string, worktree_path: string, branch: string, base_branch: string, merge_commit: string | null, executor: string | null, pr_url: string | null, pr_number: bigint | null, pr_status: string | null, pr_merged_at: string | null, created_at: string, updated_at: string, }; +export type TaskAttempt = { + id: string, + task_id: string, + worktree_path: string, + branch: string, + base_branch: string, + merge_commit: string | null, + executor: string | null, + pr_url: string | null, + pr_number: bigint | null, + pr_status: string | null, + pr_merged_at: string | null, + created_at: string, + updated_at: string, +}; export type CreateTaskAttempt = { executor: string | null, base_branch: string | null, }; @@ -64,11 +162,28 @@ export type UpdateTaskAttempt = Record; export type CreateFollowUpAttempt = { prompt: string, }; -export type TaskAttemptActivity = { id: string, execution_process_id: string, status: TaskAttemptStatus, note: string | null, created_at: string, }; +export type TaskAttemptActivity = { + id: string, + execution_process_id: string, + status: TaskAttemptStatus, + note: string | null, + created_at: string, +}; -export type TaskAttemptActivityWithPrompt = { id: string, execution_process_id: string, status: TaskAttemptStatus, note: string | null, created_at: string, prompt: string | null, }; +export type TaskAttemptActivityWithPrompt = { + id: string, + execution_process_id: string, + status: TaskAttemptStatus, + note: string | null, + created_at: string, + prompt: string | null, +}; -export type CreateTaskAttemptActivity = { execution_process_id: string, status: TaskAttemptStatus | null, note: string | null, }; +export type CreateTaskAttemptActivity = { + execution_process_id: string, + status: TaskAttemptStatus | null, + note: string | null, +}; export type DirectoryEntry = { name: string, path: string, is_directory: boolean, is_git_repo: boolean, }; @@ -80,37 +195,125 @@ export type FileDiff = { path: string, chunks: Array, }; export type WorktreeDiff = { files: Array, }; -export type BranchStatus = { is_behind: boolean, commits_behind: number, commits_ahead: number, up_to_date: boolean, merged: boolean, has_uncommitted_changes: boolean, base_branch_name: string, }; +export type BranchStatus = { + is_behind: boolean, + commits_behind: number, + commits_ahead: number, + up_to_date: boolean, + merged: boolean, + has_uncommitted_changes: boolean, + base_branch_name: string, +}; -export type ExecutionState = "NotStarted" | "SetupRunning" | "SetupComplete" | "SetupFailed" | "CodingAgentRunning" | "CodingAgentComplete" | "CodingAgentFailed" | "Complete"; +export type ExecutionState = + "NotStarted" + | "SetupRunning" + | "SetupComplete" + | "SetupFailed" + | "CodingAgentRunning" + | "CodingAgentComplete" + | "CodingAgentFailed" + | "Complete"; -export type TaskAttemptState = { execution_state: ExecutionState, has_changes: boolean, has_setup_script: boolean, setup_process_id: string | null, coding_agent_process_id: string | null, }; +export type TaskAttemptState = { + execution_state: ExecutionState, + has_changes: boolean, + has_setup_script: boolean, + setup_process_id: string | null, + coding_agent_process_id: string | null, +}; -export type ExecutionProcess = { id: string, task_attempt_id: string, process_type: ExecutionProcessType, executor_type: string | null, status: ExecutionProcessStatus, command: string, args: string | null, working_directory: string, stdout: string | null, stderr: string | null, exit_code: bigint | null, started_at: string, completed_at: string | null, created_at: string, updated_at: string, }; +export type ExecutionProcess = { + id: string, + task_attempt_id: string, + process_type: ExecutionProcessType, + executor_type: string | null, + status: ExecutionProcessStatus, + command: string, + args: string | null, + working_directory: string, + stdout: string | null, + stderr: string | null, + exit_code: bigint | null, + started_at: string, + completed_at: string | null, + created_at: string, + updated_at: string, +}; -export type ExecutionProcessSummary = { id: string, task_attempt_id: string, process_type: ExecutionProcessType, executor_type: string | null, status: ExecutionProcessStatus, command: string, args: string | null, working_directory: string, exit_code: bigint | null, started_at: string, completed_at: string | null, created_at: string, updated_at: string, }; +export type ExecutionProcessSummary = { + id: string, + task_attempt_id: string, + process_type: ExecutionProcessType, + executor_type: string | null, + status: ExecutionProcessStatus, + command: string, + args: string | null, + working_directory: string, + exit_code: bigint | null, + started_at: string, + completed_at: string | null, + created_at: string, + updated_at: string, +}; export type ExecutionProcessStatus = "running" | "completed" | "failed" | "killed"; export type ExecutionProcessType = "setupscript" | "codingagent" | "devserver"; -export type CreateExecutionProcess = { task_attempt_id: string, process_type: ExecutionProcessType, executor_type: string | null, command: string, args: string | null, working_directory: string, }; +export type CreateExecutionProcess = { + task_attempt_id: string, + process_type: ExecutionProcessType, + executor_type: string | null, + command: string, + args: string | null, + working_directory: string, +}; -export type UpdateExecutionProcess = { status: ExecutionProcessStatus | null, exit_code: bigint | null, completed_at: string | null, }; +export type UpdateExecutionProcess = { + status: ExecutionProcessStatus | null, + exit_code: bigint | null, + completed_at: string | null, +}; -export type ExecutorSession = { id: string, task_attempt_id: string, execution_process_id: string, session_id: string | null, prompt: string | null, summary: string | null, created_at: string, updated_at: string, }; +export type ExecutorSession = { + id: string, + task_attempt_id: string, + execution_process_id: string, + session_id: string | null, + prompt: string | null, + summary: string | null, + created_at: string, + updated_at: string, +}; export type CreateExecutorSession = { task_attempt_id: string, execution_process_id: string, prompt: string | null, }; export type UpdateExecutorSession = { session_id: string | null, prompt: string | null, summary: string | null, }; -export type NormalizedConversation = { entries: Array, session_id: string | null, executor_type: string, prompt: string | null, summary: string | null, }; +export type NormalizedConversation = { + entries: Array, + session_id: string | null, + executor_type: string, + prompt: string | null, + summary: string | null, +}; export type NormalizedEntry = { timestamp: string | null, entry_type: NormalizedEntryType, content: string, }; -export type NormalizedEntryType = { "type": "user_message" } | { "type": "assistant_message" } | { "type": "tool_use", tool_name: string, action_type: ActionType, } | { "type": "system_message" } | { "type": "error_message" } | { "type": "thinking" }; +export type NormalizedEntryType = { "type": "user_message" } | { "type": "assistant_message" } | { + "type": "tool_use", + tool_name: string, + action_type: ActionType, +} | { "type": "system_message" } | { "type": "error_message" } | { "type": "thinking" }; -export type ActionType = { "action": "file_read", path: string, } | { "action": "file_write", path: string, } | { "action": "command_run", command: string, } | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | { "action": "task_create", description: string, } | { "action": "other", description: string, }; +export type ActionType = { "action": "file_read", path: string, } | { "action": "file_write", path: string, } | { + "action": "command_run", + command: string, +} | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | { + "action": "task_create", + description: string, +} | { "action": "other", description: string, }; // Generated constants export const EXECUTOR_TYPES: string[] = [ @@ -123,7 +326,7 @@ export const EXECUTOR_TYPES: string[] = [ export const EDITOR_TYPES: EditorType[] = [ "vscode", - "cursor", + "cursor", "windsurf", "intellij", "zed",