improve settings navigation and legal disclosure (#1105)

This commit is contained in:
ZacharyZcR
2026-07-28 02:20:56 +08:00
committed by GitHub
parent 44a4534baa
commit a9c2aec165
12 changed files with 401 additions and 27 deletions
+25
View File
@@ -0,0 +1,25 @@
# Termix Access Disclosure
Last updated: July 27, 2026
Termix accesses resources only when required by a configured feature or an action initiated by a user.
## Configured systems
Termix can connect to hosts and services you add, including SSH, Telnet, RDP, VNC, Docker, Proxmox, serial devices, tunnels, monitoring endpoints, and remote Termix servers.
## Credentials and sessions
Termix can use saved usernames, passwords, private keys, certificates, tokens, cookies, and session metadata to authenticate and maintain requested connections. Administrators should restrict instance access and review stored credentials, roles, API keys, active sessions, and shares.
## Files, clipboard, and local data
When requested, Termix can read or write remote files, transfer files, use clipboard data, import or export database backups, and store browser or desktop preferences. Operating systems and browsers may display additional permission prompts.
## Optional integrations
Termix contacts external services only when the related feature is enabled or configured. These can include SSO identity providers, ACME certificate authorities, Tailscale, webhooks, update services, analytics, and remote synchronization servers.
## Analytics
Optional analytics can be disabled in Admin Settings. Analytics events are intended to describe product usage and technical state. They should not intentionally include credentials, terminal input, or file contents.
+25
View File
@@ -0,0 +1,25 @@
# Termix Terms of Use
Last updated: July 27, 2026
By using Termix, you agree to use it only with systems, accounts, networks, and data that you are authorized to access.
## Authorized use
You are responsible for obtaining permission before connecting to a host, opening a remote file, operating a service, or sharing a session. You must not use Termix to disrupt services, bypass access controls, violate privacy, or perform unlawful activity.
## Account and credential security
You are responsible for protecting access to your Termix instance and for securing passwords, private keys, tokens, API keys, database backups, and exported data. Review user roles, active sessions, shared sessions, and configured integrations regularly.
## Data and backups
Termix stores the data required to provide the features you configure. You are responsible for determining whether that storage is appropriate for your organization and jurisdiction. Maintain tested backups before relying on Termix for critical operations.
## Availability
Termix is provided without a service availability guarantee. Features that depend on remote hosts, browsers, operating systems, or third-party services may be unavailable or behave differently as those systems change.
## Changes
These terms may change as Termix gains new capabilities. Material changes should be reviewed before deploying a new release.
+72 -9
View File
@@ -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"
onClick={() => setSidebarOpen(false)}
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={() => {
setSettingsFullscreen(false);
setSidebarOpen(false);
}}
>
<ChevronLeft className="size-4" />
</Button>
@@ -1904,6 +1957,7 @@ export function AppShell({
)}
<div className="flex flex-1 min-h-0">
{/* Skinny icon rail — desktop only, hidden on mobile */}
{!settingsFullscreen && (
<AppRail
railView={railView}
sidebarOpen={sidebarOpen}
@@ -1914,20 +1968,27 @@ export function AppShell({
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
View File
@@ -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>
+13
View File
@@ -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;
+96
View File
@@ -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
View File
@@ -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."
}
}
},
+33 -1
View File
@@ -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 不会有意在分析事件中包含凭据、终端输入或文件内容。"
}
}
},
+1 -1
View File
@@ -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")}
+46
View File
@@ -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,
+14 -2
View File
@@ -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. */}
+13 -1
View File
@@ -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();
});
});