gitpilot / frontend /components /LoginPage.jsx
github-actions[bot]
Deploy from 0824fbaf
b21e262
// 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 (
<button
onClick={startDeviceFlow}
disabled={authProcessing}
style={styles.button}
onMouseOver={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accentHover)}
onMouseOut={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accent)}
>
{authProcessing ? "Connecting..." : "Sign in with GitHub"}
</button>
);
}
return (
<div style={styles.configCard}>
<h3 style={{marginTop:0, color: '#FFF', fontSize: '16px'}}>Authorize Device</h3>
<p style={{color: '#AAA', fontSize: '13px', marginBottom:'16px'}}>
GitPilot needs authorization to access your repositories.
</p>
<div style={{marginBottom: '16px'}}>
<div style={{color: '#AAA', fontSize: '12px', marginBottom: '4px'}}>1. Copy code:</div>
<div
style={styles.codeDisplay}
onClick={() => {
navigator.clipboard.writeText(deviceData.user_code);
}}
title="Click to copy"
>
{deviceData.user_code}
</div>
</div>
<div>
<div style={{color: '#AAA', fontSize: '12px', marginBottom: '4px'}}>2. Paste at GitHub:</div>
<a
href={deviceData.verification_uri}
target="_blank"
rel="noreferrer"
style={{
display: 'block',
backgroundColor: '#FFF',
color: '#000',
textDecoration: 'none',
padding: '10px',
borderRadius: '6px',
textAlign: 'center',
fontWeight: '600',
fontSize: '14px'
}}
>
Open Activation Page ↗
</a>
</div>
<div style={{marginTop: '20px', fontSize: '12px', color: '#666', textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px'}}>
<span style={{animation: 'spin 1s linear infinite', display: 'inline-block'}}></span>
Waiting for authorization...
<style>{`@keyframes spin { 100% { transform: rotate(360deg); } }`}</style>
</div>
<div style={{textAlign: 'center', display: 'flex', gap: '10px', justifyContent: 'center'}}>
<button
id="manual-check-btn"
onClick={handleManualCheck}
style={styles.secondaryButton}
>
Check Status
</button>
<button
onClick={handleCancelDeviceFlow}
style={styles.secondaryButton}
>
Cancel
</button>
</div>
</div>
);
};
// --- RENDER: Config Error ---
if (missingClientId) {
return (
<div style={styles.container}>
<div style={styles.card}>
<div style={{...styles.logoBadge, color: "#F59E0B", backgroundColor: "rgba(245, 158, 11, 0.1)", borderColor: "rgba(245, 158, 11, 0.2)"}}>⚠️</div>
<h1 style={styles.h1}>Configuration Error</h1>
<p style={styles.p}>Could not connect to GitHub Authentication services.</p>
<button onClick={() => setMissingClientId(false)} style={{...styles.button, backgroundColor: "#3F3F46"}}>Retry</button>
</div>
</div>
);
}
// --- RENDER: Main ---
return (
<div style={styles.container}>
<div style={styles.card}>
<div style={styles.logoBadge}>GP</div>
<h1 style={styles.h1}>GitPilot Enterprise</h1>
<p style={styles.p}>
Agentic AI workflow for your repositories.<br/>
Secure. Context-aware. Automated.
</p>
{error && <div style={styles.errorBox}>{error}</div>}
{mode === "loading" && (
<div style={{color: '#666', fontSize: '14px'}}>Initializing...</div>
)}
{mode === "web" && (
<button
onClick={handleSignInWithGitHub}
disabled={authProcessing}
style={styles.button}
onMouseOver={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accentHover)}
onMouseOut={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accent)}
>
{authProcessing ? "Connecting..." : (
<>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405 1.02 0 2.04.135 3 .405 2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" /></svg>
Sign in with GitHub
</>
)}
</button>
)}
{mode === "device" && renderDeviceFlow()}
<div style={styles.footer}>
&copy; {new Date().getFullYear()} GitPilot Inc.
</div>
</div>
</div>
);
}