feat: Squashed commit of fixing "none" authentication and adding a sessions system for mobile, electron, and web

This commit is contained in:
LukeGus
2025-10-31 12:55:01 -05:00
parent cf431e59ac
commit 1bc40b66b3
23 changed files with 2545 additions and 454 deletions

View File

@@ -45,22 +45,25 @@ If you would like, you can support the project here!\
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
access, SSH tunneling capabilities, and remote file management, with many more tools to come.
access, SSH tunneling capabilities, and remote file management, with many more tools to come. Termix is the perfect
free and self-hosted alternative to Termius available for all platforms.
# Features
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly.
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly
- **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 HDD usage on any SSH server
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
- **Database Encryption** - SQLite database files encrypted at rest with automatic encryption/decryption
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data with incremental sync
- **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
- **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, and German
- **Platform Support** - Available as a web app, desktop application (Windows & Linux), and dedicated mobile app for iOS and Android. macOS and iPadOS support is planned.
- **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.
# Planned Features
@@ -70,12 +73,26 @@ See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned fe
Supported Devices:
- Website (any modern browser like Google, Safari, and Firefox)
- Windows (app)
- Linux (app)
- iOS (app)
- Android (app)
- iPadOS and macOS are in progress
- Website (any modern browser on any platform like Chrome, Safari, and Firefox)
- Windows (x64/ia32)
- Portable EXE
- MSI Installer
- Chocolatey Package Manager
- Linux (x64/ia32)
- Portable EXE
- Appimage
- Deb
- Flatpak
- macOS (x64/ia32 on v12.0+)
- Apple App Store
- DMG
- Homebrew
- iOS (v15.1+)
- Apple App Store
- ISO
- Android (v7.0+)
- Google Play Store
- APK
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix on all platforms. Otherwise, view
a sample Docker Compose file here:

View File

@@ -146,6 +146,18 @@ async function initializeCompleteDatabase(): Promise<void> {
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
jwt_token TEXT NOT NULL,
device_type TEXT NOT NULL,
device_info TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT NOT NULL,
last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -415,6 +427,37 @@ const migrateSchema = () => {
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
// Create sessions table if it doesn't exist (for existing databases)
try {
sqlite
.prepare("SELECT id FROM sessions LIMIT 1")
.get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
jwt_token TEXT NOT NULL,
device_type TEXT NOT NULL,
device_info TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT NOT NULL,
last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
`);
databaseLogger.info("Sessions table created via migration", {
operation: "schema_migration",
});
} catch (createError) {
databaseLogger.warn("Failed to create sessions table", {
operation: "schema_migration",
error: createError,
});
}
}
databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});

View File

@@ -30,6 +30,23 @@ export const settings = sqliteTable("settings", {
value: text("value").notNull(),
});
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
jwtToken: text("jwt_token").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: text("expires_at").notNull(),
lastActiveAt: text("last_active_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const sshData = sqliteTable("ssh_data", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")

View File

@@ -4,6 +4,7 @@ import crypto from "crypto";
import { db } from "../db/index.js";
import {
users,
sessions,
sshData,
sshCredentials,
fileManagerRecent,
@@ -25,6 +26,7 @@ import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
const authManager = AuthManager.getInstance();
@@ -810,8 +812,18 @@ router.get("/oidc/callback", async (req, res) => {
});
}
// Detect platform and device info
const deviceInfo = parseUserAgent(req);
const token = await authManager.generateJWTToken(userRecord.id, {
expiresIn: "50d",
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
authLogger.success("OIDC user authenticated", {
operation: "oidc_login_success",
userId: userRecord.id,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
let frontendUrl = (redirectUri as string).replace(
@@ -826,12 +838,14 @@ router.get("/oidc/callback", async (req, res) => {
const redirectUrl = new URL(frontendUrl);
redirectUrl.searchParams.set("success", "true");
// Calculate max age based on device type
const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
: 7 * 24 * 60 * 60 * 1000;
return res
.cookie(
"jwt",
token,
authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000),
)
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
.redirect(redirectUrl.toString());
} catch (err) {
authLogger.error("OIDC callback failed", err);
@@ -951,8 +965,11 @@ router.post("/login", async (req, res) => {
});
}
// Detect platform and device info
const deviceInfo = parseUserAgent(req);
const token = await authManager.generateJWTToken(userRecord.id, {
expiresIn: "7d",
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
authLogger.success(`User logged in successfully: ${username}`, {
@@ -960,6 +977,8 @@ router.post("/login", async (req, res) => {
username,
userId: userRecord.id,
dataUnlocked: true,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
const response: Record<string, unknown> = {
@@ -976,12 +995,14 @@ router.post("/login", async (req, res) => {
response.token = token;
}
// Calculate max age based on device type
const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
: 7 * 24 * 60 * 60 * 1000;
return res
.cookie(
"jwt",
token,
authManager.getSecureCookieOptions(req, 7 * 24 * 60 * 60 * 1000),
)
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
.json(response);
} catch (err) {
authLogger.error("Failed to log in user", err);
@@ -1793,8 +1814,11 @@ router.post("/totp/verify-login", async (req, res) => {
.where(eq(users.id, userRecord.id));
}
// Detect platform and device info
const deviceInfo = parseUserAgent(req);
const token = await authManager.generateJWTToken(userRecord.id, {
expiresIn: "50d",
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
const isElectron =
@@ -1810,6 +1834,13 @@ router.post("/totp/verify-login", async (req, res) => {
});
}
authLogger.success("TOTP verification successful", {
operation: "totp_verify_success",
userId: userRecord.id,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
const response: Record<string, unknown> = {
success: true,
is_admin: !!userRecord.is_admin,
@@ -1824,12 +1855,14 @@ router.post("/totp/verify-login", async (req, res) => {
response.token = token;
}
// Calculate max age based on device type
const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
: 7 * 24 * 60 * 60 * 1000;
return res
.cookie(
"jwt",
token,
authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000),
)
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
.json(response);
} catch (err) {
authLogger.error("TOTP verification failed", err);
@@ -2093,6 +2126,10 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
const targetUserId = targetUser[0].id;
try {
// Delete all user-related data to avoid foreign key constraints
await db
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, targetUserId));
await db
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, targetUserId));
@@ -2102,12 +2139,17 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
await db
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, targetUserId));
await db
.delete(recentActivity)
.where(eq(recentActivity.userId, targetUserId));
await db
.delete(dismissedAlerts)
.where(eq(dismissedAlerts.userId, targetUserId));
await db.delete(snippets).where(eq(snippets.userId, targetUserId));
await db.delete(sshData).where(eq(sshData.userId, targetUserId));
await db
.delete(sshCredentials)
.where(eq(sshCredentials.userId, targetUserId));
} catch (cleanupError) {
authLogger.error(`Cleanup failed for user ${username}:`, cleanupError);
throw cleanupError;
@@ -2253,4 +2295,166 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
}
});
// Route: Get sessions (all for admin, own for user)
// GET /users/sessions
router.get("/sessions", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
let sessionList;
if (userRecord.is_admin) {
// Admin: Get all sessions with user info
sessionList = await authManager.getAllSessions();
// Join with users to get usernames
const enrichedSessions = await Promise.all(
sessionList.map(async (session) => {
const sessionUser = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, session.userId))
.limit(1);
return {
...session,
username: sessionUser[0]?.username || "Unknown",
};
}),
);
return res.json({ sessions: enrichedSessions });
} else {
// Regular user: Get only their own sessions
sessionList = await authManager.getUserSessions(userId);
return res.json({ sessions: sessionList });
}
} catch (err) {
authLogger.error("Failed to get sessions", err);
res.status(500).json({ error: "Failed to get sessions" });
}
});
// Route: Revoke a specific session
// DELETE /users/sessions/:sessionId
router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { sessionId } = req.params;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
// Check if session exists
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1);
if (sessionRecords.length === 0) {
return res.status(404).json({ error: "Session not found" });
}
const session = sessionRecords[0];
// Non-admin users can only revoke their own sessions
if (!userRecord.is_admin && session.userId !== userId) {
return res
.status(403)
.json({ error: "Not authorized to revoke this session" });
}
const success = await authManager.revokeSession(sessionId);
if (success) {
authLogger.success("Session revoked", {
operation: "session_revoke",
sessionId,
revokedBy: userId,
});
res.json({ message: "Session revoked successfully" });
} else {
res.status(500).json({ error: "Failed to revoke session" });
}
} catch (err) {
authLogger.error("Failed to revoke session", err);
res.status(500).json({ error: "Failed to revoke session" });
}
});
// Route: Revoke all sessions for a user
// POST /users/sessions/revoke-all
router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { targetUserId, exceptCurrent } = req.body;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
// Determine which user's sessions to revoke
let revokeUserId = userId;
if (targetUserId && userRecord.is_admin) {
// Admin can revoke any user's sessions
revokeUserId = targetUserId;
} else if (targetUserId && targetUserId !== userId) {
// Non-admin can only revoke their own sessions
return res.status(403).json({
error: "Not authorized to revoke sessions for other users",
});
}
// Get current session ID if needed
let currentSessionId: string | undefined;
if (exceptCurrent) {
const token =
req.cookies?.jwt || req.headers?.authorization?.split(" ")[1];
if (token) {
const payload = await authManager.verifyJWTToken(token);
currentSessionId = payload?.sessionId;
}
}
const revokedCount = await authManager.revokeAllUserSessions(
revokeUserId,
currentSessionId,
);
authLogger.success("User sessions revoked", {
operation: "user_sessions_revoke_all",
revokeUserId,
revokedBy: userId,
exceptCurrent,
revokedCount,
});
res.json({
message: `${revokedCount} session(s) revoked successfully`,
count: revokedCount,
});
} catch (err) {
authLogger.error("Failed to revoke user sessions", err);
res.status(500).json({ error: "Failed to revoke sessions" });
}
});
export default router;

View File

@@ -311,6 +311,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
},
};
let authMethodNotAvailable = false;
if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.sshKey &&
@@ -353,37 +355,30 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
// Use authHandler to control authentication flow
// This ensures we only try keyboard-interactive, not password auth
config.authHandler = (
methodsLeft: string[],
methodsLeft: string[] | null,
partialSuccess: boolean,
callback: (nextMethod: string | false) => void,
) => {
fileLogger.info("Auth handler called", {
operation: "ssh_auth_handler",
hostId,
sessionId,
methodsLeft,
partialSuccess,
});
// Only try keyboard-interactive
if (methodsLeft.includes("keyboard-interactive")) {
callback("keyboard-interactive");
if (methodsLeft && methodsLeft.length > 0) {
if (methodsLeft.includes("keyboard-interactive")) {
callback("keyboard-interactive");
} else {
authMethodNotAvailable = true;
fileLogger.error(
"Server does not support keyboard-interactive auth",
{
operation: "ssh_auth_handler_no_keyboard",
hostId,
sessionId,
methodsAvailable: methodsLeft,
},
);
callback(false);
}
} else {
fileLogger.error("Server does not support keyboard-interactive auth", {
operation: "ssh_auth_handler_no_keyboard",
hostId,
sessionId,
methodsLeft,
});
callback(false); // No more methods to try
callback(false);
}
};
fileLogger.info("Using keyboard-interactive auth (authType: none)", {
operation: "ssh_auth_config",
hostId,
sessionId,
});
} else {
fileLogger.warn(
"No valid authentication method provided for file manager",
@@ -446,13 +441,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
},
},
);
fileLogger.info("File manager activity logged", {
operation: "activity_log",
userId,
hostId,
hostName,
});
} catch (error) {
fileLogger.warn("Failed to log file manager activity", {
operation: "activity_log_error",
@@ -468,16 +456,34 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
client.on("error", (err) => {
if (responseSent) return;
responseSent = true;
fileLogger.error("SSH connection failed for file manager", {
operation: "file_connect",
sessionId,
hostId,
ip,
port,
username,
error: err.message,
});
res.status(500).json({ status: "error", message: err.message });
if (authMethodNotAvailable && resolvedCredentials.authType === "none") {
fileLogger.info(
"Keyboard-interactive not available, requesting credentials",
{
operation: "file_connect_auth_not_available",
sessionId,
hostId,
},
);
res.status(200).json({
status: "auth_required",
message:
"The server does not support keyboard-interactive authentication. Please provide credentials.",
reason: "no_keyboard",
});
} else {
fileLogger.error("SSH connection failed for file manager", {
operation: "file_connect",
sessionId,
hostId,
ip,
port,
username,
error: err.message,
});
res.status(500).json({ status: "error", message: err.message });
}
});
client.on("close", () => {

View File

@@ -364,6 +364,55 @@ wss.on("connection", async (ws: WebSocket, req) => {
break;
}
case "reconnect_with_credentials": {
const credentialsData = data as {
cols: number;
rows: number;
hostConfig: ConnectToHostData["hostConfig"];
password?: string;
sshKey?: string;
keyPassword?: string;
};
// Update the host config with provided credentials
if (credentialsData.password) {
credentialsData.hostConfig.password = credentialsData.password;
credentialsData.hostConfig.authType = "password";
} else if (credentialsData.sshKey) {
credentialsData.hostConfig.key = credentialsData.sshKey;
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
credentialsData.hostConfig.authType = "key";
}
// Cleanup existing connection if any
cleanupSSH();
// Reconnect with new credentials
const reconnectData: ConnectToHostData = {
cols: credentialsData.cols,
rows: credentialsData.rows,
hostConfig: credentialsData.hostConfig,
};
handleConnectToHost(reconnectData).catch((error) => {
sshLogger.error("Failed to reconnect with credentials", error, {
operation: "ssh_reconnect_with_credentials",
userId,
hostId: credentialsData.hostConfig?.id,
ip: credentialsData.hostConfig?.ip,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect with provided credentials: " +
(error instanceof Error ? error.message : "Unknown error"),
}),
);
});
break;
}
default:
sshLogger.warn("Unknown message type received", {
operation: "websocket_message_unknown_type",
@@ -741,6 +790,17 @@ wss.on("connection", async (ws: WebSocket, req) => {
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
// Notify frontend that keyboard-interactive is available (e.g., for Warpgate OIDC)
// This allows the terminal to be displayed immediately so user can see auth prompts
if (resolvedCredentials.authType === "none") {
ws.send(
JSON.stringify({
type: "keyboard_interactive_available",
message: "Keyboard-interactive authentication is available",
}),
);
}
const promptTexts = prompts.map((p) => p.prompt);
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
@@ -931,37 +991,47 @@ wss.on("connection", async (ws: WebSocket, req) => {
};
if (resolvedCredentials.authType === "none") {
// Use authHandler to control authentication flow
// This ensures we only try keyboard-interactive, not password auth
// For "none" auth type, allow natural SSH negotiation
// The authHandler will try keyboard-interactive if available, otherwise notify frontend
// This allows for Warpgate OIDC and other interactive auth scenarios
connectConfig.authHandler = (
methodsLeft: string[],
methodsLeft: string[] | null,
partialSuccess: boolean,
callback: (nextMethod: string | false) => void,
) => {
sshLogger.info("Auth handler called", {
operation: "ssh_auth_handler",
hostId: id,
methodsLeft,
partialSuccess,
});
// Only try keyboard-interactive
if (methodsLeft.includes("keyboard-interactive")) {
callback("keyboard-interactive");
if (methodsLeft && methodsLeft.length > 0) {
// Prefer keyboard-interactive if available
if (methodsLeft.includes("keyboard-interactive")) {
callback("keyboard-interactive");
} else {
// No keyboard-interactive available - notify frontend to show auth dialog
sshLogger.info(
"Server does not support keyboard-interactive auth for 'none' auth type",
{
operation: "ssh_auth_handler_no_keyboard",
hostId: id,
methodsLeft,
},
);
ws.send(
JSON.stringify({
type: "auth_method_not_available",
message:
"The server does not support keyboard-interactive authentication. Please provide credentials.",
methodsAvailable: methodsLeft,
}),
);
callback(false);
}
} else {
sshLogger.error("Server does not support keyboard-interactive auth", {
operation: "ssh_auth_handler_no_keyboard",
// No methods left or empty - try to proceed without auth
sshLogger.info("No auth methods available, proceeding without auth", {
operation: "ssh_auth_no_methods",
hostId: id,
methodsLeft,
});
callback(false); // No more methods to try
callback(false);
}
};
sshLogger.info("Using keyboard-interactive auth (authType: none)", {
operation: "ssh_auth_config",
hostId: id,
});
} else if (resolvedCredentials.authType === "password") {
if (!resolvedCredentials.password) {
sshLogger.error(

View File

@@ -4,6 +4,11 @@ import { SystemCrypto } from "./system-crypto.js";
import { DataCrypto } from "./data-crypto.js";
import { databaseLogger } from "./logger.js";
import type { Request, Response, NextFunction } from "express";
import { db } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js";
import { eq, and, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import type { DeviceType } from "./user-agent-parser.js";
interface AuthenticationResult {
success: boolean;
@@ -18,6 +23,7 @@ interface AuthenticationResult {
interface JWTPayload {
userId: string;
sessionId?: string;
pendingTOTP?: boolean;
iat?: number;
exp?: number;
@@ -132,18 +138,101 @@ class AuthManager {
async generateJWTToken(
userId: string,
options: { expiresIn?: string; pendingTOTP?: boolean } = {},
options: {
expiresIn?: string;
pendingTOTP?: boolean;
deviceType?: DeviceType;
deviceInfo?: string;
} = {},
): Promise<string> {
const jwtSecret = await this.systemCrypto.getJWTSecret();
// Determine expiration based on device type
let expiresIn = options.expiresIn;
if (!expiresIn && !options.pendingTOTP) {
if (options.deviceType === "desktop" || options.deviceType === "mobile") {
expiresIn = "30d"; // 30 days for desktop and mobile
} else {
expiresIn = "7d"; // 7 days for web
}
} else if (!expiresIn) {
expiresIn = "7d"; // Default
}
const payload: JWTPayload = { userId };
if (options.pendingTOTP) {
payload.pendingTOTP = true;
}
return jwt.sign(payload, jwtSecret, {
expiresIn: options.expiresIn || "7d",
} as jwt.SignOptions);
// Create session in database if not a temporary TOTP token
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
const sessionId = nanoid();
payload.sessionId = sessionId;
// Generate the token first to get it for storage
const token = jwt.sign(payload, jwtSecret, {
expiresIn,
} as jwt.SignOptions);
// Calculate expiration timestamp
const expirationMs = this.parseExpiresIn(expiresIn);
const expiresAt = new Date(Date.now() + expirationMs).toISOString();
// Store session in database
try {
await db.insert(sessions).values({
id: sessionId,
userId,
jwtToken: token,
deviceType: options.deviceType,
deviceInfo: options.deviceInfo,
expiresAt,
});
databaseLogger.info("Session created", {
operation: "session_create",
userId,
sessionId,
deviceType: options.deviceType,
expiresAt,
});
} catch (error) {
databaseLogger.error("Failed to create session", error, {
operation: "session_create_failed",
userId,
sessionId,
});
// Continue anyway - session tracking is non-critical
}
return token;
}
return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions);
}
/**
* Parse expiresIn string to milliseconds
*/
private parseExpiresIn(expiresIn: string): number {
const match = expiresIn.match(/^(\d+)([smhd])$/);
if (!match) return 7 * 24 * 60 * 60 * 1000; // Default 7 days
const value = parseInt(match[1]);
const unit = match[2];
switch (unit) {
case "s":
return value * 1000;
case "m":
return value * 60 * 1000;
case "h":
return value * 60 * 60 * 1000;
case "d":
return value * 24 * 60 * 60 * 1000;
default:
return 7 * 24 * 60 * 60 * 1000;
}
}
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
@@ -175,6 +264,152 @@ class AuthManager {
});
}
async revokeSession(sessionId: string): Promise<boolean> {
try {
// Get the session to blacklist the token
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1);
if (sessionRecords.length > 0) {
const session = sessionRecords[0];
this.invalidatedTokens.add(session.jwtToken);
}
// Delete the session instead of marking as revoked
await db.delete(sessions).where(eq(sessions.id, sessionId));
databaseLogger.info("Session deleted", {
operation: "session_delete",
sessionId,
});
return true;
} catch (error) {
databaseLogger.error("Failed to delete session", error, {
operation: "session_delete_failed",
sessionId,
});
return false;
}
}
async revokeAllUserSessions(
userId: string,
exceptSessionId?: string,
): Promise<number> {
try {
// Get all user sessions to blacklist tokens
let query = db.select().from(sessions).where(eq(sessions.userId, userId));
const userSessions = await query;
// Add all tokens to blacklist (except the excepted one)
for (const session of userSessions) {
if (!exceptSessionId || session.id !== exceptSessionId) {
this.invalidatedTokens.add(session.jwtToken);
}
}
// Delete sessions instead of marking as revoked
if (exceptSessionId) {
await db
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
sql`${sessions.id} != ${exceptSessionId}`,
),
);
} else {
await db.delete(sessions).where(eq(sessions.userId, userId));
}
const deletedCount = userSessions.filter(
(s) => !exceptSessionId || s.id !== exceptSessionId,
).length;
databaseLogger.info("User sessions deleted", {
operation: "user_sessions_delete",
userId,
exceptSessionId,
deletedCount,
});
return deletedCount;
} catch (error) {
databaseLogger.error("Failed to delete user sessions", error, {
operation: "user_sessions_delete_failed",
userId,
});
return 0;
}
}
async cleanupExpiredSessions(): Promise<number> {
try {
// Get expired sessions to blacklist their tokens
const expiredSessions = await db
.select()
.from(sessions)
.where(sql`${sessions.expiresAt} < datetime('now')`);
// Add expired tokens to blacklist
for (const session of expiredSessions) {
this.invalidatedTokens.add(session.jwtToken);
}
// Delete expired sessions
await db
.delete(sessions)
.where(sql`${sessions.expiresAt} < datetime('now')`);
if (expiredSessions.length > 0) {
databaseLogger.info("Expired sessions cleaned up", {
operation: "sessions_cleanup",
count: expiredSessions.length,
});
}
return expiredSessions.length;
} catch (error) {
databaseLogger.error("Failed to cleanup expired sessions", error, {
operation: "sessions_cleanup_failed",
});
return 0;
}
}
async getAllSessions(): Promise<any[]> {
try {
const allSessions = await db.select().from(sessions);
return allSessions;
} catch (error) {
databaseLogger.error("Failed to get all sessions", error, {
operation: "sessions_get_all_failed",
});
return [];
}
}
async getUserSessions(userId: string): Promise<any[]> {
try {
const userSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
return userSessions;
} catch (error) {
databaseLogger.error("Failed to get user sessions", error, {
operation: "sessions_get_user_failed",
userId,
});
return [];
}
}
getSecureCookieOptions(
req: RequestWithHeaders,
maxAge: number = 7 * 24 * 60 * 60 * 1000,
@@ -210,6 +445,55 @@ class AuthManager {
return res.status(401).json({ error: "Invalid token" });
}
// Check session status if sessionId is present
if (payload.sessionId) {
try {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, payload.sessionId))
.limit(1);
if (sessionRecords.length === 0) {
return res.status(401).json({
error: "Session not found",
code: "SESSION_NOT_FOUND",
});
}
const session = sessionRecords[0];
// Session exists, no need to check isRevoked since we delete sessions instead
// Check if session has expired
if (new Date(session.expiresAt) < new Date()) {
return res.status(401).json({
error: "Session has expired",
code: "SESSION_EXPIRED",
});
}
// Update lastActiveAt timestamp (async, non-blocking)
db.update(sessions)
.set({ lastActiveAt: new Date().toISOString() })
.where(eq(sessions.id, payload.sessionId))
.then(() => {})
.catch((error) => {
databaseLogger.warn("Failed to update session lastActiveAt", {
operation: "session_update_last_active",
sessionId: payload.sessionId,
error: error instanceof Error ? error.message : "Unknown error",
});
});
} catch (error) {
databaseLogger.error("Session check failed", error, {
operation: "session_check_failed",
sessionId: payload.sessionId,
});
// Continue anyway - session tracking failures shouldn't block auth
}
}
authReq.userId = payload.userId;
authReq.pendingTOTP = payload.pendingTOTP;
next();

View File

@@ -0,0 +1,243 @@
import type { Request } from "express";
export type DeviceType = "web" | "desktop" | "mobile";
export interface DeviceInfo {
type: DeviceType;
browser: string;
version: string;
os: string;
deviceInfo: string; // Formatted string like "Chrome 120 on Windows 11"
}
/**
* Detect the platform type based on request headers
*/
export function detectPlatform(req: Request): DeviceType {
const userAgent = req.headers["user-agent"] || "";
const electronHeader = req.headers["x-electron-app"];
// Electron app detection
if (electronHeader === "true") {
return "desktop";
}
// Mobile app detection
if (userAgent.includes("Termix-Mobile")) {
return "mobile";
}
// Default to web
return "web";
}
/**
* Parse User-Agent string to extract device information
*/
export function parseUserAgent(req: Request): DeviceInfo {
const userAgent = req.headers["user-agent"] || "Unknown";
const platform = detectPlatform(req);
// For Electron
if (platform === "desktop") {
return parseElectronUserAgent(userAgent);
}
// For Mobile app
if (platform === "mobile") {
return parseMobileUserAgent(userAgent);
}
// For web browsers
return parseWebUserAgent(userAgent);
}
/**
* Parse Electron app user agent
*/
function parseElectronUserAgent(userAgent: string): DeviceInfo {
let os = "Unknown OS";
let version = "Unknown";
// Detect OS
if (userAgent.includes("Windows")) {
os = parseWindowsVersion(userAgent);
} else if (userAgent.includes("Mac OS X")) {
os = parseMacVersion(userAgent);
} else if (userAgent.includes("Linux")) {
os = "Linux";
}
// Try to extract Electron version
const electronMatch = userAgent.match(/Electron\/([\d.]+)/);
if (electronMatch) {
version = electronMatch[1];
}
return {
type: "desktop",
browser: "Electron",
version,
os,
deviceInfo: `Termix Desktop on ${os}`,
};
}
/**
* Parse mobile app user agent
*/
function parseMobileUserAgent(userAgent: string): DeviceInfo {
let os = "Unknown OS";
let version = "Unknown";
// Detect mobile OS
if (userAgent.includes("Android")) {
const androidMatch = userAgent.match(/Android ([\d.]+)/);
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
} else if (
userAgent.includes("iOS") ||
userAgent.includes("iPhone") ||
userAgent.includes("iPad")
) {
const iosMatch = userAgent.match(/OS ([\d_]+)/);
if (iosMatch) {
const iosVersion = iosMatch[1].replace(/_/g, ".");
os = `iOS ${iosVersion}`;
} else {
os = "iOS";
}
}
// Try to extract app version (if included in UA)
const versionMatch = userAgent.match(/Termix-Mobile\/([\d.]+)/);
if (versionMatch) {
version = versionMatch[1];
}
return {
type: "mobile",
browser: "Termix Mobile",
version,
os,
deviceInfo: `Termix Mobile on ${os}`,
};
}
/**
* Parse web browser user agent
*/
function parseWebUserAgent(userAgent: string): DeviceInfo {
let browser = "Unknown Browser";
let version = "Unknown";
let os = "Unknown OS";
// Detect browser
if (userAgent.includes("Edg/")) {
const match = userAgent.match(/Edg\/([\d.]+)/);
browser = "Edge";
version = match ? match[1] : "Unknown";
} else if (userAgent.includes("Chrome/") && !userAgent.includes("Edg")) {
const match = userAgent.match(/Chrome\/([\d.]+)/);
browser = "Chrome";
version = match ? match[1] : "Unknown";
} else if (userAgent.includes("Firefox/")) {
const match = userAgent.match(/Firefox\/([\d.]+)/);
browser = "Firefox";
version = match ? match[1] : "Unknown";
} else if (userAgent.includes("Safari/") && !userAgent.includes("Chrome")) {
const match = userAgent.match(/Version\/([\d.]+)/);
browser = "Safari";
version = match ? match[1] : "Unknown";
} else if (userAgent.includes("Opera/") || userAgent.includes("OPR/")) {
const match = userAgent.match(/(?:Opera|OPR)\/([\d.]+)/);
browser = "Opera";
version = match ? match[1] : "Unknown";
}
// Detect OS
if (userAgent.includes("Windows")) {
os = parseWindowsVersion(userAgent);
} else if (userAgent.includes("Mac OS X")) {
os = parseMacVersion(userAgent);
} else if (userAgent.includes("Linux")) {
os = "Linux";
} else if (userAgent.includes("Android")) {
const match = userAgent.match(/Android ([\d.]+)/);
os = match ? `Android ${match[1]}` : "Android";
} else if (
userAgent.includes("iOS") ||
userAgent.includes("iPhone") ||
userAgent.includes("iPad")
) {
const match = userAgent.match(/OS ([\d_]+)/);
if (match) {
const iosVersion = match[1].replace(/_/g, ".");
os = `iOS ${iosVersion}`;
} else {
os = "iOS";
}
}
// Shorten version to major.minor
if (version !== "Unknown") {
const versionParts = version.split(".");
version = versionParts.slice(0, 2).join(".");
}
return {
type: "web",
browser,
version,
os,
deviceInfo: `${browser} ${version} on ${os}`,
};
}
/**
* Parse Windows version from user agent
*/
function parseWindowsVersion(userAgent: string): string {
if (userAgent.includes("Windows NT 10.0")) {
return "Windows 10/11";
} else if (userAgent.includes("Windows NT 6.3")) {
return "Windows 8.1";
} else if (userAgent.includes("Windows NT 6.2")) {
return "Windows 8";
} else if (userAgent.includes("Windows NT 6.1")) {
return "Windows 7";
} else if (userAgent.includes("Windows NT 6.0")) {
return "Windows Vista";
} else if (
userAgent.includes("Windows NT 5.1") ||
userAgent.includes("Windows NT 5.2")
) {
return "Windows XP";
}
return "Windows";
}
/**
* Parse macOS version from user agent
*/
function parseMacVersion(userAgent: string): string {
const match = userAgent.match(/Mac OS X ([\d_]+)/);
if (match) {
const version = match[1].replace(/_/g, ".");
const parts = version.split(".");
const major = parseInt(parts[0]);
const minor = parseInt(parts[1]);
// macOS naming
if (major === 10) {
if (minor >= 15) return `macOS ${major}.${minor}`;
if (minor === 14) return "macOS Mojave";
if (minor === 13) return "macOS High Sierra";
if (minor === 12) return "macOS Sierra";
} else if (major >= 11) {
return `macOS ${major}`;
}
return `macOS ${version}`;
}
return "macOS";
}

View File

@@ -1306,7 +1306,8 @@
"deleteAccount": "Konto löschen",
"closeDeleteAccount": "Schließen Konto löschen",
"deleteAccountWarning": "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden Ihr Konto und alle damit verbundenen Daten dauerhaft gelöscht.",
"deleteAccountWarningDetails": "Wenn Sie Ihr Konto löschen, werden alle Ihre Daten entfernt, einschließlich SSH-Hosts, Konfigurationen und Einstellungen. Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteAccountWarningDetails": "Wenn Sie Ihr Konto löschen, werden alle Ihre Daten entfernt, einschließlich SSH-Hosts, Konfigurationen und Einstellungen. Diese Aktion ist nicht rückgängig zu machen.",
"deleteAccountWarningShort": "Diese Aktion kann nicht rückgängig gemacht werden und löscht Ihr Konto dauerhaft.",
"cannotDeleteAccount": "Konto kann nicht gelöscht werden",
"lastAdminWarning": "Sie sind der letzte Administrator. Sie können Ihr Konto nicht löschen, da das System dann ohne Administratoren wäre. Bitte benennen Sie zunächst einen anderen Benutzer als Administrator oder wenden Sie sich an den Systemsupport.",
"confirmPassword": "Passwort bestätigen",

View File

@@ -247,7 +247,11 @@
"saveError": "Error saving configuration",
"saving": "Saving...",
"saveConfig": "Save Configuration",
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)"
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)",
"warning": "Warning",
"notValidatedWarning": "URL not validated - ensure it's correct",
"changeServer": "Change Server",
"mustIncludeProtocol": "Server URL must start with http:// or https://"
},
"versionCheck": {
"error": "Version Check Error",
@@ -467,6 +471,13 @@
"userDeletedSuccessfully": "User {{username}} deleted successfully",
"failedToDeleteUser": "Failed to delete user",
"overrideUserInfoUrl": "Override User Info URL (not required)",
"failedToFetchSessions": "Failed to fetch sessions",
"sessionRevokedSuccessfully": "Session revoked successfully",
"failedToRevokeSession": "Failed to revoke session",
"confirmRevokeSession": "Are you sure you want to revoke this session?",
"confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?",
"failedToRevokeSessions": "Failed to revoke sessions",
"sessionsRevokedSuccessfully": "Sessions revoked successfully",
"databaseSecurity": "Database Security",
"encryptionStatus": "Encryption Status",
"encryptionEnabled": "Encryption Enabled",
@@ -1297,6 +1308,13 @@
"confirmNewPassword": "Confirm Password",
"enterNewPassword": "Enter your new password for user:",
"signUp": "Sign Up",
"mobileApp": "Mobile App",
"loggingInToMobileApp": "Logging in to the mobile app",
"desktopApp": "Desktop App",
"loggingInToDesktopApp": "Logging in to the desktop app",
"loggingInToDesktopAppViaWeb": "Logging in to the desktop app via web interface",
"loadingServer": "Loading server...",
"authenticating": "Authenticating...",
"dataLossWarning": "Resetting your password this way will delete all your saved SSH hosts, credentials, and other encrypted data. This action cannot be undone. Only use this if you have forgotten your password and are not logged in.",
"authenticationDisabled": "Authentication Disabled",
"authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator."
@@ -1433,6 +1451,7 @@
"closeDeleteAccount": "Close Delete Account",
"deleteAccountWarning": "This action cannot be undone. This will permanently delete your account and all associated data.",
"deleteAccountWarningDetails": "Deleting your account will remove all your data including SSH hosts, configurations, and settings. This action is irreversible.",
"deleteAccountWarningShort": "This action is not reversible and will permanently delete your account.",
"cannotDeleteAccount": "Cannot Delete Account",
"lastAdminWarning": "You are the last admin user. You cannot delete your account as this would leave the system without any administrators. Please make another user an admin first, or contact system support.",
"confirmPassword": "Confirm Password",

View File

@@ -1353,6 +1353,7 @@
"closeDeleteAccount": "Fechar Exclusão de Conta",
"deleteAccountWarning": "Esta ação não pode ser desfeita. Isso excluirá permanentemente sua conta e todos os dados associados.",
"deleteAccountWarningDetails": "Excluir sua conta removerá todos os seus dados, incluindo hosts SSH, configurações e preferências. Esta ação é irreversível.",
"deleteAccountWarningShort": "Esta ação é irreversível e excluirá permanentemente sua conta.",
"cannotDeleteAccount": "Não é Possível Excluir Conta",
"lastAdminWarning": "Você é o último usuário administrador. Você não pode excluir sua conta pois isso deixaria o sistema sem administradores. Por favor, torne outro usuário administrador primeiro, ou contate o suporte do sistema.",
"confirmPassword": "Confirmar Senha",

View File

@@ -1414,6 +1414,7 @@
"closeDeleteAccount": "关闭删除账户",
"deleteAccountWarning": "此操作无法撤销。这将永久删除您的账户和所有相关数据。",
"deleteAccountWarningDetails": "删除您的账户将删除所有数据,包括 SSH 主机、配置和设置。此操作不可逆。",
"deleteAccountWarningShort": "此操作不可逆,将永久删除您的帐户。",
"cannotDeleteAccount": "无法删除账户",
"lastAdminWarning": "您是最后一个管理员用户。您不能删除自己的账户,否则系统将没有任何管理员。请先将其他用户设为管理员,或联系系统支持。",
"confirmPassword": "确认密码",

View File

@@ -29,6 +29,10 @@ import {
Lock,
Download,
Upload,
Monitor,
Smartphone,
Globe,
Clock,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@@ -111,6 +115,21 @@ export function AdminSettings({
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
const [importPassword, setImportPassword] = React.useState("");
const [sessions, setSessions] = React.useState<
Array<{
id: string;
userId: string;
username?: string;
deviceType: string;
deviceInfo: string;
createdAt: string;
expiresAt: string;
lastActiveAt: string;
jwtToken: string;
}>
>([]);
const [sessionsLoading, setSessionsLoading] = React.useState(false);
const requiresImportPassword = React.useMemo(
() => !currentUser?.is_oidc,
[currentUser?.is_oidc],
@@ -152,6 +171,7 @@ export function AdminSettings({
}
});
fetchUsers();
fetchSessions();
}, []);
React.useEffect(() => {
@@ -538,6 +558,168 @@ export function AdminSettings({
}
};
const fetchSessions = async () => {
if (isElectron()) {
const serverUrl = (window as { configuredServerUrl?: string })
.configuredServerUrl;
if (!serverUrl) {
return;
}
}
setSessionsLoading(true);
try {
const isDev =
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "" ||
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1");
const apiUrl = isElectron()
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/users/sessions`
: isDev
? `http://localhost:30001/users/sessions`
: `/users/sessions`;
const response = await fetch(apiUrl, {
method: "GET",
credentials: "include",
headers: {
Authorization: `Bearer ${getCookie("jwt")}`,
},
});
if (response.ok) {
const data = await response.json();
setSessions(data.sessions || []);
} else {
toast.error(t("admin.failedToFetchSessions"));
}
} catch (err) {
if (!err?.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchSessions"));
}
} finally {
setSessionsLoading(false);
}
};
const handleRevokeSession = async (sessionId: string) => {
// Check if this is the current session
const currentJWT = getCookie("jwt");
const currentSession = sessions.find((s) => s.jwtToken === currentJWT);
const isCurrentSession = currentSession?.id === sessionId;
confirmWithToast(
t("admin.confirmRevokeSession"),
async () => {
try {
const isDev =
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "" ||
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1");
const apiUrl = isElectron()
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/users/sessions/${sessionId}`
: isDev
? `http://localhost:30001/users/sessions/${sessionId}`
: `/users/sessions/${sessionId}`;
const response = await fetch(apiUrl, {
method: "DELETE",
credentials: "include",
headers: {
Authorization: `Bearer ${getCookie("jwt")}`,
},
});
if (response.ok) {
toast.success(t("admin.sessionRevokedSuccessfully"));
// If user revoked their own session, reload the page after a brief delay
if (isCurrentSession) {
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
fetchSessions();
}
} else {
toast.error(t("admin.failedToRevokeSession"));
}
} catch {
toast.error(t("admin.failedToRevokeSession"));
}
},
"destructive",
);
};
const handleRevokeAllUserSessions = async (userId: string) => {
// Check if revoking sessions for current user
const isCurrentUser = currentUser?.id === userId;
confirmWithToast(
t("admin.confirmRevokeAllSessions"),
async () => {
try {
const isDev =
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "" ||
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1");
const apiUrl = isElectron()
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/users/sessions/revoke-all`
: isDev
? `http://localhost:30001/users/sessions/revoke-all`
: `/users/sessions/revoke-all`;
const response = await fetch(apiUrl, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getCookie("jwt")}`,
},
body: JSON.stringify({
targetUserId: userId,
exceptCurrent: false,
}),
});
if (response.ok) {
const data = await response.json();
toast.success(
data.message || t("admin.sessionsRevokedSuccessfully"),
);
// If revoking sessions for current user, reload the page after a brief delay
if (isCurrentUser) {
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
fetchSessions();
}
} else {
toast.error(t("admin.failedToRevokeSessions"));
}
} catch {
toast.error(t("admin.failedToRevokeSessions"));
}
},
"destructive",
);
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8;
@@ -578,6 +760,10 @@ export function AdminSettings({
<Users className="h-4 w-4" />
{t("admin.users")}
</TabsTrigger>
<TabsTrigger value="sessions" className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Sessions
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
{t("admin.adminManagement")}
@@ -944,6 +1130,137 @@ export function AdminSettings({
</div>
</TabsContent>
<TabsContent value="sessions" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Session Management</h3>
<Button
onClick={fetchSessions}
disabled={sessionsLoading}
variant="outline"
size="sm"
>
{sessionsLoading ? t("admin.loading") : t("admin.refresh")}
</Button>
</div>
{sessionsLoading ? (
<div className="text-center py-8 text-muted-foreground">
Loading sessions...
</div>
) : sessions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No active sessions found.
</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Device</TableHead>
<TableHead className="px-4">User</TableHead>
<TableHead className="px-4">Created</TableHead>
<TableHead className="px-4">Last Active</TableHead>
<TableHead className="px-4">Expires</TableHead>
<TableHead className="px-4">
{t("admin.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => {
const DeviceIcon =
session.deviceType === "desktop"
? Monitor
: session.deviceType === "mobile"
? Smartphone
: Globe;
const createdDate = new Date(session.createdAt);
const lastActiveDate = new Date(session.lastActiveAt);
const expiresDate = new Date(session.expiresAt);
const formatDate = (date: Date) =>
date.toLocaleDateString() +
" " +
date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<TableRow
key={session.id}
className={
session.isRevoked ? "opacity-50" : undefined
}
>
<TableCell className="px-4">
<div className="flex items-center gap-2">
<DeviceIcon className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium text-sm">
{session.deviceInfo}
</span>
{session.isRevoked && (
<span className="text-xs text-red-600">
Revoked
</span>
)}
</div>
</div>
</TableCell>
<TableCell className="px-4">
{session.username || session.userId}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(createdDate)}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(lastActiveDate)}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(expiresDate)}
</TableCell>
<TableCell className="px-4">
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRevokeSession(session.id)
}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={session.isRevoked}
>
<Trash2 className="h-4 w-4" />
</Button>
{session.username && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRevokeAllUserSessions(
session.userId,
)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
title="Revoke all sessions for this user"
>
Revoke All
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
<TabsContent value="admins" className="space-y-6">
<div className="space-y-6">
<h3 className="text-lg font-semibold">

View File

@@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { TOTPDialog } from "@/ui/Desktop/Navigation/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/Desktop/Navigation/SSHAuthDialog.tsx";
import {
Upload,
FolderPlus,
@@ -100,6 +101,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const [totpRequired, setTotpRequired] = useState(false);
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = useState<string>("");
const [showAuthDialog, setShowAuthDialog] = useState(false);
const [authDialogReason, setAuthDialogReason] = useState<
"no_keyboard" | "auth_failed" | "timeout"
>("no_keyboard");
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
const [isClosing, setIsClosing] = useState<boolean>(false);
@@ -327,6 +332,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return;
}
if (result?.status === "auth_required") {
setAuthDialogReason(result.reason || "no_keyboard");
setShowAuthDialog(true);
setIsLoading(false);
return;
}
setSshSessionId(sessionId);
try {
@@ -1315,6 +1327,80 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
if (onClose) onClose();
}
async function handleAuthDialogSubmit(credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
}) {
if (!currentHost) return;
try {
setIsLoading(true);
setShowAuthDialog(false);
const sessionId = currentHost.id.toString();
const result = await connectSSH(sessionId, {
hostId: currentHost.id,
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
password: credentials.password,
sshKey: credentials.sshKey,
keyPassword: credentials.keyPassword,
authType: credentials.password ? "password" : "key",
credentialId: currentHost.credentialId,
userId: currentHost.userId,
});
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sessionId);
setTotpPrompt(result.prompt || "Verification code:");
setIsLoading(false);
return;
}
if (result?.status === "auth_required") {
setAuthDialogReason(result.reason || "auth_failed");
setShowAuthDialog(true);
setIsLoading(false);
toast.error(t("fileManager.authenticationFailed"));
return;
}
setSshSessionId(sessionId);
try {
const response = await listSSHFiles(sessionId, currentPath);
const files = Array.isArray(response)
? response
: response?.files || [];
setFiles(files);
clearSelection();
initialLoadDoneRef.current = true;
toast.success(t("fileManager.connectedSuccessfully"));
logFileManagerActivity();
} catch (dirError: unknown) {
console.error("Failed to load initial directory:", dirError);
}
} catch (error: unknown) {
console.error("SSH connection with credentials failed:", error);
setAuthDialogReason("auth_failed");
setShowAuthDialog(true);
toast.error(
t("fileManager.failedToConnect") + ": " + (error.message || error),
);
} finally {
setIsLoading(false);
}
}
function handleAuthDialogCancel() {
setShowAuthDialog(false);
if (onClose) onClose();
}
function generateUniqueName(
baseName: string,
type: "file" | "directory",
@@ -1890,6 +1976,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
/>
{currentHost && (
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authDialogReason}
onSubmit={handleAuthDialogSubmit}
onCancel={handleAuthDialogCancel}
hostInfo={{
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
name: currentHost.name,
}}
/>
)}
</div>
);
}

View File

@@ -19,6 +19,7 @@ import {
getSnippets,
} from "@/ui/main-axios.ts";
import { TOTPDialog } from "@/ui/Desktop/Navigation/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/Desktop/Navigation/SSHAuthDialog.tsx";
import {
TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG,
@@ -104,6 +105,12 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const [totpRequired, setTotpRequired] = useState(false);
const [totpPrompt, setTotpPrompt] = useState<string>("");
const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
const [showAuthDialog, setShowAuthDialog] = useState(false);
const [authDialogReason, setAuthDialogReason] = useState<
"no_keyboard" | "auth_failed" | "timeout"
>("no_keyboard");
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
useState(false);
const isVisibleRef = useRef<boolean>(false);
const isFittingRef = useRef(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -237,6 +244,38 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
if (onClose) onClose();
}
function handleAuthDialogSubmit(credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
}) {
if (webSocketRef.current && terminal) {
// Send reconnect message with credentials
webSocketRef.current.send(
JSON.stringify({
type: "reconnect_with_credentials",
data: {
cols: terminal.cols,
rows: terminal.rows,
hostConfig: {
...hostConfig,
password: credentials.password,
key: credentials.sshKey,
keyPassword: credentials.keyPassword,
},
},
}),
);
setShowAuthDialog(false);
setIsConnecting(true);
}
}
function handleAuthDialogCancel() {
setShowAuthDialog(false);
if (onClose) onClose();
}
function scheduleNotify(cols: number, rows: number) {
if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = { cols, rows };
@@ -635,6 +674,25 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
} else if (msg.type === "keyboard_interactive_available") {
// Keyboard-interactive auth is available (e.g., Warpgate OIDC)
// Show terminal immediately so user can see auth prompts
setKeyboardInteractiveDetected(true);
setIsConnecting(false);
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
} else if (msg.type === "auth_method_not_available") {
// Server doesn't support keyboard-interactive for "none" auth
// Show SSHAuthDialog for manual credential entry
setAuthDialogReason("no_keyboard");
setShowAuthDialog(true);
setIsConnecting(false);
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
}
} catch {
toast.error(t("terminal.messageParseError"));
@@ -1041,6 +1099,20 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
backgroundColor={backgroundColor}
/>
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authDialogReason}
onSubmit={handleAuthDialogSubmit}
onCancel={handleAuthDialogCancel}
hostInfo={{
ip: hostConfig.ip,
port: hostConfig.port,
username: hostConfig.username,
name: hostConfig.name,
}}
backgroundColor={backgroundColor}
/>
{isConnecting && (
<div
className="absolute inset-0 flex items-center justify-center"

View File

@@ -8,6 +8,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
import { toast } from "sonner";
import { Monitor } from "lucide-react";
import {
registerUser,
loginUser,
@@ -26,6 +27,7 @@ import {
logoutUser,
} from "../../main-axios.ts";
import { ElectronServerConfig as ServerConfigComponent } from "@/ui/Desktop/Authentication/ElectronServerConfig.tsx";
import { ElectronLoginForm } from "@/ui/Desktop/Authentication/ElectronLoginForm.tsx";
interface AuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void;
@@ -586,6 +588,43 @@ export function Auth({
);
}
// Show ElectronLoginForm when Electron has a configured server and user is not logged in
if (isElectron() && currentServerUrl && !loggedIn && !authLoading) {
return (
<div
className="w-full h-screen flex items-center justify-center p-4"
{...props}
>
<div className="w-full max-w-4xl h-[90vh]">
<ElectronLoginForm
serverUrl={currentServerUrl}
onAuthSuccess={async () => {
try {
const meRes = await getUserInfo();
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null,
});
toast.success(t("messages.loginSuccess"));
} catch (err) {
toast.error(t("errors.failedUserInfo"));
}
}}
onChangeServer={() => {
setShowServerConfig(true);
}}
/>
</div>
</div>
);
}
if (dbHealthChecking && !dbConnectionFailed) {
return (
<div
@@ -664,11 +703,33 @@ export function Auth({
);
}
// Detect if we're running in Electron's WebView/iframe
const isInElectronWebView = () => {
try {
// Check if we're in an iframe AND the parent is Electron
if (window.self !== window.top) {
// We're in an iframe, likely Electron's ElectronLoginForm
return true;
}
} catch (e) {
// Cross-origin iframe, can't access parent
return false;
}
return false;
};
return (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
{...props}
>
{isInElectronWebView() && (
<Alert className="mb-4 border-blue-500 bg-blue-500/10">
<Monitor className="h-4 w-4" />
<AlertTitle>{t("auth.desktopApp")}</AlertTitle>
<AlertDescription>{t("auth.loggingInToDesktopApp")}</AlertDescription>
</Alert>
)}
{totpRequired && (
<div className="flex flex-col gap-5">
<div className="mb-6 text-center">

View File

@@ -0,0 +1,335 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { useTranslation } from "react-i18next";
import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
import { getCookie } from "@/ui/main-axios.ts";
interface ElectronLoginFormProps {
serverUrl: string;
onAuthSuccess: () => void;
onChangeServer: () => void;
}
export function ElectronLoginForm({
serverUrl,
onAuthSuccess,
onChangeServer,
}: ElectronLoginFormProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const hasAuthenticatedRef = useRef(false);
const [currentUrl, setCurrentUrl] = useState(serverUrl);
useEffect(() => {
// Listen for messages from iframe
const handleMessage = async (event: MessageEvent) => {
// Only accept messages from our configured server
try {
const serverOrigin = new URL(serverUrl).origin;
if (event.origin !== serverOrigin) {
return;
}
if (event.data && typeof event.data === "object") {
const data = event.data;
if (
data.type === "AUTH_SUCCESS" &&
data.token &&
!hasAuthenticatedRef.current &&
!isAuthenticating
) {
console.log(
"[ElectronLoginForm] Received auth success from iframe",
);
hasAuthenticatedRef.current = true;
setIsAuthenticating(true);
try {
// Save JWT to localStorage (Electron mode)
localStorage.setItem("jwt", data.token);
// Verify it was saved
const savedToken = localStorage.getItem("jwt");
if (!savedToken) {
throw new Error("Failed to save JWT to localStorage");
}
console.log("[ElectronLoginForm] JWT saved successfully");
// Small delay to ensure everything is saved
await new Promise((resolve) => setTimeout(resolve, 200));
onAuthSuccess();
} catch (err) {
console.error("[ElectronLoginForm] Error saving JWT:", err);
setError(t("errors.authTokenSaveFailed"));
setIsAuthenticating(false);
hasAuthenticatedRef.current = false;
}
}
}
} catch (err) {
console.error("[ElectronLoginForm] Error processing message:", err);
}
};
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [serverUrl, isAuthenticating, onAuthSuccess, t]);
useEffect(() => {
// Inject script into iframe when it loads
const iframe = iframeRef.current;
if (!iframe) return;
const handleLoad = () => {
setLoading(false);
// Update current URL when iframe loads
try {
if (iframe.contentWindow) {
setCurrentUrl(iframe.contentWindow.location.href);
}
} catch (e) {
// Cross-origin, can't access - use serverUrl
setCurrentUrl(serverUrl);
}
try {
// Inject JavaScript to detect JWT
const injectedScript = `
(function() {
console.log('[Electron WebView] Script injected');
let hasNotified = false;
function postJWTToParent(token, source) {
if (hasNotified) return;
hasNotified = true;
console.log('[Electron WebView] Posting JWT to parent, source:', source);
try {
window.parent.postMessage({
type: 'AUTH_SUCCESS',
token: token,
source: source,
platform: 'desktop',
timestamp: Date.now()
}, '*');
} catch (e) {
console.error('[Electron WebView] Error posting message:', e);
}
}
function checkAuth() {
try {
const localToken = localStorage.getItem('jwt');
if (localToken && localToken.length > 20) {
postJWTToParent(localToken, 'localStorage');
return true;
}
const sessionToken = sessionStorage.getItem('jwt');
if (sessionToken && sessionToken.length > 20) {
postJWTToParent(sessionToken, 'sessionStorage');
return true;
}
const cookies = document.cookie;
if (cookies && cookies.length > 0) {
const cookieArray = cookies.split('; ');
const tokenCookie = cookieArray.find(row => row.startsWith('jwt='));
if (tokenCookie) {
const token = tokenCookie.split('=')[1];
if (token && token.length > 20) {
postJWTToParent(token, 'cookie');
return true;
}
}
}
} catch (error) {
console.error('[Electron WebView] Error in checkAuth:', error);
}
return false;
}
// Intercept localStorage.setItem
const originalSetItem = localStorage.setItem;
localStorage.setItem = function(key, value) {
originalSetItem.apply(this, arguments);
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
setTimeout(() => checkAuth(), 100);
}
};
// Intercept sessionStorage.setItem
const originalSessionSetItem = sessionStorage.setItem;
sessionStorage.setItem = function(key, value) {
originalSessionSetItem.apply(this, arguments);
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
setTimeout(() => checkAuth(), 100);
}
};
// Poll for JWT
const intervalId = setInterval(() => {
if (hasNotified) {
clearInterval(intervalId);
return;
}
if (checkAuth()) {
clearInterval(intervalId);
}
}, 500);
// Stop after 5 minutes
setTimeout(() => {
clearInterval(intervalId);
}, 300000);
// Initial check
checkAuth();
})();
`;
// Try to inject the script
try {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ type: "INJECT_SCRIPT", script: injectedScript },
"*",
);
// Also try direct execution if same origin
iframe.contentWindow.eval(injectedScript);
}
} catch (err) {
// Cross-origin restrictions - this is expected for external servers
console.warn(
"[ElectronLoginForm] Cannot inject script due to cross-origin restrictions",
);
}
} catch (err) {
console.error("[ElectronLoginForm] Error in handleLoad:", err);
}
};
const handleError = () => {
setLoading(false);
setError(t("errors.failedToLoadServer"));
};
iframe.addEventListener("load", handleLoad);
iframe.addEventListener("error", handleError);
return () => {
iframe.removeEventListener("load", handleLoad);
iframe.removeEventListener("error", handleError);
};
}, [t]);
const handleRefresh = () => {
if (iframeRef.current) {
iframeRef.current.src = serverUrl;
setLoading(true);
setError(null);
}
};
const handleBack = () => {
onChangeServer();
};
// Format URL for display (remove protocol)
const displayUrl = currentUrl.replace(/^https?:\/\//, "");
return (
<div className="fixed inset-0 w-screen h-screen bg-dark-bg flex flex-col">
{/* Navigation Bar */}
<div className="flex items-center justify-between p-4 bg-dark-bg border-b border-dark-border">
<button
onClick={handleBack}
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
disabled={isAuthenticating}
>
<ArrowLeft className="h-5 w-5" />
<span className="text-base font-medium">
{t("serverConfig.changeServer")}
</span>
</button>
<div className="flex-1 mx-4 text-center">
<span className="text-muted-foreground text-sm truncate block">
{displayUrl}
</span>
</div>
<button
onClick={handleRefresh}
className="p-2 text-foreground hover:text-primary transition-colors"
disabled={loading || isAuthenticating}
>
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{error && (
<div className="absolute top-20 left-1/2 transform -translate-x-1/2 z-50 w-full max-w-md px-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
)}
{loading && (
<div
className="absolute inset-0 flex items-center justify-center bg-dark-bg z-40"
style={{ marginTop: "60px" }}
>
<div className="flex items-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">
{t("auth.loadingServer")}
</span>
</div>
</div>
)}
{isAuthenticating && (
<div
className="absolute inset-0 flex items-center justify-center bg-dark-bg/80 z-40"
style={{ marginTop: "60px" }}
>
<div className="flex items-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">
{t("auth.authenticating")}
</span>
</div>
</div>
)}
{/* Iframe Container */}
<div className="flex-1 overflow-hidden">
<iframe
ref={iframeRef}
src={serverUrl}
className="w-full h-full border-0"
title="Server Authentication"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation"
allow="clipboard-read; clipboard-write; cross-origin-isolated"
/>
</div>
</div>
);
}

View File

@@ -7,10 +7,9 @@ import { useTranslation } from "react-i18next";
import {
getServerConfig,
saveServerConfig,
testServerConnection,
type ServerConfig,
} from "@/ui/main-axios.ts";
import { CheckCircle, XCircle, Server, Wifi } from "lucide-react";
import { Server } from "lucide-react";
interface ServerConfigProps {
onServerConfigured: (serverUrl: string) => void;
@@ -26,11 +25,7 @@ export function ElectronServerConfig({
const { t } = useTranslation();
const [serverUrl, setServerUrl] = useState("");
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [connectionStatus, setConnectionStatus] = useState<
"unknown" | "success" | "error"
>("unknown");
useEffect(() => {
loadServerConfig();
@@ -41,68 +36,32 @@ export function ElectronServerConfig({
const config = await getServerConfig();
if (config?.serverUrl) {
setServerUrl(config.serverUrl);
setConnectionStatus("success");
}
} catch {
// Ignore config loading errors
}
};
const handleTestConnection = async () => {
if (!serverUrl.trim()) {
setError(t("serverConfig.enterServerUrl"));
return;
}
setTesting(true);
setError(null);
try {
let normalizedUrl = serverUrl.trim();
if (
!normalizedUrl.startsWith("http://") &&
!normalizedUrl.startsWith("https://")
) {
normalizedUrl = `http://${normalizedUrl}`;
}
const result = await testServerConnection(normalizedUrl);
if (result.success) {
setConnectionStatus("success");
} else {
setConnectionStatus("error");
setError(result.error || t("serverConfig.connectionFailed"));
}
} catch {
setConnectionStatus("error");
setError(t("serverConfig.connectionError"));
} finally {
setTesting(false);
}
};
const handleSaveConfig = async () => {
if (!serverUrl.trim()) {
setError(t("serverConfig.enterServerUrl"));
return;
}
if (connectionStatus !== "success") {
setError(t("serverConfig.testConnectionFirst"));
return;
}
setLoading(true);
setError(null);
try {
let normalizedUrl = serverUrl.trim();
// Ensure URL has http:// or https://
if (
!normalizedUrl.startsWith("http://") &&
!normalizedUrl.startsWith("https://")
) {
normalizedUrl = `http://${normalizedUrl}`;
setError(t("serverConfig.mustIncludeProtocol"));
setLoading(false);
return;
}
const config: ServerConfig = {
@@ -126,7 +85,6 @@ export function ElectronServerConfig({
const handleUrlChange = (value: string) => {
setServerUrl(value);
setConnectionStatus("unknown");
setError(null);
};
@@ -144,52 +102,17 @@ export function ElectronServerConfig({
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
<div className="flex space-x-2">
<Input
id="server-url"
type="text"
placeholder="http://localhost:30001 or https://your-server.com"
value={serverUrl}
onChange={(e) => handleUrlChange(e.target.value)}
className="flex-1 h-10"
disabled={loading}
/>
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testing || !serverUrl.trim() || loading}
className="w-10 h-10 p-0 flex items-center justify-center"
>
{testing ? (
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
) : (
<Wifi className="w-4 h-4" />
)}
</Button>
</div>
<Input
id="server-url"
type="text"
placeholder="http://localhost:30001 or https://your-server.com"
value={serverUrl}
onChange={(e) => handleUrlChange(e.target.value)}
className="w-full h-10"
disabled={loading}
/>
</div>
{connectionStatus !== "unknown" && (
<div className="flex items-center space-x-2 text-sm">
{connectionStatus === "success" ? (
<>
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-green-600">
{t("serverConfig.connected")}
</span>
</>
) : (
<>
<XCircle className="w-4 h-4 text-red-500" />
<span className="text-red-600">
{t("serverConfig.disconnected")}
</span>
</>
)}
</div>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
@@ -213,7 +136,7 @@ export function ElectronServerConfig({
type="button"
className={onCancel && !isFirstTime ? "flex-1" : "w-full"}
onClick={handleSaveConfig}
disabled={loading || testing || connectionStatus !== "success"}
disabled={loading || !serverUrl.trim()}
>
{loading ? (
<div className="flex items-center space-x-2">

View File

@@ -16,6 +16,7 @@ import {
TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG,
} from "@/constants/terminal-themes";
import { SSHAuthDialog } from "@/ui/Desktop/Navigation/SSHAuthDialog.tsx";
interface TabData {
id: number;

View File

@@ -25,14 +25,10 @@ import {
DropdownMenuTrigger,
} from "@radix-ui/react-dropdown-menu";
import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import { FolderCard } from "@/ui/Desktop/Navigation/Hosts/FolderCard.tsx";
import { getSSHHosts } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { deleteAccount } from "@/ui/main-axios.ts";
interface SSHHost {
id: number;
@@ -87,11 +83,6 @@ export function LeftSidebar({
}: SidebarProps): React.ReactElement {
const { t } = useTranslation();
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
const [deletePassword, setDeletePassword] = React.useState("");
const [deleteLoading, setDeleteLoading] = React.useState(false);
const [deleteError, setDeleteError] = React.useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(() => {
const saved = localStorage.getItem("leftSidebarOpen");
return saved !== null ? JSON.parse(saved) : true;
@@ -300,30 +291,6 @@ export function LeftSidebar({
return [...pinned, ...rest];
}, []);
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
setDeleteError(null);
if (!deletePassword.trim()) {
setDeleteError(t("leftSidebar.passwordRequired"));
setDeleteLoading(false);
return;
}
try {
await deleteAccount(deletePassword);
handleLogout();
} catch (err: unknown) {
setDeleteError(
(err as { response?: { data?: { error?: string } } })?.response?.data
?.error || t("leftSidebar.failedToDeleteAccount"),
);
setDeleteLoading(false);
}
};
return (
<div className="min-h-svh">
<SidebarProvider open={isSidebarOpen}>
@@ -444,14 +411,6 @@ export function LeftSidebar({
>
<span>{t("common.logout")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => setDeleteAccountOpen(true)}
>
<span className="text-red-400">
{t("leftSidebar.deleteAccount")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
@@ -469,114 +428,6 @@ export function LeftSidebar({
<ChevronRight size={10} />
</div>
)}
{deleteAccountOpen && (
<div
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] pointer-events-auto isolate"
style={{
transform: "translateZ(0)",
willChange: "z-index",
}}
>
<div
className="w-[400px] h-full bg-dark-bg border-r-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[9999999]"
style={{
boxShadow: "4px 0 20px rgba(0, 0, 0, 0.5)",
transform: "translateZ(0)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-dark-border">
<h2 className="text-lg font-semibold text-white">
{t("leftSidebar.deleteAccount")}
</h2>
<Button
variant="outline"
size="sm"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t("leftSidebar.closeDeleteAccount")}
>
<span className="text-lg font-bold leading-none">×</span>
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div className="text-sm text-gray-300">
{t("leftSidebar.deleteAccountWarning")}
</div>
<Alert variant="destructive">
<AlertTitle>{t("common.warning")}</AlertTitle>
<AlertDescription>
{t("leftSidebar.deleteAccountWarningDetails")}
</AlertDescription>
</Alert>
{deleteError && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{deleteError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleDeleteAccount} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="delete-password">
{t("leftSidebar.confirmPassword")}
</Label>
<PasswordInput
id="delete-password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
placeholder={t("placeholders.confirmPassword")}
required
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={deleteLoading || !deletePassword.trim()}
>
{deleteLoading
? t("leftSidebar.deleting")
: t("leftSidebar.deleteAccount")}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
>
{t("leftSidebar.cancel")}
</Button>
</div>
</form>
</div>
</div>
</div>
<div
className="flex-1 cursor-pointer"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,281 @@
import React, { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Shield, AlertCircle, Upload } from "lucide-react";
import { useTranslation } from "react-i18next";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
interface SSHAuthDialogProps {
isOpen: boolean;
reason: "no_keyboard" | "auth_failed" | "timeout";
onSubmit: (credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
}) => void;
onCancel: () => void;
hostInfo: {
ip: string;
port: number;
username: string;
name?: string;
};
backgroundColor?: string;
}
export function SSHAuthDialog({
isOpen,
reason,
onSubmit,
onCancel,
hostInfo,
backgroundColor = "#1e1e1e",
}: SSHAuthDialogProps) {
const { t } = useTranslation();
const [authTab, setAuthTab] = useState<"password" | "key">("password");
const [password, setPassword] = useState("");
const [sshKey, setSshKey] = useState("");
const [keyPassword, setKeyPassword] = useState("");
const [loading, setLoading] = useState(false);
if (!isOpen) return null;
const getReasonMessage = () => {
switch (reason) {
case "no_keyboard":
return t("auth.sshNoKeyboardInteractive");
case "auth_failed":
return t("auth.sshAuthenticationFailed");
case "timeout":
return t("auth.sshAuthenticationTimeout");
default:
return t("auth.sshAuthenticationRequired");
}
};
const getReasonDescription = () => {
switch (reason) {
case "no_keyboard":
return t("auth.sshNoKeyboardInteractiveDescription");
case "auth_failed":
return t("auth.sshAuthFailedDescription");
case "timeout":
return t("auth.sshTimeoutDescription");
default:
return t("auth.sshProvideCredentialsDescription");
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
} = {};
if (authTab === "password") {
if (password.trim()) {
credentials.password = password;
}
} else {
if (sshKey.trim()) {
credentials.sshKey = sshKey;
if (keyPassword.trim()) {
credentials.keyPassword = keyPassword;
}
}
}
onSubmit(credentials);
} finally {
setLoading(false);
}
};
const handleKeyFileUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
setSshKey(fileContent);
} catch (error) {
console.error("Failed to read SSH key file:", error);
}
}
};
const canSubmit = () => {
if (authTab === "password") {
return password.trim() !== "";
} else {
return sshKey.trim() !== "";
}
};
const hostDisplay = hostInfo.name
? `${hostInfo.name} (${hostInfo.username}@${hostInfo.ip}:${hostInfo.port})`
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
style={{ backgroundColor: `${backgroundColor}dd` }}
>
<Card className="w-full max-w-2xl mx-4 shadow-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
{t("auth.sshAuthenticationRequired")}
</CardTitle>
<CardDescription>{hostDisplay}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant={reason === "auth_failed" ? "destructive" : "default"}>
<AlertCircle className="h-4 w-4" />
<AlertTitle>{getReasonMessage()}</AlertTitle>
<AlertDescription>{getReasonDescription()}</AlertDescription>
</Alert>
<form onSubmit={handleSubmit} className="space-y-4">
<Tabs
value={authTab}
onValueChange={(v) => setAuthTab(v as "password" | "key")}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="password">
{t("credentials.password")}
</TabsTrigger>
<TabsTrigger value="key">{t("credentials.sshKey")}</TabsTrigger>
</TabsList>
<TabsContent value="password" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="ssh-password">
{t("credentials.password")}
</Label>
<PasswordInput
id="ssh-password"
placeholder={t("placeholders.enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
<p className="text-sm text-muted-foreground">
{t("auth.sshPasswordDescription")}
</p>
</div>
</TabsContent>
<TabsContent value="key" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="ssh-key">
{t("credentials.sshPrivateKey")}
</Label>
<div className="mb-2">
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.txt,.ppk"
onChange={handleKeyFileUpload}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<Upload className="w-4 h-4 mr-2" />
<span className="truncate">
{t("credentials.uploadPrivateKeyFile")}
</span>
</Button>
</div>
</div>
<CodeMirror
value={sshKey}
onChange={(value) => setSshKey(value)}
placeholder={t("placeholders.pastePrivateKey")}
theme={oneDark}
className="border border-input rounded-md"
minHeight="200px"
maxHeight="300px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: false,
scrollPastEnd: false,
}}
extensions={[
EditorView.theme({
".cm-scroller": {
overflow: "auto",
},
}),
]}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ssh-key-password">
{t("credentials.keyPassword")} ({t("common.optional")})
</Label>
<PasswordInput
id="ssh-key-password"
placeholder={t("placeholders.keyPassword")}
value={keyPassword}
onChange={(e) => setKeyPassword(e.target.value)}
/>
<p className="text-sm text-muted-foreground">
{t("auth.sshKeyPasswordDescription")}
</p>
</div>
</TabsContent>
</Tabs>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
className="flex-1"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={!canSubmit() || loading}
className="flex-1"
>
{loading ? t("common.connecting") : t("common.connect")}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,5 +1,7 @@
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label.tsx";
import { Button } from "@/components/ui/button.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import {
Tabs,
@@ -10,8 +12,13 @@ import {
import { Separator } from "@/components/ui/separator.tsx";
import { User, Shield, AlertCircle } from "lucide-react";
import { TOTPSetup } from "@/ui/Desktop/User/TOTPSetup.tsx";
import { getUserInfo } from "@/ui/main-axios.ts";
import { getVersionInfo } from "@/ui/main-axios.ts";
import {
getUserInfo,
getVersionInfo,
deleteAccount,
logoutUser,
isElectron,
} from "@/ui/main-axios.ts";
import { PasswordReset } from "@/ui/Desktop/User/PasswordReset.tsx";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
@@ -21,6 +28,21 @@ interface UserProfileProps {
isTopbarOpen?: boolean;
}
async function handleLogout() {
try {
await logoutUser();
if (isElectron()) {
localStorage.removeItem("jwt");
}
window.location.reload();
} catch (error) {
console.error("Logout failed:", error);
window.location.reload();
}
}
export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();
@@ -36,6 +58,11 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
null,
);
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
const [deletePassword, setDeletePassword] = useState("");
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
fetchUserInfo();
fetchVersion();
@@ -76,6 +103,29 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
}
};
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
setDeleteError(null);
if (!deletePassword.trim()) {
setDeleteError(t("leftSidebar.passwordRequired"));
setDeleteLoading(false);
return;
}
try {
await deleteAccount(deletePassword);
handleLogout();
} catch (err: unknown) {
setDeleteError(
(err as { response?: { data?: { error?: string } } })?.response?.data
?.error || t("leftSidebar.failedToDeleteAccount"),
);
setDeleteLoading(false);
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8;
@@ -139,127 +189,259 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
}
return (
<div
style={wrapperStyle}
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden"
>
<div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">{t("nav.userProfile")}</h1>
</div>
<Separator className="p-0.25 w-full" />
<>
<div
style={wrapperStyle}
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden"
>
<div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">{t("nav.userProfile")}</h1>
</div>
<Separator className="p-0.25 w-full" />
<div className="px-6 py-4 overflow-auto flex-1">
<Tabs defaultValue="profile" className="w-full">
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
<TabsTrigger
value="profile"
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
>
<User className="w-4 h-4" />
{t("nav.userProfile")}
</TabsTrigger>
{!userInfo.is_oidc && (
<div className="px-6 py-4 overflow-auto flex-1">
<Tabs defaultValue="profile" className="w-full">
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
<TabsTrigger
value="security"
value="profile"
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
>
<Shield className="w-4 h-4" />
{t("profile.security")}
<User className="w-4 h-4" />
{t("nav.userProfile")}
</TabsTrigger>
)}
</TabsList>
{!userInfo.is_oidc && (
<TabsTrigger
value="security"
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
>
<Shield className="w-4 h-4" />
{t("profile.security")}
</TabsTrigger>
)}
</TabsList>
<TabsContent value="profile" className="space-y-4">
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
<h3 className="text-lg font-semibold mb-4">
{t("profile.accountInfo")}
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-gray-300">
{t("common.username")}
</Label>
<p className="text-lg font-medium mt-1 text-white">
{userInfo.username}
</p>
</div>
<div>
<Label className="text-gray-300">{t("profile.role")}</Label>
<p className="text-lg font-medium mt-1 text-white">
{userInfo.is_admin
? t("interface.administrator")
: t("interface.user")}
</p>
</div>
<div>
<Label className="text-gray-300">
{t("profile.authMethod")}
</Label>
<p className="text-lg font-medium mt-1 text-white">
{userInfo.is_oidc
? t("profile.external")
: t("profile.local")}
</p>
</div>
<div>
<Label className="text-gray-300">
{t("profile.twoFactorAuth")}
</Label>
<p className="text-lg font-medium mt-1">
{userInfo.is_oidc ? (
<span className="text-gray-400">
{t("auth.lockedOidcAuth")}
</span>
) : userInfo.totp_enabled ? (
<span className="text-green-400 flex items-center gap-1">
<Shield className="w-4 h-4" />
{t("common.enabled")}
</span>
) : (
<span className="text-gray-400">
{t("common.disabled")}
</span>
)}
</p>
</div>
<div>
<Label className="text-gray-300">
{t("common.version")}
</Label>
<p className="text-lg font-medium mt-1 text-white">
{versionInfo?.version || t("common.loading")}
</p>
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<TabsContent value="profile" className="space-y-4">
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
<h3 className="text-lg font-semibold mb-4">
{t("profile.accountInfo")}
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-gray-300">
{t("common.language")}
{t("common.username")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.selectPreferredLanguage")}
<p className="text-lg font-medium mt-1 text-white">
{userInfo.username}
</p>
</div>
<LanguageSwitcher />
<div>
<Label className="text-gray-300">
{t("profile.role")}
</Label>
<p className="text-lg font-medium mt-1 text-white">
{userInfo.is_admin
? t("interface.administrator")
: t("interface.user")}
</p>
</div>
<div>
<Label className="text-gray-300">
{t("profile.authMethod")}
</Label>
<p className="text-lg font-medium mt-1 text-white">
{userInfo.is_oidc
? t("profile.external")
: t("profile.local")}
</p>
</div>
<div>
<Label className="text-gray-300">
{t("profile.twoFactorAuth")}
</Label>
<p className="text-lg font-medium mt-1">
{userInfo.is_oidc ? (
<span className="text-gray-400">
{t("auth.lockedOidcAuth")}
</span>
) : userInfo.totp_enabled ? (
<span className="text-green-400 flex items-center gap-1">
<Shield className="w-4 h-4" />
{t("common.enabled")}
</span>
) : (
<span className="text-gray-400">
{t("common.disabled")}
</span>
)}
</p>
</div>
<div>
<Label className="text-gray-300">
{t("common.version")}
</Label>
<p className="text-lg font-medium mt-1 text-white">
{versionInfo?.version || t("common.loading")}
</p>
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("common.language")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.selectPreferredLanguage")}
</p>
</div>
<LanguageSwitcher />
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-red-400">
{t("leftSidebar.deleteAccount")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t(
"leftSidebar.deleteAccountWarningShort",
"This action is not reversible and will permanently delete your account.",
)}
</p>
</div>
<Button
variant="destructive"
onClick={() => setDeleteAccountOpen(true)}
>
{t("leftSidebar.deleteAccount")}
</Button>
</div>
</div>
</div>
</div>
</TabsContent>
</TabsContent>
<TabsContent value="security" className="space-y-4">
<TOTPSetup
isEnabled={userInfo.totp_enabled}
onStatusChange={handleTOTPStatusChange}
/>
<TabsContent value="security" className="space-y-4">
<TOTPSetup
isEnabled={userInfo.totp_enabled}
onStatusChange={handleTOTPStatusChange}
/>
{!userInfo.is_oidc && <PasswordReset userInfo={userInfo} />}
</TabsContent>
</Tabs>
{!userInfo.is_oidc && <PasswordReset userInfo={userInfo} />}
</TabsContent>
</Tabs>
</div>
</div>
</div>
</div>
{deleteAccountOpen && (
<div
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] pointer-events-auto isolate"
style={{
transform: "translateZ(0)",
willChange: "z-index",
}}
>
<div
className="w-[400px] h-full bg-dark-bg border-r-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[9999999]"
style={{
boxShadow: "4px 0 20px rgba(0, 0, 0, 0.5)",
transform: "translateZ(0)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-dark-border">
<h2 className="text-lg font-semibold text-white">
{t("leftSidebar.deleteAccount")}
</h2>
<Button
variant="outline"
size="sm"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t("leftSidebar.closeDeleteAccount")}
>
<span className="text-lg font-bold leading-none">×</span>
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div className="text-sm text-gray-300">
{t("leftSidebar.deleteAccountWarning")}
<Alert variant="destructive">
<AlertTitle>{t("common.warning")}</AlertTitle>
<AlertDescription>
{t("leftSidebar.deleteAccountWarningDetails")}
</AlertDescription>
</Alert>
{deleteError && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{deleteError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleDeleteAccount} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="delete-password">
{t("leftSidebar.confirmPassword")}
</Label>
<PasswordInput
id="delete-password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
placeholder={t("placeholders.confirmPassword")}
required
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={deleteLoading || !deletePassword.trim()}
>
{deleteLoading
? t("leftSidebar.deleting")
: t("leftSidebar.deleteAccount")}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
>
{t("leftSidebar.cancel")}
</Button>
</div>
</form>
</div>
</div>
</div>
<div
className="flex-1 cursor-pointer"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
/>
</div>
</div>
)}
</>
);
}

View File

@@ -7,6 +7,7 @@ import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
import { toast } from "sonner";
import { Smartphone } from "lucide-react";
import {
registerUser,
loginUser,
@@ -22,9 +23,51 @@ import {
verifyTOTPLogin,
logoutUser,
isElectron,
getCookie,
} from "@/ui/main-axios.ts";
import { PasswordInput } from "@/components/ui/password-input.tsx";
/**
* Detect if we're running inside a React Native WebView
*/
function isReactNativeWebView(): boolean {
return typeof window !== "undefined" && !!(window as any).ReactNativeWebView;
}
/**
* Post JWT token to React Native WebView for mobile app authentication
*/
function postJWTToWebView() {
if (!isReactNativeWebView()) {
return;
}
try {
// Get JWT from localStorage or cookies
const jwt = getCookie("jwt") || localStorage.getItem("jwt");
if (!jwt) {
console.warn("JWT not found when trying to post to WebView");
return;
}
// Post message to React Native
(window as any).ReactNativeWebView.postMessage(
JSON.stringify({
type: "AUTH_SUCCESS",
token: jwt,
source: "explicit",
platform: "mobile",
timestamp: Date.now(),
}),
);
console.log("JWT posted to React Native WebView");
} catch (error) {
console.error("Failed to post JWT to WebView:", error);
}
}
interface AuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void;
setIsAdmin: (isAdmin: boolean) => void;
@@ -231,6 +274,10 @@ export function Auth({
username: meRes.username || null,
userId: meRes.userId || null,
});
// Post JWT to React Native WebView if running in mobile app
postJWTToWebView();
setInternalLoggedIn(true);
if (tab === "signup") {
setSignupConfirmPassword("");
@@ -395,6 +442,9 @@ export function Auth({
username: res.username || null,
userId: res.userId || null,
});
// Post JWT to React Native WebView if running in mobile app
postJWTToWebView();
}, 100);
setInternalLoggedIn(true);
@@ -482,6 +532,10 @@ export function Auth({
username: meRes.username || null,
userId: meRes.userId || null,
});
// Post JWT to React Native WebView if running in mobile app
postJWTToWebView();
setInternalLoggedIn(true);
window.history.replaceState(
{},
@@ -535,6 +589,13 @@ export function Auth({
className={`w-full max-w-md flex flex-col bg-dark-bg ${className || ""}`}
{...props}
>
{isReactNativeWebView() && (
<Alert className="mb-4 border-blue-500 bg-blue-500/10">
<Smartphone className="h-4 w-4" />
<AlertTitle>{t("auth.mobileApp")}</AlertTitle>
<AlertDescription>{t("auth.loggingInToMobileApp")}</AlertDescription>
</Alert>
)}
{dbError && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle>