diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 8d44a516..9b96c74c 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -2,7 +2,7 @@ use axum::{ extract::{Request, State}, middleware::Next, response::{Json as ResponseJson, Response}, - routing::post, + routing::{get, post}, Json, Router, }; @@ -12,6 +12,7 @@ pub fn auth_router() -> Router { Router::new() .route("/auth/github/device/start", post(device_start)) .route("/auth/github/device/poll", post(device_poll)) + .route("/auth/github/check", get(github_check_token)) } #[derive(serde::Deserialize)] @@ -263,6 +264,40 @@ async fn device_poll( } } +/// GET /auth/github/check +async fn github_check_token(State(app_state): State) -> ResponseJson> { + let config = app_state.get_config().read().await; + let token = config.github.token.clone(); + drop(config); + if let Some(token) = token { + let client = reqwest::Client::new(); + let res = client + .get("https://api.github.com/user") + .bearer_auth(&token) + .header("User-Agent", "vibe-kanban-app") + .send() + .await; + match res { + Ok(r) if r.status().is_success() => ResponseJson(ApiResponse { + success: true, + data: None, + message: Some("GitHub token is valid".to_string()), + }), + _ => ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("github_token_invalid".to_string()), + }), + } + } else { + ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("github_token_invalid".to_string()), + }) + } +} + /// Middleware to set Sentry user context for every request pub async fn sentry_user_context_middleware( State(app_state): State, diff --git a/backend/src/routes/task_attempts.rs b/backend/src/routes/task_attempts.rs index 30f8b41d..43542bb1 100644 --- a/backend/src/routes/task_attempts.rs +++ b/backend/src/routes/task_attempts.rs @@ -407,6 +407,9 @@ pub async fn create_github_pr( e ); let message = match &e { + crate::models::task_attempt::TaskAttemptError::GitHubService( + crate::services::GitHubServiceError::TokenInvalid, + ) => Some("github_token_invalid".to_string()), crate::models::task_attempt::TaskAttemptError::Git(err) if err.message().contains("status code: 403") => { diff --git a/backend/src/services/github_service.rs b/backend/src/services/github_service.rs index c44b6a00..b8a49085 100644 --- a/backend/src/services/github_service.rs +++ b/backend/src/services/github_service.rs @@ -12,6 +12,7 @@ pub enum GitHubServiceError { Repository(String), PullRequest(String), Branch(String), + TokenInvalid, } impl std::fmt::Display for GitHubServiceError { @@ -22,6 +23,7 @@ impl std::fmt::Display for GitHubServiceError { GitHubServiceError::Repository(e) => write!(f, "Repository error: {}", e), GitHubServiceError::PullRequest(e) => write!(f, "Pull request error: {}", e), GitHubServiceError::Branch(e) => write!(f, "Branch error: {}", e), + GitHubServiceError::TokenInvalid => write!(f, "GitHub token is invalid or expired."), } } } @@ -30,7 +32,22 @@ impl std::error::Error for GitHubServiceError {} impl From for GitHubServiceError { fn from(err: octocrab::Error) -> Self { - GitHubServiceError::Client(err) + match &err { + octocrab::Error::GitHub { source, .. } => { + let status = source.status_code.as_u16(); + let msg = source.message.to_ascii_lowercase(); + if status == 401 + || status == 403 + || msg.contains("bad credentials") + || msg.contains("token expired") + { + GitHubServiceError::TokenInvalid + } else { + GitHubServiceError::Client(err) + } + } + _ => GitHubServiceError::Client(err), + } } } @@ -161,11 +178,27 @@ impl GitHubService { .send() .await .map_err(|e| match e { - octocrab::Error::GitHub { source, .. } => GitHubServiceError::PullRequest(format!( - "GitHub API error: {} (status: {})", - source.message, - source.status_code.as_u16() - )), + octocrab::Error::GitHub { source, .. } => { + if source.status_code.as_u16() == 401 + || source.status_code.as_u16() == 403 + || source + .message + .to_ascii_lowercase() + .contains("bad credentials") + || source + .message + .to_ascii_lowercase() + .contains("token expired") + { + GitHubServiceError::TokenInvalid + } else { + GitHubServiceError::PullRequest(format!( + "GitHub API error: {} (status: {})", + source.message, + source.status_code.as_u16() + )) + } + } _ => GitHubServiceError::PullRequest(format!("Failed to create PR: {}", e)), })?; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8181730..e176fd25 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,7 +22,7 @@ import { GitHubLoginDialog } from '@/components/GitHubLoginDialog'; const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); function AppContent() { - const { config, updateConfig, loading } = useConfig(); + const { config, updateConfig, loading, githubTokenInvalid } = useConfig(); const [showDisclaimer, setShowDisclaimer] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false); const [showGitHubLogin, setShowGitHubLogin] = useState(false); @@ -36,9 +36,12 @@ function AppContent() { } const notAuthenticated = !config.github?.username || !config.github?.token; - setShowGitHubLogin(notAuthenticated); + setShowGitHubLogin(notAuthenticated || githubTokenInvalid); } - }, [config]); + if (githubTokenInvalid) { + setShowGitHubLogin(true); + } + }, [config, githubTokenInvalid]); const handleDisclaimerAccept = async () => { if (!config) return; diff --git a/frontend/src/components/GitHubLoginDialog.tsx b/frontend/src/components/GitHubLoginDialog.tsx index ad69d90d..9da41a9d 100644 --- a/frontend/src/components/GitHubLoginDialog.tsx +++ b/frontend/src/components/GitHubLoginDialog.tsx @@ -18,7 +18,7 @@ export function GitHubLoginDialog({ open: boolean; onOpenChange: (open: boolean) => void; }) { - const { config, loading } = useConfig(); + const { config, loading, githubTokenInvalid } = useConfig(); const [fetching, setFetching] = useState(false); const [error, setError] = useState(null); const [deviceState, setDeviceState] = useState { setFetching(true); diff --git a/frontend/src/components/config-provider.tsx b/frontend/src/components/config-provider.tsx index ae1ad4d1..abe7215c 100644 --- a/frontend/src/components/config-provider.tsx +++ b/frontend/src/components/config-provider.tsx @@ -14,6 +14,7 @@ interface ConfigContextType { updateAndSaveConfig: (updates: Partial) => void; saveConfig: () => Promise; loading: boolean; + githubTokenInvalid: boolean; } const ConfigContext = createContext(undefined); @@ -25,6 +26,7 @@ interface ConfigProviderProps { export function ConfigProvider({ children }: ConfigProviderProps) { const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); + const [githubTokenInvalid, setGithubTokenInvalid] = useState(false); useEffect(() => { const loadConfig = async () => { @@ -45,6 +47,26 @@ export function ConfigProvider({ children }: ConfigProviderProps) { loadConfig(); }, []); + // Check GitHub token validity after config loads + useEffect(() => { + if (loading) return; + const checkToken = async () => { + try { + const response = await fetch('/api/auth/github/check'); + const data: ApiResponse = await response.json(); + if (!data.success && data.message === 'github_token_invalid') { + setGithubTokenInvalid(true); + } else { + setGithubTokenInvalid(false); + } + } catch (err) { + // If the check fails, assume token is invalid + setGithubTokenInvalid(true); + } + }; + checkToken(); + }, [loading]); + const updateConfig = useCallback((updates: Partial) => { setConfig((prev) => (prev ? { ...prev, ...updates } : null)); }, []); @@ -100,7 +122,14 @@ export function ConfigProvider({ children }: ConfigProviderProps) { return ( {children}