Files
donutbrowser/src/components/sync-config-dialog.tsx
T
2026-04-02 06:19:55 +04:00

715 lines
24 KiB
TypeScript

"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuEye, LuEyeOff } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ProBadge } from "@/components/ui/pro-badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { SyncSettings } from "@/types";
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE ?? "";
interface TurnstileWindow extends Window {
turnstile?: {
render: (
container: string | HTMLElement,
options: {
sitekey: string;
callback: (token: string) => void;
"expired-callback": () => void;
"error-callback": () => void;
theme: "light" | "dark" | "auto";
},
) => string;
remove: (widgetId: string) => void;
};
}
// RFC 5322 compliant email regex (emailregex.com)
// eslint-disable-next-line no-control-regex
const EMAIL_REGEX =
/(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/;
function isValidEmail(email: string): boolean {
return EMAIL_REGEX.test(email);
}
interface SyncConfigDialogProps {
isOpen: boolean;
onClose: (loginOccurred?: boolean) => void;
}
interface ProxyUsage {
used_mb: number;
limit_mb: number;
remaining_mb: number;
recurring_limit_mb: number;
extra_limit_mb: number;
}
export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
const { t } = useTranslation();
// Self-hosted state
const [serverUrl, setServerUrl] = useState("");
const [token, setToken] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [showToken, setShowToken] = useState(false);
// Cloud auth state
const {
user,
isLoggedIn,
isLoading: isCloudLoading,
requestOtp,
verifyOtp,
logout,
} = useCloudAuth();
const [email, setEmail] = useState("");
const [otpCode, setOtpCode] = useState("");
const [codeSent, setCodeSent] = useState(false);
const [isSendingCode, setIsSendingCode] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
// Turnstile captcha state
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [isCaptchaLoading, setIsCaptchaLoading] = useState(false);
const captchaContainerRef = useRef<HTMLDivElement>(null);
const turnstileWidgetIdRef = useRef<string | null>(null);
const turnstileScriptLoadedRef = useRef(false);
const [activeTab, setActiveTab] = useState<string>("cloud");
const [, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
const [connectionStatus, setConnectionStatus] = useState<
"unknown" | "testing" | "connected" | "error"
>("unknown");
const hasConfig = Boolean(serverUrl && token);
const testConnection = useCallback(async (url: string) => {
setConnectionStatus("testing");
try {
const healthUrl = `${url.replace(/\/$/, "")}/health`;
const response = await fetch(healthUrl);
setConnectionStatus(response.ok ? "connected" : "error");
} catch {
setConnectionStatus("error");
}
}, []);
const loadSettings = useCallback(async () => {
setIsLoading(true);
try {
const settings = await invoke<SyncSettings>("get_sync_settings");
setServerUrl(settings.sync_server_url ?? "");
setToken(settings.sync_token ?? "");
if (settings.sync_server_url && settings.sync_token) {
void testConnection(settings.sync_server_url);
}
} catch (error) {
console.error("Failed to load sync settings:", error);
} finally {
setIsLoading(false);
}
}, [testConnection]);
const removeTurnstileWidget = useCallback(() => {
const win = window as TurnstileWindow;
if (turnstileWidgetIdRef.current && win.turnstile) {
win.turnstile.remove(turnstileWidgetIdRef.current);
turnstileWidgetIdRef.current = null;
}
}, []);
const renderTurnstile = useCallback(() => {
const win = window as TurnstileWindow;
if (!win.turnstile || !captchaContainerRef.current) return;
removeTurnstileWidget();
captchaContainerRef.current.innerHTML = "";
const widgetId = win.turnstile.render(captchaContainerRef.current, {
sitekey: TURNSTILE_SITE_KEY,
callback: (token: string) => {
setCaptchaToken(token);
setIsCaptchaLoading(false);
},
"expired-callback": () => {
setCaptchaToken(null);
},
"error-callback": () => {
setCaptchaToken(null);
setIsCaptchaLoading(false);
},
theme: "auto",
});
turnstileWidgetIdRef.current = widgetId;
}, [removeTurnstileWidget]);
const loadTurnstileScript = useCallback((): Promise<void> => {
return new Promise((resolve) => {
const win = window as TurnstileWindow;
if (win.turnstile) {
turnstileScriptLoadedRef.current = true;
resolve();
return;
}
if (turnstileScriptLoadedRef.current) {
const check = setInterval(() => {
if ((window as TurnstileWindow).turnstile) {
clearInterval(check);
resolve();
}
}, 100);
return;
}
const existing = document.querySelector(
'script[src*="challenges.cloudflare.com/turnstile"]',
);
if (existing) {
const check = setInterval(() => {
if ((window as TurnstileWindow).turnstile) {
clearInterval(check);
turnstileScriptLoadedRef.current = true;
resolve();
}
}, 100);
return;
}
turnstileScriptLoadedRef.current = true;
const script = document.createElement("script");
script.src =
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.defer = true;
script.onload = () => {
const check = setInterval(() => {
if ((window as TurnstileWindow).turnstile) {
clearInterval(check);
resolve();
}
}, 100);
};
document.head.appendChild(script);
});
}, []);
useEffect(() => {
const emailValid = isValidEmail(email);
if (emailValid && !codeSent && TURNSTILE_SITE_KEY) {
setIsCaptchaLoading(true);
setCaptchaToken(null);
void loadTurnstileScript().then(() => {
renderTurnstile();
});
} else {
removeTurnstileWidget();
setCaptchaToken(null);
setIsCaptchaLoading(false);
}
}, [
email,
codeSent,
loadTurnstileScript,
renderTurnstile,
removeTurnstileWidget,
]);
useEffect(() => {
if (isOpen) {
setConnectionStatus("unknown");
void loadSettings();
setCodeSent(false);
setOtpCode("");
setEmail("");
setCaptchaToken(null);
setIsCaptchaLoading(false);
removeTurnstileWidget();
void invoke<ProxyUsage | null>("cloud_get_proxy_usage")
.then(setLiveProxyUsage)
.catch(() => {
setLiveProxyUsage(null);
});
}
return () => {
removeTurnstileWidget();
};
}, [isOpen, loadSettings, removeTurnstileWidget]);
// Auto-select the appropriate tab based on connection state
useEffect(() => {
if (isCloudLoading) return;
if (isLoggedIn) {
setActiveTab("cloud");
} else if (serverUrl && token) {
setActiveTab("self-hosted");
} else {
setActiveTab("cloud");
}
}, [isCloudLoading, isLoggedIn, serverUrl, token]);
const handleTestConnection = useCallback(async () => {
if (!serverUrl) {
showErrorToast("Please enter a server URL");
return;
}
setIsTesting(true);
setConnectionStatus("testing");
try {
const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`;
const response = await fetch(healthUrl);
if (response.ok) {
setConnectionStatus("connected");
showSuccessToast("Connection successful!");
} else {
setConnectionStatus("error");
showErrorToast("Server responded with an error");
}
} catch {
setConnectionStatus("error");
showErrorToast("Failed to connect to server");
} finally {
setIsTesting(false);
}
}, [serverUrl]);
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
await invoke<SyncSettings>("save_sync_settings", {
syncServerUrl: serverUrl || null,
syncToken: token || null,
});
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
showSuccessToast("Sync settings saved");
onClose();
} catch (error) {
console.error("Failed to save sync settings:", error);
showErrorToast("Failed to save settings");
} finally {
setIsSaving(false);
}
}, [serverUrl, token, onClose]);
const handleDisconnect = useCallback(async () => {
setIsSaving(true);
try {
await invoke<SyncSettings>("save_sync_settings", {
syncServerUrl: null,
syncToken: null,
});
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
setServerUrl("");
setToken("");
setConnectionStatus("unknown");
showSuccessToast("Sync disconnected");
} catch (error) {
console.error("Failed to disconnect:", error);
showErrorToast("Failed to disconnect");
} finally {
setIsSaving(false);
}
}, []);
const handleSendCode = useCallback(async () => {
if (!email || !captchaToken) return;
setIsSendingCode(true);
try {
await requestOtp(email, captchaToken);
setCodeSent(true);
removeTurnstileWidget();
setCaptchaToken(null);
showSuccessToast(t("sync.cloud.codeSent"));
} catch (error) {
console.error("Failed to send OTP:", error);
showErrorToast(String(error));
} finally {
setIsSendingCode(false);
}
}, [email, captchaToken, requestOtp, removeTurnstileWidget, t]);
const handleVerifyOtp = useCallback(async () => {
if (!email || !otpCode) return;
setIsVerifying(true);
try {
await verifyOtp(email, otpCode);
showSuccessToast(t("sync.cloud.loginSuccess"));
// Restart sync service with cloud credentials
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
// Auto-close dialog after successful login, signal that login occurred
onClose(true);
} catch (error) {
console.error("OTP verification failed:", error);
showErrorToast(String(error));
} finally {
setIsVerifying(false);
}
}, [email, otpCode, verifyOtp, t, onClose]);
const handleCloudLogout = useCallback(async () => {
try {
await logout();
showSuccessToast(t("sync.cloud.logoutSuccess"));
setServerUrl("");
setToken("");
// Restart sync service (will fall back to self-hosted or stop)
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
} catch (error) {
console.error("Failed to logout:", error);
showErrorToast(String(error));
}
}, [logout, t]);
// Determine which tabs are available
const cloudBlocked = !isLoggedIn && hasConfig;
const selfHostedBlocked = isLoggedIn;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("sync.title")}</DialogTitle>
<DialogDescription>{t("sync.description")}</DialogDescription>
</DialogHeader>
{/* If cloud is logged in, don't show tabs at all - just show cloud account */}
{isLoggedIn && user ? (
<div className="grid gap-4 py-4">
<div className="flex gap-2 items-center text-sm">
<div className="w-2 h-2 rounded-full bg-success" />
{t("sync.cloud.connected")}
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("sync.cloud.email")}
</span>
<span>{user.email}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("sync.cloud.plan")}
</span>
<span className="capitalize">
{user.plan}
{user.planPeriod ? ` (${user.planPeriod})` : ""}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("sync.cloud.profiles")}
</span>
<span>
{t("sync.cloud.profileUsage", {
used: user.cloudProfilesUsed,
limit: user.profileLimit,
})}
</span>
</div>
{user.teamName && (
<>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("sync.team.name")}
</span>
<span>{user.teamName}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("sync.team.role")}
</span>
<span className="capitalize">
{user.teamRole === "owner"
? t("sync.team.roleOwner")
: user.teamRole === "admin"
? t("sync.team.roleAdmin")
: t("sync.team.roleMember")}
</span>
</div>
<p className="text-xs text-muted-foreground pt-1">
{t("sync.team.manageOnWeb")}
</p>
</>
)}
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" asChild>
<a
href="https://donutbrowser.com/account"
target="_blank"
rel="noopener noreferrer"
>
{t("sync.cloud.manageAccount")}
</a>
</Button>
<Button
variant="outline"
className="flex-1"
onClick={() => void handleCloudLogout()}
>
{t("sync.cloud.logout")}
</Button>
</div>
</div>
) : (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full">
<TabsTrigger
value="cloud"
className="flex-1"
disabled={cloudBlocked}
>
<span className="flex items-center gap-2">
{t("sync.cloud.tabLabel")}
{cloudBlocked && <ProBadge />}
</span>
</TabsTrigger>
<TabsTrigger
value="self-hosted"
className="flex-1"
disabled={selfHostedBlocked}
>
<span className="flex items-center gap-2">
{t("sync.cloud.selfHostedTabLabel")}
{selfHostedBlocked && <ProBadge />}
</span>
</TabsTrigger>
</TabsList>
<TabsContent value="cloud">
{isCloudLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="cloud-email">{t("sync.cloud.email")}</Label>
<div className="flex gap-2">
<Input
id="cloud-email"
type="email"
placeholder={t("sync.cloud.emailPlaceholder")}
value={email}
onChange={(e) => {
setEmail(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !codeSent && captchaToken) {
void handleSendCode();
}
}}
/>
<LoadingButton
onClick={() => void handleSendCode()}
isLoading={isSendingCode}
disabled={!email || codeSent || !captchaToken}
variant="outline"
>
{t("sync.cloud.sendCode")}
</LoadingButton>
</div>
{!codeSent && isValidEmail(email) && TURNSTILE_SITE_KEY && (
<div className="mt-2">
{isCaptchaLoading && (
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
<div className="w-4 h-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
{t("sync.cloud.loadingCaptcha")}
</div>
)}
<div ref={captchaContainerRef} />
</div>
)}
</div>
{codeSent && (
<div className="space-y-2">
<Label htmlFor="cloud-otp">
{t("sync.cloud.verificationCode")}
</Label>
<Input
id="cloud-otp"
placeholder={t("sync.cloud.codePlaceholder")}
value={otpCode}
onChange={(e) => {
setOtpCode(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleVerifyOtp();
}
}}
/>
<LoadingButton
onClick={() => void handleVerifyOtp()}
isLoading={isVerifying}
disabled={!otpCode}
className="w-full"
>
{isVerifying
? t("sync.cloud.loggingIn")
: t("sync.cloud.verifyAndLogin")}
</LoadingButton>
</div>
)}
</div>
)}
</TabsContent>
<TabsContent value="self-hosted">
{isLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="sync-server-url">
{t("sync.serverUrl")}
</Label>
<Input
id="sync-server-url"
placeholder={t("sync.serverUrlPlaceholder")}
value={serverUrl}
onChange={(e) => {
setServerUrl(e.target.value);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sync-token">{t("sync.token")}</Label>
<div className="relative">
<Input
id="sync-token"
type={showToken ? "text" : "password"}
placeholder={t("sync.tokenPlaceholder")}
value={token}
onChange={(e) => {
setToken(e.target.value);
}}
className="pr-10"
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
setShowToken(!showToken);
}}
className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
aria-label={showToken ? "Hide token" : "Show token"}
>
{showToken ? (
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
) : (
<LuEye className="w-4 h-4 text-muted-foreground hover:text-foreground" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
{showToken ? "Hide token" : "Show token"}
</TooltipContent>
</Tooltip>
</div>
</div>
{connectionStatus === "testing" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-4 h-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
{t("sync.status.syncing")}
</div>
)}
{connectionStatus === "connected" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-success" />
{t("sync.status.connected")}
</div>
)}
{connectionStatus === "error" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-destructive" />
{t("sync.status.disconnected")}
</div>
)}
</div>
)}
<DialogFooter className="flex gap-2">
{hasConfig && (
<Button
variant="outline"
onClick={() => void handleDisconnect()}
disabled={isSaving}
>
Disconnect
</Button>
)}
<Button
variant="outline"
onClick={() => void handleTestConnection()}
disabled={isTesting || !serverUrl}
>
{isTesting ? "Testing..." : "Test Connection"}
</Button>
<LoadingButton
onClick={() => void handleSave()}
isLoading={isSaving}
disabled={!serverUrl || !token}
>
Save
</LoadingButton>
</DialogFooter>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
);
}