mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 20:32:25 +03:00
* fix(i18n): localize hardcoded web UI copy * test(i18n): cover hardcoded UI regressions * chore(changelog): add PR 9245 fragment
246 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|