332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react";
|
|
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { useI18n } from "@/i18n";
|
|
|
|
interface Props {
|
|
provider: OAuthProvider;
|
|
onClose: () => void;
|
|
onSuccess: (msg: string) => void;
|
|
onError: (msg: string) => void;
|
|
}
|
|
|
|
type Phase = "idle" | "starting" | "awaiting_user" | "submitting" | "polling" | "approved" | "error";
|
|
|
|
export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props) {
|
|
const [phase, setPhase] = useState<Phase>("starting");
|
|
const [start, setStart] = useState<OAuthStartResponse | null>(null);
|
|
const [pkceCode, setPkceCode] = useState("");
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
|
|
const [codeCopied, setCodeCopied] = useState(false);
|
|
const isMounted = useRef(true);
|
|
const pollTimer = useRef<number | null>(null);
|
|
const { t } = useI18n();
|
|
|
|
// Initiate flow on mount
|
|
useEffect(() => {
|
|
isMounted.current = true;
|
|
api
|
|
.startOAuthLogin(provider.id)
|
|
.then((resp) => {
|
|
if (!isMounted.current) return;
|
|
setStart(resp);
|
|
setSecondsLeft(resp.expires_in);
|
|
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
|
|
if (resp.flow === "pkce") {
|
|
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
|
|
} else {
|
|
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
if (!isMounted.current) return;
|
|
setPhase("error");
|
|
setErrorMsg(`Failed to start login: ${e}`);
|
|
});
|
|
return () => {
|
|
isMounted.current = false;
|
|
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Tick the countdown
|
|
useEffect(() => {
|
|
if (secondsLeft === null) return;
|
|
if (phase === "approved" || phase === "error") return;
|
|
const tick = window.setInterval(() => {
|
|
if (!isMounted.current) return;
|
|
setSecondsLeft((s) => {
|
|
if (s !== null && s <= 1) {
|
|
setPhase("error");
|
|
setErrorMsg(t.oauth.sessionExpired);
|
|
return 0;
|
|
}
|
|
return s !== null && s > 0 ? s - 1 : 0;
|
|
});
|
|
}, 1000);
|
|
return () => window.clearInterval(tick);
|
|
}, [secondsLeft, phase, t]);
|
|
|
|
// Device-code: poll backend every 2s
|
|
useEffect(() => {
|
|
if (!start || start.flow !== "device_code" || phase !== "polling") return;
|
|
const sid = start.session_id;
|
|
pollTimer.current = window.setInterval(async () => {
|
|
try {
|
|
const resp = await api.pollOAuthSession(provider.id, sid);
|
|
if (!isMounted.current) return;
|
|
if (resp.status === "approved") {
|
|
setPhase("approved");
|
|
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
|
onSuccess(`${provider.name} connected`);
|
|
window.setTimeout(() => isMounted.current && onClose(), 1500);
|
|
} else if (resp.status !== "pending") {
|
|
setPhase("error");
|
|
setErrorMsg(resp.error_message || `Login ${resp.status}`);
|
|
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
|
}
|
|
} catch (e) {
|
|
if (!isMounted.current) return;
|
|
setPhase("error");
|
|
setErrorMsg(`Polling failed: ${e}`);
|
|
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
|
}
|
|
}, 2000);
|
|
return () => {
|
|
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
|
};
|
|
}, [start, phase, provider.id, provider.name, onSuccess, onClose]);
|
|
|
|
const handleSubmitPkceCode = async () => {
|
|
if (!start || start.flow !== "pkce") return;
|
|
if (!pkceCode.trim()) return;
|
|
setPhase("submitting");
|
|
setErrorMsg(null);
|
|
try {
|
|
const resp = await api.submitOAuthCode(provider.id, start.session_id, pkceCode.trim());
|
|
if (!isMounted.current) return;
|
|
if (resp.ok && resp.status === "approved") {
|
|
setPhase("approved");
|
|
onSuccess(`${provider.name} connected`);
|
|
window.setTimeout(() => isMounted.current && onClose(), 1500);
|
|
} else {
|
|
setPhase("error");
|
|
setErrorMsg(resp.message || "Token exchange failed");
|
|
}
|
|
} catch (e) {
|
|
if (!isMounted.current) return;
|
|
setPhase("error");
|
|
setErrorMsg(`Submit failed: ${e}`);
|
|
}
|
|
};
|
|
|
|
const handleClose = async () => {
|
|
if (start && phase !== "approved" && phase !== "error") {
|
|
try {
|
|
await api.cancelOAuthSession(start.session_id);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
onClose();
|
|
};
|
|
|
|
const handleCopyUserCode = async (code: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(code);
|
|
setCodeCopied(true);
|
|
window.setTimeout(() => isMounted.current && setCodeCopied(false), 1500);
|
|
} catch {
|
|
onError("Clipboard write failed");
|
|
}
|
|
};
|
|
|
|
const handleBackdrop = (e: React.MouseEvent) => {
|
|
if (e.target === e.currentTarget) handleClose();
|
|
};
|
|
|
|
const fmtTime = (s: number | null) => {
|
|
if (s === null) return "";
|
|
const m = Math.floor(s / 60);
|
|
const r = s % 60;
|
|
return `${m}:${String(r).padStart(2, "0")}`;
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
|
onClick={handleBackdrop}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="oauth-modal-title"
|
|
>
|
|
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl">
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors"
|
|
aria-label={t.common.close}
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
<div className="p-6 flex flex-col gap-4">
|
|
<div>
|
|
<h2 id="oauth-modal-title" className="font-mondwest text-base tracking-wider uppercase">
|
|
{t.oauth.connect} {provider.name}
|
|
</h2>
|
|
{secondsLeft !== null && phase !== "approved" && phase !== "error" && (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t.oauth.sessionExpires.replace("{time}", fmtTime(secondsLeft))}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── starting ───────────────────────────────────── */}
|
|
{phase === "starting" && (
|
|
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t.oauth.initiatingLogin}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── PKCE: paste code ───────────────────────────── */}
|
|
{start?.flow === "pkce" && phase === "awaiting_user" && (
|
|
<>
|
|
<ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground">
|
|
<li>{t.oauth.pkceStep1}</li>
|
|
<li>{t.oauth.pkceStep2}</li>
|
|
<li>{t.oauth.pkceStep3}</li>
|
|
</ol>
|
|
<div className="flex flex-col gap-2">
|
|
<Input
|
|
value={pkceCode}
|
|
onChange={(e) => setPkceCode(e.target.value)}
|
|
placeholder={t.oauth.pasteCode}
|
|
onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()}
|
|
autoFocus
|
|
/>
|
|
<div className="flex items-center gap-2 justify-between">
|
|
<a
|
|
href={(start as Extract<OAuthStartResponse, { flow: "pkce" }>).auth_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
|
>
|
|
<ExternalLink className="h-3 w-3" />
|
|
{t.oauth.reOpenAuth}
|
|
</a>
|
|
<Button onClick={handleSubmitPkceCode} disabled={!pkceCode.trim()} size="sm">
|
|
{t.oauth.submitCode}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ── PKCE: submitting exchange ──────────────────── */}
|
|
{phase === "submitting" && (
|
|
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t.oauth.exchangingCode}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Device code: show code + URL, polling ──────── */}
|
|
{start?.flow === "device_code" && phase === "polling" && (
|
|
<>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t.oauth.enterCodePrompt}
|
|
</p>
|
|
<div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
|
|
<code className="font-mono-ui text-2xl tracking-widest text-foreground">
|
|
{(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code}
|
|
</code>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
handleCopyUserCode(
|
|
(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code,
|
|
)
|
|
}
|
|
className="text-xs"
|
|
>
|
|
{codeCopied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
|
</Button>
|
|
</div>
|
|
<a
|
|
href={(start as Extract<OAuthStartResponse, { flow: "device_code" }>).verification_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
|
>
|
|
<ExternalLink className="h-3 w-3" />
|
|
{t.oauth.reOpenVerification}
|
|
</a>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
{t.oauth.waitingAuth}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ── approved ───────────────────────────────────── */}
|
|
{phase === "approved" && (
|
|
<div className="flex items-center gap-3 py-6 text-sm text-success">
|
|
<Check className="h-5 w-5" />
|
|
{t.oauth.connectedClosing}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── error ──────────────────────────────────────── */}
|
|
{phase === "error" && (
|
|
<>
|
|
<div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
|
{errorMsg || t.oauth.loginFailed}
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleClose}>
|
|
{t.common.close}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
if (start?.session_id) {
|
|
api.cancelOAuthSession(start.session_id).catch(() => {});
|
|
}
|
|
setErrorMsg(null);
|
|
setStart(null);
|
|
setPkceCode("");
|
|
setPhase("starting");
|
|
api.startOAuthLogin(provider.id).then((resp) => {
|
|
if (!isMounted.current) return;
|
|
setStart(resp);
|
|
setSecondsLeft(resp.expires_in);
|
|
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
|
|
if (resp.flow === "pkce") {
|
|
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
|
|
} else {
|
|
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
|
|
}
|
|
}).catch((e) => {
|
|
if (!isMounted.current) return;
|
|
setPhase("error");
|
|
setErrorMsg(`${t.common.retry} failed: ${e}`);
|
|
});
|
|
}}
|
|
>
|
|
{t.common.retry}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|