mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-11-12 14:31:58 +00:00
* fix: keep concurrent ssh pool connects from overwriting each other (#1619) * fix: stop auto-accepting changed jump host keys (#1620) A jump hop whose key no longer matched the pinned fingerprint was silently accepted and the pin overwritten. Background status probes reconnect to jump hosts every cycle, so a MITM on that hop got the credentials with no user interaction. Changed jump host keys now go through the same path as any other host: prompt when there is a terminal socket, otherwise refuse. * fix(electron): validate external login URL scheme and callback port (#1621) * fix(plugins): read artifact manifest with the same tar parser as the unpack (#1622) The consent check read the first manifest.json with a hand-written tar reader, while the unpack keeps the last one and also follows pax and long-name headers. An archive with two manifests could pass the check with one capability list and get granted another. Read the manifest with node-tar, count only plain files, and refuse archives that hold more than one manifest.json. * fix: only trust the server's origin in the desktop login frame (#1624) The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it. The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent. * fix: check trusted proxy login against nginx's peer, not nginx (#1625) The backend only sees the bundled nginx on loopback, so the allowlist could only work by trusting 127.0.0.1, which let any client that reached nginx log in as anyone by sending the identity headers. nginx now passes its own TCP peer (before real_ip rewriting) in X-Termix-Proxy-Peer, and loopback connections are judged by that address instead. * fix: keep foreign keys on during user data import (#1626) Import credentials before hosts and remap the file's credential ids to the ones created here, so hosts and protocol logins no longer point at another server's ids. With rows written in dependency order, the import no longer turns off SQLite foreign keys on the shared connection. The uploaded file is now removed on every exit path. * fix: enforce unique usernames (#1628) * fix: enforce unique usernames Registration checked for a taken name, hashed the password, then inserted, with nothing in the schema enforcing uniqueness. Concurrent sign-ups could create two accounts with one name, after which login, password reset, proxy login and admin lookups by username picked between them arbitrarily. Add a unique index on users.username for every dialect. Existing duplicates are renamed (later rows get a -<id> suffix, the oldest keeps the name) rather than deleted. Registration and admin user creation now return the same 409 when the insert loses the race. * fix: avoid username collisions during migration * fix: don't auto-enable synced plugins signed only by a server-sent key (#1623) * fix: don't auto-enable synced plugins signed only by a server-sent key Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust. * fix: enforce local plugin trust across remote source labels * fix: require positive local trust for synced plugin keys * fix(desktop): harden external editor and session cookie IPC (#1627) * fix(desktop): stop the renderer choosing the external editor binary or reading the session cookie open-external-editor now launches only the editor saved from a file dialog and refuses to hand executable file types to the OS opener. The unused get-session-cookie and wait-session-cookie channels, which returned the httpOnly jwt, are removed. * fix: validate setting keys before editor authorization * fix(sidebar): sort hosts by IP address numerically (#1629) * fix(desktop): find spawn-helper in universal macOS builds (#1630) The universal build ships app-arm64.asar and app-x64.asar instead of app.asar, so node-pty's helper path rewrite never pointed at the unpacked copy and every local terminal failed with posix_spawnp failed. * fix(sync): send shared hosts with text columns in their stored form (#1631) resolveHostById turns forceKeyboardInteractive into a boolean, and the shared-host export passed it on as is. A desktop could not bind the boolean into the text column, so every shared host failed to apply. * fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633) The vault plugin renames vault_profiles to p_vault_profiles, and SQLite rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then dropped p_vault_profiles and left ssh_data pointing at a missing table, so creating, cloning and deleting hosts failed with "no such table: main.p_vault_profiles". Postgres and MySQL refused the uninstall instead. The column was already copied into the vault plugin's host settings by 2.9, so retire it on every engine. The SQLite boot drop also repairs databases already in the broken state; MySQL drops the column's foreign key first. * fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632) * fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own * fix(sync): switch off an untrusted mirrored plugin when the server updates it * fix: require local consent for mirrored plugin activation * docs: describe consent fields on the plugin state endpoint * fix(i18n): keep Indonesian technical terms consistent (#1636) Use the English terms Indonesian users know for tunnel, snippet, deploy, file manager, remote desktop, jump host, passphrase, sidebar, toolbar, endpoint, browser and SSH agent, instead of mixing literal translations (terowongan, cuplikan, terapkan, bilah sisi, titik akhir, ...) with the English words. Match the split shortcuts to the split screen menu. Also fix the user data export description, which said secrets are encrypted while the English says they are decrypted. * fix(settings): leave user manage view when switching admin sections (#1649) The admin panel stays mounted across Settings pages, so an open user manage view kept replacing every other admin section until Back to users was clicked. Reset it whenever the selected section changes. * fix(sync): don't delete a local row when the server can't send its copy (#1650) * fix(sync): don't delete a local row when the server can't send its copy A push conflict on a live record whose server row failed to serialize came back as row: null, deleted: false, and the desktop treated that as a delete. The server now rejects that op as unavailable, the desktop never deletes on a conflict without a row, and serialization failures are logged. * fix(sync): retry temporarily unavailable records * fix: smooth host editor jumps to lower sections * chore: add changelog entries for 26.10.2 fixes * fix: stop building ssh2 native crypto in the docker image (#1644) * chore: test that an update keeps a changed auth type (#1601) * feat: option to hide the sidebar after opening a host (#1652) * feat: sort the dashboard host status card (Plugin-Host-Metrics#6) * fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2) * fix: say the server is down and keep retrying instead of a database error (#1637) * fix: show why a client tunnel failed to start (Plugin-Tunnels#2) * fix(sdk): skip comment-only chunks when splitting migrations * feat: setting to let the hover side menu push content again * fix: use the vite proxy for plugin sockets on any dev port * feat(sdk): let a plugin raise its public route rate limit * chore: update translations * chore: add the overview video to the readme * chore: update the overview video and add a play button to its thumbnail * feat: plugin builds bundle worker entries and import wasm and fonts as bytes * feat: plugin packs include LICENSE and NOTICE * chore: drop guacd from compose files and dev runner * chore: update remote desktop origin comment for the built-in engine * fix: show plugin names and descriptions in the UI language (#1658) * fix: show plugin names and descriptions in the UI language Read plugin.name and plugin.description from each plugin's own locale namespace, falling back to the manifest, everywhere core shows a plugin's name: the Plugins manager, onboarding picker, host editor tabs and sections, host filters and bulk actions, beta updates, interface presets and plugin crash/loading notices. * docs(sdk): document plugin.name and plugin.description locale keys --------- Co-authored-by: ZacharyZcR <zkl205305@gmail.com> * fix(hosts): reject or infer a missing authType on host create (#1664) Creating an SSH host through the API without authType hit the NOT NULL auth_type column and came back as a 500. The create route now infers the type from the credential, key or password in the body, falls back to the host defaults, and otherwise answers 400. A non-string authType is a 400 on create and update. * chore: changelog for merged plugin name and host auth type PRs * fix: move macOS split pane navigation to Cmd+Option+Arrow * fix: pick a free port for the desktop backend when 30001 is taken (#1663) * feat: build the Windows desktop app for ARM64 (#1615) * fix(sync): keep the changes cursor within the 32-bit seq column (#1665) The desktop learned the server's entity types by asking for changes after Number.MAX_SAFE_INTEGER. sync_records.seq is a 32-bit integer on Postgres, which rejects that bound value, so every pass needing the probe failed with 22003 out of range. The server now clamps the cursor to the largest seq the column holds and answers 400 for a cursor or limit that is not a non-negative integer. The desktop asks for a one-row page from its saved cursor instead. * fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667) Successful plugin_as_user and plugin_service_* entries written by a plugin with the same user, action, resource and details are recorded once per 10 minutes. Failures and every other action are still written each time. * feat(plugins): publish host.created when a host is added (#1668) Plugins that keep an inventory of hosts had no way to hear about a new one short of polling. Core now publishes host.created with the same { hostId, userId } payload as host.updated wherever a host row is created: the host create route, ctx.hosts.create, JSON and SSH config bulk import, database file import, and sync writing a host it did not have yet. * fix: show the login for signed-out full-screen view links and return to them (#1669) A ?view= link opened without a session spun forever: the gate rendered the bare view when the session check failed, so the plugin runtime never started and the view never registered. A regular browser now keeps the link in sessionStorage, shows the normal login and opens the link again after any login, including redirect logins. Only same-origin paths with a view are restored. The desktop app, embedded sign-in frames and guest views keep their current behavior. * fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670) * fix(desktop): send linked server proxy headers on WebSocket upgrades isLinkedOrigin compared the request origin with the link's http(s) origin, so the terminal WebSocket (wss://host/...) never matched and its upgrade went out without the proxy headers and basic auth. Behind a forward-auth proxy that upgrade was redirected to the login and the terminal closed with "connection closed unexpectedly". ws and wss now count as http and https for the comparison, the same mapping the WebSocket handshake itself uses, with host and port still matched exactly. * fix(desktop): allow a linked server's self-signed certificate for its WebSockets isInvalidCertificateAllowedForUrl compared the URL with a local getOrigin copy, so wss://host never matched the link's https://host origin and an allowed self-signed certificate did not cover the terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin, which maps ws and wss the same way as the proxy headers, and the local copy is removed. * feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666) ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background work a session for calling Termix's own API as an existing user. It needs users:impersonate, defaults to 300s and is capped at 900s, is stored as a normal session row marked as plugin-issued, and every issue is audited. Such sessions are refused on routes that change the user's password, second factors, API keys or linked logins. ctx.users.getLocale(userId) reads the user's UI language under users:read. SDK 1.0.11, plugin API 1.3.0. * chore: changelog for merged sync, audit, event and desktop PRs * feat: add shell.rightEdge slot so plugin panels can push the page over * chore: pin bundled plugins to the latest releases * chore: lint, format, and bump version to 26.10.2 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Ferdina Kusumah <19812460+FerdinaKusumah@users.noreply.github.com> Co-authored-by: ZacharyZcR <zkl205305@gmail.com> Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
2050 lines
75 KiB
TypeScript
2050 lines
75 KiB
TypeScript
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
|
import { FormFooter } from "@/components/list-kit";
|
|
import { useState, useRef, useEffect } from "react";
|
|
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
|
|
import { useTranslation } from "react-i18next";
|
|
import { copyToClipboard } from "@/lib/clipboard";
|
|
import {
|
|
getUserInfo,
|
|
getApiKeys,
|
|
createApiKey,
|
|
deleteApiKey,
|
|
changePassword,
|
|
deleteAccount,
|
|
logoutUser,
|
|
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 type { LinkedAccountInfo, UserRole } from "@/main-axios";
|
|
import type React from "react";
|
|
import { isElectron } from "@/lib/electron";
|
|
import { shell } from "@/plugin-host/shell-bridge";
|
|
import { Button } from "@/components/button";
|
|
import { Input } from "@/components/input";
|
|
import { VersionBadge } from "@/components/version-badge";
|
|
import {
|
|
AlertCircle,
|
|
ChevronDown,
|
|
Copy,
|
|
Database,
|
|
Eye,
|
|
EyeOff,
|
|
KeyRound,
|
|
LayoutTemplate,
|
|
Network,
|
|
Palette,
|
|
Plus,
|
|
RotateCcw,
|
|
Shield,
|
|
ShieldCheck,
|
|
Trash2,
|
|
Type,
|
|
User,
|
|
} from "lucide-react";
|
|
import { SettingRow, FakeSwitch } from "@/components/section-card";
|
|
import { NavigationVisibilityToggles } from "./NavigationVisibilityToggles";
|
|
import { InterfacePresetSettings } from "./InterfacePresetSettings";
|
|
import { useSettingsEmbedded } from "@/settings/settings-embed";
|
|
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
|
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 { Select2 } from "@/components/select2";
|
|
import { THEMES } from "@/settings/appearance/appearance-controls";
|
|
import { LANGUAGES } from "@/i18n/languages";
|
|
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
|
|
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
|
|
import { isPrerelease } from "@/plugins/plugin-model";
|
|
import {
|
|
FeatureSettingsSection,
|
|
featureSectionId,
|
|
useFeatureSettings,
|
|
type FeatureSectionId,
|
|
} from "@/settings/FeatureSettingsSections";
|
|
import { docsUrl } from "@/lib/docs";
|
|
import { roleLabel } from "@/lib/role-label";
|
|
|
|
type UserProfileSection =
|
|
| "account"
|
|
| "interface"
|
|
| "appearance"
|
|
| "security"
|
|
| "api-keys"
|
|
| "data"
|
|
| FeatureSectionId;
|
|
|
|
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;
|
|
}) {
|
|
const embedded = useSettingsEmbedded();
|
|
if (hidden) return null;
|
|
if (embedded) {
|
|
if (!open) return null;
|
|
return (
|
|
<div
|
|
id={`${id}-content`}
|
|
className="flex flex-col border border-border bg-card px-3 pb-3"
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<InlineView
|
|
open={open}
|
|
onOpenChange={(nextOpen) => {
|
|
if (nextOpen || !createdToken) onOpenChange(nextOpen);
|
|
}}
|
|
title={t("newUi.sidebar.userProfile.createApiKeyTitle")}
|
|
footer={
|
|
createdToken ? (
|
|
<FormFooter
|
|
onSave={close}
|
|
saveLabel={t("newUi.sidebar.userProfile.done")}
|
|
/>
|
|
) : (
|
|
<FormFooter
|
|
onCancel={close}
|
|
cancelLabel={t("newUi.sidebar.userProfile.cancel")}
|
|
onSave={() => void handleCreate()}
|
|
saveLabel={t("newUi.sidebar.userProfile.createKey")}
|
|
saving={creating}
|
|
/>
|
|
)
|
|
}
|
|
>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.createApiKeyDescription")}
|
|
</p>
|
|
{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>
|
|
)}
|
|
</InlineView>
|
|
);
|
|
}
|
|
|
|
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,
|
|
section,
|
|
onOpenSettingsPage,
|
|
}: {
|
|
/** Shows just this section, flat, as a Settings page. */
|
|
section?: string;
|
|
/** Moves the Settings tab to another page. */
|
|
onOpenSettingsPage?: (id: string) => void;
|
|
username?: string;
|
|
onLogout?: () => void;
|
|
userPrefs?: {
|
|
reopenTabsOnLogin: boolean;
|
|
storageMode?: string | null;
|
|
commandPaletteEnabled?: boolean | null;
|
|
showHostTags?: boolean | null;
|
|
hostTrayOnClick?: boolean | null;
|
|
compactHostView?: boolean | null;
|
|
pinAppRail?: boolean | null;
|
|
expandAppRailOnHover?: boolean | null;
|
|
showPinAppRailButton?: 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 confirm = useConfirm();
|
|
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"]),
|
|
);
|
|
const featureSettings = useFeatureSettings("user");
|
|
|
|
// User info
|
|
const [userId, setUserId] = useState("");
|
|
const [accountUsername, setAccountUsername] = useState(username ?? "");
|
|
const [accountTotpEnabled, setAccountTotpEnabled] = useState(false);
|
|
const [userRole, setUserRole] = useState("");
|
|
const [isAdmin, setIsAdmin] = useState(false);
|
|
const [authMethod, setAuthMethod] = useState("");
|
|
const [version, setVersion] = useState("");
|
|
const [versionStatus, setVersionStatus] = useState<
|
|
"up_to_date" | "requires_update" | "beta" | "unknown"
|
|
>("up_to_date");
|
|
const [releaseUrl, setReleaseUrl] = useState("");
|
|
const [isExternal, setIsExternal] = useState(false);
|
|
const [isDualAuth, setIsDualAuth] = useState(false);
|
|
|
|
// 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]);
|
|
|
|
// On the desktop app, the account it is linked to on a server, if any.
|
|
// "Cloud" storage only has somewhere to go once it is linked.
|
|
const [linkedAccount, setLinkedAccount] = useState<
|
|
LinkedAccountInfo | null | undefined
|
|
>(() => (isElectron() ? undefined : null));
|
|
const isLinked = !!linkedAccount;
|
|
|
|
useEffect(() => {
|
|
if (isElectron() && linkedAccount === null && storageMode === "cloud") {
|
|
setStorageMode("local");
|
|
onPrefsChange?.({ storageMode: "local" });
|
|
}
|
|
}, [linkedAccount, storageMode, onPrefsChange]);
|
|
|
|
// Settings toggles, all backed by localStorage
|
|
const [commandPaletteEnabled, setCommandPaletteEnabled] = useState(() => {
|
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
|
return v !== null ? v === "true" : true;
|
|
});
|
|
// 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();
|
|
|
|
const [pinAppRail, setPinAppRail] = useState(() =>
|
|
readRailPreference("pinAppRail"),
|
|
);
|
|
const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(() =>
|
|
readRailPreference("expandAppRailOnHover"),
|
|
);
|
|
const [appRailHoverPushes, setAppRailHoverPushes] = useState(() =>
|
|
readRailPreference("appRailHoverPushesContent"),
|
|
);
|
|
const [showPinAppRailButton, setShowPinAppRailButton] = useState(() =>
|
|
readRailPreference("showPinAppRailButton"),
|
|
);
|
|
const [disableUpdateCheck, setDisableUpdateCheck] = useState(
|
|
() => localStorage.getItem("disableUpdateCheck") === "true",
|
|
);
|
|
const [confirmTabClose, setConfirmTabClose] = useState(
|
|
() => localStorage.getItem("confirmTabClose") === "true",
|
|
);
|
|
const [showTabNumbers, setShowTabNumbers] = useState(
|
|
() => localStorage.getItem("showTabNumbers") === "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((info) => {
|
|
setUserId(info.userId);
|
|
setIsExternal(info.is_external ?? false);
|
|
setIsDualAuth(info.is_dual_auth ?? false);
|
|
const linked = info.linked ?? null;
|
|
setLinkedAccount(linked);
|
|
// A linked desktop is the account it is signed in to on the server.
|
|
setAccountUsername(linked?.username || info.username);
|
|
setAccountTotpEnabled(info.totp_enabled ?? false);
|
|
setIsAdmin(Boolean(linked ? linked.isAdmin : info.is_admin));
|
|
setUserRole(
|
|
(linked ? linked.isAdmin : 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_external) {
|
|
setAuthMethod(t("newUi.sidebar.userProfile.authMethodExternal"));
|
|
} else {
|
|
setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal"));
|
|
}
|
|
if (linked) {
|
|
setUserRoles(
|
|
linked.roles.map((name, index) => ({
|
|
id: index,
|
|
roleId: index,
|
|
roleName: name,
|
|
roleDisplayName: name,
|
|
})) as unknown as UserRole[],
|
|
);
|
|
} else {
|
|
getUserRoles(info.userId)
|
|
.then(({ roles }) => setUserRoles(roles ?? []))
|
|
.catch(() => {});
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
getApiKeys()
|
|
.then(({ apiKeys: keys }) => setApiKeys(keys))
|
|
.catch(() => {});
|
|
getVersionInfo()
|
|
.then((info) => {
|
|
setVersion(info.localVersion);
|
|
setVersionStatus(info.status ?? "unknown");
|
|
setReleaseUrl(releaseUrlFrom(info));
|
|
})
|
|
.catch(() => {
|
|
// Backend/network unreachable (offline machine). The installer
|
|
// version is still known locally via Electron, so use that instead
|
|
// of leaving the version blank.
|
|
if (isElectron()) {
|
|
window.electronAPI
|
|
?.getAppVersion?.()
|
|
.then((appVersion) => {
|
|
if (appVersion) {
|
|
setVersion(appVersion);
|
|
setVersionStatus("unknown");
|
|
}
|
|
})
|
|
.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"));
|
|
const showPinButtonHandler = () =>
|
|
setShowPinAppRailButton(readRailPreference("showPinAppRailButton"));
|
|
window.addEventListener("pinAppRailChanged", pinHandler);
|
|
window.addEventListener("expandAppRailOnHoverChanged", hoverHandler);
|
|
window.addEventListener(
|
|
"showPinAppRailButtonChanged",
|
|
showPinButtonHandler,
|
|
);
|
|
return () => {
|
|
window.removeEventListener("pinAppRailChanged", pinHandler);
|
|
window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler);
|
|
window.removeEventListener(
|
|
"showPinAppRailButtonChanged",
|
|
showPinButtonHandler,
|
|
);
|
|
};
|
|
}, []);
|
|
|
|
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",
|
|
"commandPaletteShortcutEnabled",
|
|
"showHostTags",
|
|
"hostTrayOnClick",
|
|
"compactHostView",
|
|
"pinAppRail",
|
|
"expandAppRailOnHover",
|
|
"showPinAppRailButton",
|
|
"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",
|
|
];
|
|
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.commandPaletteEnabled != null) {
|
|
setCommandPaletteEnabled(prefs.commandPaletteEnabled);
|
|
localStorage.setItem(
|
|
"commandPaletteShortcutEnabled",
|
|
String(prefs.commandPaletteEnabled),
|
|
);
|
|
}
|
|
// 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.showPinAppRailButton != null) {
|
|
setShowPinAppRailButton(prefs.showPinAppRailButton);
|
|
localStorage.setItem(
|
|
"showPinAppRailButton",
|
|
String(prefs.showPinAppRailButton),
|
|
);
|
|
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
|
|
}
|
|
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");
|
|
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"));
|
|
setShowPinAppRailButton(false);
|
|
localStorage.setItem("showPinAppRailButton", "false");
|
|
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
|
|
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",
|
|
commandPaletteEnabled: true,
|
|
pinAppRail: false,
|
|
expandAppRailOnHover: true,
|
|
showPinAppRailButton: 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 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 restoredShowPinButton =
|
|
restore("showPinAppRailButton", "false") === "true";
|
|
setShowPinAppRailButton(restoredShowPinButton);
|
|
localStorage.setItem("showPinAppRailButton", String(restoredShowPinButton));
|
|
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
|
|
|
|
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 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.pluginItemsImported || 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 = !isExternal || isDualAuth;
|
|
const isOpen = (id: string) =>
|
|
section ? section === id : openSections.has(id as UserProfileSection);
|
|
const shows = (id: string) => !section || section === id;
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
section
|
|
? "flex w-full flex-col gap-2"
|
|
: "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}
|
|
/>
|
|
|
|
{/* 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() || isLinked) && shows("data") && (
|
|
<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/10 text-accent-brand"
|
|
: "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/10 text-accent-brand"
|
|
: "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={isOpen("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" />
|
|
{isLinked
|
|
? t("newUi.sidebar.userProfile.linkedProfileTitle", {
|
|
server:
|
|
linkedAccount?.serverName || linkedAccount?.serverUrl,
|
|
})
|
|
: t("newUi.sidebar.userProfile.desktopProfileTitle")}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground leading-relaxed mt-1">
|
|
{isLinked
|
|
? t("newUi.sidebar.userProfile.linkedProfileDescription")
|
|
: t("newUi.sidebar.userProfile.desktopProfileDescription")}
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-2 h-7 text-[10px] rounded-none"
|
|
onClick={() => shell.openRailView("sync")}
|
|
>
|
|
{isLinked
|
|
? t("newUi.sidebar.userProfile.manageSync")
|
|
: t("newUi.sidebar.userProfile.setUpSync")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{(!isElectron() || isLinked) && (
|
|
<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"
|
|
>
|
|
{roleLabel(t, r.roleDisplayName)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{!isElectron() && (
|
|
<>
|
|
<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">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
|
{t("newUi.sidebar.userProfile.versionLabel")}
|
|
</span>
|
|
<VersionBadge
|
|
status={versionStatus}
|
|
releaseUrl={releaseUrl}
|
|
className="shrink-0 px-1 py-px text-[9px]"
|
|
/>
|
|
</div>
|
|
<span
|
|
className="mt-1.5 block truncate text-sm font-bold text-accent-brand"
|
|
title={version ? `v${version}` : undefined}
|
|
>
|
|
{version ? `v${version}` : "-"}
|
|
</span>
|
|
</div>
|
|
|
|
{(isPrerelease(version) || isAdmin) && (
|
|
<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">
|
|
{isPrerelease(version)
|
|
? t("newUi.sidebar.userProfile.onBetaTitle")
|
|
: t("newUi.sidebar.userProfile.tryBetaTitle")}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{isPrerelease(version)
|
|
? t("newUi.sidebar.userProfile.onBetaDescription")
|
|
: t("newUi.sidebar.userProfile.tryBetaDescription")}
|
|
</span>
|
|
</div>
|
|
{isPrerelease(version) ? (
|
|
<a
|
|
href={reportBetaFeedbackUrl(version)}
|
|
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.sendBetaFeedback")}
|
|
</a>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenSettingsPage?.("updates")}
|
|
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.openUpdates")}
|
|
</button>
|
|
)}
|
|
</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={docsUrl("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">
|
|
<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={isOpen("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={isOpen("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>
|
|
<Select2
|
|
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>
|
|
))}
|
|
</Select2>
|
|
</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">
|
|
<Select2
|
|
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>
|
|
))}
|
|
</Select2>
|
|
</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>
|
|
<Select2
|
|
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>
|
|
))}
|
|
</Select2>
|
|
<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.settingsTabsAndShortcuts")}
|
|
</span>
|
|
<SettingRow
|
|
label={t("newUi.sidebar.userProfile.keyboardShortcuts")}
|
|
description={t(
|
|
"newUi.sidebar.userProfile.keyboardShortcutsDescription",
|
|
)}
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onOpenSettingsPage?.("keybindings")}
|
|
>
|
|
{t("newUi.sidebar.userProfile.manageShortcuts")}
|
|
</Button>
|
|
</SettingRow>
|
|
<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>
|
|
<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>
|
|
<SettingRow
|
|
label={t("newUi.sidebar.userProfile.showTabNumbers")}
|
|
description={t("newUi.sidebar.userProfile.showTabNumbersDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={showTabNumbers}
|
|
onChange={(v) => {
|
|
setShowTabNumbers(v);
|
|
localStorage.setItem("showTabNumbers", v.toString());
|
|
window.dispatchEvent(new Event("showTabNumbersChanged"));
|
|
}}
|
|
/>
|
|
</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>
|
|
<SettingRow
|
|
label={t("newUi.sidebar.userProfile.appRailHoverPushesContent")}
|
|
description={t(
|
|
"newUi.sidebar.userProfile.appRailHoverPushesContentDesc",
|
|
)}
|
|
>
|
|
<FakeSwitch
|
|
checked={appRailHoverPushes}
|
|
disabled={!expandAppRailOnHover}
|
|
onChange={(v) => {
|
|
setAppRailHoverPushes(v);
|
|
setRailPreference("appRailHoverPushesContent", v);
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("newUi.sidebar.userProfile.showPinAppRailButton")}
|
|
description={t(
|
|
"newUi.sidebar.userProfile.showPinAppRailButtonDesc",
|
|
)}
|
|
>
|
|
<FakeSwitch
|
|
checked={showPinAppRailButton}
|
|
onChange={(v) => {
|
|
setShowPinAppRailButton(v);
|
|
setRailPreference("showPinAppRailButton", v);
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
|
|
<div
|
|
id="settings-navigation"
|
|
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>
|
|
<NavigationVisibilityToggles
|
|
hidden={hiddenRailTabs}
|
|
onChange={(next) => {
|
|
setHiddenRailTabs(next);
|
|
const serialized = JSON.stringify([...next]);
|
|
localStorage.setItem("hiddenRailTabs", serialized);
|
|
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
|
|
if (storageMode === "cloud")
|
|
saveToCloud({ hiddenRailTabs: serialized });
|
|
}}
|
|
/>
|
|
</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. A linked account's sign-in is managed on its server. */}
|
|
<AccordionSection
|
|
hidden={isElectron()}
|
|
id="security"
|
|
label={t("newUi.sidebar.userProfile.sectionSecurity")}
|
|
icon={<Shield className="size-3.5" />}
|
|
open={isOpen("security")}
|
|
onToggle={() => toggle("security")}
|
|
>
|
|
<div className="flex flex-col gap-4 pt-3">
|
|
<AuthEnrollmentSections />
|
|
|
|
{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={isOpen("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 () => {
|
|
const ok = await confirm({
|
|
title: t("admin.revokeKeyConfirm", {
|
|
name: key.name,
|
|
}),
|
|
confirmLabel: t("admin.revokeKey"),
|
|
});
|
|
if (!ok) return;
|
|
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={isOpen("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>
|
|
|
|
{featureSettings.map((plugin) => (
|
|
<FeatureSettingsSection
|
|
key={plugin.id}
|
|
plugin={plugin}
|
|
scope="user"
|
|
open={isOpen(featureSectionId(plugin.id))}
|
|
onToggle={() => toggle(featureSectionId(plugin.id))}
|
|
/>
|
|
))}
|
|
|
|
{/* Delete account dialog */}
|
|
|
|
<InlineView
|
|
open={showDeleteConfirm}
|
|
onOpenChange={(open) => {
|
|
setShowDeleteConfirm(open);
|
|
if (!open) setDeletePassword("");
|
|
}}
|
|
title={t("newUi.sidebar.userProfile.deleteAccount")}
|
|
>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("newUi.sidebar.userProfile.deleteAccountPermanent")}
|
|
</p>
|
|
<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>
|
|
</InlineView>
|
|
</div>
|
|
);
|
|
}
|