v1.9.0 #437

Merged
LukeGus merged 33 commits from dev-1.9.0 into main 2025-11-17 15:46:05 +00:00
14 changed files with 267 additions and 128 deletions
Showing only changes of commit 3feaced488 - Show all commits

View File

@@ -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 是一个开源、永久免费、自托管的一体化服务器管理平
- Windowsx64/ia32
- 便携版
- MSI 安装程序
- Chocolatey 软件包管理器
- Chocolatey 软件包管理器(即将推出)
- Linuxx64/ia32
- 便携版
- AppImage
- Deb
- Flatpak
- Flatpak(即将推出)
- macOSx64/ia32 on v12.0+
- Apple App Store
- Apple App Store(即将推出)
- DMG
- Homebrew
- Homebrew(即将推出)
- iOS/iPadOSv15.1+
- Apple App Store
- ISO

View File

@@ -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

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 };

View File

@@ -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)",

View File

@@ -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>

View File

@@ -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;

View File

@@ -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")}

View File

@@ -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"

View File

@@ -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")}

View File

@@ -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" })}

View File

@@ -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"

View File

@@ -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>