Files
donutbrowser/src/components/profile-sync-dialog.tsx
T
2026-06-08 00:06:44 +04:00

310 lines
9.9 KiB
TypeScript

"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { getEntitlements } from "@/lib/entitlements";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
import { isSyncEnabled } from "@/types";
interface ProfileSyncDialogProps {
isOpen: boolean;
onClose: () => void;
profile: BrowserProfile | null;
onSyncConfigOpen: () => void;
}
export function ProfileSyncDialog({
isOpen,
onClose,
profile,
onSyncConfigOpen,
}: ProfileSyncDialogProps) {
const { t } = useTranslation();
const { user: cloudUser } = useCloudAuth();
const isCloudSyncEligible = getEntitlements(cloudUser).cloudBackup;
// Encryption available to everyone except team members who aren't owners
const canUseEncryption =
cloudUser == null ||
cloudUser.plan !== "team" ||
cloudUser.teamRole === "owner";
const [isSaving, setIsSaving] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [syncMode, setSyncMode] = useState<SyncMode>(
profile?.sync_mode ?? "Disabled",
);
const [hasSelfHostedConfig, setHasSelfHostedConfig] = useState(false);
const [hasE2ePassword, setHasE2ePassword] = useState(false);
const [isCheckingConfig, setIsCheckingConfig] = useState(false);
const [userChangedMode, setUserChangedMode] = useState(false);
const hasConfig = isCloudSyncEligible || hasSelfHostedConfig;
const checkSyncConfig = useCallback(async () => {
setIsCheckingConfig(true);
try {
const settings = await invoke<SyncSettings>("get_sync_settings");
setHasSelfHostedConfig(
Boolean(settings.sync_server_url && settings.sync_token),
);
const hasPassword = await invoke<boolean>("check_has_e2e_password");
setHasE2ePassword(hasPassword);
} catch {
setHasSelfHostedConfig(false);
} finally {
setIsCheckingConfig(false);
}
}, []);
useEffect(() => {
if (isOpen && profile) {
setSyncMode(profile.sync_mode ?? "Disabled");
setUserChangedMode(false);
void checkSyncConfig();
}
}, [isOpen, profile, checkSyncConfig]);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
onClose();
}
},
[onClose],
);
const handleModeChange = useCallback(
async (newMode: string) => {
if (!profile) return;
if (!hasConfig) {
showErrorToast(t("sync.mode.noPasswordWarning"));
onSyncConfigOpen();
onClose();
return;
}
if (newMode === "Encrypted" && !canUseEncryption) {
showErrorToast(t("settings.encryption.requiresProOrOwner"));
return;
}
if (newMode === "Encrypted" && !hasE2ePassword) {
showErrorToast(t("sync.mode.passwordRequired"));
return;
}
setIsSaving(true);
try {
await invoke("set_profile_sync_mode", {
profileId: profile.id,
syncMode: newMode,
});
setSyncMode(newMode as SyncMode);
setUserChangedMode(true);
showSuccessToast(
newMode !== "Disabled"
? t("sync.mode.enabledToast")
: t("sync.mode.disabledToast"),
);
} catch (error) {
console.error("Failed to set sync mode:", error);
showErrorToast(String(error));
} finally {
setIsSaving(false);
}
},
[
profile,
hasConfig,
hasE2ePassword,
canUseEncryption,
onSyncConfigOpen,
onClose,
t,
],
);
const handleSyncNow = useCallback(async () => {
if (!profile) return;
if (!hasConfig) {
showErrorToast(t("sync.mode.noPasswordWarning"));
onSyncConfigOpen();
onClose();
return;
}
setIsSyncing(true);
try {
await invoke("request_profile_sync", { profileId: profile.id });
showSuccessToast(t("sync.mode.syncQueued"));
} catch (error) {
console.error("Failed to queue sync:", error);
showErrorToast(String(error));
} finally {
setIsSyncing(false);
}
}, [profile, hasConfig, onSyncConfigOpen, onClose, t]);
const formatLastSync = (timestamp?: number) => {
if (!timestamp) return t("common.labels.never");
const date = new Date(timestamp * 1000);
return date.toLocaleString();
};
if (!profile) return null;
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("sync.mode.title")}</DialogTitle>
<DialogDescription>
{t("sync.mode.description", {
name: profile.name,
defaultValue: `Manage sync settings for "${profile.name}"`,
})}
</DialogDescription>
</DialogHeader>
{isCheckingConfig ? (
<div className="flex justify-center py-8">
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
{!hasConfig && (
<div className="p-3 text-sm rounded-md bg-muted">
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
<Button
variant="outline"
size="sm"
onClick={() => {
onSyncConfigOpen();
onClose();
}}
>
{t("sync.mode.configureService")}
</Button>
</div>
)}
{hasConfig && (
<>
<RadioGroup
value={syncMode}
onValueChange={handleModeChange}
disabled={isSaving}
className="grid gap-3"
>
<div className="flex items-start gap-x-3">
<RadioGroupItem value="Disabled" id="sync-disabled" />
<Label htmlFor="sync-disabled" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.disabled")}
</span>
<p className="text-sm text-muted-foreground">
{t("sync.mode.disabledDescription")}
</p>
</Label>
</div>
<div className="flex items-start gap-x-3">
<RadioGroupItem value="Regular" id="sync-regular" />
<Label htmlFor="sync-regular" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.regular")}
</span>
<p className="text-sm text-muted-foreground">
{t("sync.mode.regularDescription")}
</p>
</Label>
</div>
<div className="flex items-start gap-x-3">
<RadioGroupItem
value="Encrypted"
id="sync-encrypted"
disabled={!canUseEncryption}
/>
<Label
htmlFor="sync-encrypted"
className={
canUseEncryption
? "cursor-pointer"
: "cursor-not-allowed opacity-50"
}
>
<span className="font-medium">
{t("sync.mode.encrypted")}
</span>
<p className="text-sm text-muted-foreground">
{canUseEncryption
? t("sync.mode.encryptedDescription")
: t("settings.encryption.requiresProOrOwner")}
</p>
</Label>
</div>
</RadioGroup>
{syncMode === "Encrypted" &&
!hasE2ePassword &&
userChangedMode && (
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
{t("sync.mode.noPasswordWarning")}
</div>
)}
<div className="space-y-2">
<Label>{t("sync.mode.lastSynced")}</Label>
<div className="flex gap-2 items-center">
<Badge variant="outline">
{formatLastSync(profile.last_sync)}
</Badge>
{isSyncEnabled(profile) && (
<Badge
variant={profile.last_sync ? "default" : "secondary"}
>
{profile.last_sync
? t("common.status.synced")
: t("common.status.pending")}
</Badge>
)}
</div>
</div>
</>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</Button>
{hasConfig && isSyncEnabled(profile) && (
<LoadingButton onClick={handleSyncNow} isLoading={isSyncing}>
{t("sync.mode.syncNow")}
</LoadingButton>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}