v1.9.0 #437
15
README-CN.md
15
README-CN.md
@@ -54,14 +54,17 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
|
||||
- **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥
|
||||
- **服务器统计** - 在任何 SSH 服务器上查看 CPU、内存和磁盘使用情况以及网络、正常运行时间和系统信息
|
||||
- **仪表板** - 在仪表板上一目了然地查看服务器信息
|
||||
- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。
|
||||
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件
|
||||
- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地帐户链接在一起。
|
||||
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多信息。
|
||||
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据
|
||||
- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向
|
||||
- **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁的桌面/移动设备友好界面
|
||||
- **语言** - 内置支持英语、中文、德语和葡萄牙语
|
||||
- **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 macOS)以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序。
|
||||
- **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令。
|
||||
- **命令历史** - 自动完成并查看以前运行的 SSH 命令
|
||||
- **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接
|
||||
- **SSH 功能丰富** - 支持跳板机、warpgate、基于 TOTP 的连接等。
|
||||
|
||||
# 计划功能
|
||||
|
||||
@@ -75,16 +78,16 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
|
||||
- Windows(x64/ia32)
|
||||
- 便携版
|
||||
- MSI 安装程序
|
||||
- Chocolatey 软件包管理器
|
||||
- Chocolatey 软件包管理器(即将推出)
|
||||
- Linux(x64/ia32)
|
||||
- 便携版
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- Flatpak(即将推出)
|
||||
- macOS(x64/ia32 on v12.0+)
|
||||
- Apple App Store
|
||||
- Apple App Store(即将推出)
|
||||
- DMG
|
||||
- Homebrew
|
||||
- Homebrew(即将推出)
|
||||
- iOS/iPadOS(v15.1+)
|
||||
- Apple App Store
|
||||
- ISO
|
||||
|
||||
15
README.md
15
README.md
@@ -56,14 +56,17 @@ free and self-hosted alternative to Termius available for all platforms.
|
||||
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys
|
||||
- **Server Stats** - View CPU, memory, and disk usage along with network, uptime, and system information on any SSH server
|
||||
- **Dashboard** - View server information at a glance on your dashboard
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions.
|
||||
- **Database Encryption** - Backend stored as encrypted SQLite database files
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
|
||||
- **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.
|
||||
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data
|
||||
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
|
||||
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn
|
||||
- **Languages** - Built-in support for English, Chinese, German, and Portuguese
|
||||
- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS), and dedicated mobile/tablet app for iOS and Android.
|
||||
- **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
|
||||
- **Command History** - Auto-complete and view previously ran SSH commands
|
||||
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
|
||||
- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, etc.
|
||||
|
||||
# Planned Features
|
||||
|
||||
@@ -77,16 +80,16 @@ Supported Devices:
|
||||
- Windows (x64/ia32)
|
||||
- Portable
|
||||
- MSI Installer
|
||||
- Chocolatey Package Manager
|
||||
- Chocolatey Package Manager (coming soon)
|
||||
- Linux (x64/ia32)
|
||||
- Portable
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- Flatpak (coming soon)
|
||||
- macOS (x64/ia32 on v12.0+)
|
||||
- Apple App Store
|
||||
- Apple App Store (coming soon)
|
||||
- DMG
|
||||
- Homebrew
|
||||
- Homebrew (coming soon)
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- ISO
|
||||
|
||||
@@ -80,7 +80,10 @@ router.get(
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.selectDistinct({ command: commandHistory.command })
|
||||
.select({
|
||||
command: commandHistory.command,
|
||||
maxExecutedAt: sql<number>`MAX(${commandHistory.executedAt})`,
|
||||
})
|
||||
.from(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
@@ -88,10 +91,11 @@ router.get(
|
||||
eq(commandHistory.hostId, hostIdNum),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(commandHistory.executedAt))
|
||||
.groupBy(commandHistory.command)
|
||||
.orderBy(desc(sql`MAX(${commandHistory.executedAt})`))
|
||||
.limit(500);
|
||||
|
||||
const uniqueCommands = Array.from(new Set(result.map((r) => r.command)));
|
||||
const uniqueCommands = result.map((r) => r.command);
|
||||
|
||||
res.json(uniqueCommands);
|
||||
} catch (err) {
|
||||
|
||||
@@ -752,9 +752,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
let user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
and(eq(users.is_oidc, true), eq(users.oidc_identifier, identifier)),
|
||||
);
|
||||
.where(eq(users.oidc_identifier, identifier));
|
||||
|
||||
let isFirstUser = false;
|
||||
if (!user || user.length === 0) {
|
||||
@@ -851,10 +849,15 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
|
||||
user = await db.select().from(users).where(eq(users.id, id));
|
||||
} else {
|
||||
const isDualAuth =
|
||||
user[0].password_hash && user[0].password_hash.trim() !== "";
|
||||
|
||||
if (!isDualAuth) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ username: name })
|
||||
.where(eq(users.id, user[0].id));
|
||||
}
|
||||
|
||||
user = await db.select().from(users).where(eq(users.id, user[0].id));
|
||||
}
|
||||
@@ -1169,11 +1172,17 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
return res.status(401).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const hasPassword =
|
||||
user[0].password_hash && user[0].password_hash.trim() !== "";
|
||||
const hasOidc = user[0].is_oidc && user[0].oidc_identifier;
|
||||
const isDualAuth = hasPassword && hasOidc;
|
||||
|
||||
res.json({
|
||||
userId: user[0].id,
|
||||
username: user[0].username,
|
||||
is_admin: !!user[0].is_admin,
|
||||
is_oidc: !!user[0].is_oidc,
|
||||
is_dual_auth: isDualAuth,
|
||||
totp_enabled: !!user[0].totp_enabled,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -169,6 +169,39 @@ class UserCrypto {
|
||||
sessionDurationMs: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const oidcEncryptedDEK = await this.getOIDCEncryptedDEK(userId);
|
||||
|
||||
if (oidcEncryptedDEK) {
|
||||
const systemKey = this.deriveOIDCSystemKey(userId);
|
||||
const DEK = this.decryptDEK(oidcEncryptedDEK, systemKey);
|
||||
systemKey.fill(0);
|
||||
|
||||
if (!DEK || DEK.length === 0) {
|
||||
databaseLogger.error(
|
||||
"Failed to decrypt OIDC DEK for dual-auth user",
|
||||
{
|
||||
operation: "oidc_auth_dual_decrypt_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const oldSession = this.userSessions.get(userId);
|
||||
if (oldSession) {
|
||||
oldSession.dataKey.fill(0);
|
||||
}
|
||||
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: Buffer.from(DEK),
|
||||
expiresAt: now + sessionDurationMs,
|
||||
});
|
||||
|
||||
DEK.fill(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
const encryptedDEK = await this.getEncryptedDEK(userId);
|
||||
|
||||
@@ -201,7 +234,12 @@ class UserCrypto {
|
||||
DEK.fill(0);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
databaseLogger.error("OIDC authentication failed", error, {
|
||||
operation: "oidc_auth_error",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown",
|
||||
});
|
||||
await this.setupOIDCUserEncryption(userId, sessionDurationMs);
|
||||
return true;
|
||||
}
|
||||
@@ -327,25 +365,21 @@ class UserCrypto {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a password-based user's encryption to OIDC encryption.
|
||||
* Convert a password-based user's encryption to DUAL-AUTH encryption.
|
||||
* This is used when linking an OIDC account to a password account for dual-auth.
|
||||
*
|
||||
* If user has no existing encryption setup, this does nothing.
|
||||
* If user has encryption but no active session, we delete the old keys
|
||||
* and let OIDC create new ones on next login (data will be lost).
|
||||
* If user has an active session, we re-encrypt their DEK with OIDC system key (data preserved).
|
||||
* IMPORTANT: This does NOT delete the password-based KEK salt!
|
||||
* The user needs to maintain BOTH password and OIDC authentication methods.
|
||||
* We keep the password KEK salt so password login still works.
|
||||
* We also store the DEK encrypted with OIDC system key for OIDC login.
|
||||
*/
|
||||
async convertToOIDCEncryption(userId: string): Promise<void> {
|
||||
try {
|
||||
const { getDb } = await import("../database/db/index.js");
|
||||
const { settings } = await import("../database/db/schema.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
|
||||
const existingEncryptedDEK = await this.getEncryptedDEK(userId);
|
||||
const existingKEKSalt = await this.getKEKSalt(userId);
|
||||
|
||||
if (!existingEncryptedDEK && !existingKEKSalt) {
|
||||
databaseLogger.info("No existing encryption to convert for user", {
|
||||
databaseLogger.warn("No existing encryption to convert for user", {
|
||||
operation: "convert_to_oidc_encryption_skip",
|
||||
userId,
|
||||
});
|
||||
@@ -354,41 +388,44 @@ class UserCrypto {
|
||||
|
||||
const existingDEK = this.getUserDataKey(userId);
|
||||
|
||||
if (existingDEK) {
|
||||
if (!existingDEK) {
|
||||
throw new Error(
|
||||
"Cannot convert to OIDC encryption - user session not active. Please log in with password first.",
|
||||
);
|
||||
}
|
||||
|
||||
const systemKey = this.deriveOIDCSystemKey(userId);
|
||||
const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey);
|
||||
systemKey.fill(0);
|
||||
|
||||
await this.storeEncryptedDEK(userId, oidcEncryptedDEK);
|
||||
const key = `user_encrypted_dek_oidc_${userId}`;
|
||||
const value = JSON.stringify(oidcEncryptedDEK);
|
||||
|
||||
const { getDb } = await import("../database/db/index.js");
|
||||
const { settings } = await import("../database/db/schema.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await getDb()
|
||||
.update(settings)
|
||||
.set({ value })
|
||||
.where(eq(settings.key, key));
|
||||
} else {
|
||||
await getDb().insert(settings).values({ key, value });
|
||||
}
|
||||
|
||||
databaseLogger.info(
|
||||
"Converted user encryption from password to OIDC (data preserved)",
|
||||
"Converted user encryption to dual-auth (password + OIDC)",
|
||||
{
|
||||
operation: "convert_to_oidc_encryption_preserved",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (existingEncryptedDEK) {
|
||||
await getDb()
|
||||
.delete(settings)
|
||||
.where(eq(settings.key, `user_encrypted_dek_${userId}`));
|
||||
}
|
||||
|
||||
databaseLogger.warn(
|
||||
"Deleted old encryption keys during OIDC conversion - user data may be lost",
|
||||
{
|
||||
operation: "convert_to_oidc_encryption_data_loss",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (existingKEKSalt) {
|
||||
await getDb()
|
||||
.delete(settings)
|
||||
.where(eq(settings.key, `user_kek_salt_${userId}`));
|
||||
}
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
@@ -582,6 +619,26 @@ class UserCrypto {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getOIDCEncryptedDEK(
|
||||
userId: string,
|
||||
): Promise<EncryptedDEK | null> {
|
||||
try {
|
||||
const key = `user_encrypted_dek_oidc_${userId}`;
|
||||
const result = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(result[0].value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { UserCrypto, type KEKSalt, type EncryptedDEK };
|
||||
|
||||
@@ -510,6 +510,10 @@
|
||||
"accountsLinkedSuccessfully": "OIDC user {{oidcUsername}} has been linked to {{targetUsername}}",
|
||||
"failedToLinkAccounts": "Failed to link accounts",
|
||||
"linkTargetUsernameRequired": "Target username is required",
|
||||
"unlinkOIDCTitle": "Unlink OIDC Authentication",
|
||||
"unlinkOIDCDescription": "Remove OIDC authentication from {{username}}? The user will only be able to login with username/password after this.",
|
||||
"unlinkOIDCSuccess": "OIDC unlinked from {{username}}",
|
||||
"failedToUnlinkOIDC": "Failed to unlink OIDC",
|
||||
"databaseSecurity": "Database Security",
|
||||
"encryptionStatus": "Encryption Status",
|
||||
"encryptionEnabled": "Encryption Enabled",
|
||||
@@ -1576,6 +1580,7 @@
|
||||
"authMethod": "Authentication Method",
|
||||
"local": "Local",
|
||||
"external": "External (OIDC)",
|
||||
"externalAndLocal": "Dual Auth",
|
||||
"selectPreferredLanguage": "Select your preferred language for the interface",
|
||||
"fileColorCoding": "File Color Coding",
|
||||
"fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)",
|
||||
|
||||
@@ -183,13 +183,13 @@ function AppContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden">
|
||||
<div className="h-screen w-screen overflow-hidden bg-background">
|
||||
<CommandPalette
|
||||
isOpen={isCommandPaletteOpen}
|
||||
setIsOpen={setIsCommandPaletteOpen}
|
||||
/>
|
||||
{!isAuthenticated && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
|
||||
<Dashboard
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
@@ -293,6 +293,8 @@ function AppContent() {
|
||||
animation:
|
||||
"ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
animationDelay: "0ms",
|
||||
willChange: "width, height, opacity",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -301,6 +303,8 @@ function AppContent() {
|
||||
animation:
|
||||
"ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
animationDelay: "200ms",
|
||||
willChange: "width, height, opacity",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -309,6 +313,8 @@ function AppContent() {
|
||||
animation:
|
||||
"ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
animationDelay: "400ms",
|
||||
willChange: "width, height, opacity",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -317,6 +323,8 @@ function AppContent() {
|
||||
animation:
|
||||
"ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
animationDelay: "600ms",
|
||||
willChange: "width, height, opacity",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -324,6 +332,7 @@ function AppContent() {
|
||||
style={{
|
||||
animation:
|
||||
"logoFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
willChange: "opacity, transform",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -333,6 +342,7 @@ function AppContent() {
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
animation:
|
||||
"logoGlow 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
willChange: "color, text-shadow",
|
||||
}}
|
||||
>
|
||||
TERMIX
|
||||
@@ -342,6 +352,7 @@ function AppContent() {
|
||||
style={{
|
||||
animation:
|
||||
"subtitleFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
willChange: "opacity, transform",
|
||||
}}
|
||||
>
|
||||
SSH SERVER MANAGER
|
||||
@@ -370,23 +381,19 @@ function AppContent() {
|
||||
@keyframes logoFade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
filter: blur(8px);
|
||||
transform: scale(0.85) translateZ(0);
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: blur(0px);
|
||||
transform: scale(1) translateZ(0);
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: blur(0px);
|
||||
transform: scale(1) translateZ(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.05);
|
||||
filter: blur(4px);
|
||||
transform: scale(1.05) translateZ(0);
|
||||
}
|
||||
}
|
||||
@keyframes logoGlow {
|
||||
@@ -416,19 +423,19 @@ function AppContent() {
|
||||
@keyframes subtitleFade {
|
||||
0%, 30% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transform: translateY(10px) translateZ(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translateY(0) translateZ(0);
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translateY(0) translateZ(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
transform: translateY(-5px) translateZ(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
@@ -695,28 +695,28 @@ export function AdminSettings({
|
||||
};
|
||||
|
||||
const handleUnlinkOIDC = async (userId: string, username: string) => {
|
||||
const confirmed = await confirm(
|
||||
{
|
||||
title: "Unlink OIDC Authentication",
|
||||
description: `Remove OIDC authentication from ${username}? The user will only be able to login with username/password after this.`,
|
||||
},
|
||||
"default",
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.unlinkOIDCDescription", { username }),
|
||||
async () => {
|
||||
try {
|
||||
const result = await unlinkOIDCFromPasswordAccount(userId);
|
||||
|
||||
toast.success(result.message || `OIDC unlinked from ${username}`);
|
||||
toast.success(
|
||||
result.message || t("admin.unlinkOIDCSuccess", { username }),
|
||||
);
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
};
|
||||
toast.error(err.response?.data?.error || "Failed to unlink OIDC");
|
||||
toast.error(
|
||||
err.response?.data?.error || t("admin.failedToUnlinkOIDC"),
|
||||
);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
|
||||
@@ -153,7 +153,6 @@ export function PermissionsDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Current info */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<Label className="text-gray-400">
|
||||
@@ -171,7 +170,6 @@ export function PermissionsDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Owner permissions */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("fileManager.owner")} {file.owner && `(${file.owner})`}
|
||||
@@ -215,7 +213,6 @@ export function PermissionsDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group permissions */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("fileManager.group")} {file.group && `(${file.group})`}
|
||||
@@ -259,7 +256,6 @@ export function PermissionsDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Others permissions */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("fileManager.others")}
|
||||
|
||||
@@ -2933,6 +2933,20 @@ export function HostManagerEditor({
|
||||
<TabsContent value="statistics" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://docs.termix.site/server-stats",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="statsConfig.statusCheckEnabled"
|
||||
|
||||
@@ -107,7 +107,6 @@ export function FolderEditDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Color Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("hosts.folderColor")}
|
||||
@@ -130,7 +129,6 @@ export function FolderEditDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("hosts.folderIcon")}
|
||||
@@ -154,7 +152,6 @@ export function FolderEditDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("hosts.preview")}
|
||||
|
||||
@@ -215,6 +215,7 @@ export function SSHToolsSidebar({
|
||||
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0);
|
||||
const commandHistoryScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -248,12 +249,18 @@ export function SSHToolsSidebar({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (isOpen && activeTab === "command-history") {
|
||||
if (activeTerminalHostId) {
|
||||
const scrollTop = commandHistoryScrollRef.current?.scrollTop || 0;
|
||||
setIsHistoryLoading(true);
|
||||
setHistoryError(null);
|
||||
|
||||
getCommandHistory(activeTerminalHostId)
|
||||
.then((history) => {
|
||||
if (cancelled) return;
|
||||
|
||||
setCommandHistory((prevHistory) => {
|
||||
const newHistory = Array.isArray(history) ? history : [];
|
||||
if (JSON.stringify(prevHistory) !== JSON.stringify(newHistory)) {
|
||||
@@ -266,15 +273,33 @@ export function SSHToolsSidebar({
|
||||
}
|
||||
return prevHistory;
|
||||
});
|
||||
setIsHistoryLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
|
||||
console.error("Failed to fetch command history", err);
|
||||
const errorMessage =
|
||||
err?.response?.status === 401
|
||||
? "Authentication required. Please refresh the page."
|
||||
: err?.response?.status === 403
|
||||
? "Data access locked. Please re-authenticate."
|
||||
: err?.message || "Failed to load command history";
|
||||
|
||||
setHistoryError(errorMessage);
|
||||
setCommandHistory([]);
|
||||
setIsHistoryLoading(false);
|
||||
});
|
||||
} else {
|
||||
setCommandHistory([]);
|
||||
setHistoryError(null);
|
||||
setIsHistoryLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isOpen,
|
||||
activeTab,
|
||||
@@ -1304,7 +1329,6 @@ export function SSHToolsSidebar({
|
||||
|
||||
return (
|
||||
<div key={folderName || "uncategorized"}>
|
||||
{/* Folder Header */}
|
||||
<div className="flex items-center gap-2 mb-2 hover:bg-dark-hover-alt p-2 rounded-lg transition-colors group/folder">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||
@@ -1382,7 +1406,6 @@ export function SSHToolsSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Folder Content */}
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-2 ml-6">
|
||||
{folderSnippets.map((snippet) => (
|
||||
@@ -1556,7 +1579,28 @@ export function SSHToolsSidebar({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
{!activeTerminal ? (
|
||||
{historyError ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-4">
|
||||
<p className="text-destructive font-medium mb-2">
|
||||
{t("commandHistory.error", {
|
||||
defaultValue: "Error loading history",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{historyError}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setHistoryRefreshCounter((prev) => prev + 1)
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.retry", { defaultValue: "Retry" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : !activeTerminal ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Terminal className="h-12 w-12 mb-4 opacity-20 mx-auto" />
|
||||
<p className="mb-2 font-medium">
|
||||
@@ -1571,6 +1615,15 @@ export function SSHToolsSidebar({
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : isHistoryLoading && commandHistory.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Loader2 className="h-12 w-12 mb-4 opacity-20 mx-auto animate-spin" />
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.loading", {
|
||||
defaultValue: "Loading command history...",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredCommands.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? (
|
||||
@@ -1649,7 +1702,6 @@ export function SSHToolsSidebar({
|
||||
className="flex flex-col flex-1 overflow-hidden"
|
||||
>
|
||||
<div className="space-y-4 flex-1 overflow-y-auto overflow-x-hidden pb-4">
|
||||
{/* Split Mode Tabs */}
|
||||
<Tabs
|
||||
value={splitMode}
|
||||
onValueChange={(value) =>
|
||||
@@ -1681,12 +1733,10 @@ export function SSHToolsSidebar({
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Drag-and-Drop Interface */}
|
||||
{splitMode !== "none" && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* Available Tabs List */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{t("splitScreen.availableTabs", {
|
||||
@@ -1735,7 +1785,6 @@ export function SSHToolsSidebar({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Drop Grid */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{t("splitScreen.layout", {
|
||||
@@ -1816,7 +1865,6 @@ export function SSHToolsSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleApplySplit}
|
||||
@@ -1840,7 +1888,6 @@ export function SSHToolsSidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Help Text for None mode */}
|
||||
{splitMode === "none" && (
|
||||
<div className="text-center py-8">
|
||||
<LayoutGrid className="h-12 w-12 mb-4 opacity-20 mx-auto" />
|
||||
@@ -2097,7 +2144,6 @@ export function SSHToolsSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-white">
|
||||
{t("snippets.folderColor", { defaultValue: "Folder Color" })}
|
||||
@@ -2125,7 +2171,6 @@ export function SSHToolsSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-white">
|
||||
{t("snippets.folderIcon", { defaultValue: "Folder Icon" })}
|
||||
@@ -2151,7 +2196,6 @@ export function SSHToolsSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-white">
|
||||
{t("snippets.preview", { defaultValue: "Preview" })}
|
||||
|
||||
@@ -851,9 +851,7 @@ export function Auth({
|
||||
className={`fixed inset-0 flex items-center justify-center ${className || ""}`}
|
||||
{...props}
|
||||
>
|
||||
{/* Split Screen Layout */}
|
||||
<div className="w-full h-full flex flex-col md:flex-row">
|
||||
{/* Left Side - Brand Showcase */}
|
||||
<div
|
||||
className="hidden md:flex md:w-2/5 items-center justify-center relative border-r-2 border-bg-border-dark"
|
||||
style={{
|
||||
@@ -867,7 +865,6 @@ export function Auth({
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
{/* Logo and Branding */}
|
||||
<div className="relative text-center px-8">
|
||||
<div
|
||||
className="text-7xl font-bold tracking-wider mb-4 text-foreground"
|
||||
@@ -884,7 +881,6 @@ export function Auth({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Auth Form */}
|
||||
<div className="flex-1 flex p-6 md:p-12 bg-background overflow-y-auto">
|
||||
<div className="m-auto w-full max-w-md backdrop-blur-sm bg-card/50 rounded-2xl p-8 shadow-xl border-2 border-dark-border animate-in fade-in slide-in-from-bottom-4 duration-500 flex flex-col">
|
||||
{isInElectronWebView() && !webviewAuthSuccess && (
|
||||
@@ -991,7 +987,6 @@ export function Auth({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Tab Navigation */}
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
@@ -1043,7 +1038,6 @@ export function Auth({
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Page Title */}
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="text-2xl font-bold">
|
||||
{tab === "login"
|
||||
|
||||
@@ -82,6 +82,7 @@ export function UserProfile({
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
is_dual_auth: boolean;
|
||||
totp_enabled: boolean;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -125,6 +126,7 @@ export function UserProfile({
|
||||
username: info.username,
|
||||
is_admin: info.is_admin,
|
||||
is_oidc: info.is_oidc,
|
||||
is_dual_auth: info.is_dual_auth || false,
|
||||
totp_enabled: info.totp_enabled || false,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
@@ -263,7 +265,7 @@ export function UserProfile({
|
||||
<User className="w-4 h-4" />
|
||||
{t("nav.userProfile")}
|
||||
</TabsTrigger>
|
||||
{!userInfo.is_oidc && (
|
||||
{(!userInfo.is_oidc || userInfo.is_dual_auth) && (
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
|
||||
@@ -303,7 +305,9 @@ export function UserProfile({
|
||||
{t("profile.authMethod")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{userInfo.is_oidc
|
||||
{userInfo.is_dual_auth
|
||||
? t("profile.externalAndLocal")
|
||||
: userInfo.is_oidc
|
||||
? t("profile.external")
|
||||
: t("profile.local")}
|
||||
</p>
|
||||
@@ -313,7 +317,7 @@ export function UserProfile({
|
||||
{t("profile.twoFactorAuth")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_oidc ? (
|
||||
{userInfo.is_oidc && !userInfo.is_dual_auth ? (
|
||||
<span className="text-gray-400">
|
||||
{t("auth.lockedOidcAuth")}
|
||||
</span>
|
||||
@@ -417,7 +421,9 @@ export function UserProfile({
|
||||
onStatusChange={handleTOTPStatusChange}
|
||||
/>
|
||||
|
||||
{!userInfo.is_oidc && <PasswordReset userInfo={userInfo} />}
|
||||
{(!userInfo.is_oidc || userInfo.is_dual_auth) && (
|
||||
<PasswordReset userInfo={userInfo} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user