mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-26 18:17:49 +02:00
1437 lines
51 KiB
TypeScript
1437 lines
51 KiB
TypeScript
"use client";
|
|
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
|
|
import Color from "color";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { BsCamera, BsMic } from "react-icons/bs";
|
|
import { DnsBlocklistDialog } from "@/components/dns-blocklist-dialog";
|
|
import { LoadingButton } from "@/components/loading-button";
|
|
import { useTheme } from "@/components/theme-provider";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
ColorPicker,
|
|
ColorPickerAlpha,
|
|
ColorPickerEyeDropper,
|
|
ColorPickerFormat,
|
|
ColorPickerHue,
|
|
ColorPickerOutput,
|
|
ColorPickerSelection,
|
|
} from "@/components/ui/color-picker";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
|
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
|
|
import { useLanguage } from "@/hooks/use-language";
|
|
import type { PermissionType } from "@/hooks/use-permissions";
|
|
import { usePermissions } from "@/hooks/use-permissions";
|
|
import {
|
|
getThemeByColors,
|
|
getThemeById,
|
|
THEME_VARIABLES,
|
|
THEMES,
|
|
} from "@/lib/themes";
|
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
|
import { cn } from "@/lib/utils";
|
|
import { RippleButton } from "./ui/ripple";
|
|
|
|
interface AppSettings {
|
|
set_as_default_browser: boolean;
|
|
theme: string;
|
|
custom_theme?: Record<string, string>;
|
|
api_enabled: boolean;
|
|
api_port: number;
|
|
api_token?: string;
|
|
disable_auto_updates?: boolean;
|
|
keep_decrypted_profiles_in_ram?: boolean;
|
|
}
|
|
|
|
interface CustomThemeState {
|
|
selectedThemeId: string | null;
|
|
colors: Record<string, string>;
|
|
}
|
|
|
|
interface PermissionInfo {
|
|
permission_type: PermissionType;
|
|
isGranted: boolean;
|
|
description: string;
|
|
}
|
|
|
|
// Version update progress toasts are handled globally via useVersionUpdater
|
|
|
|
interface SettingsDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onIntegrationsOpen?: () => void;
|
|
subPage?: boolean;
|
|
}
|
|
|
|
export function SettingsDialog({
|
|
isOpen,
|
|
onClose,
|
|
onIntegrationsOpen,
|
|
subPage,
|
|
}: SettingsDialogProps) {
|
|
const [settings, setSettings] = useState<AppSettings>({
|
|
set_as_default_browser: false,
|
|
theme: "system",
|
|
custom_theme: undefined,
|
|
api_enabled: false,
|
|
api_port: 10108,
|
|
api_token: undefined,
|
|
});
|
|
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
|
|
set_as_default_browser: false,
|
|
theme: "system",
|
|
custom_theme: undefined,
|
|
api_enabled: false,
|
|
api_port: 10108,
|
|
api_token: undefined,
|
|
});
|
|
const [customThemeState, setCustomThemeState] = useState<CustomThemeState>({
|
|
selectedThemeId: null,
|
|
colors: {},
|
|
});
|
|
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isSettingDefault, setIsSettingDefault] = useState(false);
|
|
const [isClearingCache, setIsClearingCache] = useState(false);
|
|
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
|
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
|
|
const [requestingPermission, setRequestingPermission] =
|
|
useState<PermissionType | null>(null);
|
|
const [isMacOS, setIsMacOS] = useState(false);
|
|
const [dnsBlocklistDialogOpen, setDnsBlocklistDialogOpen] = useState(false);
|
|
const [isLinux, setIsLinux] = useState(false);
|
|
const [hasE2ePassword, setHasE2ePassword] = useState(false);
|
|
const [e2ePassword, setE2ePassword] = useState("");
|
|
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
|
|
const [e2eError, setE2eError] = useState("");
|
|
const [isSavingE2e, setIsSavingE2e] = useState(false);
|
|
const [isRemovingE2e, setIsRemovingE2e] = useState(false);
|
|
const [isVerifyE2eOpen, setIsVerifyE2eOpen] = useState(false);
|
|
const [verifyE2ePassword, setVerifyE2ePassword] = useState("");
|
|
const [isVerifyingE2e, setIsVerifyingE2e] = useState(false);
|
|
const [systemInfo, setSystemInfo] = useState<{
|
|
app_version: string;
|
|
os: string;
|
|
arch: string;
|
|
portable: boolean;
|
|
} | null>(null);
|
|
|
|
const { t } = useTranslation();
|
|
const { setTheme } = useTheme();
|
|
const {
|
|
requestPermission,
|
|
isMicrophoneAccessGranted,
|
|
isCameraAccessGranted,
|
|
} = usePermissions();
|
|
const { trialStatus } = useCommercialTrial();
|
|
const { user: cloudUser } = useCloudAuth();
|
|
// Encryption is available to everyone except team members who aren't owners
|
|
const canUseEncryption =
|
|
cloudUser == null ||
|
|
cloudUser.plan !== "team" ||
|
|
cloudUser.teamRole === "owner";
|
|
const {
|
|
currentLanguage,
|
|
changeLanguage,
|
|
supportedLanguages,
|
|
isLoading: isLanguageLoading,
|
|
} = useLanguage();
|
|
const [selectedLanguage, setSelectedLanguage] = useState<string | null>(null);
|
|
const [originalLanguage, setOriginalLanguage] = useState<string | null>(null);
|
|
|
|
const getPermissionIcon = useCallback((type: PermissionType) => {
|
|
switch (type) {
|
|
case "microphone":
|
|
return <BsMic className="size-4" />;
|
|
case "camera":
|
|
return <BsCamera className="size-4" />;
|
|
}
|
|
}, []);
|
|
|
|
const getPermissionDisplayName = useCallback(
|
|
(type: PermissionType) => {
|
|
switch (type) {
|
|
case "microphone":
|
|
return t("settings.permissions.microphone");
|
|
case "camera":
|
|
return t("settings.permissions.camera");
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const getStatusBadge = useCallback(
|
|
(isGranted: boolean) => {
|
|
if (isGranted) {
|
|
return (
|
|
<Badge
|
|
variant="default"
|
|
className="text-success-foreground bg-success"
|
|
>
|
|
{t("common.status.granted")}
|
|
</Badge>
|
|
);
|
|
}
|
|
return <Badge variant="secondary">{t("common.status.notGranted")}</Badge>;
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const getPermissionDescription = useCallback(
|
|
(type: PermissionType) => {
|
|
switch (type) {
|
|
case "microphone":
|
|
return t("settings.permissions.microphoneDescription");
|
|
case "camera":
|
|
return t("settings.permissions.cameraDescription");
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const loadSettings = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const appSettings = await invoke<AppSettings>("get_app_settings");
|
|
const tokyoNightTheme = getThemeById("tokyo-night");
|
|
if (!tokyoNightTheme) {
|
|
throw new Error("Tokyo Night theme not found");
|
|
}
|
|
const merged: AppSettings = {
|
|
...appSettings,
|
|
custom_theme:
|
|
appSettings.custom_theme &&
|
|
Object.keys(appSettings.custom_theme).length > 0
|
|
? appSettings.custom_theme
|
|
: tokyoNightTheme.colors,
|
|
};
|
|
setSettings(merged);
|
|
setOriginalSettings(merged);
|
|
|
|
// Initialize custom theme state
|
|
if (merged.theme === "custom" && merged.custom_theme) {
|
|
const matchingTheme = getThemeByColors(merged.custom_theme);
|
|
setCustomThemeState({
|
|
selectedThemeId: matchingTheme?.id ?? null,
|
|
colors: merged.custom_theme,
|
|
});
|
|
} else if (merged.theme === "custom") {
|
|
// Initialize with Tokyo Night if no custom theme exists
|
|
setCustomThemeState({
|
|
selectedThemeId: "tokyo-night",
|
|
colors: tokyoNightTheme.colors,
|
|
});
|
|
}
|
|
// Check E2E password status
|
|
try {
|
|
const hasPassword = await invoke<boolean>("check_has_e2e_password");
|
|
setHasE2ePassword(hasPassword);
|
|
} catch {
|
|
setHasE2ePassword(false);
|
|
}
|
|
// Load system info
|
|
try {
|
|
const info = await invoke<{
|
|
app_version: string;
|
|
os: string;
|
|
arch: string;
|
|
portable: boolean;
|
|
}>("get_system_info");
|
|
setSystemInfo(info);
|
|
} catch {
|
|
setSystemInfo(null);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load settings:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const applyCustomTheme = useCallback((vars: Record<string, string>) => {
|
|
const root = document.documentElement;
|
|
Object.entries(vars).forEach(([k, v]) => {
|
|
root.style.setProperty(k, v, "important");
|
|
});
|
|
}, []);
|
|
|
|
const clearCustomTheme = useCallback(() => {
|
|
const root = document.documentElement;
|
|
THEME_VARIABLES.forEach(({ key }) => {
|
|
root.style.removeProperty(key as string);
|
|
});
|
|
}, []);
|
|
|
|
const loadPermissions = useCallback(() => {
|
|
setIsLoadingPermissions(true);
|
|
try {
|
|
if (!isMacOS) {
|
|
// On non-macOS platforms, don't show permissions
|
|
setPermissions([]);
|
|
return;
|
|
}
|
|
|
|
const permissionList: PermissionInfo[] = [
|
|
{
|
|
permission_type: "microphone",
|
|
isGranted: isMicrophoneAccessGranted,
|
|
description: getPermissionDescription("microphone"),
|
|
},
|
|
{
|
|
permission_type: "camera",
|
|
isGranted: isCameraAccessGranted,
|
|
description: getPermissionDescription("camera"),
|
|
},
|
|
];
|
|
|
|
setPermissions(permissionList);
|
|
} catch (error) {
|
|
console.error("Failed to load permissions:", error);
|
|
} finally {
|
|
setIsLoadingPermissions(false);
|
|
}
|
|
}, [
|
|
getPermissionDescription,
|
|
isCameraAccessGranted,
|
|
isMacOS,
|
|
isMicrophoneAccessGranted,
|
|
]);
|
|
|
|
const checkDefaultBrowserStatus = useCallback(async () => {
|
|
try {
|
|
const isDefault = await invoke<boolean>("is_default_browser");
|
|
setIsDefaultBrowser(isDefault);
|
|
} catch (error) {
|
|
console.error("Failed to check default browser status:", error);
|
|
}
|
|
}, []);
|
|
|
|
const handleSetDefaultBrowser = useCallback(async () => {
|
|
setIsSettingDefault(true);
|
|
try {
|
|
await invoke("set_as_default_browser");
|
|
await checkDefaultBrowserStatus();
|
|
} catch (error) {
|
|
console.error("Failed to set as default browser:", error);
|
|
} finally {
|
|
setIsSettingDefault(false);
|
|
}
|
|
}, [checkDefaultBrowserStatus]);
|
|
|
|
const handleClearCache = useCallback(async () => {
|
|
setIsClearingCache(true);
|
|
try {
|
|
await invoke("clear_all_version_cache_and_refetch");
|
|
// Also clear traffic stats cache
|
|
await invoke("clear_all_traffic_stats");
|
|
// Don't show immediate success toast - let the version update progress events handle it
|
|
} catch (error) {
|
|
console.error("Failed to clear cache:", error);
|
|
showErrorToast(t("settings.advanced.clearCacheFailed"), {
|
|
description:
|
|
error instanceof Error ? error.message : t("common.errors.unknown"),
|
|
duration: 4000,
|
|
});
|
|
} finally {
|
|
setIsClearingCache(false);
|
|
}
|
|
}, [t]);
|
|
|
|
const handleRequestPermission = useCallback(
|
|
async (permissionType: PermissionType) => {
|
|
setRequestingPermission(permissionType);
|
|
try {
|
|
await requestPermission(permissionType);
|
|
showSuccessToast(
|
|
t("settings.permissions.accessRequested", {
|
|
permission: getPermissionDisplayName(permissionType),
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to request permission:", error);
|
|
} finally {
|
|
setRequestingPermission(null);
|
|
}
|
|
},
|
|
[getPermissionDisplayName, requestPermission, t],
|
|
);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
setIsSaving(true);
|
|
try {
|
|
// Update settings with current custom theme state
|
|
let settingsToSave: AppSettings = {
|
|
...settings,
|
|
custom_theme:
|
|
settings.theme === "custom"
|
|
? customThemeState.colors
|
|
: settings.custom_theme,
|
|
};
|
|
|
|
console.log("[settings-dialog] Saving settings:", {
|
|
theme: settingsToSave.theme,
|
|
hasCustomTheme: !!settingsToSave.custom_theme,
|
|
customThemeKeys: settingsToSave.custom_theme
|
|
? Object.keys(settingsToSave.custom_theme).length
|
|
: 0,
|
|
});
|
|
|
|
const savedSettings = await invoke<AppSettings>("save_app_settings", {
|
|
settings: settingsToSave,
|
|
});
|
|
|
|
console.log("[settings-dialog] Saved settings response:", {
|
|
theme: savedSettings.theme,
|
|
hasCustomTheme: !!savedSettings.custom_theme,
|
|
customThemeKeys: savedSettings.custom_theme
|
|
? Object.keys(savedSettings.custom_theme).length
|
|
: 0,
|
|
});
|
|
|
|
// Update settings with any generated tokens
|
|
setSettings(savedSettings);
|
|
settingsToSave = savedSettings;
|
|
// Pass the actual theme value through. Calling setTheme("dark") here
|
|
// when the user is on "custom" pushes the provider state to "dark",
|
|
// which triggers its clear-custom-vars effect and wipes the CSS
|
|
// variables we set just below — that's the bug where saving a custom
|
|
// theme made it disappear until the app was restarted.
|
|
setTheme(settings.theme);
|
|
|
|
// Apply or clear custom variables only on Save
|
|
if (settings.theme === "custom") {
|
|
if (Object.keys(customThemeState.colors).length > 0) {
|
|
try {
|
|
const root = document.documentElement;
|
|
// Clear any previous custom vars first
|
|
THEME_VARIABLES.forEach(({ key }) => {
|
|
root.style.removeProperty(key as string);
|
|
});
|
|
Object.entries(customThemeState.colors).forEach(([k, v]) => {
|
|
root.style.setProperty(k, v, "important");
|
|
});
|
|
} catch {
|
|
/* empty */
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
const root = document.documentElement;
|
|
THEME_VARIABLES.forEach(({ key }) => {
|
|
root.style.removeProperty(key as string);
|
|
});
|
|
} catch {
|
|
/* empty */
|
|
}
|
|
}
|
|
|
|
// Save language if changed
|
|
if (selectedLanguage !== originalLanguage) {
|
|
await changeLanguage(
|
|
selectedLanguage === "system"
|
|
? null
|
|
: (selectedLanguage as
|
|
| "en"
|
|
| "es"
|
|
| "pt"
|
|
| "fr"
|
|
| "zh"
|
|
| "ja"
|
|
| "ko"
|
|
| "ru"),
|
|
);
|
|
setOriginalLanguage(selectedLanguage);
|
|
}
|
|
|
|
setOriginalSettings(settingsToSave);
|
|
onClose();
|
|
} catch (error) {
|
|
console.error("Failed to save settings:", error);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [
|
|
onClose,
|
|
setTheme,
|
|
settings,
|
|
customThemeState,
|
|
selectedLanguage,
|
|
originalLanguage,
|
|
changeLanguage,
|
|
]);
|
|
|
|
const updateSetting = useCallback(
|
|
(
|
|
key: keyof AppSettings,
|
|
value: boolean | string | Record<string, string> | undefined,
|
|
) => {
|
|
setSettings((prev) => ({ ...prev, [key]: value as unknown as never }));
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleClose = useCallback(() => {
|
|
// Restore original theme when closing without saving
|
|
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
|
|
applyCustomTheme(originalSettings.custom_theme);
|
|
} else {
|
|
clearCustomTheme();
|
|
}
|
|
|
|
// Reset custom theme state to original
|
|
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
|
|
const matchingTheme = getThemeByColors(originalSettings.custom_theme);
|
|
setCustomThemeState({
|
|
selectedThemeId: matchingTheme?.id ?? null,
|
|
colors: originalSettings.custom_theme,
|
|
});
|
|
}
|
|
|
|
onClose();
|
|
}, [
|
|
originalSettings.theme,
|
|
originalSettings.custom_theme,
|
|
applyCustomTheme,
|
|
clearCustomTheme,
|
|
onClose,
|
|
]);
|
|
|
|
// Only clear custom theme when switching away from custom, don't apply live changes
|
|
useEffect(() => {
|
|
if (settings.theme !== "custom") {
|
|
clearCustomTheme();
|
|
}
|
|
}, [settings.theme, clearCustomTheme]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
loadSettings().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
checkDefaultBrowserStatus().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
|
|
// Check if we're on macOS
|
|
const userAgent = navigator.userAgent;
|
|
const isMac = userAgent.includes("Mac");
|
|
setIsMacOS(isMac);
|
|
const isLin = !userAgent.includes("Mac") && !userAgent.includes("Win");
|
|
setIsLinux(isLin);
|
|
|
|
if (isMac) {
|
|
loadPermissions();
|
|
}
|
|
|
|
// Set up interval to check default browser status
|
|
const intervalId = setInterval(() => {
|
|
checkDefaultBrowserStatus().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}, 2000);
|
|
|
|
// Cleanup interval on component unmount or dialog close
|
|
return () => {
|
|
clearInterval(intervalId);
|
|
};
|
|
}
|
|
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
|
|
|
|
// Initialize language selection when dialog opens or language loads
|
|
useEffect(() => {
|
|
if (isOpen && !isLanguageLoading) {
|
|
setSelectedLanguage(currentLanguage);
|
|
setOriginalLanguage(currentLanguage);
|
|
}
|
|
}, [isOpen, currentLanguage, isLanguageLoading]);
|
|
|
|
// Update permissions when the permission states change
|
|
useEffect(() => {
|
|
if (isMacOS) {
|
|
const permissionList: PermissionInfo[] = [
|
|
{
|
|
permission_type: "microphone",
|
|
isGranted: isMicrophoneAccessGranted,
|
|
description: getPermissionDescription("microphone"),
|
|
},
|
|
{
|
|
permission_type: "camera",
|
|
isGranted: isCameraAccessGranted,
|
|
description: getPermissionDescription("camera"),
|
|
},
|
|
];
|
|
setPermissions(permissionList);
|
|
} else {
|
|
setPermissions([]);
|
|
}
|
|
}, [
|
|
isMacOS,
|
|
isMicrophoneAccessGranted,
|
|
isCameraAccessGranted,
|
|
getPermissionDescription,
|
|
]);
|
|
|
|
// Check if settings have changed (excluding default browser setting)
|
|
const hasChanges =
|
|
settings.theme !== originalSettings.theme ||
|
|
settings.api_enabled !== originalSettings.api_enabled ||
|
|
selectedLanguage !== originalLanguage ||
|
|
(settings.theme === "custom" &&
|
|
JSON.stringify(customThemeState.colors) !==
|
|
JSON.stringify(originalSettings.custom_theme ?? {})) ||
|
|
(settings.theme !== "custom" &&
|
|
JSON.stringify(settings.custom_theme ?? {}) !==
|
|
JSON.stringify(originalSettings.custom_theme ?? {})) ||
|
|
settings.disable_auto_updates !== originalSettings.disable_auto_updates;
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
|
|
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
|
|
{!subPage && (
|
|
<DialogHeader className="shrink-0">
|
|
<DialogTitle>{t("settings.title")}</DialogTitle>
|
|
</DialogHeader>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
"grid overflow-y-auto flex-1 gap-6 min-h-0",
|
|
subPage ? "py-2" : "py-4",
|
|
)}
|
|
>
|
|
{/* Appearance Section */}
|
|
<div className="space-y-4">
|
|
<Label className="text-base font-medium">
|
|
{t("settings.appearance.title")}
|
|
</Label>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="theme-select" className="text-sm">
|
|
{t("settings.appearance.theme")}
|
|
</Label>
|
|
<Select
|
|
value={settings.theme}
|
|
onValueChange={(value) => {
|
|
updateSetting("theme", value);
|
|
if (value === "custom") {
|
|
const tokyoNightTheme = getThemeById("tokyo-night");
|
|
if (tokyoNightTheme) {
|
|
setCustomThemeState({
|
|
selectedThemeId: "tokyo-night",
|
|
colors: tokyoNightTheme.colors,
|
|
});
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger id="theme-select">
|
|
<SelectValue
|
|
placeholder={t("settings.appearance.selectTheme")}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="light">
|
|
{t("settings.appearance.light")}
|
|
</SelectItem>
|
|
<SelectItem value="dark">
|
|
{t("settings.appearance.dark")}
|
|
</SelectItem>
|
|
<SelectItem value="system">
|
|
{t("settings.appearance.system")}
|
|
</SelectItem>
|
|
<SelectItem value="custom">
|
|
{t("common.labels.custom")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.appearance.themeDescription")}
|
|
</p>
|
|
|
|
{settings.theme === "custom" && (
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="theme-preset-select"
|
|
className="text-sm font-medium"
|
|
>
|
|
{t("settings.appearance.themePreset")}
|
|
</Label>
|
|
<Select
|
|
value={customThemeState.selectedThemeId ?? "custom"}
|
|
onValueChange={(value) => {
|
|
if (value === "custom") {
|
|
setCustomThemeState((prev) => ({
|
|
...prev,
|
|
selectedThemeId: null,
|
|
}));
|
|
} else {
|
|
const theme = getThemeById(value);
|
|
if (theme) {
|
|
setCustomThemeState({
|
|
selectedThemeId: value,
|
|
colors: theme.colors,
|
|
});
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger id="theme-preset-select">
|
|
<SelectValue
|
|
placeholder={t(
|
|
"settings.appearance.selectThemePreset",
|
|
)}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{THEMES.map((theme) => (
|
|
<SelectItem key={theme.id} value={theme.id}>
|
|
{theme.name}
|
|
</SelectItem>
|
|
))}
|
|
<SelectItem value="custom">
|
|
{t("settings.appearance.yourOwn")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="text-sm font-medium">
|
|
{t("settings.appearance.customColors")}
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
{THEME_VARIABLES.map(({ key, label }) => {
|
|
const colorValue =
|
|
customThemeState.colors[key] ?? "#000000";
|
|
return (
|
|
<div
|
|
key={key}
|
|
className="flex flex-col gap-1 items-center"
|
|
>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={label}
|
|
className="size-8 rounded-md border shadow-sm cursor-pointer"
|
|
style={{ backgroundColor: colorValue }}
|
|
/>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-[320px] p-3"
|
|
sideOffset={6}
|
|
>
|
|
<ColorPicker
|
|
className="p-3 rounded-md border shadow-sm bg-background"
|
|
value={colorValue}
|
|
onColorChange={([r, g, b, a]) => {
|
|
const next = Color({ r, g, b }).alpha(a);
|
|
const nextStr = next.hexa();
|
|
const newColors = {
|
|
...customThemeState.colors,
|
|
[key]: nextStr,
|
|
};
|
|
|
|
// Check if colors match any preset theme
|
|
const matchingTheme =
|
|
getThemeByColors(newColors);
|
|
|
|
setCustomThemeState({
|
|
selectedThemeId: matchingTheme?.id ?? null,
|
|
colors: newColors,
|
|
});
|
|
}}
|
|
>
|
|
<ColorPickerSelection className="h-36 rounded" />
|
|
<div className="flex gap-3 items-center mt-3">
|
|
<ColorPickerEyeDropper />
|
|
<div className="grid gap-1 w-full">
|
|
<ColorPickerHue />
|
|
<ColorPickerAlpha />
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 items-center mt-3">
|
|
<ColorPickerOutput />
|
|
<ColorPickerFormat />
|
|
</div>
|
|
</ColorPicker>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<div className="text-[10px] text-muted-foreground text-center leading-tight">
|
|
{label}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Language Section */}
|
|
<div className="space-y-4">
|
|
<Label className="text-base font-medium">
|
|
{t("settings.language.title")}
|
|
</Label>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="language-select" className="text-sm">
|
|
{t("settings.language.interface")}
|
|
</Label>
|
|
<Select
|
|
value={selectedLanguage ?? "system"}
|
|
onValueChange={(value) => {
|
|
setSelectedLanguage(value);
|
|
}}
|
|
disabled={isLanguageLoading}
|
|
>
|
|
<SelectTrigger id="language-select">
|
|
<SelectValue
|
|
placeholder={t("settings.language.selectLanguage")}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="system">
|
|
{t("settings.language.systemDefault")}
|
|
</SelectItem>
|
|
{supportedLanguages.map((lang) => (
|
|
<SelectItem key={lang.code} value={lang.code}>
|
|
{lang.nativeName} ({lang.name})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.language.description")}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Default Browser Section - hidden in portable mode */}
|
|
{!systemInfo?.portable && (
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<Label className="text-base font-medium">
|
|
{t("settings.defaultBrowser.title")}
|
|
</Label>
|
|
<Badge variant={isDefaultBrowser ? "default" : "secondary"}>
|
|
{isDefaultBrowser
|
|
? t("common.status.active")
|
|
: t("common.status.inactive")}
|
|
</Badge>
|
|
</div>
|
|
|
|
<LoadingButton
|
|
isLoading={isSettingDefault}
|
|
onClick={() => {
|
|
handleSetDefaultBrowser().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}}
|
|
disabled={isDefaultBrowser}
|
|
variant={isDefaultBrowser ? "outline" : "default"}
|
|
className="w-full"
|
|
>
|
|
{isDefaultBrowser
|
|
? t("settings.defaultBrowser.alreadyDefault")
|
|
: t("settings.defaultBrowser.setAsDefault")}
|
|
</LoadingButton>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.defaultBrowser.description")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Permissions Section - Only show on macOS */}
|
|
{isMacOS && (
|
|
<div className="space-y-4">
|
|
<Label className="text-base font-medium">
|
|
{t("settings.permissions.title")}
|
|
</Label>
|
|
|
|
{isLoadingPermissions ? (
|
|
<div className="text-sm text-muted-foreground">
|
|
{t("settings.permissions.loading")}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{permissions.map((permission) => (
|
|
<div
|
|
key={permission.permission_type}
|
|
className="flex justify-between items-center p-3 rounded-lg border"
|
|
>
|
|
<div className="flex items-center gap-x-3">
|
|
{getPermissionIcon(permission.permission_type)}
|
|
<div>
|
|
<div className="text-sm font-medium">
|
|
{getPermissionDisplayName(
|
|
permission.permission_type,
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{permission.description}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-x-2">
|
|
{getStatusBadge(permission.isGranted)}
|
|
{!permission.isGranted && (
|
|
<LoadingButton
|
|
size="sm"
|
|
isLoading={
|
|
requestingPermission ===
|
|
permission.permission_type
|
|
}
|
|
onClick={() => {
|
|
handleRequestPermission(
|
|
permission.permission_type,
|
|
).catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}}
|
|
>
|
|
Grant
|
|
</LoadingButton>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
These permissions allow browsers launched from Donut Browser
|
|
to access system resources. Each website will still ask for
|
|
your permission individually.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Integrations Section */}
|
|
<div className="space-y-4">
|
|
<Label className="text-base font-medium">
|
|
{t("settings.integrations.title")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.integrations.description")}
|
|
</p>
|
|
<RippleButton
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={onIntegrationsOpen}
|
|
>
|
|
{t("integrations.openSettings")}
|
|
</RippleButton>
|
|
</div>
|
|
|
|
{/* DNS Blocklist Section */}
|
|
<div className="space-y-4">
|
|
<Label className="text-base font-medium">
|
|
{t("dnsBlocklist.title")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("dnsBlocklist.settingsDescription")}
|
|
</p>
|
|
<RippleButton
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => setDnsBlocklistDialogOpen(true)}
|
|
>
|
|
{t("dnsBlocklist.manageLists")}
|
|
</RippleButton>
|
|
</div>
|
|
|
|
{/* Sync Encryption Section */}
|
|
<div className="space-y-4">
|
|
<Label className="text-base font-medium">
|
|
{t("settings.encryption.title")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.encryption.description")}
|
|
</p>
|
|
|
|
{!canUseEncryption ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("settings.encryption.requiresProOrOwner")}
|
|
</p>
|
|
) : hasE2ePassword ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="default">
|
|
{t("settings.encryption.passwordSet")}
|
|
</Badge>
|
|
<span className="text-sm text-muted-foreground">
|
|
{t("settings.encryption.passwordSetDescription")}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2 flex-wrap">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={isRemovingE2e}
|
|
onClick={() => {
|
|
setVerifyE2ePassword("");
|
|
setIsVerifyE2eOpen(true);
|
|
}}
|
|
>
|
|
{t("settings.encryption.validatePassword")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={isRemovingE2e}
|
|
onClick={() => {
|
|
setHasE2ePassword(false);
|
|
setE2ePassword("");
|
|
setE2ePasswordConfirm("");
|
|
setE2eError("");
|
|
}}
|
|
>
|
|
{t("settings.encryption.changePassword")}
|
|
</Button>
|
|
<LoadingButton
|
|
variant="destructive"
|
|
size="sm"
|
|
isLoading={isRemovingE2e}
|
|
onClick={async () => {
|
|
setIsRemovingE2e(true);
|
|
try {
|
|
// Await the rollover so the user sees an error if
|
|
// re-syncing fails. Previously the rollover was
|
|
// fire-and-forget (`void invoke(...)`) which left
|
|
// half-removed state on screen with no feedback —
|
|
// the source of issue #360 "completely bugged".
|
|
await invoke("delete_e2e_password");
|
|
setHasE2ePassword(false);
|
|
try {
|
|
await invoke(
|
|
"rollover_encryption_for_all_entities",
|
|
);
|
|
} catch (rolloverErr) {
|
|
console.error(
|
|
"Rollover after password removal failed:",
|
|
rolloverErr,
|
|
);
|
|
showErrorToast(String(rolloverErr));
|
|
}
|
|
showSuccessToast(t("settings.encryption.removed"));
|
|
} catch (error) {
|
|
showErrorToast(String(error));
|
|
} finally {
|
|
setIsRemovingE2e(false);
|
|
}
|
|
}}
|
|
>
|
|
{t("settings.encryption.removePassword")}
|
|
</LoadingButton>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<Input
|
|
type="password"
|
|
placeholder={t("settings.encryption.passwordPlaceholder")}
|
|
value={e2ePassword}
|
|
onChange={(e) => {
|
|
setE2ePassword(e.target.value);
|
|
setE2eError("");
|
|
}}
|
|
/>
|
|
<Input
|
|
type="password"
|
|
placeholder={t("settings.encryption.confirmPlaceholder")}
|
|
value={e2ePasswordConfirm}
|
|
onChange={(e) => {
|
|
setE2ePasswordConfirm(e.target.value);
|
|
setE2eError("");
|
|
}}
|
|
/>
|
|
{e2eError && (
|
|
<p className="text-sm text-destructive">{e2eError}</p>
|
|
)}
|
|
<LoadingButton
|
|
variant="default"
|
|
size="sm"
|
|
isLoading={isSavingE2e}
|
|
onClick={async () => {
|
|
if (e2ePassword.length < 8) {
|
|
setE2eError(t("settings.encryption.passwordTooShort"));
|
|
return;
|
|
}
|
|
if (e2ePassword !== e2ePasswordConfirm) {
|
|
setE2eError(t("settings.encryption.passwordMismatch"));
|
|
return;
|
|
}
|
|
setIsSavingE2e(true);
|
|
try {
|
|
await invoke("set_e2e_password", {
|
|
password: e2ePassword,
|
|
});
|
|
setHasE2ePassword(true);
|
|
setE2ePassword("");
|
|
setE2ePasswordConfirm("");
|
|
try {
|
|
// Await rollover so any failure surfaces to the
|
|
// user instead of being lost via fire-and-forget.
|
|
// Without this, "change password" leaves entities
|
|
// half-re-encrypted with no visible error.
|
|
await invoke("rollover_encryption_for_all_entities");
|
|
} catch (rolloverErr) {
|
|
console.error(
|
|
"Rollover after password set failed:",
|
|
rolloverErr,
|
|
);
|
|
showErrorToast(String(rolloverErr));
|
|
}
|
|
showSuccessToast(
|
|
t("settings.encryption.passwordSaved"),
|
|
);
|
|
} catch (error) {
|
|
showErrorToast(String(error));
|
|
} finally {
|
|
setIsSavingE2e(false);
|
|
}
|
|
}}
|
|
>
|
|
{t("settings.encryption.setPassword")}
|
|
</LoadingButton>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Commercial License Section */}
|
|
<div className="space-y-4">
|
|
<Label className="text-base font-medium">
|
|
{t("settings.commercial.title")}
|
|
</Label>
|
|
|
|
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
|
|
{cloudUser != null && cloudUser.plan !== "free" ? (
|
|
// Paid Donut plan supersedes the local commercial trial —
|
|
// the trial only exists to gate commercial use until the
|
|
// user subscribes. Showing "Trial expired" to a paying
|
|
// customer reads like a billing error, so swap in a
|
|
// subscription-active badge instead.
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium text-success">
|
|
{t("settings.commercial.subscriptionActive", {
|
|
plan: cloudUser.plan,
|
|
})}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.commercial.subscriptionActiveDescription")}
|
|
</p>
|
|
</div>
|
|
) : trialStatus?.type === "Active" ? (
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">
|
|
{t("settings.commercial.trialActive", {
|
|
days: trialStatus.days_remaining,
|
|
hours: trialStatus.hours_remaining,
|
|
})}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.commercial.trialActiveDescription")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium text-warning">
|
|
{t("settings.commercial.trialExpired")}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.commercial.trialExpiredDescription")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Advanced Section */}
|
|
<div className="space-y-4">
|
|
<Label className="text-base font-medium">
|
|
{t("settings.advanced.title")}
|
|
</Label>
|
|
|
|
{!isLinux && (
|
|
<div className="flex items-start gap-x-3 p-3 rounded-lg border">
|
|
<Checkbox
|
|
id="disable-auto-updates"
|
|
checked={settings.disable_auto_updates ?? false}
|
|
onCheckedChange={(checked) => {
|
|
updateSetting("disable_auto_updates", checked as boolean);
|
|
}}
|
|
/>
|
|
<div className="space-y-1">
|
|
<Label
|
|
htmlFor="disable-auto-updates"
|
|
className="text-sm font-medium"
|
|
>
|
|
{t("settings.disableAutoUpdates")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.disableAutoUpdatesDescription")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-start gap-x-3 p-3 rounded-lg border">
|
|
<Checkbox
|
|
id="keep-decrypted-profiles-in-ram"
|
|
checked={settings.keep_decrypted_profiles_in_ram ?? false}
|
|
onCheckedChange={(checked) => {
|
|
updateSetting(
|
|
"keep_decrypted_profiles_in_ram",
|
|
checked as boolean,
|
|
);
|
|
}}
|
|
/>
|
|
<div className="space-y-1">
|
|
<Label
|
|
htmlFor="keep-decrypted-profiles-in-ram"
|
|
className="text-sm font-medium"
|
|
>
|
|
{t("settings.keepDecryptedProfilesInRam")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.keepDecryptedProfilesInRamDescription")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<LoadingButton
|
|
isLoading={isClearingCache}
|
|
onClick={() => {
|
|
handleClearCache().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}}
|
|
variant="outline"
|
|
className="w-full"
|
|
>
|
|
{t("settings.advanced.clearCache")}
|
|
</LoadingButton>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.advanced.clearCacheDescription")}
|
|
</p>
|
|
|
|
<div className="grid grid-cols-2 gap-2 pt-2">
|
|
<RippleButton
|
|
variant="outline"
|
|
className="text-xs"
|
|
onClick={async () => {
|
|
try {
|
|
const content = await invoke<string>("read_log_files");
|
|
await writeClipboardText(content);
|
|
showSuccessToast(t("settings.advanced.copyLogsSuccess"));
|
|
} catch (err) {
|
|
showErrorToast(String(err));
|
|
}
|
|
}}
|
|
>
|
|
{t("settings.advanced.copyLogs")}
|
|
</RippleButton>
|
|
<RippleButton
|
|
variant="outline"
|
|
className="text-xs"
|
|
onClick={async () => {
|
|
try {
|
|
await invoke("open_log_directory");
|
|
} catch (err) {
|
|
showErrorToast(String(err));
|
|
}
|
|
}}
|
|
>
|
|
{t("settings.advanced.openLogDir")}
|
|
</RippleButton>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.advanced.copyLogsDescription")}
|
|
</p>
|
|
</div>
|
|
|
|
{/* System Info */}
|
|
{systemInfo && (
|
|
<div className="pt-2 border-t">
|
|
<p className="text-xs text-muted-foreground font-mono whitespace-pre-line select-all">
|
|
{`Donut Browser ${systemInfo.app_version}\n${systemInfo.os} ${systemInfo.arch}${systemInfo.portable ? " (portable)" : ""}`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{subPage ? (
|
|
<div className="shrink-0 flex items-center justify-end gap-2 pt-2 border-t border-border">
|
|
<LoadingButton
|
|
size="sm"
|
|
isLoading={isSaving}
|
|
onClick={() => {
|
|
handleSave().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}}
|
|
disabled={isLoading || !hasChanges}
|
|
>
|
|
{t("common.buttons.saveSettings")}
|
|
</LoadingButton>
|
|
</div>
|
|
) : (
|
|
<DialogFooter className="shrink-0">
|
|
<RippleButton variant="outline" onClick={handleClose}>
|
|
{t("common.buttons.cancel")}
|
|
</RippleButton>
|
|
<LoadingButton
|
|
isLoading={isSaving}
|
|
onClick={() => {
|
|
handleSave().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}}
|
|
disabled={isLoading || !hasChanges}
|
|
>
|
|
{t("common.buttons.saveSettings")}
|
|
</LoadingButton>
|
|
</DialogFooter>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
<DnsBlocklistDialog
|
|
isOpen={dnsBlocklistDialogOpen}
|
|
onClose={() => setDnsBlocklistDialogOpen(false)}
|
|
/>
|
|
<Dialog
|
|
open={isVerifyE2eOpen}
|
|
onOpenChange={(open) => {
|
|
if (!isVerifyingE2e) {
|
|
setIsVerifyE2eOpen(open);
|
|
if (!open) setVerifyE2ePassword("");
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t("settings.encryption.validateDialog.title")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("settings.encryption.validateDialog.description")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<Input
|
|
type="password"
|
|
placeholder={t("settings.encryption.passwordPlaceholder")}
|
|
value={verifyE2ePassword}
|
|
autoFocus
|
|
onChange={(e) => setVerifyE2ePassword(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && verifyE2ePassword.length > 0) {
|
|
e.preventDefault();
|
|
void (async () => {
|
|
setIsVerifyingE2e(true);
|
|
try {
|
|
const ok = await invoke<boolean>("verify_e2e_password", {
|
|
password: verifyE2ePassword,
|
|
});
|
|
if (ok) {
|
|
showSuccessToast(
|
|
t("settings.encryption.validateDialog.matchToast"),
|
|
);
|
|
setIsVerifyE2eOpen(false);
|
|
setVerifyE2ePassword("");
|
|
} else {
|
|
showErrorToast(
|
|
t("settings.encryption.validateDialog.mismatchToast"),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
showErrorToast(String(error));
|
|
} finally {
|
|
setIsVerifyingE2e(false);
|
|
}
|
|
})();
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
disabled={isVerifyingE2e}
|
|
onClick={() => {
|
|
setIsVerifyE2eOpen(false);
|
|
setVerifyE2ePassword("");
|
|
}}
|
|
>
|
|
{t("common.buttons.cancel")}
|
|
</Button>
|
|
<LoadingButton
|
|
isLoading={isVerifyingE2e}
|
|
disabled={verifyE2ePassword.length === 0}
|
|
onClick={async () => {
|
|
setIsVerifyingE2e(true);
|
|
try {
|
|
const ok = await invoke<boolean>("verify_e2e_password", {
|
|
password: verifyE2ePassword,
|
|
});
|
|
if (ok) {
|
|
showSuccessToast(
|
|
t("settings.encryption.validateDialog.matchToast"),
|
|
);
|
|
setIsVerifyE2eOpen(false);
|
|
setVerifyE2ePassword("");
|
|
} else {
|
|
showErrorToast(
|
|
t("settings.encryption.validateDialog.mismatchToast"),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
showErrorToast(String(error));
|
|
} finally {
|
|
setIsVerifyingE2e(false);
|
|
}
|
|
}}
|
|
>
|
|
{t("settings.encryption.validateDialog.submit")}
|
|
</LoadingButton>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|