mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
improve settings navigation and legal disclosure (#1105)
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
+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