gitpilot / frontend /components /AuthPage.jsx
github-actions[bot]
Deploy from 53884f28
6078519
Raw
History Blame Contribute Delete
19.6 kB
import React, { useEffect, useRef, useState } from "react";
import "./auth.css";
import { resolveBackendUrl } from "../utils/backend.js";
import { getSessionToken, setSessionToken } from "../utils/api.js";
// Premium /auth — GitPilot account (email/password) + "Continue with GitHub".
// GitPilot account = who you are; GitHub = repository access (the device flow).
//
// One layout for every state: <div.gp-auth> (full-viewport background) wraps a
// single <gp-auth-card>. Sign in, create account, and the GitHub authorization
// step all render INSIDE that same card — no nested wrappers, no second
// rectangle. The GitHub step shows "Connecting to GitHub…" with a spinner and
// then either redirects (web OAuth) or shows the device code in place.
const api = (path) => `${resolveBackendUrl()}${path}`;
// The account session also travels in a header (not just the cross-site cookie)
// so email/password sign-in survives the Vercel-frontend / HF-backend split.
function sessionHeaders() {
const t = getSessionToken();
return t ? { "X-GitPilot-Session": t } : {};
}
// Persist the portable session token returned by account endpoints.
function rememberSession(data) {
if (data && data.session_token) setSessionToken(data.session_token);
}
async function post(path, body) {
const r = await fetch(api(path), {
method: "POST",
headers: { "content-type": "application/json", ...sessionHeaders() },
credentials: "include", // also send/receive the session cookie when same-origin
body: JSON.stringify(body || {}),
});
let data = {};
try { data = await r.json(); } catch { /* empty body */ }
return { ok: r.ok, status: r.status, data };
}
async function getJSON(path, opts = {}) {
const r = await fetch(api(path), {
credentials: "include",
...opts,
headers: { ...sessionHeaders(), ...(opts.headers || {}) },
});
let data = {};
try { data = await r.json(); } catch { /* empty body */ }
return { ok: r.ok, status: r.status, data };
}
const GithubMark = () => (
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.58 2 12.25c0 4.53 2.87 8.37 6.84 9.73.5.1.68-.22.68-.49v-1.7c-2.78.62-3.37-1.21-3.37-1.21-.45-1.18-1.11-1.49-1.11-1.49-.91-.64.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.89 1.56 2.34 1.11 2.91.85.09-.66.35-1.11.63-1.36-2.22-.26-4.55-1.14-4.55-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.7 0 0 .84-.28 2.75 1.05a9.3 9.3 0 0 1 5 0c1.91-1.33 2.75-1.05 2.75-1.05.55 1.4.2 2.44.1 2.7.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.06.36.32.68.94.68 1.9v2.82c0 .27.18.6.69.49A10.02 10.02 0 0 0 22 12.25C22 6.58 17.52 2 12 2z" /></svg>
);
const Eye = ({ off }) => (off
? <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.5 18.5 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19M1 1l22 22"/></svg>
: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
);
export default function AuthPage({ onAuthenticated, backendReady = false, connectMode = false }) {
const params = new URLSearchParams(window.location.search);
const isVerifyLink =
!!params.get("token") && window.location.pathname.includes("verify-email");
// connectMode / ?view=github: jump straight to the GitHub device flow (used by
// the in-workspace "Connect GitHub" button for already-signed-in accounts).
const wantsGithub = !!params.get("code") || params.get("view") === "github" || connectMode;
const [mode, setMode] = useState(params.get("mode") === "signup" ? "signup" : "signin");
// Pick the opening view: GitHub flow → github; an email confirmation link
// (?token= on /verify-email) → verify; otherwise email.
const [view, setView] = useState(
wantsGithub ? "github" : isVerifyLink ? "verify" : "email"
); // "email" | "github" | "verify"
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [showPw, setShowPw] = useState(false);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState(null); // {kind, text}
// GitHub authorization state (rendered inside the same card)
const [ghPhase, setGhPhase] = useState("connecting"); // "connecting" | "device" | "error"
const [ghDevice, setGhDevice] = useState(null); // {user_code, verification_uri, ...}
const [ghError, setGhError] = useState("");
const pollTimer = useRef(null);
const stopPolling = useRef(false);
const ghStarted = useRef(false);
// Email-verification state (dedicated screen, not a silent redirect)
const [verifyPhase, setVerifyPhase] = useState("verifying"); // "verifying" | "success" | "error"
const [verifyMsg, setVerifyMsg] = useState("");
const [verifyUser, setVerifyUser] = useState(null);
const [resendBusy, setResendBusy] = useState(false);
const verifyStarted = useRef(false);
// Exchange the email-confirmation token once, then show success/error in place.
useEffect(() => {
if (view !== "verify" || verifyStarted.current) return;
verifyStarted.current = true;
const token = params.get("token");
setVerifyPhase("verifying");
(async () => {
const r = await post("/api/account/verify-email", { token });
// Strip the token from the URL so a refresh can't replay a stale link.
window.history.replaceState({}, document.title, "/auth");
if (r.ok) {
rememberSession(r.data);
setVerifyUser(r.data);
setVerifyPhase("success");
} else {
setVerifyMsg(r.data.detail || "This confirmation link is invalid or has expired.");
setVerifyPhase("error");
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
const resendVerification = async () => {
if (!email) { setNote({ kind: "err", text: "Enter your email to resend the link." }); return; }
setResendBusy(true);
try {
await post("/api/account/resend-verification", { email });
setNote({ kind: "ok", text: "If your account needs confirmation, a new link is on its way." });
} finally {
setResendBusy(false);
}
};
// ── GitHub authorization (device + web OAuth), all inside the same card ──
function finishGitHub(data) {
stopPolling.current = true;
if (pollTimer.current) clearTimeout(pollTimer.current);
if (!data || !data.access_token || !data.user) {
setGhPhase("error");
setGhError("GitHub returned an incomplete session. Please try again.");
return;
}
try {
localStorage.setItem("github_token", data.access_token);
localStorage.setItem("github_user", JSON.stringify(data.user));
} catch { /* storage may be blocked */ }
if (typeof onAuthenticated === "function") {
onAuthenticated({ access_token: data.access_token, user: data.user });
}
// Linked from inside the workspace → reload into it with the new GitHub token.
if (connectMode) {
window.location.href = "/";
}
}
async function pollDevice(deviceCode, interval) {
if (stopPolling.current) return;
try {
const r = await fetch(api("/api/auth/device/poll"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_code: deviceCode }),
});
if (r.status === 200) { finishGitHub(await r.json()); return; }
if (r.status === 202) {
pollTimer.current = setTimeout(() => pollDevice(deviceCode, interval), interval * 1000);
return;
}
const err = await r.json().catch(() => ({ error: "Unknown polling error" }));
if (err.error === "slow_down") {
pollTimer.current = setTimeout(() => pollDevice(deviceCode, interval + 5), (interval + 5) * 1000);
return;
}
throw new Error(err.error || `Polling failed: ${r.status}`);
} catch (e) {
if (!stopPolling.current) { setGhPhase("error"); setGhError(e.message || "Could not reach GitHub."); }
}
}
async function startGitHub() {
setGhPhase("connecting");
setGhError("");
setGhDevice(null);
stopPolling.current = false;
// If we returned from a web OAuth redirect, exchange the code first.
const code = params.get("code");
const state = params.get("state") || "";
if (code) {
window.history.replaceState({}, document.title, window.location.pathname);
try {
const r = await post("/api/auth/callback", { code, state });
if (r.ok) { finishGitHub(r.data); return; }
throw new Error(r.data.detail || "GitHub sign-in failed.");
} catch (e) {
setGhPhase("error"); setGhError(e.message || "GitHub sign-in failed."); return;
}
}
// Try the standard web OAuth redirect; fall back to device flow.
try {
const r = await getJSON("/api/auth/url");
if (r.ok && r.data.authorization_url) {
if (r.data.state) sessionStorage.setItem("gitpilot_oauth_state", r.data.state);
window.location.href = r.data.authorization_url; // redirect to GitHub
return;
}
} catch { /* fall through to device flow */ }
// Device flow (no client secret configured): show the code in place.
try {
const r = await post("/api/auth/device/code", {});
if (r.data.error) {
if (String(r.data.error).includes("400") || String(r.data.error).includes("Bad Request")) {
throw new Error("Device Flow is disabled for this GitHub App. Enable it under GitHub App Settings → General → 'Enable Device Flow'.");
}
throw new Error(r.data.error);
}
if (!r.data.device_code) throw new Error("GitHub did not return a device code.");
setGhDevice(r.data);
setGhPhase("device");
pollDevice(r.data.device_code, r.data.interval || 5);
} catch (e) {
setGhPhase("error");
setGhError(e.message || "Could not start GitHub sign-in.");
}
}
// Kick off the GitHub flow when entering the github view.
useEffect(() => {
if (view === "github") {
if (ghStarted.current) return;
ghStarted.current = true;
startGitHub();
} else {
ghStarted.current = false;
stopPolling.current = true;
if (pollTimer.current) clearTimeout(pollTimer.current);
}
return () => {
stopPolling.current = true;
if (pollTimer.current) clearTimeout(pollTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
const goEmail = () => {
stopPolling.current = true;
if (pollTimer.current) clearTimeout(pollTimer.current);
setGhDevice(null);
setGhError("");
setView("email");
};
const submit = async (e) => {
e.preventDefault();
setNote(null);
setBusy(true);
try {
if (mode === "signin") {
const r = await post("/api/account/login", { email, password });
if (r.ok) {
rememberSession(r.data);
if (typeof onAuthenticated === "function") onAuthenticated({ user: r.data });
}
else if (r.status === 403) setNote({ kind: "err", text: "Verify your email before signing in." });
else setNote({ kind: "err", text: r.data.detail || "Invalid email or password." });
} else {
const r = await post("/api/account/signup", { email, password, name: name || null });
if (r.status === 202) setNote({ kind: "ok", text: "Check your email — we sent a confirmation link." });
else setNote({ kind: "err", text: r.data.detail || "Could not create the account." });
}
} catch {
setNote({ kind: "err", text: "Network error — is the backend reachable?" });
} finally {
setBusy(false);
}
};
const forgot = async () => {
if (!email) { setNote({ kind: "err", text: "Enter your email first." }); return; }
await post("/api/account/password/forgot", { email });
setNote({ kind: "ok", text: "If an account exists, we'll send a reset link." });
};
// ── Email-confirmation card (landed from the verify-email link) ──
if (view === "verify") {
return (
<div className="gp-auth">
<a className="gp-home" href="/">← Back to home</a>
<div className="gp-auth-card">
<a className="gp-auth-logo" href="/" aria-label="Back to GitPilot home">GP</a>
{verifyPhase === "verifying" && (
<>
<h2>Confirming your email…</h2>
<p className="lead">Activating your GitPilot account. This only takes a moment.</p>
<div className="gp-spinwrap"><span className="gp-spin" /></div>
</>
)}
{verifyPhase === "success" && (
<>
<div className="gp-verify-check" aria-hidden="true"></div>
<h2>Email confirmed</h2>
<p className="lead">
Welcome{verifyUser?.name ? `, ${verifyUser.name}` : ""}! Your GitPilot
account is active.
</p>
<button
type="button"
className="gp-submit"
onClick={() => onAuthenticated && onAuthenticated({ user: verifyUser })}
>
Continue to GitPilot →
</button>
</>
)}
{verifyPhase === "error" && (
<>
<h2>Confirmation link problem</h2>
<div className="gp-note err">{verifyMsg}</div>
<p className="lead">Links expire after 15 minutes. Enter your email to get a fresh one.</p>
{note && <div className={`gp-note ${note.kind}`}>{note.text}</div>}
<div className="gp-field">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
</div>
<button type="button" className="gp-submit" onClick={resendVerification} disabled={resendBusy}>
{resendBusy ? "Sending…" : "Resend confirmation email"}
</button>
<button type="button" className="gp-link" onClick={() => setView("email")} style={{ marginTop: 12 }}>
Back to sign in
</button>
</>
)}
<div className="gp-copy">© {new Date().getFullYear()} GitPilot Inc.</div>
</div>
</div>
);
}
// ── GitHub authorization card (same shell, no nested rectangle) ──
if (view === "github") {
return (
<div className="gp-auth">
<a className="gp-home" href="/">← Back to home</a>
<div className="gp-auth-card">
{connectMode
? <button className="gp-back" onClick={() => { window.location.href = "/"; }}>← Back to workspace</button>
: <button className="gp-back" onClick={goEmail}>← Back to sign in</button>}
<a className="gp-auth-logo" href="/" aria-label="Back to GitPilot home">GP</a>
{ghPhase === "connecting" && (
<>
<h2>Connecting to GitHub…</h2>
<p className="lead">Opening a secure authorization session for your repositories.</p>
<div className="gp-spinwrap"><span className="gp-spin" /></div>
</>
)}
{ghPhase === "device" && ghDevice && (
<>
<h2>Authorize GitPilot</h2>
<p className="lead">Enter this code on GitHub to grant repository access.</p>
<button
type="button"
className="gp-code"
title="Click to copy"
onClick={() => navigator.clipboard?.writeText(ghDevice.user_code)}
>
{ghDevice.user_code}
</button>
<a className="gp-github" href={ghDevice.verification_uri} target="_blank" rel="noreferrer">
<GithubMark /> Open GitHub to authorize ↗
</a>
<div className="gp-waiting"><span className="gp-spin gp-spin-sm" /> Waiting for authorization…</div>
</>
)}
{ghPhase === "error" && (
<>
<h2>GitHub sign-in failed</h2>
<div className="gp-note err">{ghError}</div>
<button type="button" className="gp-github" onClick={startGitHub}>
<GithubMark /> Try again
</button>
</>
)}
<div className="gp-copy">© {new Date().getFullYear()} GitPilot Inc.</div>
</div>
</div>
);
}
// ── Email / password card (sign in + create account share this shell) ──
return (
<div className="gp-auth">
<a className="gp-home" href="/">← Back to home</a>
<form className="gp-auth-card" onSubmit={submit}>
{mode === "signup" && (
<button type="button" className="gp-back" onClick={() => { setMode("signin"); setNote(null); }}>← Back to sign in</button>
)}
<a className="gp-auth-logo" href="/" aria-label="Back to GitPilot home">GP</a>
{mode === "signin" ? (
<>
<h2>GitPilot Enterprise</h2>
<p className="lead">Agentic AI workflow for your repositories.<br />Secure. Context-aware. Automated.</p>
</>
) : (
<>
<h2>Create your account</h2>
<p className="lead">Start using GitPilot with a secure workspace.</p>
</>
)}
<button type="button" className="gp-github" onClick={() => setView("github")}>
<GithubMark /> Continue with GitHub
</button>
<div className="gp-or">or</div>
{note && <div className={`gp-note ${note.kind}`}>{note.text}</div>}
{mode === "signup" && (
<div className="gp-field">
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name (optional)" />
</div>
)}
<div className="gp-field">
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
</div>
<div className="gp-field gp-pw">
<input type={showPw ? "text" : "password"} required minLength={8} value={password}
onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
<button type="button" className="gp-eye" onClick={() => setShowPw((s) => !s)} aria-label="Toggle password">
<Eye off={showPw} />
</button>
</div>
{mode === "signin" && (
<div className="gp-forgot"><button type="button" className="gp-link" onClick={forgot}>Forgot password?</button></div>
)}
<button className="gp-submit" type="submit" disabled={busy}>
{busy ? "Please wait…" : mode === "signin" ? "Sign in" : "Create account"}
</button>
<div className="gp-foot">
{mode === "signin" ? (
<>Don&apos;t have an account? <button type="button" className="gp-link" onClick={() => { setMode("signup"); setNote(null); }}>Create account</button></>
) : (
<>Already have an account? <button type="button" className="gp-link" onClick={() => { setMode("signin"); setNote(null); }}>Sign in</button></>
)}
</div>
<div className="gp-copy">© {new Date().getFullYear()} GitPilot Inc.</div>
</form>
</div>
);
}