mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
A "Show Commands" toggle in the snippets settings menu hides the command text under each snippet name, for people who dock the panel on the narrow right rail and only need the names. Local preference, on by default.
2820 lines
106 KiB
TypeScript
2820 lines
106 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { copyToClipboard } from "@/lib/clipboard";
|
|
import {
|
|
getUserInfo,
|
|
getRemoteSyncUserInfo,
|
|
getApiKeys,
|
|
createApiKey,
|
|
deleteApiKey,
|
|
changePassword,
|
|
deleteAccount,
|
|
logoutUser,
|
|
setupTOTP,
|
|
enableTOTP,
|
|
disableTOTP,
|
|
getVersionInfo,
|
|
releaseUrlFrom,
|
|
getUserRoles,
|
|
saveUserPreferences,
|
|
getUserPreferences,
|
|
} from "@/main-axios";
|
|
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
|
|
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
|
import { useHostSidebarPreferences } from "./tree/hooks/useHostSidebarPreferences";
|
|
import {
|
|
deleteWebAuthnCredential,
|
|
listWebAuthnCredentials,
|
|
registerWebAuthnCredential,
|
|
type WebAuthnCredentialSummary,
|
|
type WebAuthnUserVerification,
|
|
} from "@/api/webauthn-api";
|
|
import type { UserRole } from "@/main-axios";
|
|
import type React from "react";
|
|
import { isElectron } from "@/lib/electron";
|
|
import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx";
|
|
import { shouldForceLocalPreferenceStorage } from "@/settings/remote-sync-state";
|
|
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
|
|
import { Button } from "@/components/button";
|
|
import { Input } from "@/components/input";
|
|
import { VersionBadge } from "@/components/version-badge";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/dialog";
|
|
import {
|
|
Activity,
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
Copy,
|
|
Database,
|
|
Eye,
|
|
EyeOff,
|
|
KeyRound,
|
|
LayoutTemplate,
|
|
Network,
|
|
Palette,
|
|
Plus,
|
|
RotateCcw,
|
|
Shield,
|
|
ShieldCheck,
|
|
Trash2,
|
|
Type,
|
|
User,
|
|
X,
|
|
} from "lucide-react";
|
|
import { SettingRow, FakeSwitch } from "@/components/section-card";
|
|
import { visibleRailItems } from "./rail-items";
|
|
import { InterfacePresetSettings } from "./InterfacePresetSettings";
|
|
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
|
import { KeybindingsDialog } from "./KeybindingsDialog";
|
|
import {
|
|
ACCENT_PRESET_COLORS,
|
|
applyAccentColor,
|
|
applyFontSize,
|
|
applyUiFont,
|
|
FONT_SIZES,
|
|
UI_FONTS,
|
|
} from "@/lib/theme";
|
|
import type { ApiKey } from "@/main-axios";
|
|
import { useTheme } from "@/components/theme-provider";
|
|
import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
|
|
import { toast } from "sonner";
|
|
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
|
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
|
|
import { ConnectionDefaultsSettings } from "./ConnectionDefaultsSettings";
|
|
|
|
type UserProfileSection =
|
|
| "account"
|
|
| "interface"
|
|
| "appearance"
|
|
| "security"
|
|
| "api-keys"
|
|
| "data"
|
|
| "c2s-tunnels";
|
|
|
|
const THEMES: { id: ThemeId; preview: string }[] = [
|
|
{ id: "system", preview: "auto" },
|
|
{ id: "light", preview: "#ffffff" },
|
|
{ id: "dark", preview: "#1a1c22" },
|
|
{ id: "dracula", preview: "#282a36" },
|
|
{ id: "catppuccin", preview: "#1e1e2e" },
|
|
{ id: "nord", preview: "#2e3440" },
|
|
{ id: "solarized", preview: "#002b36" },
|
|
{ id: "tokyo-night", preview: "#1a1b26" },
|
|
{ id: "one-dark", preview: "#282c34" },
|
|
{ id: "gruvbox", preview: "#282828" },
|
|
];
|
|
|
|
const LANGUAGES = [
|
|
{ code: "en", label: "English" },
|
|
{ code: "af", label: "Afrikaans" },
|
|
{ code: "ar", label: "العربية" },
|
|
{ code: "bn", label: "বাংলা" },
|
|
{ code: "bg", label: "Български" },
|
|
{ code: "ca", label: "Català" },
|
|
{ code: "zh-CN", label: "中文 (简体)" },
|
|
{ code: "zh-TW", label: "中文 (繁體)" },
|
|
{ code: "cs", label: "Čeština" },
|
|
{ code: "da", label: "Dansk" },
|
|
{ code: "nl", label: "Nederlands" },
|
|
{ code: "fi", label: "Suomi" },
|
|
{ code: "fr", label: "Français" },
|
|
{ code: "de", label: "Deutsch" },
|
|
{ code: "el", label: "Ελληνικά" },
|
|
{ code: "he", label: "עברית" },
|
|
{ code: "hi", label: "हिन्दी" },
|
|
{ code: "hu", label: "Magyar" },
|
|
{ code: "id", label: "Indonesia" },
|
|
{ code: "it", label: "Italiano" },
|
|
{ code: "ja", label: "日本語" },
|
|
{ code: "ko", label: "한국어" },
|
|
{ code: "no", label: "Norsk" },
|
|
{ code: "pl", label: "Polski" },
|
|
{ code: "pt-PT", label: "Português (PT)" },
|
|
{ code: "pt-BR", label: "Português (BR)" },
|
|
{ code: "ro", label: "Română" },
|
|
{ code: "ru", label: "Русский" },
|
|
{ code: "sr", label: "Српски" },
|
|
{ code: "es-ES", label: "Español" },
|
|
{ code: "sv-SE", label: "Svenska" },
|
|
{ code: "th", label: "ไทย" },
|
|
{ code: "tr", label: "Türkçe" },
|
|
{ code: "uk", label: "Українська" },
|
|
{ code: "vi", label: "Tiếng Việt" },
|
|
];
|
|
|
|
export function AccordionSection({
|
|
id,
|
|
label,
|
|
icon,
|
|
open,
|
|
onToggle,
|
|
hidden = false,
|
|
children,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
hidden?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
if (hidden) return null;
|
|
|
|
return (
|
|
<div className="border border-border bg-card overflow-hidden">
|
|
<button
|
|
onClick={onToggle}
|
|
aria-expanded={open}
|
|
aria-controls={`${id}-content`}
|
|
className="flex items-center gap-2 w-full px-3 py-2.5 text-left hover:bg-muted/40 transition-colors"
|
|
>
|
|
<span className="text-muted-foreground shrink-0">{icon}</span>
|
|
<span className="text-xs font-bold uppercase tracking-widest text-foreground flex-1">
|
|
{label}
|
|
</span>
|
|
<ChevronDown
|
|
className={`size-3.5 text-muted-foreground shrink-0 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
|
|
/>
|
|
</button>
|
|
{open && (
|
|
<div id={`${id}-content`} className="border-t border-border px-3 pb-3">
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ApiErrorLike = {
|
|
response?: {
|
|
data?: {
|
|
error?: string;
|
|
};
|
|
};
|
|
};
|
|
|
|
function apiErrorMessage(error: unknown, fallback: string) {
|
|
return (error as ApiErrorLike).response?.data?.error || fallback;
|
|
}
|
|
|
|
type CreatedProfileApiKey = {
|
|
id: string;
|
|
name: string;
|
|
token?: string;
|
|
tokenPrefix?: string;
|
|
createdAt?: string;
|
|
expiresAt?: string | null;
|
|
};
|
|
|
|
export function NewApiKeyDialog({
|
|
open,
|
|
onOpenChange,
|
|
onAdd,
|
|
userId,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (v: boolean) => void;
|
|
onAdd: (key: CreatedProfileApiKey) => void;
|
|
userId: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [name, setName] = useState("");
|
|
const [expiry, setExpiry] = useState("");
|
|
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
const close = () => {
|
|
setName("");
|
|
setExpiry("");
|
|
setCreatedToken(null);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
const handleCreate = async () => {
|
|
if (!name.trim()) {
|
|
toast.error(t("newUi.sidebar.userProfile.apiKeyNameRequired"));
|
|
return;
|
|
}
|
|
if (creating) return;
|
|
setCreating(true);
|
|
try {
|
|
const created = await createApiKey(
|
|
name.trim(),
|
|
userId,
|
|
expiry ? new Date(expiry).toISOString() : undefined,
|
|
);
|
|
onAdd(created);
|
|
setCreatedToken(created.token);
|
|
toast.success(t("newUi.sidebar.userProfile.apiKeyCreated", { name }));
|
|
} catch {
|
|
toast.error(t("newUi.sidebar.userProfile.apiKeyCreateFailed"));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(nextOpen) => {
|
|
if (nextOpen || !createdToken) onOpenChange(nextOpen);
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-md rounded-none border-border bg-card p-0 gap-0 overflow-hidden">
|
|
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="size-8 border border-border bg-muted flex items-center justify-center shrink-0">
|
|
<Network className="size-3.5 text-accent-brand" />
|
|
</div>
|
|
<div>
|
|
<DialogTitle className="text-base font-bold leading-none">
|
|
{t("newUi.sidebar.userProfile.createApiKeyTitle")}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
|
{t("newUi.sidebar.userProfile.createApiKeyDescription")}
|
|
</DialogDescription>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
{createdToken ? (
|
|
<div className="flex flex-col gap-3 px-5 py-4">
|
|
<span className="text-xs font-semibold text-accent-brand">
|
|
{t("newUi.sidebar.userProfile.apiKeyCreatedWarning")}
|
|
</span>
|
|
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-2">
|
|
<code className="min-w-0 flex-1 break-all text-xs text-accent-brand">
|
|
{createdToken}
|
|
</code>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-7 shrink-0"
|
|
aria-label={t("newUi.sidebar.userProfile.copyApiKey")}
|
|
onClick={() => {
|
|
copyToClipboard(createdToken);
|
|
toast.info(t("newUi.sidebar.userProfile.copiedToClipboard"));
|
|
}}
|
|
>
|
|
<Copy className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-4 px-5 py-4">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
|
|
</label>
|
|
<Input
|
|
autoFocus
|
|
placeholder={t(
|
|
"newUi.sidebar.userProfile.apiKeyNamePlaceholder",
|
|
)}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
|
className="rounded-none bg-muted/50 border-border text-sm h-9"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
|
|
<span className="text-muted-foreground/50 normal-case font-medium">
|
|
({t("newUi.sidebar.userProfile.optional")})
|
|
</span>
|
|
</label>
|
|
<Input
|
|
type="date"
|
|
value={expiry}
|
|
onChange={(e) => setExpiry(e.target.value)}
|
|
className="rounded-none bg-muted/50 border-border text-sm h-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter className="px-5 py-3 border-t border-border bg-muted/20">
|
|
{createdToken ? (
|
|
<Button
|
|
variant="outline"
|
|
className="rounded-none border-accent-brand/40 text-[10px] font-bold uppercase tracking-widest text-accent-brand"
|
|
onClick={close}
|
|
>
|
|
{t("newUi.sidebar.userProfile.done")}
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={close}
|
|
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
|
>
|
|
{t("newUi.sidebar.userProfile.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest gap-1.5"
|
|
onClick={handleCreate}
|
|
disabled={creating}
|
|
>
|
|
<KeyRound className="size-3" />{" "}
|
|
{t("newUi.sidebar.userProfile.createKey")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function PasswordChangeSection({
|
|
showPassword,
|
|
setShowPassword,
|
|
onLogout,
|
|
}: {
|
|
showPassword: boolean;
|
|
setShowPassword: (v: boolean | ((prev: boolean) => boolean)) => void;
|
|
onLogout?: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [currentPw, setCurrentPw] = useState("");
|
|
const [newPw, setNewPw] = useState("");
|
|
const [confirmPw, setConfirmPw] = useState("");
|
|
|
|
async function handleUpdate() {
|
|
if (!currentPw || !newPw) {
|
|
toast.error(t("newUi.sidebar.userProfile.passwordFieldsRequired"));
|
|
return;
|
|
}
|
|
if (newPw !== confirmPw) {
|
|
toast.error(t("newUi.sidebar.userProfile.passwordMismatch"));
|
|
return;
|
|
}
|
|
if (newPw.length < 6) {
|
|
toast.error(t("newUi.sidebar.userProfile.passwordTooShort"));
|
|
return;
|
|
}
|
|
try {
|
|
await changePassword(currentPw, newPw);
|
|
toast.success(t("newUi.sidebar.userProfile.passwordUpdated"));
|
|
setCurrentPw("");
|
|
setNewPw("");
|
|
setConfirmPw("");
|
|
onLogout?.();
|
|
} catch (e: unknown) {
|
|
toast.error(
|
|
apiErrorMessage(e, t("newUi.sidebar.userProfile.passwordUpdateFailed")),
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3 border-t border-border pt-3">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.changePassword")}
|
|
</span>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
{t("newUi.sidebar.userProfile.currentPasswordLabel")}
|
|
</label>
|
|
<div className="relative">
|
|
<Input
|
|
type={showPassword ? "text" : "password"}
|
|
placeholder={t(
|
|
"newUi.sidebar.userProfile.currentPasswordPlaceholder",
|
|
)}
|
|
value={currentPw}
|
|
onChange={(e) => setCurrentPw(e.target.value)}
|
|
className="pr-9 text-sm"
|
|
/>
|
|
<button
|
|
onClick={() => setShowPassword((o) => !o)}
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
{showPassword ? (
|
|
<EyeOff className="size-4" />
|
|
) : (
|
|
<Eye className="size-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
{t("newUi.sidebar.userProfile.newPasswordLabel")}
|
|
</label>
|
|
<Input
|
|
type="password"
|
|
placeholder={t("newUi.sidebar.userProfile.newPasswordPlaceholder")}
|
|
value={newPw}
|
|
onChange={(e) => setNewPw(e.target.value)}
|
|
className="text-sm"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
{t("newUi.sidebar.userProfile.confirmPasswordLabel")}
|
|
</label>
|
|
<Input
|
|
type="password"
|
|
placeholder={t(
|
|
"newUi.sidebar.userProfile.confirmPasswordPlaceholder",
|
|
)}
|
|
value={confirmPw}
|
|
onChange={(e) => setConfirmPw(e.target.value)}
|
|
className="text-sm"
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand self-end"
|
|
onClick={handleUpdate}
|
|
>
|
|
<KeyRound className="size-3.5" />
|
|
{t("newUi.sidebar.userProfile.updatePassword")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function UserProfilePanel({
|
|
username,
|
|
onLogout,
|
|
userPrefs,
|
|
onPrefsChange,
|
|
remoteSyncInitialServerUrl,
|
|
}: {
|
|
username?: string;
|
|
onLogout?: () => void;
|
|
remoteSyncInitialServerUrl?: string;
|
|
userPrefs?: {
|
|
reopenTabsOnLogin: boolean;
|
|
storageMode?: string | null;
|
|
commandAutocomplete?: boolean | null;
|
|
commandPaletteEnabled?: boolean | null;
|
|
aiAssistantEnabled?: boolean | null;
|
|
aiReadOnlyCommands?: boolean | null;
|
|
showHostTags?: boolean | null;
|
|
hostTrayOnClick?: boolean | null;
|
|
compactHostView?: boolean | null;
|
|
pinAppRail?: boolean | null;
|
|
expandAppRailOnHover?: boolean | null;
|
|
foldersCollapsed?: boolean | null;
|
|
confirmSnippetExecution?: boolean | null;
|
|
disableUpdateCheck?: boolean | null;
|
|
confirmTabClose?: boolean | null;
|
|
hiddenRailTabs?: string | null;
|
|
statusColorScheme?: string | null;
|
|
};
|
|
onPrefsChange?: (prefs: {
|
|
reopenTabsOnLogin?: boolean;
|
|
storageMode?: "local" | "cloud";
|
|
}) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const themeLabel: Record<ThemeId, string> = {
|
|
system: t("newUi.sidebar.userProfile.themeSystem"),
|
|
light: t("newUi.sidebar.userProfile.themeLight"),
|
|
dark: t("newUi.sidebar.userProfile.themeDark"),
|
|
dracula: t("newUi.sidebar.userProfile.themeDracula"),
|
|
catppuccin: t("newUi.sidebar.userProfile.themeCatppuccin"),
|
|
nord: t("newUi.sidebar.userProfile.themeNord"),
|
|
solarized: t("newUi.sidebar.userProfile.themeSolarized"),
|
|
"tokyo-night": t("newUi.sidebar.userProfile.themeTokyoNight"),
|
|
"one-dark": t("newUi.sidebar.userProfile.themeOneDark"),
|
|
gruvbox: t("newUi.sidebar.userProfile.themeGruvbox"),
|
|
};
|
|
const [openSections, setOpenSections] = useState<Set<UserProfileSection>>(
|
|
() => new Set(["account"]),
|
|
);
|
|
|
|
// User info
|
|
const [userId, setUserId] = useState("");
|
|
const [accountUsername, setAccountUsername] = useState(username ?? "");
|
|
const [accountTotpEnabled, setAccountTotpEnabled] = useState(false);
|
|
const [userRole, setUserRole] = useState("");
|
|
const [authMethod, setAuthMethod] = useState("");
|
|
const [version, setVersion] = useState("");
|
|
const [versionStatus, setVersionStatus] = useState<
|
|
"up_to_date" | "requires_update" | "beta"
|
|
>("up_to_date");
|
|
const [releaseUrl, setReleaseUrl] = useState("");
|
|
const [isOidc, setIsOidc] = useState(false);
|
|
const [isDualAuth, setIsDualAuth] = useState(false);
|
|
|
|
// TOTP
|
|
const [totpEnabled, setTotpEnabled] = useState(false);
|
|
const [totpStep, setTotpStep] = useState<
|
|
"idle" | "setup" | "verify" | "backup"
|
|
>("idle");
|
|
const [totpQrCode, setTotpQrCode] = useState("");
|
|
const [totpSecret, setTotpSecret] = useState("");
|
|
const [totpCode, setTotpCode] = useState("");
|
|
const [totpBackupCodes, setTotpBackupCodes] = useState<string[]>([]);
|
|
const [totpLoading, setTotpLoading] = useState(false);
|
|
const [showDisableTotp, setShowDisableTotp] = useState(false);
|
|
const [disableTotpInput, setDisableTotpInput] = useState("");
|
|
const [showAddTotp, setShowAddTotp] = useState(false);
|
|
const [addTotpInput, setAddTotpInput] = useState("");
|
|
const [addingTotpAuthenticator, setAddingTotpAuthenticator] = useState(false);
|
|
const [passkeys, setPasskeys] = useState<WebAuthnCredentialSummary[]>([]);
|
|
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
|
const [passkeyName, setPasskeyName] = useState("");
|
|
const [passkeyUserVerification, setPasskeyUserVerification] =
|
|
useState<WebAuthnUserVerification>("preferred");
|
|
|
|
// Delete account
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
const [deletePassword, setDeletePassword] = useState("");
|
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
|
|
|
// Data export/import
|
|
const [importFile, setImportFile] = useState<File | null>(null);
|
|
const [exportLoading, setExportLoading] = useState(false);
|
|
const [importLoading, setImportLoading] = useState(false);
|
|
|
|
// UI state
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [newKeyOpen, setNewKeyOpen] = useState(false);
|
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
|
const { theme, setTheme } = useTheme();
|
|
const localSnapshot = useRef<Record<string, string | null>>({});
|
|
|
|
// Appearance state — initialized from localStorage
|
|
const [accentColor, setAccentColor] = useState(
|
|
() => localStorage.getItem("termix-accent") ?? "#f59145",
|
|
);
|
|
const [customColorInput, setCustomColorInput] = useState(
|
|
() => localStorage.getItem("termix-accent") ?? "#f59145",
|
|
);
|
|
const [fontSize, setFontSize] = useState<FontSizeId>(
|
|
() => (localStorage.getItem("termix-font-size") as FontSizeId) ?? "md",
|
|
);
|
|
const [uiFont, setUiFont] = useState<UiFontId>(
|
|
() =>
|
|
(localStorage.getItem("termix-ui-font") as UiFontId) ?? "jetbrains-mono",
|
|
);
|
|
const [language, setLanguage] = useState(() =>
|
|
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
|
|
);
|
|
const [storageMode, setStorageMode] = useState<"local" | "cloud">(() =>
|
|
userPrefs?.storageMode === "cloud" ? "cloud" : "local",
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (userPrefs?.storageMode) {
|
|
setStorageMode(userPrefs.storageMode === "cloud" ? "cloud" : "local");
|
|
}
|
|
}, [userPrefs?.storageMode]);
|
|
|
|
// Remote sync is not connected by default on the desktop app (it's an
|
|
// opt-in feature configured from this same panel). "cloud" storage mode
|
|
// and Termix ID both assume a real multi-device server account, so they
|
|
// stay hidden/forced-off until the user actually connects one.
|
|
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState<
|
|
boolean | null
|
|
>(() => (isElectron() ? null : true));
|
|
|
|
useEffect(() => {
|
|
if (!isElectron()) return;
|
|
let cancelled = false;
|
|
const refreshSyncStatus = () => {
|
|
window.electronAPI
|
|
?.invoke?.("get-remote-sync-config")
|
|
.then((config) => {
|
|
if (!cancelled) {
|
|
setIsRemoteSyncConnected(
|
|
!!(config as { serverUrl?: string } | null)?.serverUrl,
|
|
);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
};
|
|
refreshSyncStatus();
|
|
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() =>
|
|
refreshSyncStatus(),
|
|
);
|
|
return () => {
|
|
cancelled = true;
|
|
unsubscribe?.();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
shouldForceLocalPreferenceStorage(
|
|
isElectron(),
|
|
isRemoteSyncConnected,
|
|
storageMode,
|
|
)
|
|
) {
|
|
setStorageMode("local");
|
|
onPrefsChange?.({ storageMode: "local" });
|
|
}
|
|
}, [isRemoteSyncConnected, storageMode, onPrefsChange]);
|
|
|
|
// Settings toggles — all backed by localStorage
|
|
const [commandAutocomplete, setCommandAutocomplete] = useState(
|
|
() => localStorage.getItem("commandAutocomplete") === "true",
|
|
);
|
|
const [terminalLinkClickBehavior, setTerminalLinkClickBehavior] = useState(
|
|
() => localStorage.getItem("terminalLinkClickBehavior") ?? "confirm",
|
|
);
|
|
const [commandPaletteEnabled, setCommandPaletteEnabled] = useState(() => {
|
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
|
return v !== null ? v === "true" : true;
|
|
});
|
|
const [keybindingsDialogOpen, setKeybindingsDialogOpen] = useState(false);
|
|
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(false);
|
|
const [aiReadOnlyCommands, setAiReadOnlyCommands] = useState(false);
|
|
// Null until the status call answers; the whole section stays hidden while
|
|
// unknown so a user who cannot have the feature never sees it mentioned.
|
|
const [aiGloballyEnabled, setAiGloballyEnabled] = useState<boolean | null>(
|
|
null,
|
|
);
|
|
// Sidebar display customization (density, tags, tray trigger, status
|
|
// colors) now lives in the dedicated Customize Sidebar panel opened from
|
|
// the Hosts toolbar, not here -- resetToDefaults still needs write access.
|
|
const { update: updateSidebarPrefs } = useHostSidebarPreferences();
|
|
const uiPrefs = useUiPreferencesContext();
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
import("@/api/ai-api")
|
|
.then(({ getAiStatus }) => getAiStatus())
|
|
.then((status) => {
|
|
if (cancelled) return;
|
|
setAiGloballyEnabled(status.globallyEnabled);
|
|
setAiAssistantEnabled(status.enabled);
|
|
setAiReadOnlyCommands(status.allowReadOnlyCommands);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setAiGloballyEnabled(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
/**
|
|
* Turning the assistant off also hides its rail tab, so declining removes
|
|
* the feature from view entirely rather than leaving an inert entry behind.
|
|
*/
|
|
const applyAiEnabled = (enabled: boolean) => {
|
|
setAiAssistantEnabled(enabled);
|
|
|
|
const hidden = new Set<string>(
|
|
JSON.parse(localStorage.getItem("hiddenRailTabs") ?? "[]"),
|
|
);
|
|
if (enabled) hidden.delete("ai");
|
|
else hidden.add("ai");
|
|
|
|
const serialized = JSON.stringify([...hidden]);
|
|
localStorage.setItem("hiddenRailTabs", serialized);
|
|
setHiddenRailTabs(hidden);
|
|
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
|
|
|
|
if (storageMode === "cloud") {
|
|
saveToCloud({ aiAssistantEnabled: enabled, hiddenRailTabs: serialized });
|
|
}
|
|
};
|
|
const [pinAppRail, setPinAppRail] = useState(() =>
|
|
readRailPreference("pinAppRail"),
|
|
);
|
|
const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(() =>
|
|
readRailPreference("expandAppRailOnHover"),
|
|
);
|
|
// Read values are unused now that the Snippets settings UI lives in
|
|
// SnippetsPanel.tsx; the setters still back the cloud-sync/reset/snapshot
|
|
// machinery for these two localStorage-backed prefs below.
|
|
const [_foldersCollapsed, setFoldersCollapsed] = useState(
|
|
() => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
|
);
|
|
const [_confirmSnippetExecution, setConfirmSnippetExecution] = useState(
|
|
() => localStorage.getItem("confirmSnippetExecution") === "true",
|
|
);
|
|
const [disableUpdateCheck, setDisableUpdateCheck] = useState(
|
|
() => localStorage.getItem("disableUpdateCheck") === "true",
|
|
);
|
|
const [confirmTabClose, setConfirmTabClose] = useState(
|
|
() => localStorage.getItem("confirmTabClose") === "true",
|
|
);
|
|
const [hiddenRailTabs, setHiddenRailTabs] = useState<Set<string>>(() => {
|
|
try {
|
|
const stored = localStorage.getItem("hiddenRailTabs");
|
|
return stored ? new Set(JSON.parse(stored)) : new Set();
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
});
|
|
|
|
// API keys
|
|
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
|
|
|
// RBAC roles
|
|
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
|
|
|
useEffect(() => {
|
|
getUserInfo()
|
|
.then(async (localInfo) => {
|
|
setUserId(localInfo.userId);
|
|
setTotpEnabled(localInfo.totp_enabled ?? false);
|
|
setIsOidc(localInfo.is_oidc ?? false);
|
|
setIsDualAuth(localInfo.is_dual_auth ?? false);
|
|
const remoteInfo = await getRemoteSyncUserInfo();
|
|
const info = remoteInfo ?? localInfo;
|
|
setAccountUsername(info.username);
|
|
setAccountTotpEnabled(info.totp_enabled ?? false);
|
|
setUserRole(
|
|
info.is_admin
|
|
? t("newUi.sidebar.userProfile.roleAdministrator")
|
|
: t("newUi.sidebar.userProfile.roleUser"),
|
|
);
|
|
if (info.is_dual_auth) {
|
|
setAuthMethod(t("newUi.sidebar.userProfile.authMethodDual"));
|
|
} else if (info.is_oidc) {
|
|
setAuthMethod(t("newUi.sidebar.userProfile.authMethodOidc"));
|
|
} else {
|
|
setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal"));
|
|
}
|
|
if (remoteInfo) {
|
|
setUserRoles(remoteInfo.roles ?? []);
|
|
} else {
|
|
getUserRoles(localInfo.userId)
|
|
.then(({ roles }) => setUserRoles(roles ?? []))
|
|
.catch(() => {});
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
getApiKeys()
|
|
.then(({ apiKeys: keys }) => setApiKeys(keys))
|
|
.catch(() => {});
|
|
listWebAuthnCredentials()
|
|
.then(({ credentials }) => setPasskeys(credentials ?? []))
|
|
.catch(() => {});
|
|
getVersionInfo()
|
|
.then((info) => {
|
|
setVersion(info.localVersion);
|
|
setVersionStatus(info.status ?? "up_to_date");
|
|
setReleaseUrl(releaseUrlFrom(info));
|
|
})
|
|
.catch(() => {});
|
|
}, [t]);
|
|
|
|
// The rail can toggle these from its right-click menu while this panel is
|
|
// mounted, so mirror the change back into the switches instead of letting
|
|
// them drift from the stored value.
|
|
useEffect(() => {
|
|
const pinHandler = () => setPinAppRail(readRailPreference("pinAppRail"));
|
|
const hoverHandler = () =>
|
|
setExpandAppRailOnHover(readRailPreference("expandAppRailOnHover"));
|
|
window.addEventListener("pinAppRailChanged", pinHandler);
|
|
window.addEventListener("expandAppRailOnHoverChanged", hoverHandler);
|
|
return () => {
|
|
window.removeEventListener("pinAppRailChanged", pinHandler);
|
|
window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler);
|
|
};
|
|
}, []);
|
|
|
|
function saveToCloud(prefs: Parameters<typeof saveUserPreferences>[0]) {
|
|
void saveUserPreferences(prefs).catch(() => {});
|
|
}
|
|
|
|
async function handleStorageModeChange(mode: "local" | "cloud") {
|
|
setStorageMode(mode);
|
|
onPrefsChange?.({ storageMode: mode });
|
|
if (mode === "cloud") {
|
|
// Snapshot current browser localStorage values so any tab can restore them later
|
|
const SNAPSHOT_KEYS = [
|
|
"termix-accent",
|
|
"termix-font-size",
|
|
"termix-ui-font",
|
|
"i18nextLng",
|
|
"commandAutocomplete",
|
|
"commandPaletteShortcutEnabled",
|
|
"showHostTags",
|
|
"hostTrayOnClick",
|
|
"compactHostView",
|
|
"pinAppRail",
|
|
"expandAppRailOnHover",
|
|
"defaultSnippetFoldersCollapsed",
|
|
"snippetShowCommands",
|
|
"confirmSnippetExecution",
|
|
"disableUpdateCheck",
|
|
"confirmTabClose",
|
|
"hiddenRailTabs",
|
|
"statusColorScheme",
|
|
"uiPreferences",
|
|
// Layout/view keys that were never snapshotted, so switching to cloud
|
|
// and back silently reset them.
|
|
"dashboardTab.slots",
|
|
"dashboardTab.mainWidthPct",
|
|
"termix-terminal-toolbar-density",
|
|
"fileManagerViewMode",
|
|
];
|
|
const snap: Record<string, string | null> = { __theme: theme };
|
|
for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key);
|
|
localSnapshot.current = snap;
|
|
localStorage.setItem("termix-local-snapshot", JSON.stringify(snap));
|
|
|
|
try {
|
|
const prefs = await getUserPreferences();
|
|
if (prefs.theme) setTheme(prefs.theme as ThemeId);
|
|
if (prefs.fontSize) {
|
|
setFontSize(prefs.fontSize as FontSizeId);
|
|
applyFontSize(prefs.fontSize as FontSizeId);
|
|
}
|
|
if (prefs.accentColor) {
|
|
setAccentColor(prefs.accentColor);
|
|
setCustomColorInput(prefs.accentColor);
|
|
localStorage.setItem("termix-accent", prefs.accentColor);
|
|
applyAccentColor(prefs.accentColor);
|
|
}
|
|
if (prefs.language) {
|
|
const language = await changeAppLanguage(prefs.language);
|
|
setLanguage(language);
|
|
}
|
|
if (prefs.commandAutocomplete != null) {
|
|
setCommandAutocomplete(prefs.commandAutocomplete);
|
|
localStorage.setItem(
|
|
"commandAutocomplete",
|
|
String(prefs.commandAutocomplete),
|
|
);
|
|
}
|
|
if (prefs.commandPaletteEnabled != null) {
|
|
setCommandPaletteEnabled(prefs.commandPaletteEnabled);
|
|
localStorage.setItem(
|
|
"commandPaletteShortcutEnabled",
|
|
String(prefs.commandPaletteEnabled),
|
|
);
|
|
}
|
|
if (prefs.aiAssistantEnabled != null) {
|
|
setAiAssistantEnabled(prefs.aiAssistantEnabled);
|
|
}
|
|
if (prefs.aiReadOnlyCommands != null) {
|
|
setAiReadOnlyCommands(prefs.aiReadOnlyCommands);
|
|
}
|
|
// showHostTags/hostTrayOnClick/compactHostView/statusColorScheme are
|
|
// no longer restored here -- useHostSidebarPreferences independently
|
|
// fetches its own authoritative copy from /host-sidebar/preferences.
|
|
if (prefs.pinAppRail != null) {
|
|
setPinAppRail(prefs.pinAppRail);
|
|
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
|
|
window.dispatchEvent(new Event("pinAppRailChanged"));
|
|
}
|
|
if (prefs.expandAppRailOnHover != null) {
|
|
setExpandAppRailOnHover(prefs.expandAppRailOnHover);
|
|
localStorage.setItem(
|
|
"expandAppRailOnHover",
|
|
String(prefs.expandAppRailOnHover),
|
|
);
|
|
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
|
}
|
|
if (prefs.foldersCollapsed != null) {
|
|
setFoldersCollapsed(prefs.foldersCollapsed);
|
|
localStorage.setItem(
|
|
"defaultSnippetFoldersCollapsed",
|
|
String(prefs.foldersCollapsed),
|
|
);
|
|
}
|
|
if (prefs.confirmSnippetExecution != null) {
|
|
setConfirmSnippetExecution(prefs.confirmSnippetExecution);
|
|
localStorage.setItem(
|
|
"confirmSnippetExecution",
|
|
String(prefs.confirmSnippetExecution),
|
|
);
|
|
}
|
|
if (prefs.disableUpdateCheck != null) {
|
|
setDisableUpdateCheck(prefs.disableUpdateCheck);
|
|
localStorage.setItem(
|
|
"disableUpdateCheck",
|
|
String(prefs.disableUpdateCheck),
|
|
);
|
|
}
|
|
if (prefs.confirmTabClose != null) {
|
|
setConfirmTabClose(prefs.confirmTabClose);
|
|
localStorage.setItem(
|
|
"confirmTabClose",
|
|
String(prefs.confirmTabClose),
|
|
);
|
|
}
|
|
if (prefs.hiddenRailTabs != null) {
|
|
const s = new Set<string>(JSON.parse(prefs.hiddenRailTabs));
|
|
setHiddenRailTabs(s);
|
|
localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs);
|
|
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
|
|
}
|
|
} catch {
|
|
// leave UI as-is on error
|
|
}
|
|
saveToCloud({ storageMode: "cloud" });
|
|
} else {
|
|
restoreLocalSnapshot();
|
|
saveToCloud({ storageMode: "local" });
|
|
}
|
|
}
|
|
|
|
function resetToDefaults() {
|
|
clearLocalAdaptivePreferences();
|
|
const DEFAULT_ACCENT = "#f59145";
|
|
setTheme("system");
|
|
setFontSize("md");
|
|
applyFontSize("md");
|
|
setUiFont("jetbrains-mono");
|
|
applyUiFont("jetbrains-mono");
|
|
setAccentColor(DEFAULT_ACCENT);
|
|
setCustomColorInput(DEFAULT_ACCENT);
|
|
localStorage.setItem("termix-accent", DEFAULT_ACCENT);
|
|
applyAccentColor(DEFAULT_ACCENT);
|
|
setLanguage("en");
|
|
void changeAppLanguage("en");
|
|
setCommandAutocomplete(false);
|
|
localStorage.setItem("commandAutocomplete", "false");
|
|
setCommandPaletteEnabled(true);
|
|
localStorage.setItem("commandPaletteShortcutEnabled", "true");
|
|
updateSidebarPrefs((prev) => ({
|
|
...prev,
|
|
display: {
|
|
...prev.display,
|
|
showTags: true,
|
|
trayTrigger: "hover",
|
|
density: "comfortable",
|
|
statusColorScheme: "accent",
|
|
},
|
|
}));
|
|
setPinAppRail(false);
|
|
localStorage.setItem("pinAppRail", "false");
|
|
window.dispatchEvent(new Event("pinAppRailChanged"));
|
|
setExpandAppRailOnHover(true);
|
|
localStorage.setItem("expandAppRailOnHover", "true");
|
|
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
|
setFoldersCollapsed(true);
|
|
localStorage.removeItem("defaultSnippetFoldersCollapsed");
|
|
setConfirmSnippetExecution(false);
|
|
localStorage.setItem("confirmSnippetExecution", "false");
|
|
setDisableUpdateCheck(false);
|
|
localStorage.setItem("disableUpdateCheck", "false");
|
|
setConfirmTabClose(false);
|
|
localStorage.setItem("confirmTabClose", "false");
|
|
setHiddenRailTabs(new Set());
|
|
localStorage.removeItem("hiddenRailTabs");
|
|
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
|
|
// Back to the preset that matches the pre-preset UI, with nothing pinned.
|
|
uiPrefs?.setPreset("balanced");
|
|
uiPrefs?.clearAllOverrides();
|
|
if (storageMode === "cloud") {
|
|
saveToCloud({
|
|
theme: "system",
|
|
fontSize: "md",
|
|
accentColor: DEFAULT_ACCENT,
|
|
language: "en",
|
|
commandAutocomplete: false,
|
|
commandPaletteEnabled: true,
|
|
pinAppRail: false,
|
|
expandAppRailOnHover: true,
|
|
foldersCollapsed: true,
|
|
confirmSnippetExecution: false,
|
|
disableUpdateCheck: false,
|
|
confirmTabClose: false,
|
|
hiddenRailTabs: "[]",
|
|
});
|
|
}
|
|
localSnapshot.current = {};
|
|
localStorage.removeItem("termix-local-snapshot");
|
|
toast.success(t("newUi.sidebar.userProfile.resetToDefaultsSuccess"));
|
|
}
|
|
|
|
function restoreLocalSnapshot() {
|
|
// Prefer the persisted snapshot (survives new tabs/reloads), fall back to in-memory ref.
|
|
// If neither exists there were never any browser values to restore, so use current localStorage.
|
|
const persisted = localStorage.getItem("termix-local-snapshot");
|
|
const snap: Record<string, string | null> = persisted
|
|
? (JSON.parse(persisted) as Record<string, string | null>)
|
|
: localSnapshot.current;
|
|
const hasSnap = Object.keys(snap).length > 0;
|
|
const restore = (key: string, fallback: string | null = null) =>
|
|
hasSnap
|
|
? snap[key] !== undefined
|
|
? snap[key]
|
|
: fallback
|
|
: (localStorage.getItem(key) ?? fallback);
|
|
|
|
const restoredTheme =
|
|
((hasSnap ? snap["__theme"] : theme) as ThemeId) ?? "system";
|
|
setTheme(restoredTheme);
|
|
|
|
const restoredFontSize =
|
|
(restore("termix-font-size", "md") as FontSizeId) ?? "md";
|
|
setFontSize(restoredFontSize);
|
|
applyFontSize(restoredFontSize);
|
|
|
|
const restoredUiFont =
|
|
(restore("termix-ui-font", "jetbrains-mono") as UiFontId) ??
|
|
"jetbrains-mono";
|
|
setUiFont(restoredUiFont);
|
|
applyUiFont(restoredUiFont);
|
|
|
|
const restoredAccent = restore("termix-accent", "#f59145") ?? "#f59145";
|
|
setAccentColor(restoredAccent);
|
|
setCustomColorInput(restoredAccent);
|
|
localStorage.setItem("termix-accent", restoredAccent);
|
|
applyAccentColor(restoredAccent);
|
|
|
|
const restoredLang = normalizeLanguageCode(restore("i18nextLng", "en"));
|
|
setLanguage(restoredLang);
|
|
void changeAppLanguage(restoredLang);
|
|
|
|
const restoredAutocomplete =
|
|
restore("commandAutocomplete", "false") === "true";
|
|
setCommandAutocomplete(restoredAutocomplete);
|
|
localStorage.setItem("commandAutocomplete", String(restoredAutocomplete));
|
|
|
|
const restoredPalette =
|
|
restore("commandPaletteShortcutEnabled", "true") !== "false";
|
|
setCommandPaletteEnabled(restoredPalette);
|
|
localStorage.setItem(
|
|
"commandPaletteShortcutEnabled",
|
|
String(restoredPalette),
|
|
);
|
|
|
|
// showHostTags/hostTrayOnClick/compactHostView/statusColorScheme are no
|
|
// longer part of this snapshot -- useHostSidebarPreferences keeps its own
|
|
// localStorage cache independent of storageMode switches.
|
|
|
|
const restoredPinRail = restore("pinAppRail", "false") === "true";
|
|
setPinAppRail(restoredPinRail);
|
|
localStorage.setItem("pinAppRail", String(restoredPinRail));
|
|
window.dispatchEvent(new Event("pinAppRailChanged"));
|
|
|
|
const restoredExpandRailOnHover =
|
|
restore("expandAppRailOnHover", "true") !== "false";
|
|
setExpandAppRailOnHover(restoredExpandRailOnHover);
|
|
localStorage.setItem(
|
|
"expandAppRailOnHover",
|
|
String(restoredExpandRailOnHover),
|
|
);
|
|
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
|
|
|
const restoredFolders =
|
|
restore("defaultSnippetFoldersCollapsed", null) !== "false";
|
|
setFoldersCollapsed(restoredFolders);
|
|
const snapFolders = hasSnap
|
|
? snap["defaultSnippetFoldersCollapsed"]
|
|
: localStorage.getItem("defaultSnippetFoldersCollapsed");
|
|
if (snapFolders == null) {
|
|
localStorage.removeItem("defaultSnippetFoldersCollapsed");
|
|
} else {
|
|
localStorage.setItem("defaultSnippetFoldersCollapsed", snapFolders);
|
|
}
|
|
|
|
const restoredConfirmSnippet =
|
|
restore("confirmSnippetExecution", "false") === "true";
|
|
setConfirmSnippetExecution(restoredConfirmSnippet);
|
|
localStorage.setItem(
|
|
"confirmSnippetExecution",
|
|
String(restoredConfirmSnippet),
|
|
);
|
|
|
|
const restoredUpdateCheck =
|
|
restore("disableUpdateCheck", "false") === "true";
|
|
setDisableUpdateCheck(restoredUpdateCheck);
|
|
localStorage.setItem("disableUpdateCheck", String(restoredUpdateCheck));
|
|
|
|
const restoredConfirmTab = restore("confirmTabClose", "false") === "true";
|
|
setConfirmTabClose(restoredConfirmTab);
|
|
localStorage.setItem("confirmTabClose", String(restoredConfirmTab));
|
|
|
|
const restoredHiddenRaw = hasSnap
|
|
? snap["hiddenRailTabs"]
|
|
: localStorage.getItem("hiddenRailTabs");
|
|
if (restoredHiddenRaw == null) {
|
|
setHiddenRailTabs(new Set());
|
|
localStorage.removeItem("hiddenRailTabs");
|
|
} else {
|
|
try {
|
|
setHiddenRailTabs(new Set(JSON.parse(restoredHiddenRaw)));
|
|
} catch {
|
|
setHiddenRailTabs(new Set());
|
|
}
|
|
localStorage.setItem("hiddenRailTabs", restoredHiddenRaw);
|
|
}
|
|
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
|
|
|
|
localStorage.removeItem("termix-local-snapshot");
|
|
localSnapshot.current = {};
|
|
}
|
|
|
|
function handleThemeChange(id: ThemeId) {
|
|
setTheme(id);
|
|
if (storageMode === "cloud") saveToCloud({ theme: id });
|
|
}
|
|
|
|
function handleAccentChange(value: string) {
|
|
setAccentColor(value);
|
|
setCustomColorInput(value);
|
|
localStorage.setItem("termix-accent", value);
|
|
applyAccentColor(value);
|
|
if (storageMode === "cloud") saveToCloud({ accentColor: value });
|
|
}
|
|
|
|
function handleFontSizeChange(id: FontSizeId) {
|
|
setFontSize(id);
|
|
applyFontSize(id);
|
|
if (storageMode === "cloud") saveToCloud({ fontSize: id });
|
|
}
|
|
|
|
function handleUiFontChange(id: UiFontId) {
|
|
setUiFont(id);
|
|
applyUiFont(id);
|
|
}
|
|
|
|
function handleLanguageChange(code: string) {
|
|
void changeAppLanguage(code)
|
|
.then((language) => {
|
|
setLanguage(language);
|
|
if (storageMode === "cloud") saveToCloud({ language });
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function toggle(id: UserProfileSection) {
|
|
setOpenSections((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
async function handleStartTotpSetup() {
|
|
setTotpLoading(true);
|
|
try {
|
|
const result = await setupTOTP();
|
|
setTotpQrCode(result.qr_code);
|
|
setTotpSecret(result.secret);
|
|
setTotpCode("");
|
|
setTotpStep("setup");
|
|
} catch {
|
|
toast.error(t("newUi.sidebar.userProfile.totpSetupFailed"));
|
|
} finally {
|
|
setTotpLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleAddTotpAuthenticator() {
|
|
if (!addTotpInput) {
|
|
toast.error(t("newUi.sidebar.userProfile.totpAddInputRequired"));
|
|
return;
|
|
}
|
|
setTotpLoading(true);
|
|
try {
|
|
const result = await setupTOTP(addTotpInput);
|
|
setTotpQrCode(result.qr_code);
|
|
setTotpSecret(result.secret);
|
|
setAddingTotpAuthenticator(true);
|
|
setShowAddTotp(false);
|
|
setAddTotpInput("");
|
|
setTotpStep("setup");
|
|
} catch (e: unknown) {
|
|
toast.error(
|
|
apiErrorMessage(e, t("newUi.sidebar.userProfile.totpAddFailed")),
|
|
);
|
|
} finally {
|
|
setTotpLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleVerifyTotp() {
|
|
if (!totpCode || totpCode.length !== 6) {
|
|
toast.error(t("newUi.sidebar.userProfile.totpEnter6Digits"));
|
|
return;
|
|
}
|
|
setTotpLoading(true);
|
|
try {
|
|
const result = await enableTOTP(totpCode);
|
|
setTotpBackupCodes(result.backup_codes ?? []);
|
|
setTotpEnabled(true);
|
|
if (!isRemoteSyncConnected) setAccountTotpEnabled(true);
|
|
setTotpStep("backup");
|
|
toast.success(t("newUi.sidebar.userProfile.totpEnabledSuccess"));
|
|
} catch (e: unknown) {
|
|
toast.error(
|
|
apiErrorMessage(e, t("newUi.sidebar.userProfile.totpInvalidCode")),
|
|
);
|
|
} finally {
|
|
setTotpLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleDisableTotp() {
|
|
if (!disableTotpInput) {
|
|
toast.error(t("newUi.sidebar.userProfile.totpDisableInputRequired"));
|
|
return;
|
|
}
|
|
setTotpLoading(true);
|
|
try {
|
|
await disableTOTP(disableTotpInput);
|
|
setTotpEnabled(false);
|
|
if (!isRemoteSyncConnected) setAccountTotpEnabled(false);
|
|
setShowDisableTotp(false);
|
|
setDisableTotpInput("");
|
|
toast.success(t("newUi.sidebar.userProfile.totpDisabledSuccess"));
|
|
} catch (e: unknown) {
|
|
toast.error(
|
|
apiErrorMessage(e, t("newUi.sidebar.userProfile.totpDisableFailed")),
|
|
);
|
|
} finally {
|
|
setTotpLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleRegisterPasskey() {
|
|
setPasskeyLoading(true);
|
|
try {
|
|
await registerWebAuthnCredential(
|
|
passkeyName || "Passkey",
|
|
passkeyUserVerification,
|
|
);
|
|
const { credentials } = await listWebAuthnCredentials();
|
|
setPasskeys(credentials ?? []);
|
|
setPasskeyName("");
|
|
toast.success(t("newUi.sidebar.userProfile.passkeyAdded"));
|
|
} catch (e: unknown) {
|
|
toast.error(
|
|
apiErrorMessage(e, t("newUi.sidebar.userProfile.passkeyAddFailed")),
|
|
);
|
|
} finally {
|
|
setPasskeyLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeletePasskey(credentialId: string) {
|
|
setPasskeyLoading(true);
|
|
try {
|
|
await deleteWebAuthnCredential(credentialId);
|
|
setPasskeys((prev) => prev.filter((item) => item.id !== credentialId));
|
|
toast.success(t("newUi.sidebar.userProfile.passkeyDeleted"));
|
|
} catch (e: unknown) {
|
|
toast.error(
|
|
apiErrorMessage(e, t("newUi.sidebar.userProfile.passkeyDeleteFailed")),
|
|
);
|
|
} finally {
|
|
setPasskeyLoading(false);
|
|
}
|
|
}
|
|
|
|
function downloadBackupCodes() {
|
|
const blob = new Blob([totpBackupCodes.join("\n")], { type: "text/plain" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "termix-backup-codes.txt";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
async function handleDeleteAccount() {
|
|
if (!deletePassword.trim()) {
|
|
toast.error(t("newUi.sidebar.userProfile.deletePasswordRequired"));
|
|
return;
|
|
}
|
|
setDeleteLoading(true);
|
|
try {
|
|
await deleteAccount(deletePassword);
|
|
await logoutUser().catch(() => {});
|
|
window.location.reload();
|
|
} catch (e: unknown) {
|
|
toast.error(
|
|
apiErrorMessage(e, t("newUi.sidebar.userProfile.deleteFailed")),
|
|
);
|
|
setDeleteLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleExportData() {
|
|
setExportLoading(true);
|
|
try {
|
|
const apiUrl = getDatabaseTransferUrl("export", {
|
|
electron: isElectron(),
|
|
configuredServerUrl: null,
|
|
location: window.location,
|
|
});
|
|
|
|
const response = await fetch(apiUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const blob = await response.blob();
|
|
const contentDisposition = response.headers.get("content-disposition");
|
|
const filename =
|
|
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
|
|
"termix-export.sqlite";
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
toast.success(t("newUi.sidebar.userProfile.exportSuccess"));
|
|
} else {
|
|
const err = await response.json().catch(() => ({}));
|
|
toast.error(err.error || t("newUi.sidebar.userProfile.exportFailed"));
|
|
}
|
|
} catch {
|
|
toast.error(t("newUi.sidebar.userProfile.exportFailed"));
|
|
} finally {
|
|
setExportLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleImportData() {
|
|
if (!importFile) {
|
|
toast.error(t("newUi.sidebar.userProfile.importSelectFile"));
|
|
return;
|
|
}
|
|
setImportLoading(true);
|
|
try {
|
|
const apiUrl = getDatabaseTransferUrl("import", {
|
|
electron: isElectron(),
|
|
configuredServerUrl: null,
|
|
location: window.location,
|
|
});
|
|
|
|
const formData = new FormData();
|
|
formData.append("file", importFile);
|
|
|
|
const response = await fetch(apiUrl, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
const s = result.summary;
|
|
const total =
|
|
(s.sshHostsImported || 0) +
|
|
(s.sshCredentialsImported || 0) +
|
|
(s.fileManagerItemsImported || 0) +
|
|
(s.dismissedAlertsImported || 0) +
|
|
(s.settingsImported || 0);
|
|
toast.success(
|
|
t("newUi.sidebar.userProfile.importCompleted", {
|
|
total,
|
|
skipped: s.skippedItems || 0,
|
|
}),
|
|
);
|
|
setImportFile(null);
|
|
setTimeout(() => window.location.reload(), 1500);
|
|
} else {
|
|
toast.error(
|
|
t("newUi.sidebar.userProfile.importFailed", {
|
|
error: result.summary?.errors?.join(", ") || "Unknown error",
|
|
}),
|
|
);
|
|
}
|
|
} else {
|
|
const err = await response.json().catch(() => ({}));
|
|
toast.error(
|
|
t("newUi.sidebar.userProfile.importFailed", {
|
|
error: err.error || "Unknown error",
|
|
}),
|
|
);
|
|
}
|
|
} catch {
|
|
toast.error(
|
|
t("newUi.sidebar.userProfile.importFailed", {
|
|
error: "Unknown error",
|
|
}),
|
|
);
|
|
} finally {
|
|
setImportLoading(false);
|
|
}
|
|
}
|
|
|
|
const canChangePasword = !isOidc || isDualAuth;
|
|
|
|
return (
|
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3">
|
|
<NewApiKeyDialog
|
|
open={newKeyOpen}
|
|
onOpenChange={setNewKeyOpen}
|
|
onAdd={(key) => setApiKeys((prev) => [key as ApiKey, ...prev])}
|
|
userId={userId}
|
|
/>
|
|
|
|
{/* Donate banner */}
|
|
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
|
|
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
|
|
{t("newUi.sidebar.userProfile.donateTitle")}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
|
{t("newUi.sidebar.userProfile.donateDescription")}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
|
{t("newUi.sidebar.userProfile.donateMilestones")}
|
|
</p>
|
|
<a
|
|
href="https://donate.termix.site/"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest bg-accent-brand text-white px-2 py-1 hover:opacity-90 transition-opacity"
|
|
>
|
|
{t("newUi.sidebar.userProfile.donateButton")}
|
|
</a>
|
|
</div>
|
|
|
|
{/* Storage mode toggle — only meaningful once a remote server is
|
|
connected; with no sync there's nowhere for "cloud" to sync to,
|
|
so this stays forced to local storage and hidden. */}
|
|
{(!isElectron() || isRemoteSyncConnected === true) && (
|
|
<div className="border border-border bg-card px-3 py-2.5 flex flex-col gap-2">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.storageModeSwitch")}
|
|
</span>
|
|
<div className="flex border border-border overflow-hidden w-full">
|
|
<button
|
|
onClick={() => handleStorageModeChange("local")}
|
|
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
|
storageMode === "local"
|
|
? "bg-accent-brand text-white"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
|
}`}
|
|
>
|
|
{t("newUi.sidebar.userProfile.storageModeLocal")}
|
|
</button>
|
|
<button
|
|
onClick={() => handleStorageModeChange("cloud")}
|
|
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
|
storageMode === "cloud"
|
|
? "bg-accent-brand text-white"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
|
}`}
|
|
>
|
|
{t("newUi.sidebar.userProfile.storageModeCloud")}
|
|
</button>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
|
{t("newUi.sidebar.userProfile.storageModeDescription")}
|
|
</p>
|
|
<button
|
|
onClick={resetToDefaults}
|
|
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<RotateCcw className="size-3" />
|
|
{t("newUi.sidebar.userProfile.resetToDefaults")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Account */}
|
|
<AccordionSection
|
|
id="account"
|
|
label={t("newUi.sidebar.userProfile.sectionAccount")}
|
|
icon={<User className="size-3.5" />}
|
|
open={openSections.has("account")}
|
|
onToggle={() => toggle("account")}
|
|
>
|
|
<div className="flex flex-col gap-0 pt-2">
|
|
{isElectron() ? (
|
|
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 mb-2">
|
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-accent-brand">
|
|
<ShieldCheck className="size-3.5" />
|
|
{t("newUi.sidebar.userProfile.desktopProfileTitle")}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground leading-relaxed mt-1">
|
|
{t("newUi.sidebar.userProfile.desktopProfileDescription")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-0">
|
|
<div className="flex flex-col py-2">
|
|
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
|
{t("newUi.sidebar.userProfile.usernameLabel")}
|
|
</span>
|
|
<span className="text-sm font-semibold mt-0.5">
|
|
{accountUsername || "—"}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col py-2">
|
|
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
|
{t("newUi.sidebar.userProfile.roleLabel")}
|
|
</span>
|
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
|
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-accent-brand/40 bg-accent-brand/10 text-accent-brand w-fit">
|
|
{userRole || "—"}
|
|
</span>
|
|
{userRoles.map((r) => (
|
|
<span
|
|
key={r.roleId}
|
|
className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-border bg-muted text-muted-foreground w-fit"
|
|
>
|
|
{r.roleDisplayName}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col py-2">
|
|
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
|
{t("newUi.sidebar.userProfile.authMethodLabel")}
|
|
</span>
|
|
<span className="text-sm font-semibold mt-0.5">
|
|
{authMethod || "—"}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col py-2">
|
|
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
|
{t("newUi.sidebar.userProfile.twoFaLabel")}
|
|
</span>
|
|
<span className="flex items-center gap-1 mt-0.5">
|
|
{accountTotpEnabled ? (
|
|
<>
|
|
<ShieldCheck className="size-3.5 text-accent-brand" />
|
|
<span className="text-sm font-semibold text-accent-brand">
|
|
{t("newUi.sidebar.userProfile.twoFaOn")}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="text-sm font-semibold text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.twoFaOff")}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="border-t border-border pt-3 mt-1">
|
|
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
|
{t("newUi.sidebar.userProfile.versionLabel")}
|
|
</span>
|
|
<div className="flex items-center justify-between mt-1.5">
|
|
<span className="text-sm font-bold text-accent-brand">
|
|
{version ? `v${version}` : "—"}
|
|
</span>
|
|
<VersionBadge status={versionStatus} releaseUrl={releaseUrl} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-3 mt-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-xs font-medium">
|
|
{t("newUi.sidebar.userProfile.betaProgramTitle")}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.betaProgramDescription")}{" "}
|
|
<a
|
|
href="https://github.com/Termix-SSH/Support/issues/new?template=beta_feedback.yml"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-accent-brand hover:underline"
|
|
>
|
|
{t("newUi.sidebar.userProfile.betaProgramFeedback")}
|
|
</a>
|
|
</span>
|
|
</div>
|
|
<a
|
|
href="https://docs.termix.site/install/server/docker#beta-builds"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
|
|
>
|
|
{t("newUi.sidebar.userProfile.betaProgramLearnMore")}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-3 mt-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-xs font-medium">
|
|
{t("newUi.sidebar.userProfile.cliTitle")}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.cliDescription")}
|
|
</span>
|
|
</div>
|
|
<a
|
|
href="https://docs.termix.site/cli"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
|
|
>
|
|
{t("newUi.sidebar.userProfile.cliLearnMore")}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{isElectron() && (
|
|
<div className="border-t border-border pt-3 mt-3">
|
|
<RemoteSyncPanel initialServerUrl={remoteSyncInitialServerUrl} />
|
|
</div>
|
|
)}
|
|
|
|
{!isElectron() && (
|
|
<div className="border-t border-border pt-3 mt-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-xs font-medium text-destructive">
|
|
{t("newUi.sidebar.userProfile.deleteAccount")}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.deleteAccountDescription")}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-3 text-[10px] h-7"
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
>
|
|
{t("newUi.sidebar.userProfile.deleteButton")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</AccordionSection>
|
|
|
|
{/* Interface */}
|
|
<AccordionSection
|
|
id="interface"
|
|
label={t("newUi.sidebar.userProfile.sectionInterface")}
|
|
icon={<LayoutTemplate size={13} />}
|
|
open={openSections.has("interface")}
|
|
onToggle={() => toggle("interface")}
|
|
>
|
|
<div className="flex flex-col gap-4 pt-3">
|
|
<InterfacePresetSettings
|
|
onRunSetupAgain={() =>
|
|
window.dispatchEvent(new Event("termix:open-onboarding"))
|
|
}
|
|
/>
|
|
</div>
|
|
</AccordionSection>
|
|
|
|
{/* Appearance */}
|
|
<AccordionSection
|
|
id="appearance"
|
|
label={t("newUi.sidebar.userProfile.sectionAppearance")}
|
|
icon={<Palette className="size-3.5" />}
|
|
open={openSections.has("appearance")}
|
|
onToggle={() => toggle("appearance")}
|
|
>
|
|
<div className="flex flex-col gap-4 pt-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.languageLabel")}
|
|
</span>
|
|
<select
|
|
value={language}
|
|
onChange={(e) => handleLanguageChange(e.target.value)}
|
|
className="px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring w-full"
|
|
>
|
|
{LANGUAGES.map((lang) => (
|
|
<option key={lang.code} value={lang.code}>
|
|
{lang.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.themeLabel")}
|
|
</span>
|
|
<div className="relative">
|
|
<select
|
|
value={theme}
|
|
onChange={(e) => handleThemeChange(e.target.value as ThemeId)}
|
|
className="w-full px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring appearance-none pr-7"
|
|
>
|
|
{THEMES.map((th) => (
|
|
<option key={th.id} value={th.id}>
|
|
{themeLabel[th.id]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 size-3 text-muted-foreground pointer-events-none" />
|
|
</div>
|
|
<div className="flex gap-1 mt-0.5">
|
|
{THEMES.filter((th) => th.id !== "system").map((th) => (
|
|
<button
|
|
key={th.id}
|
|
title={themeLabel[th.id]}
|
|
onClick={() => handleThemeChange(th.id)}
|
|
className={`h-4 flex-1 border transition-all ${theme === th.id ? "border-accent-brand ring-1 ring-accent-brand" : "border-border/50"}`}
|
|
style={{ background: th.preview }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
|
<Type className="size-3" />
|
|
{t("newUi.sidebar.userProfile.interfaceFontLabel")}
|
|
</span>
|
|
<select
|
|
value={uiFont}
|
|
onChange={(event) =>
|
|
handleUiFontChange(event.target.value as UiFontId)
|
|
}
|
|
className="px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring w-full"
|
|
>
|
|
{UI_FONTS.map((font) => (
|
|
<option key={font.id} value={font.id}>
|
|
{font.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.interfaceFontDescription")}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
|
<Type className="size-3" />
|
|
{t("newUi.sidebar.userProfile.fontSizeLabel")}
|
|
</span>
|
|
<div className="flex gap-1">
|
|
{FONT_SIZES.map((fs) => (
|
|
<button
|
|
key={fs.id}
|
|
onClick={() => handleFontSizeChange(fs.id)}
|
|
className={`flex-1 py-1.5 border text-[10px] font-bold transition-colors ${
|
|
fontSize === fs.id
|
|
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
|
: "border-border text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{fs.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.accentColorLabel")}
|
|
</span>
|
|
<div className="grid grid-cols-6 gap-1">
|
|
{ACCENT_PRESET_COLORS.map((ac) => (
|
|
<button
|
|
key={ac.value}
|
|
title={ac.label}
|
|
onClick={() => handleAccentChange(ac.value)}
|
|
className={`h-6 border-2 transition-all ${
|
|
accentColor === ac.value
|
|
? "border-foreground scale-110"
|
|
: "border-transparent hover:border-foreground/40"
|
|
}`}
|
|
style={{ background: ac.value }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-1.5">
|
|
<button
|
|
onClick={() => colorInputRef.current?.click()}
|
|
className="size-5 shrink-0 border border-border/60 cursor-pointer"
|
|
style={{ background: accentColor }}
|
|
title={t("newUi.sidebar.userProfile.colorPickerTooltip")}
|
|
/>
|
|
<input
|
|
ref={colorInputRef}
|
|
type="color"
|
|
value={accentColor.startsWith("#") ? accentColor : "#f97316"}
|
|
onChange={(e) => handleAccentChange(e.target.value)}
|
|
className="sr-only"
|
|
/>
|
|
<Input
|
|
value={customColorInput}
|
|
onChange={(e) => setCustomColorInput(e.target.value)}
|
|
onBlur={() => {
|
|
const v = customColorInput.trim();
|
|
if (
|
|
/^#[0-9a-fA-F]{6}$/.test(v) ||
|
|
/^#[0-9a-fA-F]{3}$/.test(v)
|
|
) {
|
|
handleAccentChange(v);
|
|
} else {
|
|
setCustomColorInput(accentColor);
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
const v = customColorInput.trim();
|
|
if (
|
|
/^#[0-9a-fA-F]{6}$/.test(v) ||
|
|
/^#[0-9a-fA-F]{3}$/.test(v)
|
|
) {
|
|
handleAccentChange(v);
|
|
}
|
|
}
|
|
}}
|
|
placeholder="#f97316"
|
|
className="h-6 text-[11px] font-mono border-0 bg-transparent p-0 focus-visible:ring-0 flex-1 min-w-0"
|
|
/>
|
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
hex
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
|
|
{t("newUi.sidebar.userProfile.settingsTerminal")}
|
|
</span>
|
|
<ConnectionDefaultsSettings />
|
|
<SettingRow
|
|
label={t("newUi.sidebar.userProfile.commandAutocomplete")}
|
|
description={t(
|
|
"newUi.sidebar.userProfile.commandAutocompleteDesc",
|
|
)}
|
|
>
|
|
<FakeSwitch
|
|
checked={commandAutocomplete}
|
|
onChange={(v) => {
|
|
setCommandAutocomplete(v);
|
|
localStorage.setItem("commandAutocomplete", v.toString());
|
|
if (storageMode === "cloud")
|
|
saveToCloud({ commandAutocomplete: v });
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
<div className="flex flex-col gap-1.5 py-3 border-b border-border">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-sm font-medium leading-snug">
|
|
{t("newUi.sidebar.userProfile.localEcho")}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground leading-snug">
|
|
{t("newUi.sidebar.userProfile.localEchoDesc")}
|
|
</span>
|
|
</div>
|
|
<select
|
|
defaultValue={
|
|
localStorage.getItem("terminalLocalEchoMode") ?? "auto"
|
|
}
|
|
onChange={(e) =>
|
|
localStorage.setItem("terminalLocalEchoMode", e.target.value)
|
|
}
|
|
className="h-7 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
<option value="off">{t("hosts.localEchoOff")}</option>
|
|
<option value="auto">{t("hosts.localEchoAuto")}</option>
|
|
<option value="on">{t("hosts.localEchoOn")}</option>
|
|
</select>
|
|
</div>
|
|
<SettingRow
|
|
label={t("newUi.sidebar.userProfile.keyboardShortcuts")}
|
|
description={t(
|
|
"newUi.sidebar.userProfile.keyboardShortcutsDescription",
|
|
)}
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setKeybindingsDialogOpen(true)}
|
|
>
|
|
{t("newUi.sidebar.userProfile.manageShortcuts")}
|
|
</Button>
|
|
</SettingRow>
|
|
<div className="flex flex-col gap-1.5 py-3 border-b border-border">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-sm font-medium leading-snug">
|
|
{t("newUi.sidebar.userProfile.terminalLinkBehavior")}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground leading-snug">
|
|
{t("newUi.sidebar.userProfile.terminalLinkBehaviorDesc")}
|
|
</span>
|
|
</div>
|
|
<select
|
|
value={terminalLinkClickBehavior}
|
|
onChange={(e) => {
|
|
setTerminalLinkClickBehavior(e.target.value);
|
|
localStorage.setItem(
|
|
"terminalLinkClickBehavior",
|
|
e.target.value,
|
|
);
|
|
}}
|
|
className="h-7 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
<option value="confirm">
|
|
{t("hosts.linkClickBehaviorConfirm")}
|
|
</option>
|
|
<option value="direct">
|
|
{t("hosts.linkClickBehaviorDirect")}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<SettingRow
|
|
label={t("newUi.sidebar.userProfile.commandPalette")}
|
|
description={t("newUi.sidebar.userProfile.commandPaletteDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={commandPaletteEnabled}
|
|
onChange={(v) => {
|
|
setCommandPaletteEnabled(v);
|
|
localStorage.setItem(
|
|
"commandPaletteShortcutEnabled",
|
|
v.toString(),
|
|
);
|
|
window.dispatchEvent(
|
|
new Event("commandPaletteShortcutEnabledChanged"),
|
|
);
|
|
if (storageMode === "cloud")
|
|
saveToCloud({ commandPaletteEnabled: v });
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
{aiGloballyEnabled && (
|
|
<>
|
|
<SettingRow
|
|
label={t("ai.enableTitle")}
|
|
description={t("ai.enableDescription")}
|
|
>
|
|
<FakeSwitch
|
|
checked={aiAssistantEnabled}
|
|
onChange={applyAiEnabled}
|
|
/>
|
|
</SettingRow>
|
|
{aiAssistantEnabled && (
|
|
<SettingRow
|
|
label={t("ai.readOnlyCommands")}
|
|
description={t("ai.readOnlyCommandsDescription")}
|
|
>
|
|
<FakeSwitch
|
|
checked={aiReadOnlyCommands}
|
|
onChange={(v) => {
|
|
setAiReadOnlyCommands(v);
|
|
if (storageMode === "cloud")
|
|
saveToCloud({ aiReadOnlyCommands: v });
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
)}
|
|
</>
|
|
)}
|
|
<SettingRow
|
|
label={t("newUi.sidebar.userProfile.reopenTabsOnLogin")}
|
|
description={t("newUi.sidebar.userProfile.reopenTabsOnLoginDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={userPrefs?.reopenTabsOnLogin ?? false}
|
|
onChange={(v) => {
|
|
onPrefsChange?.({ reopenTabsOnLogin: v });
|
|
import("@/main-axios").then(({ saveUserPreferences }) => {
|
|
saveUserPreferences({ reopenTabsOnLogin: v }).catch(
|
|
() => {},
|
|
);
|
|
});
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("newUi.sidebar.userProfile.confirmTabClose")}
|
|
description={t("newUi.sidebar.userProfile.confirmTabCloseDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={confirmTabClose}
|
|
onChange={(v) => {
|
|
setConfirmTabClose(v);
|
|
localStorage.setItem("confirmTabClose", v.toString());
|
|
if (storageMode === "cloud")
|
|
saveToCloud({ confirmTabClose: v });
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
|
|
{t("newUi.sidebar.userProfile.settingsAppRail")}
|
|
</span>
|
|
<p className="text-[10px] text-muted-foreground mb-2">
|
|
{t("newUi.sidebar.userProfile.sidebarSettingsMoved")}
|
|
</p>
|
|
<SettingRow
|
|
label={t("newUi.sidebar.userProfile.pinAppRail")}
|
|
description={t("newUi.sidebar.userProfile.pinAppRailDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={pinAppRail}
|
|
onChange={(v) => {
|
|
setPinAppRail(v);
|
|
setRailPreference("pinAppRail", v);
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("newUi.sidebar.userProfile.expandAppRailOnHover")}
|
|
description={t(
|
|
"newUi.sidebar.userProfile.expandAppRailOnHoverDesc",
|
|
)}
|
|
>
|
|
<FakeSwitch
|
|
checked={expandAppRailOnHover}
|
|
onChange={(v) => {
|
|
setExpandAppRailOnHover(v);
|
|
setRailPreference("expandAppRailOnHover", v);
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
|
|
{t("newUi.sidebar.userProfile.settingsNavigation")}
|
|
</span>
|
|
<p className="text-[10px] text-muted-foreground mb-2">
|
|
{t("newUi.sidebar.userProfile.navigationTabsDesc")}
|
|
</p>
|
|
{visibleRailItems()
|
|
.filter((tab) => tab.id !== "ai" || aiGloballyEnabled)
|
|
.map((tab) => (
|
|
<div
|
|
key={tab.id}
|
|
className="flex items-center justify-between py-1.5"
|
|
>
|
|
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
|
<span className="text-muted-foreground">
|
|
<tab.icon size={12} />
|
|
</span>
|
|
{t(tab.labelKey)}
|
|
</span>
|
|
<FakeSwitch
|
|
checked={!hiddenRailTabs.has(tab.id)}
|
|
onChange={(visible) => {
|
|
const next = new Set(hiddenRailTabs);
|
|
if (visible) next.delete(tab.id);
|
|
else next.add(tab.id);
|
|
setHiddenRailTabs(next);
|
|
const serialized = JSON.stringify([...next]);
|
|
localStorage.setItem("hiddenRailTabs", serialized);
|
|
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
|
|
if (storageMode === "cloud")
|
|
saveToCloud({ hiddenRailTabs: serialized });
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
|
|
{t("newUi.sidebar.userProfile.settingsUpdates")}
|
|
</span>
|
|
<SettingRow
|
|
label={t("newUi.sidebar.userProfile.disableUpdateChecks")}
|
|
description={t(
|
|
"newUi.sidebar.userProfile.disableUpdateChecksDesc",
|
|
)}
|
|
>
|
|
<FakeSwitch
|
|
checked={disableUpdateCheck}
|
|
onChange={(v) => {
|
|
setDisableUpdateCheck(v);
|
|
localStorage.setItem("disableUpdateCheck", v.toString());
|
|
if (storageMode === "cloud")
|
|
saveToCloud({ disableUpdateCheck: v });
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
</div>
|
|
</AccordionSection>
|
|
|
|
{/* The embedded desktop backend auto-authenticates its machine-local
|
|
profile, so server login controls would imply protection they do
|
|
not provide. Remote Sync owns its separate account UI above. */}
|
|
<AccordionSection
|
|
hidden={isElectron()}
|
|
id="security"
|
|
label={t("newUi.sidebar.userProfile.sectionSecurity")}
|
|
icon={<Shield className="size-3.5" />}
|
|
open={openSections.has("security")}
|
|
onToggle={() => toggle("security")}
|
|
>
|
|
<div className="flex flex-col gap-4 pt-3">
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex flex-col gap-0.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-medium">
|
|
{t("newUi.sidebar.userProfile.totpAuthenticator")}
|
|
</span>
|
|
<a
|
|
href="https://docs.termix.site/features/authentication/totp"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-[10px] text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{totpEnabled
|
|
? t("newUi.sidebar.userProfile.totpEnabled")
|
|
: t("newUi.sidebar.userProfile.totpDisabled")}
|
|
</span>
|
|
</div>
|
|
{totpEnabled ? (
|
|
<div className="ml-3 flex shrink-0 gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
onClick={() => setShowAddTotp((open) => !open)}
|
|
disabled={totpLoading || totpStep !== "idle"}
|
|
>
|
|
{t("newUi.sidebar.userProfile.totpAddAuthenticator")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
onClick={() => setShowDisableTotp((o) => !o)}
|
|
>
|
|
{t("newUi.sidebar.userProfile.disable")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="shrink-0 ml-3 text-[10px] h-7 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
onClick={handleStartTotpSetup}
|
|
disabled={totpLoading || totpStep !== "idle"}
|
|
>
|
|
{t("newUi.sidebar.userProfile.enable")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{totpEnabled && showAddTotp && (
|
|
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.totpAddTitle")}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.totpAddDescription")}
|
|
</span>
|
|
<Input
|
|
placeholder={t(
|
|
"newUi.sidebar.userProfile.totpDisablePlaceholder",
|
|
)}
|
|
value={addTotpInput}
|
|
onChange={(e) => setAddTotpInput(e.target.value)}
|
|
onKeyDown={(e) =>
|
|
e.key === "Enter" && handleAddTotpAuthenticator()
|
|
}
|
|
className="text-sm"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="flex-1 text-xs"
|
|
onClick={() => {
|
|
setShowAddTotp(false);
|
|
setAddTotpInput("");
|
|
}}
|
|
>
|
|
{t("newUi.sidebar.userProfile.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
onClick={handleAddTotpAuthenticator}
|
|
disabled={totpLoading}
|
|
>
|
|
{t("common.continue")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Disable TOTP form */}
|
|
{totpEnabled && showDisableTotp && (
|
|
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.totpDisableTitle")}
|
|
</span>
|
|
<Input
|
|
placeholder={t(
|
|
"newUi.sidebar.userProfile.totpDisablePlaceholder",
|
|
)}
|
|
value={disableTotpInput}
|
|
onChange={(e) => setDisableTotpInput(e.target.value)}
|
|
className="text-sm"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="flex-1 text-xs"
|
|
onClick={() => {
|
|
setShowDisableTotp(false);
|
|
setDisableTotpInput("");
|
|
}}
|
|
>
|
|
{t("newUi.sidebar.userProfile.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1 text-xs border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
onClick={handleDisableTotp}
|
|
disabled={totpLoading}
|
|
>
|
|
{t("newUi.sidebar.userProfile.totpDisableConfirm")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* TOTP setup: scan QR */}
|
|
{totpStep === "setup" && (
|
|
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.setupTotp")}
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
setTotpStep("idle");
|
|
setAddingTotpAuthenticator(false);
|
|
}}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
{totpQrCode ? (
|
|
<div className="flex items-center justify-center p-3 bg-background border border-border">
|
|
<img
|
|
src={totpQrCode}
|
|
alt={t("newUi.sidebar.userProfile.qrCode")}
|
|
className="size-32"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center p-3 bg-background border border-border">
|
|
<div className="size-24 bg-muted flex items-center justify-center text-[10px] text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.qrCode")}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 bg-muted/30 border border-border px-2 py-1.5">
|
|
<span className="text-[10px] font-mono flex-1 tracking-widest select-all truncate">
|
|
{totpSecret}
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
copyToClipboard(totpSecret);
|
|
toast.info(t("newUi.sidebar.userProfile.secretCopied"));
|
|
}}
|
|
className="text-muted-foreground hover:text-accent-brand shrink-0"
|
|
>
|
|
<Copy className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground text-center">
|
|
{t(
|
|
addingTotpAuthenticator
|
|
? "newUi.sidebar.userProfile.totpAddScanInstructions"
|
|
: "newUi.sidebar.userProfile.totpInstructions",
|
|
)}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
onClick={() => {
|
|
if (addingTotpAuthenticator) {
|
|
setAddingTotpAuthenticator(false);
|
|
setTotpStep("idle");
|
|
toast.success(
|
|
t("newUi.sidebar.userProfile.totpAddSuccess"),
|
|
);
|
|
} else {
|
|
setTotpStep("verify");
|
|
}
|
|
}}
|
|
>
|
|
{t(
|
|
addingTotpAuthenticator
|
|
? "newUi.sidebar.userProfile.done"
|
|
: "newUi.sidebar.userProfile.totpContinueVerify",
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* TOTP setup: verify code */}
|
|
{!totpEnabled && totpStep === "verify" && (
|
|
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.totpVerifyTitle")}
|
|
</span>
|
|
<button
|
|
onClick={() => setTotpStep("setup")}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
<Input
|
|
placeholder={t(
|
|
"newUi.sidebar.userProfile.totpCodePlaceholder",
|
|
)}
|
|
value={totpCode}
|
|
onChange={(e) =>
|
|
setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))
|
|
}
|
|
onKeyDown={(e) => e.key === "Enter" && handleVerifyTotp()}
|
|
className="text-center font-mono tracking-widest text-lg h-10"
|
|
maxLength={6}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="flex-1 text-xs"
|
|
onClick={() => setTotpStep("setup")}
|
|
>
|
|
{t("newUi.sidebar.userProfile.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
onClick={handleVerifyTotp}
|
|
disabled={totpLoading || totpCode.length !== 6}
|
|
>
|
|
<CheckCircle2 className="size-3.5" />
|
|
{t("newUi.sidebar.userProfile.verify")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* TOTP setup: backup codes */}
|
|
{totpStep === "backup" && totpBackupCodes.length > 0 && (
|
|
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.totpBackupTitle")}
|
|
</span>
|
|
<div className="grid grid-cols-2 gap-1">
|
|
{totpBackupCodes.map((code) => (
|
|
<span
|
|
key={code}
|
|
className="text-[10px] font-mono bg-muted border border-border px-2 py-1 text-center"
|
|
>
|
|
{code}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
onClick={downloadBackupCodes}
|
|
>
|
|
{t("newUi.sidebar.userProfile.totpDownloadBackup")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-xs"
|
|
onClick={() => setTotpStep("idle")}
|
|
>
|
|
{t("newUi.sidebar.userProfile.done")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 border-t border-border pt-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-xs font-medium">
|
|
{t("newUi.sidebar.userProfile.passkeys")}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.passkeysDesc")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[1fr_auto] gap-2">
|
|
<Input
|
|
placeholder={t("newUi.sidebar.userProfile.passkeyName")}
|
|
value={passkeyName}
|
|
onChange={(e) => setPasskeyName(e.target.value)}
|
|
className="h-8 text-xs"
|
|
disabled={passkeyLoading}
|
|
/>
|
|
<select
|
|
value={passkeyUserVerification}
|
|
onChange={(e) =>
|
|
setPasskeyUserVerification(
|
|
e.target.value as WebAuthnUserVerification,
|
|
)
|
|
}
|
|
className="h-8 w-28 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
|
disabled={passkeyLoading}
|
|
>
|
|
<option value="preferred">
|
|
{t("newUi.sidebar.userProfile.passkeyUvPreferred")}
|
|
</option>
|
|
<option value="required">
|
|
{t("newUi.sidebar.userProfile.passkeyUvRequired")}
|
|
</option>
|
|
<option value="discouraged">
|
|
{t("newUi.sidebar.userProfile.passkeyUvDiscouraged")}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
onClick={handleRegisterPasskey}
|
|
disabled={passkeyLoading}
|
|
>
|
|
<KeyRound className="size-3" />
|
|
{t("newUi.sidebar.userProfile.addPasskey")}
|
|
</Button>
|
|
|
|
<div className="flex flex-col divide-y divide-border border border-border">
|
|
{passkeys.length === 0 ? (
|
|
<div className="py-4 text-center text-[10px] text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.noPasskeys")}
|
|
</div>
|
|
) : (
|
|
passkeys.map((passkey) => (
|
|
<div
|
|
key={passkey.id}
|
|
className="flex items-center justify-between gap-2 px-2 py-2"
|
|
>
|
|
<div className="min-w-0 flex flex-col">
|
|
<span className="text-xs font-medium truncate">
|
|
{passkey.name}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground truncate">
|
|
{passkey.deviceType || "unknown"}
|
|
{passkey.backedUp ? " / synced" : ""}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-6 text-muted-foreground hover:text-destructive"
|
|
onClick={() => handleDeletePasskey(passkey.id)}
|
|
disabled={passkeyLoading}
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</Button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{canChangePasword && (
|
|
<PasswordChangeSection
|
|
showPassword={showPassword}
|
|
setShowPassword={setShowPassword}
|
|
onLogout={onLogout}
|
|
/>
|
|
)}
|
|
</div>
|
|
</AccordionSection>
|
|
|
|
{/* API Keys */}
|
|
<AccordionSection
|
|
id="api-keys"
|
|
label={t("newUi.sidebar.userProfile.sectionApiKeys")}
|
|
icon={<Network className="size-3.5" />}
|
|
open={openSections.has("api-keys")}
|
|
onToggle={() => toggle("api-keys")}
|
|
>
|
|
<div className="flex flex-col gap-2 pt-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.apiKeyCount", {
|
|
count: apiKeys.length,
|
|
})}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 text-[10px] font-bold uppercase tracking-widest border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 gap-1"
|
|
onClick={() => setNewKeyOpen(true)}
|
|
>
|
|
<Plus className="size-3" />{" "}
|
|
{t("newUi.sidebar.userProfile.newKey")}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex flex-col divide-y divide-border">
|
|
{apiKeys.length === 0 ? (
|
|
<div className="py-6 text-center text-muted-foreground text-xs">
|
|
{t("newUi.sidebar.userProfile.noApiKeys")}
|
|
</div>
|
|
) : (
|
|
apiKeys.map((key) => (
|
|
<div
|
|
key={key.id}
|
|
className="flex items-start justify-between py-2.5 gap-2"
|
|
>
|
|
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<span className="text-xs font-semibold truncate">
|
|
{key.name}
|
|
</span>
|
|
{key.isActive && (
|
|
<span className="text-[9px] font-bold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand uppercase shrink-0">
|
|
{t("newUi.sidebar.userProfile.apiKeyActive")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[10px] font-mono text-muted-foreground truncate">
|
|
{key.tokenPrefix}…
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.apiKeyUser")}:{" "}
|
|
{key.username}
|
|
</span>
|
|
{key.expiresAt && (
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.apiKeyExpires")}:{" "}
|
|
{new Date(key.expiresAt).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-0.5 shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-6 text-muted-foreground hover:text-destructive"
|
|
onClick={async () => {
|
|
try {
|
|
await deleteApiKey(key.id);
|
|
setApiKeys((prev) =>
|
|
prev.filter((k) => k.id !== key.id),
|
|
);
|
|
toast.success(
|
|
t("newUi.sidebar.userProfile.apiKeyRevoked", {
|
|
name: key.name,
|
|
}),
|
|
);
|
|
} catch {
|
|
toast.error(
|
|
t("newUi.sidebar.userProfile.apiKeyRevokeFailed"),
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-2 text-[10px] text-muted-foreground flex flex-col gap-1">
|
|
<p>
|
|
{t("newUi.sidebar.userProfile.apiKeyUsageHint")}{" "}
|
|
<code className="font-mono text-accent-brand bg-accent-brand/10 px-1">
|
|
Authorization: Bearer
|
|
</code>{" "}
|
|
{t("newUi.sidebar.userProfile.apiKeyUsageHintHeader")}
|
|
</p>
|
|
<p>{t("newUi.sidebar.userProfile.apiKeyPermissionsHint")}</p>
|
|
</div>
|
|
</div>
|
|
</AccordionSection>
|
|
|
|
<AccordionSection
|
|
id="data"
|
|
label={t("newUi.sidebar.userProfile.sectionData")}
|
|
icon={<Database className="size-3.5" />}
|
|
open={openSections.has("data")}
|
|
onToggle={() => toggle("data")}
|
|
>
|
|
<div className="flex flex-col gap-3 pt-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-xs font-medium">
|
|
{t("newUi.sidebar.userProfile.exportData")}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.exportDataDesc")}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand mt-1"
|
|
onClick={handleExportData}
|
|
disabled={exportLoading}
|
|
>
|
|
{exportLoading
|
|
? t("newUi.sidebar.userProfile.exporting")
|
|
: t("newUi.sidebar.userProfile.export")}
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
|
|
<span className="text-xs font-medium">
|
|
{t("newUi.sidebar.userProfile.importData")}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{importFile
|
|
? t("newUi.sidebar.userProfile.importDataSelected", {
|
|
name: importFile.name,
|
|
})
|
|
: t("newUi.sidebar.userProfile.importDataDesc")}
|
|
</span>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<div className="relative">
|
|
<input
|
|
type="file"
|
|
accept=".sqlite,.db"
|
|
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
|
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="pointer-events-none text-xs"
|
|
>
|
|
{importFile
|
|
? t("newUi.sidebar.userProfile.changeFile")
|
|
: t("newUi.sidebar.userProfile.selectFile")}
|
|
</Button>
|
|
</div>
|
|
{importFile && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
onClick={handleImportData}
|
|
disabled={importLoading}
|
|
>
|
|
{importLoading
|
|
? t("newUi.sidebar.userProfile.importing")
|
|
: t("newUi.sidebar.userProfile.import")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AccordionSection>
|
|
|
|
{isElectron() && (
|
|
<AccordionSection
|
|
id="c2s-tunnels"
|
|
label={t("newUi.sidebar.userProfile.sectionC2sTunnels")}
|
|
icon={<Activity className="size-3.5" />}
|
|
open={openSections.has("c2s-tunnels")}
|
|
onToggle={() => toggle("c2s-tunnels")}
|
|
>
|
|
<C2STunnelPresetManager />
|
|
</AccordionSection>
|
|
)}
|
|
|
|
{/* Delete account dialog */}
|
|
<Dialog
|
|
open={showDeleteConfirm}
|
|
onOpenChange={(open) => {
|
|
setShowDeleteConfirm(open);
|
|
if (!open) setDeletePassword("");
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-lg font-bold text-destructive">
|
|
{t("newUi.sidebar.userProfile.deleteAccount")}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.deleteAccountPermanent")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex flex-col gap-3 mt-1">
|
|
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
|
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
|
<span className="text-xs text-destructive">
|
|
{t("newUi.sidebar.userProfile.deleteAccountWarning")}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.confirmPasswordLabel")}
|
|
</label>
|
|
<Input
|
|
type="password"
|
|
placeholder={t(
|
|
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
|
|
)}
|
|
value={deletePassword}
|
|
onChange={(e) => setDeletePassword(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 mt-2">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setShowDeleteConfirm(false);
|
|
setDeletePassword("");
|
|
}}
|
|
>
|
|
{t("newUi.sidebar.userProfile.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
onClick={handleDeleteAccount}
|
|
disabled={deleteLoading || !deletePassword.trim()}
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
{deleteLoading
|
|
? t("newUi.sidebar.userProfile.deleting")
|
|
: t("newUi.sidebar.userProfile.deleteAccount")}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<KeybindingsDialog
|
|
open={keybindingsDialogOpen}
|
|
onOpenChange={setKeybindingsDialogOpen}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|