mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
improve settings navigation and legal disclosure (#1105)
This commit is contained in:
+82
-19
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Separator } from "@/components/separator";
|
||||
import { Button } from "@/components/button";
|
||||
import { Sheet, SheetContent } from "@/components/sheet";
|
||||
import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Maximize2, Minimize2 } from "lucide-react";
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
@@ -125,6 +125,7 @@ import {
|
||||
createSSHHost,
|
||||
getActiveSessions,
|
||||
getUserPreferences,
|
||||
saveUserPreferences,
|
||||
dismissDonationModal,
|
||||
isElectron,
|
||||
type UserPreferences,
|
||||
@@ -139,7 +140,7 @@ import { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
||||
import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||
import { changeAppLanguage } from "@/i18n/i18n";
|
||||
import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n";
|
||||
import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
|
||||
|
||||
function buildHostTree(
|
||||
@@ -263,6 +264,7 @@ export function AppShell({
|
||||
});
|
||||
const [sidebarDragging, setSidebarDragging] = useState(false);
|
||||
const [sidebarEditing, setSidebarEditing] = useState(false);
|
||||
const [settingsFullscreen, setSettingsFullscreen] = useState(false);
|
||||
const [isAppFullscreen, setIsAppFullscreen] = useState(
|
||||
() => !!document.fullscreenElement,
|
||||
);
|
||||
@@ -287,6 +289,21 @@ export function AppShell({
|
||||
}, [paneTabIds, tabs]);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isSettingsView =
|
||||
railView === "user-profile" || railView === "admin-settings";
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsFullscreen) return;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setSettingsFullscreen(false);
|
||||
};
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [settingsFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingsView) setSettingsFullscreen(false);
|
||||
}, [isSettingsView]);
|
||||
|
||||
const sidebarOpenBeforeMobile = useRef(sidebarOpen);
|
||||
useEffect(() => {
|
||||
@@ -691,6 +708,7 @@ export function AppShell({
|
||||
useEffect(() => {
|
||||
getUserPreferences()
|
||||
.then((prefs) => {
|
||||
const loginLanguage = consumeLoginLanguage();
|
||||
setUserPrefs(prefs);
|
||||
if (prefs.storageMode === "cloud") {
|
||||
// Persist the current browser values before overwriting, so any tab can restore them
|
||||
@@ -724,8 +742,12 @@ export function AppShell({
|
||||
localStorage.setItem("termix-accent", prefs.accentColor);
|
||||
applyAccentColor(prefs.accentColor);
|
||||
}
|
||||
if (prefs.language && prefs.language !== i18n.language) {
|
||||
void changeAppLanguage(prefs.language);
|
||||
const preferredLanguage = loginLanguage ?? prefs.language;
|
||||
if (preferredLanguage && preferredLanguage !== i18n.language) {
|
||||
void changeAppLanguage(preferredLanguage);
|
||||
}
|
||||
if (loginLanguage && loginLanguage !== prefs.language) {
|
||||
void saveUserPreferences({ language: loginLanguage });
|
||||
}
|
||||
if (
|
||||
prefs.commandAutocomplete !== null &&
|
||||
@@ -1510,6 +1532,7 @@ export function AppShell({
|
||||
setSidebarOpen(false);
|
||||
} else {
|
||||
if (view !== railView) setSidebarEditing(false);
|
||||
if (view !== railView) setSettingsFullscreen(false);
|
||||
setRailView(view);
|
||||
setSidebarOpen(true);
|
||||
}
|
||||
@@ -1867,12 +1890,42 @@ export function AppShell({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isSettingsView && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
||||
title={
|
||||
settingsFullscreen
|
||||
? t("newUi.sidebar.userProfile.exitFullscreenSettings")
|
||||
: t("newUi.sidebar.userProfile.openFullscreenSettings")
|
||||
}
|
||||
aria-label={
|
||||
settingsFullscreen
|
||||
? t("newUi.sidebar.userProfile.exitFullscreenSettings")
|
||||
: t("newUi.sidebar.userProfile.openFullscreenSettings")
|
||||
}
|
||||
onClick={() => setSettingsFullscreen((value) => !value)}
|
||||
>
|
||||
{settingsFullscreen ? (
|
||||
<Minimize2 className="size-4" />
|
||||
) : (
|
||||
<Maximize2 className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Separator orientation="vertical" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
onClick={() => {
|
||||
setSettingsFullscreen(false);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
@@ -1904,30 +1957,38 @@ export function AppShell({
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Skinny icon rail — desktop only, hidden on mobile */}
|
||||
<AppRail
|
||||
railView={railView}
|
||||
sidebarOpen={sidebarOpen}
|
||||
splitMode={splitMode}
|
||||
username={username}
|
||||
isAdmin={showMultiUserUI}
|
||||
onRailClick={handleRailClick}
|
||||
onOpenTab={openSingletonTab}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
{!settingsFullscreen && (
|
||||
<AppRail
|
||||
railView={railView}
|
||||
sidebarOpen={sidebarOpen}
|
||||
splitMode={splitMode}
|
||||
username={username}
|
||||
isAdmin={showMultiUserUI}
|
||||
onRailClick={handleRailClick}
|
||||
onOpenTab={openSingletonTab}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Desktop: inline resizable sidebar */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={`relative flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
||||
className={`${settingsFullscreen ? "fixed inset-0 z-50" : "relative"} flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
||||
style={{
|
||||
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
|
||||
width: settingsFullscreen
|
||||
? "100vw"
|
||||
: sidebarOpen
|
||||
? sidebarEditing
|
||||
? 560
|
||||
: sidebarWidth
|
||||
: 0,
|
||||
transition: sidebarDragging ? "none" : "width 0.2s",
|
||||
}}
|
||||
>
|
||||
{sidebarHeader}
|
||||
{sidebarPanelContent}
|
||||
|
||||
{sidebarOpen && !sidebarEditing && (
|
||||
{sidebarOpen && !sidebarEditing && !settingsFullscreen && (
|
||||
<div
|
||||
onMouseDown={onSidebarMouseDown}
|
||||
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
||||
@@ -1942,7 +2003,7 @@ export function AppShell({
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="p-0 flex flex-col min-h-0 w-[min(85vw,360px)] max-w-full bg-sidebar border-r border-border gap-0"
|
||||
className={`p-0 flex flex-col min-h-0 max-w-full bg-sidebar border-r border-border gap-0 ${settingsFullscreen ? "w-screen" : "w-[min(85vw,360px)]"}`}
|
||||
style={{ height: "100dvh" }}
|
||||
>
|
||||
{sidebarHeader}
|
||||
@@ -1953,6 +2014,8 @@ export function AppShell({
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
inert={settingsFullscreen ? true : undefined}
|
||||
aria-hidden={settingsFullscreen || undefined}
|
||||
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-all duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
|
||||
>
|
||||
{!isMobile && !sidebarOpen && (
|
||||
|
||||
+20
-2
@@ -37,11 +37,16 @@ import {
|
||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||
import type { SSOProviderPublic } from "@/types/index";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import {
|
||||
changeAppLanguage,
|
||||
normalizeLanguageCode,
|
||||
rememberLoginLanguage,
|
||||
} from "@/i18n/i18n";
|
||||
import {
|
||||
removeSilentSigninFromSearch,
|
||||
shouldTriggerSilentSignin,
|
||||
} from "./silent-signin";
|
||||
import { LegalDisclosureDialog } from "@/legal/LegalDisclosure";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
@@ -237,9 +242,11 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const [language, setLanguage] = useState(() =>
|
||||
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
|
||||
);
|
||||
const [legalOpen, setLegalOpen] = useState(false);
|
||||
|
||||
function handleLanguageChange(code: string) {
|
||||
void changeAppLanguage(code)
|
||||
const language = rememberLoginLanguage(code);
|
||||
void changeAppLanguage(language)
|
||||
.then((language) => setLanguage(language))
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -1583,6 +1590,17 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLegalOpen(true)}
|
||||
className="self-center text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.sectionLegal")}
|
||||
</button>
|
||||
<LegalDisclosureDialog
|
||||
open={legalOpen}
|
||||
onOpenChange={setLegalOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@ const localeLoaders = {
|
||||
} satisfies Record<string, () => Promise<LocaleModule>>;
|
||||
|
||||
export const supportedLngs = ["en", ...Object.keys(localeLoaders)];
|
||||
const PENDING_LOGIN_LANGUAGE_KEY = "termix-pending-login-language";
|
||||
|
||||
export function normalizeLanguageCode(language?: string | null): string {
|
||||
if (!language) return "en";
|
||||
@@ -123,4 +124,16 @@ export async function changeAppLanguage(language: string): Promise<string> {
|
||||
return normalizedLanguage;
|
||||
}
|
||||
|
||||
export function rememberLoginLanguage(language: string): string {
|
||||
const normalizedLanguage = normalizeLanguageCode(language);
|
||||
sessionStorage.setItem(PENDING_LOGIN_LANGUAGE_KEY, normalizedLanguage);
|
||||
return normalizedLanguage;
|
||||
}
|
||||
|
||||
export function consumeLoginLanguage(): string | null {
|
||||
const language = sessionStorage.getItem(PENDING_LOGIN_LANGUAGE_KEY);
|
||||
sessionStorage.removeItem(PENDING_LOGIN_LANGUAGE_KEY);
|
||||
return language ? normalizeLanguageCode(language) : null;
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Database, FileKey, Network, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
|
||||
const accessItems = [
|
||||
{ key: "connections", icon: Network },
|
||||
{ key: "credentials", icon: FileKey },
|
||||
{ key: "storage", icon: Database },
|
||||
{ key: "services", icon: ShieldCheck },
|
||||
] as const;
|
||||
|
||||
export function LegalDisclosure() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 pt-3 text-xs">
|
||||
<article aria-labelledby="terms-heading" className="flex flex-col gap-2">
|
||||
<h3 id="terms-heading" className="font-bold text-foreground">
|
||||
{t("newUi.sidebar.userProfile.termsTitle")}
|
||||
</h3>
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.termsIntro")}
|
||||
</p>
|
||||
<ul className="list-disc space-y-1.5 pl-4 leading-relaxed text-muted-foreground">
|
||||
<li>{t("newUi.sidebar.userProfile.termsAuthorization")}</li>
|
||||
<li>{t("newUi.sidebar.userProfile.termsSecurity")}</li>
|
||||
<li>{t("newUi.sidebar.userProfile.termsAcceptableUse")}</li>
|
||||
<li>{t("newUi.sidebar.userProfile.termsAvailability")}</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article
|
||||
aria-labelledby="access-heading"
|
||||
className="flex flex-col gap-2 border-t border-border pt-4"
|
||||
>
|
||||
<h3 id="access-heading" className="font-bold text-foreground">
|
||||
{t("newUi.sidebar.userProfile.accessDisclosureTitle")}
|
||||
</h3>
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.accessDisclosureIntro")}
|
||||
</p>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{accessItems.map(({ key, icon: Icon }) => (
|
||||
<section
|
||||
key={key}
|
||||
className="border border-border bg-background p-3"
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-2 font-semibold text-foreground">
|
||||
<Icon className="size-3.5 text-accent-brand" />
|
||||
{t(`newUi.sidebar.userProfile.access.${key}.title`)}
|
||||
</div>
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
{t(`newUi.sidebar.userProfile.access.${key}.description`)}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.telemetryDisclosure")}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalDisclosureDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("newUi.sidebar.userProfile.sectionLegal")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("newUi.sidebar.userProfile.legalDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<LegalDisclosure />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+33
-1
@@ -3330,6 +3330,8 @@
|
||||
"sectionApiKeys": "API Keys",
|
||||
"sectionData": "Data",
|
||||
"sectionC2sTunnels": "C2S Tunnels",
|
||||
"sectionLegal": "Terms and Access",
|
||||
"legalDescription": "Review the conditions for using Termix and the resources its features can access.",
|
||||
"usernameLabel": "Username",
|
||||
"roleLabel": "Role",
|
||||
"roleAdministrator": "Administrator",
|
||||
@@ -3381,6 +3383,9 @@
|
||||
"statusColorsDesc": "Use green/red for online/offline status instead of the accent color",
|
||||
"pinAppRail": "Pin App Rail",
|
||||
"pinAppRailDesc": "Keep the left sidebar app rail always expanded instead of expanding on hover",
|
||||
"unpinAppRail": "Unpin App Rail",
|
||||
"openFullscreenSettings": "Open settings full screen",
|
||||
"exitFullscreenSettings": "Exit full-screen settings",
|
||||
"expandAppRailOnHover": "Expand App Rail on Hover",
|
||||
"expandAppRailOnHoverDesc": "Allow the left sidebar app rail to expand when the pointer moves over it",
|
||||
"settingsNavigation": "Navigation",
|
||||
@@ -3498,7 +3503,34 @@
|
||||
"themeSolarized": "Solarized",
|
||||
"themeTokyoNight": "Tokyo Night",
|
||||
"themeOneDark": "One Dark",
|
||||
"themeGruvbox": "Gruvbox"
|
||||
"themeGruvbox": "Gruvbox",
|
||||
"termsTitle": "Terms of Use",
|
||||
"termsIntro": "By using Termix, you agree to use it only with systems, accounts, and data you are authorized to access.",
|
||||
"termsAuthorization": "You are responsible for obtaining permission before connecting to a host or sharing a session.",
|
||||
"termsSecurity": "You are responsible for protecting credentials, backups, API keys, and access to this Termix instance.",
|
||||
"termsAcceptableUse": "Do not use Termix to disrupt services, bypass access controls, or perform unlawful activity.",
|
||||
"termsAvailability": "Termix is provided without a service guarantee. Review backups and test recovery before relying on it for critical operations.",
|
||||
"accessDisclosureTitle": "Access Disclosure",
|
||||
"accessDisclosureIntro": "Termix accesses only the resources required by features you configure or actions you start.",
|
||||
"access": {
|
||||
"connections": {
|
||||
"title": "Configured systems",
|
||||
"description": "Connects to hosts and services you add, including SSH, Telnet, RDP, VNC, Docker, Proxmox, tunnels, and monitoring endpoints."
|
||||
},
|
||||
"credentials": {
|
||||
"title": "Credentials and sessions",
|
||||
"description": "Uses saved credentials, keys, tokens, and session data to authenticate and maintain the connections you request."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Files and local data",
|
||||
"description": "Reads or writes remote files, clipboard data, imports, exports, and local preferences only when the related feature is used."
|
||||
},
|
||||
"services": {
|
||||
"title": "Optional integrations",
|
||||
"description": "Contacts SSO, ACME, Tailscale, webhook, update, and remote-sync services only when they are enabled or configured."
|
||||
}
|
||||
},
|
||||
"telemetryDisclosure": "Optional analytics can be disabled in Admin Settings. Termix does not intentionally include credentials, terminal input, or file contents in analytics events."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3328,6 +3328,8 @@
|
||||
"sectionApiKeys": "API 密钥",
|
||||
"sectionData": "数据",
|
||||
"sectionC2sTunnels": "C2S 隧道",
|
||||
"sectionLegal": "使用协议与访问清单",
|
||||
"legalDescription": "查看 Termix 的使用条件,以及各项功能可能访问的资源。",
|
||||
"usernameLabel": "用户名",
|
||||
"roleLabel": "角色",
|
||||
"roleAdministrator": "管理员",
|
||||
@@ -3379,6 +3381,9 @@
|
||||
"statusColorsDesc": "使用绿色/红色表示在线/离线状态,而非强调色",
|
||||
"pinAppRail": "固定应用边栏",
|
||||
"pinAppRailDesc": "保持左侧应用边栏始终展开,而非悬停展开",
|
||||
"unpinAppRail": "取消固定应用边栏",
|
||||
"openFullscreenSettings": "全屏打开设置",
|
||||
"exitFullscreenSettings": "退出全屏设置",
|
||||
"expandAppRailOnHover": "悬停展开应用边栏",
|
||||
"expandAppRailOnHoverDesc": "允许鼠标悬停时展开左侧应用边栏",
|
||||
"settingsNavigation": "导航",
|
||||
@@ -3496,7 +3501,34 @@
|
||||
"themeSolarized": "Solarized",
|
||||
"themeTokyoNight": "Tokyo Night",
|
||||
"themeOneDark": "One Dark",
|
||||
"themeGruvbox": "Gruvbox"
|
||||
"themeGruvbox": "Gruvbox",
|
||||
"termsTitle": "使用协议",
|
||||
"termsIntro": "使用 Termix 即表示您同意仅访问已获得授权的系统、账户和数据。",
|
||||
"termsAuthorization": "连接主机或共享会话前,您有责任获得系统所有者的明确许可。",
|
||||
"termsSecurity": "您有责任保护凭据、备份、API 密钥以及此 Termix 实例的访问权限。",
|
||||
"termsAcceptableUse": "不得使用 Termix 干扰服务、绕过访问控制或从事违法活动。",
|
||||
"termsAvailability": "Termix 不提供服务可用性保证。用于关键操作前,请建立备份并验证恢复流程。",
|
||||
"accessDisclosureTitle": "访问清单",
|
||||
"accessDisclosureIntro": "Termix 仅访问您已配置功能或主动发起操作所需的资源。",
|
||||
"access": {
|
||||
"connections": {
|
||||
"title": "已配置的系统",
|
||||
"description": "连接您添加的主机与服务,包括 SSH、Telnet、RDP、VNC、Docker、Proxmox、隧道和监控端点。"
|
||||
},
|
||||
"credentials": {
|
||||
"title": "凭据与会话",
|
||||
"description": "使用保存的凭据、密钥、令牌和会话数据,对您请求的连接进行认证并维持会话。"
|
||||
},
|
||||
"storage": {
|
||||
"title": "文件与本地数据",
|
||||
"description": "仅在使用对应功能时读取或写入远程文件、剪贴板数据、导入导出内容和本地偏好设置。"
|
||||
},
|
||||
"services": {
|
||||
"title": "可选集成",
|
||||
"description": "仅在启用或配置后访问 SSO、ACME、Tailscale、Webhook、更新检查和远程同步服务。"
|
||||
}
|
||||
},
|
||||
"telemetryDisclosure": "可在管理员设置中关闭可选分析。Termix 不会有意在分析事件中包含凭据、终端输入或文件内容。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -923,7 +923,7 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminGeneralSettingsSection
|
||||
open={openSections.has("general")}
|
||||
onToggle={() => toggle("general")}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
LayoutPanelLeft,
|
||||
LogOut,
|
||||
Network,
|
||||
Pin,
|
||||
PinOff,
|
||||
Play,
|
||||
Plug,
|
||||
ScrollText,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||
import { getAlertFirings } from "@/api/alerts-api";
|
||||
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
export type RailView =
|
||||
@@ -287,6 +290,20 @@ export function AppRail({
|
||||
: new Set([...hiddenTabs, "termix-id"]);
|
||||
const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs);
|
||||
|
||||
const togglePinned = () => {
|
||||
const next = !pinned;
|
||||
setPinned(next);
|
||||
localStorage.setItem("pinAppRail", String(next));
|
||||
window.dispatchEvent(new Event("pinAppRailChanged"));
|
||||
void getUserPreferences()
|
||||
.then((preferences) => {
|
||||
if (preferences.storageMode === "cloud") {
|
||||
return saveUserPreferences({ pinAppRail: next });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
|
||||
@@ -356,6 +373,35 @@ export function AppRail({
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
||||
<button
|
||||
onClick={togglePinned}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${
|
||||
pinned
|
||||
? "text-accent-brand bg-accent-brand/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
title={
|
||||
pinned
|
||||
? t("newUi.sidebar.userProfile.unpinAppRail")
|
||||
: t("newUi.sidebar.userProfile.pinAppRail")
|
||||
}
|
||||
aria-pressed={pinned}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
>
|
||||
{pinned ? <PinOff size={16} /> : <Pin size={16} />}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{pinned
|
||||
? t("newUi.sidebar.userProfile.unpinAppRail")
|
||||
: t("newUi.sidebar.userProfile.pinAppRail")}
|
||||
</span>
|
||||
</button>
|
||||
{[
|
||||
{
|
||||
view: "alerts" as RailView,
|
||||
|
||||
@@ -85,6 +85,7 @@ import { useTheme } from "@/components/theme-provider";
|
||||
import type { FontSizeId, ThemeId } from "@/types/ui-types";
|
||||
import { toast } from "sonner";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import { LegalDisclosure } from "@/legal/LegalDisclosure";
|
||||
|
||||
type UserProfileSection =
|
||||
| "account"
|
||||
@@ -92,7 +93,8 @@ type UserProfileSection =
|
||||
| "security"
|
||||
| "api-keys"
|
||||
| "data"
|
||||
| "c2s-tunnels";
|
||||
| "c2s-tunnels"
|
||||
| "legal";
|
||||
|
||||
const THEMES: { id: ThemeId; preview: string }[] = [
|
||||
{ id: "system", preview: "auto" },
|
||||
@@ -1308,7 +1310,7 @@ export function UserProfilePanel({
|
||||
const canChangePasword = !isOidc || isDualAuth;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3">
|
||||
<NewApiKeyDialog
|
||||
open={newKeyOpen}
|
||||
onOpenChange={setNewKeyOpen}
|
||||
@@ -2063,6 +2065,16 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection
|
||||
id="legal"
|
||||
label={t("newUi.sidebar.userProfile.sectionLegal")}
|
||||
icon={<ScrollText className="size-3.5" />}
|
||||
open={openSections.has("legal")}
|
||||
onToggle={() => toggle("legal")}
|
||||
>
|
||||
<LegalDisclosure />
|
||||
</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. */}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "../../i18n/i18n";
|
||||
import {
|
||||
changeAppLanguage,
|
||||
consumeLoginLanguage,
|
||||
normalizeLanguageCode,
|
||||
rememberLoginLanguage,
|
||||
} from "../../i18n/i18n";
|
||||
|
||||
describe("i18n language handling", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("normalizes persisted desktop language codes", () => {
|
||||
@@ -18,4 +24,10 @@ describe("i18n language handling", () => {
|
||||
await expect(changeAppLanguage("zh_CN")).resolves.toBe("zh-CN");
|
||||
expect(localStorage.getItem("i18nextLng")).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("keeps an explicit login language until preferences are hydrated", () => {
|
||||
expect(rememberLoginLanguage("zh_CN")).toBe("zh-CN");
|
||||
expect(consumeLoginLanguage()).toBe("zh-CN");
|
||||
expect(consumeLoginLanguage()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user