// frontend/components/LoginPage.jsx import React, { useState, useEffect, useRef } from "react"; import { apiUrl, safeFetchJSON } from "../utils/api.js"; import { initApp } from "../utils/appInit.js"; /** * GitPilot – Enterprise Agentic Login * Theme: "Claude Code" / Anthropic Enterprise (Dark + Warm Orange) */ export default function LoginPage({ onAuthenticated, backendReady = false }) { // Auth State const [authProcessing, setAuthProcessing] = useState(false); const [error, setError] = useState(""); // Mode State: 'loading' | 'web' (Has Secret) | 'device' (No Secret) const [mode, setMode] = useState("loading"); // Device Flow State const [deviceData, setDeviceData] = useState(null); const pollTimer = useRef(null); const stopPolling = useRef(false); // Flag to safely stop async polling // Web Flow State const [missingClientId, setMissingClientId] = useState(false); // REF FIX: Prevents React StrictMode from running the auth exchange twice const processingRef = useRef(false); const authCheckDone = useRef(false); // 1. Initialization Effect — runs once on mount AND when backendReady changes useEffect(() => { // Skip if already resolved if (authCheckDone.current && mode !== "loading") return; const params = new URLSearchParams(window.location.search); const code = params.get("code"); const state = params.get("state"); // A. If returning from GitHub (Web Flow Callback) if (code) { if (!processingRef.current) { processingRef.current = true; setMode("web"); consumeOAuthCallback(code, state); } return; } // B. Use the shared singleton init — reuses App.jsx's result. // No duplicate /api/auth/status calls, no separate retry loops. initApp().then((result) => { authCheckDone.current = true; if (result.ready) { setError(""); setMode(result.authMode === "web" ? "web" : "device"); } else { // Backend unreachable — allow device flow as fallback setError(result.error || "Backend unavailable"); setMode("device"); } }); // Cleanup polling on unmount return () => { stopPolling.current = true; if (pollTimer.current) clearTimeout(pollTimer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [backendReady]); // =========================================================================== // WEB FLOW LOGIC (Standard OAuth2) // =========================================================================== async function consumeOAuthCallback(code, state) { const expectedState = sessionStorage.getItem("gitpilot_oauth_state"); if (state && expectedState && expectedState !== state) { console.warn("OAuth state mismatch - proceeding with caution."); } setAuthProcessing(true); setError(""); window.history.replaceState({}, document.title, window.location.pathname); try { const data = await safeFetchJSON(apiUrl("/api/auth/callback"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, state: state || "" }), }); handleSuccess(data); } catch (err) { console.error("Login Error:", err); setError(err instanceof Error ? err.message : "Login failed."); setAuthProcessing(false); } } async function handleSignInWithGitHub() { setError(""); setMissingClientId(false); setAuthProcessing(true); try { const data = await safeFetchJSON(apiUrl("/api/auth/url")); if (data.state) { sessionStorage.setItem("gitpilot_oauth_state", data.state); } window.location.href = data.authorization_url; } catch (err) { console.error("Auth Start Error:", err); // Check for missing client ID (404/500 errors) if (err.message && (err.message.includes('404') || err.message.includes('500'))) { setMissingClientId(true); } else { setError(err instanceof Error ? err.message : "Could not start sign-in."); } setAuthProcessing(false); } } // =========================================================================== // DEVICE FLOW LOGIC (No Client Secret Required) // =========================================================================== const startDeviceFlow = async () => { setError(""); setAuthProcessing(true); stopPolling.current = false; // Reset stop flag try { const data = await safeFetchJSON(apiUrl("/api/auth/device/code"), { method: "POST" }); // Handle Errors if (data.error) { if (data.error.includes("400") || data.error.includes("Bad Request")) { throw new Error("Device Flow is disabled in GitHub. Please go to your GitHub App Settings > 'General' > 'Identifying and authorizing users' and check the box 'Enable Device Flow'."); } throw new Error(data.error); } if (!data.device_code) throw new Error("Invalid device code response"); setDeviceData(data); setAuthProcessing(false); // Start Polling (Recursive Timeout Pattern) pollDeviceToken(data.device_code, data.interval || 5); } catch (err) { setError(err.message); setAuthProcessing(false); } }; const pollDeviceToken = async (deviceCode, interval) => { if (stopPolling.current) return; try { const response = await fetch(apiUrl("/api/auth/device/poll"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_code: deviceCode }) }); // 1. Success (200) if (response.status === 200) { const data = await response.json(); handleSuccess(data); return; } // 2. Pending (202) -> Continue Polling if (response.status === 202) { // Schedule next poll pollTimer.current = setTimeout( () => pollDeviceToken(deviceCode, interval), interval * 1000 ); return; } // 3. Error (4xx/5xx) -> Stop Polling & Show Error const errData = await response.json().catch(() => ({ error: "Unknown polling error" })); // Special case: If it's just a 'slow_down' warning (sometimes 400), we just wait longer if (errData.error === "slow_down") { pollTimer.current = setTimeout( () => pollDeviceToken(deviceCode, interval + 5), (interval + 5) * 1000 ); return; } // Terminal errors throw new Error(errData.error || `Polling failed: ${response.status}`); } catch (e) { console.error("Poll error:", e); if (!stopPolling.current) { setError(e.message || "Failed to connect to authentication server."); setDeviceData(null); // Return to initial state } } }; const handleManualCheck = async () => { if (!deviceData?.device_code) return; try { const response = await fetch(apiUrl("/api/auth/device/poll"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_code: deviceData.device_code }) }); if (response.status === 200) { const data = await response.json(); handleSuccess(data); } else if (response.status === 202) { // Visual feedback for pending state const btn = document.getElementById("manual-check-btn"); if (btn) { const originalText = btn.innerText; btn.innerText = "Still Pending..."; btn.disabled = true; setTimeout(() => { btn.innerText = originalText; btn.disabled = false; }, 2000); } } } catch (e) { console.error("Manual check failed", e); } }; const handleCancelDeviceFlow = () => { stopPolling.current = true; if (pollTimer.current) clearTimeout(pollTimer.current); setDeviceData(null); setError(""); }; // =========================================================================== // SHARED HELPERS // =========================================================================== function handleSuccess(data) { stopPolling.current = true; // Ensure polling stops if (pollTimer.current) clearTimeout(pollTimer.current); if (!data.access_token || !data.user) { setError("Server returned incomplete session data."); return; } try { localStorage.setItem("github_token", data.access_token); localStorage.setItem("github_user", JSON.stringify(data.user)); } catch (e) { console.warn("LocalStorage access denied:", e); } if (typeof onAuthenticated === "function") { onAuthenticated({ access_token: data.access_token, user: data.user, }); } } // --- Design Token System --- const theme = { bg: "#131316", cardBg: "#1C1C1F", border: "#27272A", accent: "#D95C3D", accentHover: "#C44F32", textPrimary: "#EDEDED", textSecondary: "#A1A1AA", font: '"Söhne", "Inter", -apple-system, sans-serif', }; const styles = { container: { minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", backgroundColor: theme.bg, fontFamily: theme.font, color: theme.textPrimary, letterSpacing: "-0.01em", }, card: { backgroundColor: theme.cardBg, width: "100%", maxWidth: "440px", borderRadius: "12px", border: `1px solid ${theme.border}`, boxShadow: "0 24px 48px -12px rgba(0, 0, 0, 0.6)", padding: "48px 40px", textAlign: "center", position: "relative", }, logoBadge: { width: "48px", height: "48px", backgroundColor: "rgba(217, 92, 61, 0.15)", color: theme.accent, borderRadius: "10px", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "22px", fontWeight: "700", margin: "0 auto 32px auto", border: "1px solid rgba(217, 92, 61, 0.2)", }, h1: { fontSize: "24px", fontWeight: "600", marginBottom: "12px", color: theme.textPrimary, }, p: { fontSize: "14px", color: theme.textSecondary, lineHeight: "1.6", marginBottom: "40px", }, button: { width: "100%", height: "48px", backgroundColor: theme.accent, color: "#FFFFFF", border: "none", borderRadius: "8px", fontSize: "14px", fontWeight: "500", cursor: (authProcessing || (mode === 'loading')) ? "not-allowed" : "pointer", opacity: (authProcessing || (mode === 'loading')) ? 0.7 : 1, transition: "background-color 0.2s ease", display: "flex", alignItems: "center", justifyContent: "center", gap: "10px", boxShadow: "0 4px 12px rgba(217, 92, 61, 0.25)", }, secondaryButton: { backgroundColor: "transparent", color: "#A1A1AA", border: "1px solid #3F3F46", padding: "8px 16px", borderRadius: "6px", fontSize: "12px", cursor: "pointer", marginTop: "16px", minWidth: "100px" }, errorBox: { backgroundColor: "rgba(185, 28, 28, 0.15)", border: "1px solid rgba(185, 28, 28, 0.3)", color: "#FCA5A5", padding: "12px", borderRadius: "8px", fontSize: "13px", marginBottom: "24px", textAlign: "left", }, configCard: { textAlign: "left", backgroundColor: "#111", border: "1px solid #333", padding: "24px", borderRadius: "8px", marginBottom: "24px", }, codeDisplay: { backgroundColor: "#27272A", color: theme.accent, fontSize: "20px", fontWeight: "700", padding: "12px", borderRadius: "6px", textAlign: "center", letterSpacing: "2px", margin: "12px 0", border: `1px dashed ${theme.accent}`, cursor: "pointer", }, footer: { marginTop: "48px", fontSize: "12px", color: "#52525B", } }; // --- RENDER: Device Flow UI --- const renderDeviceFlow = () => { if (!deviceData) { return ( ); } return (

Authorize Device

GitPilot needs authorization to access your repositories.

1. Copy code:
{ navigator.clipboard.writeText(deviceData.user_code); }} title="Click to copy" > {deviceData.user_code}
2. Paste at GitHub:
Open Activation Page ↗
Waiting for authorization...
); }; // --- RENDER: Config Error --- if (missingClientId) { return (
⚠️

Configuration Error

Could not connect to GitHub Authentication services.

); } // --- RENDER: Main --- return (
GP

GitPilot Enterprise

Agentic AI workflow for your repositories.
Secure. Context-aware. Automated.

{error &&
{error}
} {mode === "loading" && (
Initializing...
)} {mode === "web" && ( )} {mode === "device" && renderDeviceFlow()}
© {new Date().getFullYear()} GitPilot Inc.
); }