mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-26 10:08:04 +02:00
refactor: creation button disaster recovery
This commit is contained in:
@@ -1215,13 +1215,14 @@ pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
||||
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
CLOUD_AUTH.logout().await?;
|
||||
|
||||
// Clear sync settings if they point to the cloud URL (prevent leak into Self-Hosted tab)
|
||||
// Always clear the stored sync URL and token on cloud logout. While the
|
||||
// user was signed in, the cloud auth flow populated these with the hosted
|
||||
// sync server's URL + a server-issued token — leaving them in place would
|
||||
// pre-fill the Self-Hosted tab with our production URL and a token the
|
||||
// user never typed. The cloud-URL-only check we used to do here missed
|
||||
// trailing-slash / scheme variants and any future cloud endpoint moves.
|
||||
let manager = crate::settings_manager::SettingsManager::instance();
|
||||
if let Ok(sync_settings) = manager.get_sync_settings() {
|
||||
if sync_settings.sync_server_url.as_deref() == Some(CLOUD_SYNC_URL) {
|
||||
let _ = manager.save_sync_server_url(None);
|
||||
}
|
||||
}
|
||||
let _ = manager.save_sync_server_url(None);
|
||||
let _ = manager.remove_sync_token(&app_handle).await;
|
||||
|
||||
// Remove cloud-managed and cloud-derived proxies
|
||||
|
||||
@@ -991,6 +991,17 @@ pub async fn save_sync_settings(
|
||||
sync_server_url: Option<String>,
|
||||
sync_token: Option<String>,
|
||||
) -> Result<SyncSettings, String> {
|
||||
// Cloud login and self-hosted sync share the same sync engine and a
|
||||
// profile can't be sync'd to two backends at once. Block any *write*
|
||||
// (non-null URL or token) while the user is signed into their cloud
|
||||
// account — the clearing path (both `None`) is always allowed so logged-
|
||||
// in users can wipe a stale self-hosted config that pre-dates their
|
||||
// sign-in.
|
||||
let is_setting_self_hosted = sync_server_url.is_some() || sync_token.is_some();
|
||||
if is_setting_self_hosted && crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
return Err(serde_json::json!({ "code": "SELF_HOSTED_REQUIRES_LOGOUT" }).to_string());
|
||||
}
|
||||
|
||||
let manager = SettingsManager::instance();
|
||||
|
||||
manager
|
||||
|
||||
@@ -3526,6 +3526,49 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
// Enable sync for all eligible profiles. Without this the user would see
|
||||
// groups/proxies/vpns syncing while their profiles stay local-only — the
|
||||
// long-standing source of issue #352. Encrypted mode wins when an E2E
|
||||
// password is already configured; otherwise we fall back to plain Regular.
|
||||
{
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
let desired_mode = if encryption::has_e2e_password() {
|
||||
SyncMode::Encrypted
|
||||
} else {
|
||||
SyncMode::Regular
|
||||
};
|
||||
let desired_mode_str = match desired_mode {
|
||||
SyncMode::Encrypted => "Encrypted",
|
||||
SyncMode::Regular => "Regular",
|
||||
SyncMode::Disabled => "Disabled",
|
||||
};
|
||||
for profile in &profiles {
|
||||
// Skip profiles that are already syncing (any non-Disabled mode),
|
||||
// ephemeral profiles (data wipes on quit, sync is meaningless), and
|
||||
// cross-OS profiles (the OS-specific binary isn't installed locally
|
||||
// so a sync round-trip would be one-sided).
|
||||
if profile.sync_mode != SyncMode::Disabled || profile.ephemeral || profile.is_cross_os() {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = set_profile_sync_mode(
|
||||
app_handle.clone(),
|
||||
profile.id.to_string(),
|
||||
desired_mode_str.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to enable sync for profile {} ({}): {e}",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enable sync for all unsynced proxies
|
||||
{
|
||||
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
|
||||
|
||||
+434
-102
@@ -1,12 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCloud, LuLogOut, LuRefreshCw, LuUser } from "react-icons/lu";
|
||||
import {
|
||||
LuCloud,
|
||||
LuEye,
|
||||
LuEyeOff,
|
||||
LuLogOut,
|
||||
LuRefreshCw,
|
||||
LuUser,
|
||||
} from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SyncSettings } from "@/types";
|
||||
|
||||
interface AccountPageProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,6 +31,8 @@ interface AccountPageProps {
|
||||
onOpenSignIn: () => void;
|
||||
}
|
||||
|
||||
type ConnectionStatus = "unknown" | "testing" | "connected" | "error";
|
||||
|
||||
export function AccountPage({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -22,8 +40,34 @@ export function AccountPage({
|
||||
onOpenSignIn,
|
||||
}: AccountPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user, isLoggedIn, logout, refreshProfile } = useCloudAuth();
|
||||
const {
|
||||
user,
|
||||
isLoggedIn,
|
||||
isLoading: isCloudLoading,
|
||||
logout,
|
||||
refreshProfile,
|
||||
} = useCloudAuth();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
// Self-hosted server state. Loaded once when the dialog opens and persisted
|
||||
// via `save_sync_settings` so the rest of the app picks up the new URL/token
|
||||
// from `SettingsManager`.
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [isSavingSelfHosted, setIsSavingSelfHosted] = useState(false);
|
||||
const [isTestingConnection, setIsTestingConnection] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<ConnectionStatus>("unknown");
|
||||
|
||||
const hasConfig = Boolean(serverUrl && token);
|
||||
// Self-hosted and cloud are mutually exclusive — both share the same sync
|
||||
// engine and a profile can't be sync'd to two backends. The tab trigger is
|
||||
// disabled here AND the backend rejects mixed state (see `save_sync_settings`
|
||||
// / `cloud_logout`), so even if someone bypasses the UI we don't end up
|
||||
// with split-brain.
|
||||
const selfHostedDisabled = isLoggedIn || isCloudLoading;
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
@@ -38,119 +82,407 @@ export function AccountPage({
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
await logout();
|
||||
// The backend wipes sync URL + token as part of cloud_logout (see
|
||||
// `cloud_auth::cloud_logout`); pull the now-empty settings back into
|
||||
// the form so a user who flips to the Self-hosted tab doesn't see the
|
||||
// pre-logout production URL still sitting there.
|
||||
await loadSelfHostedSettings();
|
||||
showSuccessToast(t("account.loggedOut"));
|
||||
} catch (e) {
|
||||
showErrorToast(String(e));
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSelfHostedSettings = useCallback(async () => {
|
||||
try {
|
||||
const settings = await invoke<SyncSettings>("get_sync_settings");
|
||||
setServerUrl(settings.sync_server_url ?? "");
|
||||
setToken(settings.sync_token ?? "");
|
||||
setConnectionStatus(
|
||||
settings.sync_server_url && settings.sync_token ? "unknown" : "unknown",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load sync settings:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadSelfHostedSettings();
|
||||
}
|
||||
}, [isOpen, loadSelfHostedSettings]);
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!serverUrl) {
|
||||
showErrorToast(t("sync.config.serverUrlRequired"));
|
||||
return;
|
||||
}
|
||||
setIsTestingConnection(true);
|
||||
setConnectionStatus("testing");
|
||||
try {
|
||||
const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`;
|
||||
const response = await fetch(healthUrl);
|
||||
if (response.ok) {
|
||||
setConnectionStatus("connected");
|
||||
showSuccessToast(t("sync.config.connectionSuccess"));
|
||||
} else {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast(t("sync.config.serverError"));
|
||||
}
|
||||
} catch {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast(t("sync.config.connectFailed"));
|
||||
} finally {
|
||||
setIsTestingConnection(false);
|
||||
}
|
||||
}, [serverUrl, t]);
|
||||
|
||||
const handleSaveSelfHosted = useCallback(async () => {
|
||||
setIsSavingSelfHosted(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(t("sync.config.settingsSaved"));
|
||||
} catch (error) {
|
||||
console.error("Failed to save sync settings:", error);
|
||||
// Use the structured backend-error translator so the cloud-vs-self-
|
||||
// hosted mutex (`SELF_HOSTED_REQUIRES_LOGOUT`) shows a clear message
|
||||
// instead of the generic "save failed" toast.
|
||||
showErrorToast(translateBackendError(t as never, error));
|
||||
} finally {
|
||||
setIsSavingSelfHosted(false);
|
||||
}
|
||||
}, [serverUrl, token, t]);
|
||||
|
||||
const handleDisconnectSelfHosted = useCallback(async () => {
|
||||
setIsSavingSelfHosted(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(t("sync.config.disconnected"));
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
showErrorToast(t("sync.config.disconnectFailed"));
|
||||
} finally {
|
||||
setIsSavingSelfHosted(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-2xl flex flex-col">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid place-items-center w-12 h-12 rounded-full bg-accent text-foreground shrink-0">
|
||||
<LuUser className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="text-base font-semibold truncate">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("account.plan", {
|
||||
plan: user.plan,
|
||||
period: user.planPeriod ?? "—",
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("account.signedOutDescription")}
|
||||
</p>
|
||||
</>
|
||||
<Tabs defaultValue="account">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"w-full",
|
||||
subPage &&
|
||||
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5 font-medium uppercase">{user.plan}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.teamRole")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.period")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<LuRefreshCw className="w-3 h-3" />
|
||||
{t("account.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<LuLogOut className="w-3 h-3" />
|
||||
{t("account.logout")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<TabsTrigger
|
||||
value="account"
|
||||
className={cn(
|
||||
"flex-1",
|
||||
subPage &&
|
||||
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
|
||||
)}
|
||||
>
|
||||
<LuCloud className="w-3 h-3" />
|
||||
{t("account.signIn")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{t("account.tabs.account")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="self-hosted"
|
||||
disabled={selfHostedDisabled}
|
||||
title={
|
||||
selfHostedDisabled
|
||||
? t("account.selfHosted.disabledWhileLoggedIn")
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex-1",
|
||||
subPage &&
|
||||
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs disabled:opacity-50 disabled:hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("account.tabs.selfHosted")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="account" className="mt-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid place-items-center w-12 h-12 rounded-full bg-accent text-foreground shrink-0">
|
||||
<LuUser className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="text-base font-semibold truncate">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("account.plan", {
|
||||
plan: user.plan,
|
||||
period: user.planPeriod ?? "—",
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("account.signedOutDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5 font-medium uppercase">
|
||||
{user.plan}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.teamRole")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.period")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<LuRefreshCw className="w-3 h-3" />
|
||||
{t("account.refresh")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
isLoading={isLoggingOut}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<LuLogOut className="w-3 h-3" />
|
||||
{t("account.logout")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<LuCloud className="w-3 h-3" />
|
||||
{t("account.signIn")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="self-hosted" className="mt-4">
|
||||
{selfHostedDisabled ? (
|
||||
// Defensive: the tab trigger is disabled while the user is
|
||||
// logged in, so this branch shouldn't be reachable via UI —
|
||||
// but if state flips mid-render (e.g. a cloud login finishes
|
||||
// while the tab is open), show the explanation instead of
|
||||
// a silent empty card.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("account.selfHosted.disabledWhileLoggedIn")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-server-url" className="text-xs">
|
||||
{t("sync.serverUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="self-hosted-server-url"
|
||||
type="url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
setServerUrl(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-token" className="text-xs">
|
||||
{t("sync.token")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="self-hosted-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowToken((v) => !v);
|
||||
}}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<LuEye className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("account.selfHosted.connectionStatus")}
|
||||
</span>
|
||||
{connectionStatus === "connected" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-success-foreground bg-success"
|
||||
>
|
||||
{t("sync.status.connected")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<Badge variant="destructive">
|
||||
{t("sync.status.error")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "testing" && (
|
||||
<Badge variant="secondary">
|
||||
{t("sync.status.syncing")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "unknown" && (
|
||||
<Badge variant="secondary">
|
||||
{t("account.selfHosted.statusUnknown")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={isTestingConnection}
|
||||
disabled={!serverUrl || isSavingSelfHosted}
|
||||
onClick={() => void handleTestConnection()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.testConnection")}
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={isSavingSelfHosted}
|
||||
disabled={!serverUrl || !token || isTestingConnection}
|
||||
onClick={() => void handleSaveSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isSavingSelfHosted || isTestingConnection}
|
||||
onClick={() => void handleDisconnectSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -156,6 +156,8 @@ const HomeHeader = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isWindows = platform === "windows";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dragRootRef}
|
||||
@@ -163,7 +165,15 @@ const HomeHeader = ({
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
className="flex items-center gap-2 h-11 px-3 border-b border-border bg-card select-none"
|
||||
className={cn(
|
||||
"flex items-center gap-2 h-11 pl-3 border-b border-border bg-card select-none",
|
||||
// Windows: WindowDragArea renders two 44px native-style controls
|
||||
// (minimize + close) fixed at top-right with z-50, total 88px wide.
|
||||
// Reserve 100px on the right edge so the "+ New" button and search
|
||||
// input clear them with a few pixels of breathing room — issues
|
||||
// #358, #361, #362 all reported the same overlap before this fix.
|
||||
isWindows ? "pr-[100px]" : "pr-3",
|
||||
)}
|
||||
>
|
||||
{isMacOS && (
|
||||
<div
|
||||
|
||||
@@ -131,6 +131,7 @@ export function SettingsDialog({
|
||||
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
|
||||
const [e2eError, setE2eError] = useState("");
|
||||
const [isSavingE2e, setIsSavingE2e] = useState(false);
|
||||
const [isRemovingE2e, setIsRemovingE2e] = useState(false);
|
||||
const [systemInfo, setSystemInfo] = useState<{
|
||||
app_version: string;
|
||||
os: string;
|
||||
@@ -994,6 +995,7 @@ export function SettingsDialog({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isRemovingE2e}
|
||||
onClick={() => {
|
||||
setHasE2ePassword(false);
|
||||
setE2ePassword("");
|
||||
@@ -1003,22 +1005,41 @@ export function SettingsDialog({
|
||||
>
|
||||
{t("settings.encryption.changePassword")}
|
||||
</Button>
|
||||
<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"));
|
||||
void invoke("rollover_encryption_for_all_entities");
|
||||
} catch (error) {
|
||||
showErrorToast(String(error));
|
||||
} finally {
|
||||
setIsRemovingE2e(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("settings.encryption.removePassword")}
|
||||
</Button>
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1065,10 +1086,22 @@ export function SettingsDialog({
|
||||
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"),
|
||||
);
|
||||
void invoke("rollover_encryption_for_all_entities");
|
||||
} catch (error) {
|
||||
showErrorToast(String(error));
|
||||
} finally {
|
||||
@@ -1089,7 +1122,23 @@ export function SettingsDialog({
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
|
||||
{trialStatus?.type === "Active" ? (
|
||||
{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", {
|
||||
|
||||
@@ -165,7 +165,9 @@
|
||||
"trialActive": "Trial: {{days}} days, {{hours}} hours remaining",
|
||||
"trialActiveDescription": "Commercial use is free during the trial. When it ends, all features keep working — personal use stays free, only commercial use will require a license.",
|
||||
"trialExpired": "Trial expired",
|
||||
"trialExpiredDescription": "Personal use remains free. Commercial use requires a license."
|
||||
"trialExpiredDescription": "Personal use remains free. Commercial use requires a license.",
|
||||
"subscriptionActive": "Subscribed — {{plan}} plan",
|
||||
"subscriptionActiveDescription": "Your Donut Browser subscription is active. Commercial use is licensed for the duration of your plan."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
@@ -1742,7 +1744,8 @@
|
||||
"internal": "Something went wrong: {{detail}}",
|
||||
"invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.",
|
||||
"cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
|
||||
"cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable."
|
||||
"cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable.",
|
||||
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
@@ -1808,6 +1811,19 @@
|
||||
"status": "Status",
|
||||
"teamRole": "Team role",
|
||||
"period": "Billing period"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Account",
|
||||
"selfHosted": "Self-hosted"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "Self-hosted sync server",
|
||||
"description": "Point Donut at your own donut-sync server to sync profiles, proxies, groups, and extensions without using the hosted cloud.",
|
||||
"disabledWhileLoggedIn": "Self-hosted sync is unavailable while you're signed into your Donut account. Sign out to use a custom server.",
|
||||
"connectionStatus": "Connection:",
|
||||
"statusUnknown": "Untested",
|
||||
"testConnection": "Test connection",
|
||||
"disconnect": "Disconnect"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,9 @@
|
||||
"trialActive": "Prueba: {{days}} días, {{hours}} horas restantes",
|
||||
"trialActiveDescription": "El uso comercial es gratuito durante la prueba. Al finalizar, todas las funciones siguen funcionando — el uso personal sigue siendo gratuito, solo el uso comercial requerirá una licencia.",
|
||||
"trialExpired": "Prueba expirada",
|
||||
"trialExpiredDescription": "El uso personal sigue siendo gratuito. El uso comercial requiere una licencia."
|
||||
"trialExpiredDescription": "El uso personal sigue siendo gratuito. El uso comercial requiere una licencia.",
|
||||
"subscriptionActive": "Suscrito — plan {{plan}}",
|
||||
"subscriptionActiveDescription": "Tu suscripción a Donut Browser está activa. El uso comercial está autorizado mientras tu plan esté vigente."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avanzado",
|
||||
@@ -1742,7 +1744,8 @@
|
||||
"internal": "Algo salió mal: {{detail}}",
|
||||
"invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.",
|
||||
"cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.",
|
||||
"cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible."
|
||||
"cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible.",
|
||||
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
@@ -1808,6 +1811,19 @@
|
||||
"status": "Estado",
|
||||
"teamRole": "Rol en el equipo",
|
||||
"period": "Período"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Cuenta",
|
||||
"selfHosted": "Autoalojado"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "Servidor de sincronización autoalojado",
|
||||
"description": "Conecta Donut a tu propio servidor donut-sync para sincronizar perfiles, proxies, grupos y extensiones sin usar la nube alojada.",
|
||||
"disabledWhileLoggedIn": "La sincronización autoalojada no está disponible mientras estás conectado a tu cuenta de Donut. Cierra sesión para usar un servidor personalizado.",
|
||||
"connectionStatus": "Conexión:",
|
||||
"statusUnknown": "Sin probar",
|
||||
"testConnection": "Probar conexión",
|
||||
"disconnect": "Desconectar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,9 @@
|
||||
"trialActive": "Essai: {{days}} jours, {{hours}} heures restantes",
|
||||
"trialActiveDescription": "L'utilisation commerciale est gratuite pendant l'essai. À l'expiration, toutes les fonctionnalités continuent de fonctionner — l'utilisation personnelle reste gratuite, seule l'utilisation commerciale nécessitera une licence.",
|
||||
"trialExpired": "Essai expiré",
|
||||
"trialExpiredDescription": "L'utilisation personnelle reste gratuite. L'utilisation commerciale nécessite une licence."
|
||||
"trialExpiredDescription": "L'utilisation personnelle reste gratuite. L'utilisation commerciale nécessite une licence.",
|
||||
"subscriptionActive": "Abonné — formule {{plan}}",
|
||||
"subscriptionActiveDescription": "Votre abonnement Donut Browser est actif. L'usage commercial est licencié pendant toute la durée de votre formule."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avancé",
|
||||
@@ -1742,7 +1744,8 @@
|
||||
"internal": "Une erreur s'est produite : {{detail}}",
|
||||
"invalidLaunchHookUrl": "URL du hook de lancement invalide. Utilisez une URL http:// ou https:// complète.",
|
||||
"cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.",
|
||||
"cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible."
|
||||
"cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible.",
|
||||
"selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
@@ -1808,6 +1811,19 @@
|
||||
"status": "Statut",
|
||||
"teamRole": "Rôle d’équipe",
|
||||
"period": "Période"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Compte",
|
||||
"selfHosted": "Auto-hébergé"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "Serveur de synchronisation auto-hébergé",
|
||||
"description": "Connectez Donut à votre propre serveur donut-sync pour synchroniser profils, proxys, groupes et extensions sans utiliser le cloud hébergé.",
|
||||
"disabledWhileLoggedIn": "La synchronisation auto-hébergée n'est pas disponible lorsque vous êtes connecté à votre compte Donut. Déconnectez-vous pour utiliser un serveur personnalisé.",
|
||||
"connectionStatus": "Connexion :",
|
||||
"statusUnknown": "Non testé",
|
||||
"testConnection": "Tester la connexion",
|
||||
"disconnect": "Déconnecter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,9 @@
|
||||
"trialActive": "トライアル: 残り {{days}} 日 {{hours}} 時間",
|
||||
"trialActiveDescription": "トライアル期間中は商用利用が無料です。期間が終了してもすべての機能はそのまま使用できます — 個人利用は引き続き無料で、商用利用のみライセンスが必要になります。",
|
||||
"trialExpired": "トライアル期限切れ",
|
||||
"trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。"
|
||||
"trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。",
|
||||
"subscriptionActive": "サブスクリプション中 — {{plan}} プラン",
|
||||
"subscriptionActiveDescription": "Donut Browser のサブスクリプションが有効です。プランの期間中、商用利用がライセンスされます。"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "詳細設定",
|
||||
@@ -1742,7 +1744,8 @@
|
||||
"internal": "問題が発生しました: {{detail}}",
|
||||
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
|
||||
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
|
||||
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。"
|
||||
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。",
|
||||
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
@@ -1808,6 +1811,19 @@
|
||||
"status": "ステータス",
|
||||
"teamRole": "チームロール",
|
||||
"period": "請求周期"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "アカウント",
|
||||
"selfHosted": "セルフホスト"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "セルフホスト同期サーバー",
|
||||
"description": "Donut を独自の donut-sync サーバーに接続して、ホスト型クラウドを使わずにプロファイル、プロキシ、グループ、拡張機能を同期します。",
|
||||
"disabledWhileLoggedIn": "Donut アカウントにサインインしている間はセルフホスト同期を利用できません。カスタムサーバーを使うにはサインアウトしてください。",
|
||||
"connectionStatus": "接続:",
|
||||
"statusUnknown": "未テスト",
|
||||
"testConnection": "接続をテスト",
|
||||
"disconnect": "切断"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,9 @@
|
||||
"trialActive": "Teste: {{days}} dias, {{hours}} horas restantes",
|
||||
"trialActiveDescription": "O uso comercial é gratuito durante o teste. Após o término, todos os recursos continuam funcionando — o uso pessoal permanece gratuito, apenas o uso comercial exigirá uma licença.",
|
||||
"trialExpired": "Teste expirado",
|
||||
"trialExpiredDescription": "O uso pessoal continua gratuito. O uso comercial requer uma licença."
|
||||
"trialExpiredDescription": "O uso pessoal continua gratuito. O uso comercial requer uma licença.",
|
||||
"subscriptionActive": "Assinado — plano {{plan}}",
|
||||
"subscriptionActiveDescription": "Sua assinatura do Donut Browser está ativa. O uso comercial está licenciado enquanto seu plano estiver vigente."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avançado",
|
||||
@@ -1742,7 +1744,8 @@
|
||||
"internal": "Algo deu errado: {{detail}}",
|
||||
"invalidLaunchHookUrl": "URL do hook de inicialização inválida. Use uma URL completa http:// ou https://.",
|
||||
"cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.",
|
||||
"cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível."
|
||||
"cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível.",
|
||||
"selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
@@ -1808,6 +1811,19 @@
|
||||
"status": "Status",
|
||||
"teamRole": "Função na equipe",
|
||||
"period": "Período"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Conta",
|
||||
"selfHosted": "Auto-hospedado"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "Servidor de sincronização auto-hospedado",
|
||||
"description": "Conecte o Donut ao seu próprio servidor donut-sync para sincronizar perfis, proxies, grupos e extensões sem usar a nuvem hospedada.",
|
||||
"disabledWhileLoggedIn": "A sincronização auto-hospedada não está disponível enquanto você está conectado à sua conta Donut. Saia para usar um servidor personalizado.",
|
||||
"connectionStatus": "Conexão:",
|
||||
"statusUnknown": "Não testado",
|
||||
"testConnection": "Testar conexão",
|
||||
"disconnect": "Desconectar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,9 @@
|
||||
"trialActive": "Пробный период: осталось {{days}} дней, {{hours}} часов",
|
||||
"trialActiveDescription": "Коммерческое использование бесплатно в течение пробного периода. После его окончания все функции продолжают работать — личное использование остаётся бесплатным, и только для коммерческого использования потребуется лицензия.",
|
||||
"trialExpired": "Пробный период истёк",
|
||||
"trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия."
|
||||
"trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия.",
|
||||
"subscriptionActive": "Подписка активна — план {{plan}}",
|
||||
"subscriptionActiveDescription": "Ваша подписка на Donut Browser активна. Коммерческое использование лицензировано на срок действия плана."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Дополнительно",
|
||||
@@ -1742,7 +1744,8 @@
|
||||
"internal": "Что-то пошло не так: {{detail}}",
|
||||
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
|
||||
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
|
||||
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно."
|
||||
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.",
|
||||
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
@@ -1808,6 +1811,19 @@
|
||||
"status": "Статус",
|
||||
"teamRole": "Роль в команде",
|
||||
"period": "Период"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Аккаунт",
|
||||
"selfHosted": "Свой сервер"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "Свой сервер синхронизации",
|
||||
"description": "Подключите Donut к собственному серверу donut-sync, чтобы синхронизировать профили, прокси, группы и расширения без использования облака.",
|
||||
"disabledWhileLoggedIn": "Свой сервер синхронизации недоступен, пока вы вошли в аккаунт Donut. Выйдите из аккаунта, чтобы использовать собственный сервер.",
|
||||
"connectionStatus": "Соединение:",
|
||||
"statusUnknown": "Не проверено",
|
||||
"testConnection": "Проверить соединение",
|
||||
"disconnect": "Отключить"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,9 @@
|
||||
"trialActive": "试用期:剩余 {{days}} 天 {{hours}} 小时",
|
||||
"trialActiveDescription": "试用期内商业使用免费。试用期结束后,所有功能继续正常使用 — 个人使用仍然免费,只有商业使用需要许可证。",
|
||||
"trialExpired": "试用期已过期",
|
||||
"trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。"
|
||||
"trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。",
|
||||
"subscriptionActive": "已订阅 — {{plan}} 方案",
|
||||
"subscriptionActiveDescription": "您的 Donut Browser 订阅已激活。在订阅有效期内允许商业使用。"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高级",
|
||||
@@ -1742,7 +1744,8 @@
|
||||
"internal": "出现问题:{{detail}}",
|
||||
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
|
||||
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
|
||||
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。"
|
||||
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。",
|
||||
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
@@ -1808,6 +1811,19 @@
|
||||
"status": "状态",
|
||||
"teamRole": "团队角色",
|
||||
"period": "计费周期"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "账户",
|
||||
"selfHosted": "自托管"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "自托管同步服务器",
|
||||
"description": "将 Donut 连接到您自己的 donut-sync 服务器,无需使用托管云即可同步配置文件、代理、组和扩展程序。",
|
||||
"disabledWhileLoggedIn": "登录 Donut 账户时无法使用自托管同步。请先退出登录以使用自定义服务器。",
|
||||
"connectionStatus": "连接:",
|
||||
"statusUnknown": "未测试",
|
||||
"testConnection": "测试连接",
|
||||
"disconnect": "断开连接"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export type BackendErrorCode =
|
||||
| "INVALID_LAUNCH_HOOK_URL"
|
||||
| "COOKIE_DB_LOCKED"
|
||||
| "COOKIE_DB_UNAVAILABLE"
|
||||
| "SELF_HOSTED_REQUIRES_LOGOUT"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface BackendError {
|
||||
@@ -93,6 +94,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.cookieDbLocked");
|
||||
case "COOKIE_DB_UNAVAILABLE":
|
||||
return t("backendErrors.cookieDbUnavailable");
|
||||
case "SELF_HOSTED_REQUIRES_LOGOUT":
|
||||
return t("backendErrors.selfHostedRequiresLogout");
|
||||
case "INTERNAL_ERROR":
|
||||
return t("backendErrors.internal", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
|
||||
Reference in New Issue
Block a user