Files
OmniRoute/src/shared/components/KiroSocialOAuthModal.tsx
Alex Jordan 7ca73697b0 fix(i18n): localize hardcoded web UI copy (#9245)
* fix(i18n): localize hardcoded web UI copy

* test(i18n): cover hardcoded UI regressions

* chore(changelog): add PR 9245 fragment
2026-08-11 10:37:50 -03:00

246 lines
8.1 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
import Button from "./Button";
import { copyToClipboard } from "@/shared/utils/clipboard";
import { getNextKiroSocialPollInterval } from "@/lib/oauth/kiroSocialPoll";
type KiroSocialOAuthModalProps = {
isOpen: boolean;
provider: "google" | "github";
targetProvider?: string;
providerLabel?: string;
onSuccess?: () => void;
onClose: () => void;
};
export default function KiroSocialOAuthModal({
isOpen,
provider,
targetProvider,
providerLabel = "Kiro",
onSuccess,
onClose,
}: KiroSocialOAuthModalProps) {
const t = useTranslations("kiroSocialOAuthModal");
const [step, setStep] = useState<"loading" | "polling" | "success" | "error">("loading");
const [error, setError] = useState<string | null>(null);
const [userCode, setUserCode] = useState("");
const [authUrl, setAuthUrl] = useState("");
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onSuccessRef = useRef(onSuccess);
useEffect(() => {
onSuccessRef.current = onSuccess;
}, [onSuccess]);
useEffect(() => {
if (!isOpen || !provider) return;
let cancelled = false;
const stopPolling = () => {
if (pollRef.current) clearTimeout(pollRef.current);
pollRef.current = null;
};
const fail = (message: string) => {
stopPolling();
if (cancelled) return;
setError(message);
setStep("error");
};
const initAuth = async () => {
try {
setError(null);
setStep("loading");
const res = await fetch(`/api/oauth/kiro/social-authorize?provider=${provider}`);
const data = await res.json();
if (cancelled) return;
if (!res.ok) {
throw new Error(data.error || t("errorStartAuthorization"));
}
setUserCode(data.userCode || "");
setAuthUrl(data.authUrl || "");
setStep("polling");
const baseIntervalMs = Math.max(1, Number(data.interval) || 5) * 1000;
let currentIntervalMs = baseIntervalMs;
const expiresAt = Date.now() + Math.max(1, Number(data.expiresIn) || 300) * 1000;
const schedule = (delayMs: number) => {
if (cancelled) return;
pollRef.current = setTimeout(poll, delayMs);
};
const poll = async () => {
pollRef.current = null;
if (cancelled) return;
if (Date.now() >= expiresAt) {
fail(t("errorAuthorizationExpired"));
return;
}
try {
const pollRes = await fetch("/api/oauth/kiro/social-exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceCode: data.deviceCode, provider, targetProvider }),
});
const pollData = await pollRes.json();
if (cancelled) return;
if (pollData.success) {
stopPolling();
setStep("success");
onSuccessRef.current?.();
return;
}
if (!pollData.pending) {
fail(pollData.error || t("errorAuthorizationFailed"));
return;
}
currentIntervalMs = getNextKiroSocialPollInterval(currentIntervalMs, pollData.error);
schedule(currentIntervalMs);
} catch {
schedule(currentIntervalMs);
}
};
schedule(baseIntervalMs);
} catch (err: any) {
fail(err.message);
}
};
initAuth();
return () => {
cancelled = true;
stopPolling();
};
}, [isOpen, provider, targetProvider, t]);
const handleClose = () => {
if (pollRef.current) {
clearTimeout(pollRef.current);
pollRef.current = null;
}
onClose();
};
const providerName = provider === "google" ? "Google" : "GitHub";
return (
<Modal
isOpen={isOpen}
title={t("title", { providerLabel, providerName })}
onClose={handleClose}
size="lg"
>
<div className="flex flex-col gap-4">
{step === "loading" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">{t("initializing")}</h3>
<p className="text-sm text-text-muted">{t("settingUp", { providerName })}</p>
</div>
)}
{step === "polling" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-pulse">
open_in_browser
</span>
</div>
<h3 className="text-lg font-semibold mb-2">{t("openIncognito")}</h3>
<p className="text-sm text-text-muted mb-3">{t("incognitoDescription")}</p>
{authUrl && (
<div className="mb-4">
<div className="flex items-center gap-2 justify-center">
<a
href={authUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-mono text-primary underline break-all max-w-md inline-block"
>
{authUrl.length > 80 ? authUrl.slice(0, 80) + "..." : authUrl}
</a>
<button
onClick={() => copyToClipboard(authUrl)}
className="shrink-0 p-1 rounded hover:bg-sidebar"
title={t("copyLink")}
>
<span className="material-symbols-outlined text-base">content_copy</span>
</button>
</div>
</div>
)}
{userCode && (
<div className="mb-4">
<p className="text-xs text-text-muted mb-1">{t("verificationCode")}</p>
<p className="font-mono text-2xl font-bold tracking-widest">{userCode}</p>
</div>
)}
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined text-base animate-spin">
progress_activity
</span>
{t("waiting")}
</div>
<div className="mt-6">
<Button onClick={handleClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</div>
)}
{step === "success" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-green-600">
check_circle
</span>
</div>
<h3 className="text-lg font-semibold mb-2">{t("successTitle")}</h3>
<p className="text-sm text-text-muted mb-4">
{t("successMessage", { providerLabel, providerName })}
</p>
<Button onClick={handleClose} fullWidth>
{t("done")}
</Button>
</div>
)}
{step === "error" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-red-600">error</span>
</div>
<h3 className="text-lg font-semibold mb-2">{t("errorTitle")}</h3>
<p className="text-sm text-red-600 mb-4">{error}</p>
<div className="flex gap-2">
<Button onClick={handleClose} variant="ghost" fullWidth>
{t("close")}
</Button>
</div>
</div>
)}
</div>
</Modal>
);
}