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