feat: General bug fixes, added server stat commands, improved split screen, link accounts, etc
This commit is contained in:
61
DOWNLOADS.md
61
DOWNLOADS.md
@@ -1,61 +0,0 @@
|
||||
# Termix Download Links
|
||||
|
||||
## Windows
|
||||
|
||||
| Architecture | Type | Download Link |
|
||||
| ------------ | -------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| x64 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_nsis.exe) |
|
||||
| x64 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_msi.msi) |
|
||||
| x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_portable.zip) |
|
||||
| ia32 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_nsis.exe) |
|
||||
| ia32 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_msi.msi) |
|
||||
| ia32 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_portable.zip) |
|
||||
|
||||
## Linux
|
||||
|
||||
| Architecture | Type | Download Link |
|
||||
| ------------ | -------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| x64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_appimage.AppImage) |
|
||||
| x64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_deb.deb) |
|
||||
| x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_portable.tar.gz) |
|
||||
| arm64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_appimage.AppImage) |
|
||||
| arm64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_deb.deb) |
|
||||
| arm64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_portable.tar.gz) |
|
||||
| armv7l | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_appimage.AppImage) |
|
||||
| armv7l | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_deb.deb) |
|
||||
| armv7l | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_portable.tar.gz) |
|
||||
|
||||
## macOS
|
||||
|
||||
| Architecture | Type | Download Link |
|
||||
| ------------ | ------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| Universal | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_dmg.dmg) |
|
||||
| Universal | Mac App Store | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_mas.pkg) |
|
||||
| x64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_x64_dmg.dmg) |
|
||||
| arm64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_arm64_dmg.dmg) |
|
||||
|
||||
---
|
||||
|
||||
## All Platforms - Complete Download List
|
||||
|
||||
| Platform | Architecture | Type | Download Link |
|
||||
| -------- | ------------ | ------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Windows | x64 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_nsis.exe) |
|
||||
| Windows | x64 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_msi.msi) |
|
||||
| Windows | x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_portable.zip) |
|
||||
| Windows | ia32 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_nsis.exe) |
|
||||
| Windows | ia32 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_msi.msi) |
|
||||
| Windows | ia32 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_portable.zip) |
|
||||
| Linux | x64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_appimage.AppImage) |
|
||||
| Linux | x64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_deb.deb) |
|
||||
| Linux | x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_portable.tar.gz) |
|
||||
| Linux | arm64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_appimage.AppImage) |
|
||||
| Linux | arm64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_deb.deb) |
|
||||
| Linux | arm64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_portable.tar.gz) |
|
||||
| Linux | armv7l | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_appimage.AppImage) |
|
||||
| Linux | armv7l | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_deb.deb) |
|
||||
| Linux | armv7l | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_portable.tar.gz) |
|
||||
| macOS | Universal | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_dmg.dmg) |
|
||||
| macOS | Universal | Mac App Store | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_mas.pkg) |
|
||||
| macOS | x64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_x64_dmg.dmg) |
|
||||
| macOS | arm64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_arm64_dmg.dmg) |
|
||||
@@ -316,7 +316,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
host_name TEXT NOT NULL,
|
||||
host_name TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
@@ -489,6 +489,7 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
|
||||
@@ -89,6 +89,7 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
defaultPath: text("default_path"),
|
||||
statsConfig: text("stats_config"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
quickActions: text("quick_actions"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -238,6 +239,7 @@ export const recentActivity = sqliteTable("recent_activity", {
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
hostName: text("host_name"),
|
||||
timestamp: text("timestamp")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -524,6 +524,8 @@ router.delete(
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
|
||||
// Update hosts using this credential to set credentialId to null
|
||||
// This prevents orphaned references before deletion
|
||||
const hostsUsingCredential = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
@@ -552,14 +554,8 @@ router.delete(
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentialUsage.credentialId, parseInt(id)),
|
||||
eq(sshCredentialUsage.userId, userId),
|
||||
),
|
||||
);
|
||||
// sshCredentialUsage will be automatically deleted by ON DELETE CASCADE
|
||||
// No need for manual deletion
|
||||
|
||||
await db
|
||||
.delete(sshCredentials)
|
||||
|
||||
@@ -257,4 +257,244 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
// Execute a snippet on a host
|
||||
// POST /snippets/execute
|
||||
router.post(
|
||||
"/execute",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { snippetId, hostId } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !snippetId || !hostId) {
|
||||
authLogger.warn("Invalid snippet execution request", {
|
||||
userId,
|
||||
snippetId,
|
||||
hostId,
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Snippet ID and Host ID are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the snippet
|
||||
const snippetResult = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.id, parseInt(snippetId)),
|
||||
eq(snippets.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (snippetResult.length === 0) {
|
||||
return res.status(404).json({ error: "Snippet not found" });
|
||||
}
|
||||
|
||||
const snippet = snippetResult[0];
|
||||
|
||||
// Import SSH connection utilities
|
||||
const { Client } = await import("ssh2");
|
||||
const { sshData, sshCredentials } = await import("../db/schema.js");
|
||||
|
||||
// Get host configuration
|
||||
const hostResult = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
|
||||
);
|
||||
|
||||
if (hostResult.length === 0) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const host = hostResult[0];
|
||||
|
||||
// Resolve credentials if needed
|
||||
let password = host.password;
|
||||
let privateKey = host.key;
|
||||
let passphrase = host.key_password;
|
||||
|
||||
if (host.credentialId) {
|
||||
const credResult = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (credResult.length > 0) {
|
||||
const cred = credResult[0];
|
||||
password = cred.password || undefined;
|
||||
privateKey = cred.private_key || cred.key || undefined;
|
||||
passphrase = cred.key_password || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Create SSH connection
|
||||
const conn = new Client();
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
const executePromise = new Promise<{
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
conn.end();
|
||||
reject(new Error("Command execution timeout (30s)"));
|
||||
}, 30000);
|
||||
|
||||
conn.on("ready", () => {
|
||||
conn.exec(snippet.content, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
stream.on("close", () => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
if (errorOutput) {
|
||||
resolve({ success: false, output, error: errorOutput });
|
||||
} else {
|
||||
resolve({ success: true, output });
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Connect to SSH
|
||||
const config: any = {
|
||||
host: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 30000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
timeout: 30000,
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
LC_CTYPE: "en_US.UTF-8",
|
||||
LC_MESSAGES: "en_US.UTF-8",
|
||||
LC_MONETARY: "en_US.UTF-8",
|
||||
LC_NUMERIC: "en_US.UTF-8",
|
||||
LC_TIME: "en_US.UTF-8",
|
||||
LC_COLLATE: "en_US.UTF-8",
|
||||
COLORTERM: "truecolor",
|
||||
},
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (password) {
|
||||
config.password = password;
|
||||
}
|
||||
|
||||
if (privateKey) {
|
||||
const cleanKey = privateKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (passphrase) {
|
||||
config.passphrase = passphrase;
|
||||
}
|
||||
}
|
||||
|
||||
conn.connect(config);
|
||||
});
|
||||
|
||||
const result = await executePromise;
|
||||
|
||||
authLogger.success(
|
||||
`Snippet executed: ${snippet.name} on host ${hostId}`,
|
||||
{
|
||||
operation: "snippet_execute_success",
|
||||
userId,
|
||||
snippetId,
|
||||
hostId,
|
||||
},
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to execute snippet", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to execute snippet",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -237,6 +237,7 @@ router.post(
|
||||
defaultPath,
|
||||
tunnelConnections,
|
||||
jumpHosts,
|
||||
quickActions,
|
||||
statsConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
@@ -274,6 +275,9 @@ router.post(
|
||||
? JSON.stringify(tunnelConnections)
|
||||
: null,
|
||||
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
||||
quickActions: Array.isArray(quickActions)
|
||||
? JSON.stringify(quickActions)
|
||||
: null,
|
||||
enableFileManager: enableFileManager ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||
@@ -456,6 +460,7 @@ router.put(
|
||||
defaultPath,
|
||||
tunnelConnections,
|
||||
jumpHosts,
|
||||
quickActions,
|
||||
statsConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
@@ -494,6 +499,9 @@ router.put(
|
||||
? JSON.stringify(tunnelConnections)
|
||||
: null,
|
||||
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
||||
quickActions: Array.isArray(quickActions)
|
||||
? JSON.stringify(quickActions)
|
||||
: null,
|
||||
enableFileManager: enableFileManager ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||
@@ -672,6 +680,9 @@ router.get(
|
||||
? JSON.parse(row.tunnelConnections as string)
|
||||
: [],
|
||||
jumpHosts: row.jumpHosts ? JSON.parse(row.jumpHosts as string) : [],
|
||||
quickActions: row.quickActions
|
||||
? JSON.parse(row.quickActions as string)
|
||||
: [],
|
||||
enableFileManager: !!row.enableFileManager,
|
||||
statsConfig: row.statsConfig
|
||||
? JSON.parse(row.statsConfig as string)
|
||||
@@ -745,6 +756,8 @@ router.get(
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [],
|
||||
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts) : [],
|
||||
quickActions: host.quickActions ? JSON.parse(host.quickActions) : [],
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
statsConfig: host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
|
||||
@@ -762,6 +762,44 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
.get();
|
||||
isFirstUser = ((countResult as { count?: number })?.count || 0) === 0;
|
||||
|
||||
// Check if registration is allowed (unless this is the first user)
|
||||
if (!isFirstUser) {
|
||||
try {
|
||||
const regRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'allow_registration'",
|
||||
)
|
||||
.get();
|
||||
if (regRow && (regRow as Record<string, unknown>).value !== "true") {
|
||||
authLogger.warn(
|
||||
"OIDC user attempted to register when registration is disabled",
|
||||
{
|
||||
operation: "oidc_registration_disabled",
|
||||
identifier,
|
||||
name,
|
||||
},
|
||||
);
|
||||
|
||||
let frontendUrl = (redirectUri as string).replace(
|
||||
"/users/oidc/callback",
|
||||
"",
|
||||
);
|
||||
if (frontendUrl.includes("localhost")) {
|
||||
frontendUrl = "http://localhost:5173";
|
||||
}
|
||||
const redirectUrl = new URL(frontendUrl);
|
||||
redirectUrl.searchParams.set("error", "registration_disabled");
|
||||
|
||||
return res.redirect(redirectUrl.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
authLogger.warn("Failed to check registration status during OIDC", {
|
||||
operation: "oidc_registration_check",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
await db.insert(users).values({
|
||||
id,
|
||||
@@ -1681,6 +1719,7 @@ router.get("/list", authenticateJWT, async (req, res) => {
|
||||
username: users.username,
|
||||
is_admin: users.is_admin,
|
||||
is_oidc: users.is_oidc,
|
||||
password_hash: users.password_hash,
|
||||
})
|
||||
.from(users);
|
||||
|
||||
@@ -2517,21 +2556,15 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Convert OIDC user to password user (link accounts)
|
||||
// POST /users/convert-oidc-to-password
|
||||
router.post("/convert-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
// Route: Link OIDC user to existing password account (merge accounts)
|
||||
// POST /users/link-oidc-to-password
|
||||
router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const { targetUserId, newPassword, totpCode } = req.body;
|
||||
const { oidcUserId, targetUsername } = req.body;
|
||||
|
||||
if (!isNonEmptyString(targetUserId) || !isNonEmptyString(newPassword)) {
|
||||
if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) {
|
||||
return res.status(400).json({
|
||||
error: "Target user ID and new password are required",
|
||||
});
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({
|
||||
error: "New password must be at least 8 characters long",
|
||||
error: "OIDC user ID and target username are required",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2545,185 +2578,128 @@ router.post("/convert-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
// Get target user
|
||||
// Get OIDC user
|
||||
const oidcUserRecords = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, oidcUserId));
|
||||
if (!oidcUserRecords || oidcUserRecords.length === 0) {
|
||||
return res.status(404).json({ error: "OIDC user not found" });
|
||||
}
|
||||
|
||||
const oidcUser = oidcUserRecords[0];
|
||||
|
||||
// Verify user is OIDC
|
||||
if (!oidcUser.is_oidc) {
|
||||
return res.status(400).json({
|
||||
error: "Source user is not an OIDC user",
|
||||
});
|
||||
}
|
||||
|
||||
// Get target password user
|
||||
const targetUserRecords = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId));
|
||||
.where(eq(users.username, targetUsername));
|
||||
if (!targetUserRecords || targetUserRecords.length === 0) {
|
||||
return res.status(404).json({ error: "Target user not found" });
|
||||
return res.status(404).json({ error: "Target password user not found" });
|
||||
}
|
||||
|
||||
const targetUser = targetUserRecords[0];
|
||||
|
||||
// Verify user is OIDC
|
||||
if (!targetUser.is_oidc) {
|
||||
// Verify target user has password authentication
|
||||
if (targetUser.is_oidc || !targetUser.password_hash) {
|
||||
return res.status(400).json({
|
||||
error: "User is already a password-based user",
|
||||
error: "Target user must be a password-based account",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify TOTP if enabled
|
||||
if (targetUser.totp_enabled && targetUser.totp_secret) {
|
||||
if (!totpCode) {
|
||||
return res.status(400).json({
|
||||
error: "TOTP code required for this user",
|
||||
code: "TOTP_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: targetUser.totp_secret,
|
||||
encoding: "base32",
|
||||
token: totpCode,
|
||||
window: 2,
|
||||
// Check if target user already has OIDC configured
|
||||
if (targetUser.client_id && targetUser.oidc_identifier) {
|
||||
return res.status(400).json({
|
||||
error: "Target user already has OIDC authentication configured",
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
}
|
||||
}
|
||||
|
||||
authLogger.info("Converting OIDC user to password user", {
|
||||
operation: "convert_oidc_to_password",
|
||||
targetUserId,
|
||||
adminUserId,
|
||||
authLogger.info("Linking OIDC user to password account", {
|
||||
operation: "link_oidc_to_password",
|
||||
oidcUserId,
|
||||
oidcUsername: oidcUser.username,
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
// Step 1: Get current DEK from memory (requires user to be logged in)
|
||||
// For admin conversion, we need to authenticate as OIDC user first
|
||||
const deviceType = "web";
|
||||
const unlocked = await authManager.authenticateOIDCUser(
|
||||
targetUserId,
|
||||
deviceType,
|
||||
);
|
||||
|
||||
if (!unlocked) {
|
||||
return res.status(500).json({
|
||||
error: "Failed to unlock user data for conversion",
|
||||
});
|
||||
}
|
||||
|
||||
// Get the DEK from memory
|
||||
const { DataCrypto } = await import("../../utils/data-crypto.js");
|
||||
const currentDEK = DataCrypto.getUserDataKey(targetUserId);
|
||||
|
||||
if (!currentDEK) {
|
||||
return res.status(500).json({
|
||||
error: "User data encryption key not available",
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Setup new password-based encryption
|
||||
const { UserCrypto } = await import("../../utils/user-crypto.js");
|
||||
|
||||
// Generate new KEK from password
|
||||
const kekSalt = crypto.randomBytes(32);
|
||||
const kekSaltHex = kekSalt.toString("hex");
|
||||
|
||||
// Derive KEK from new password
|
||||
const kek = await new Promise<Buffer>((resolve, reject) => {
|
||||
crypto.pbkdf2(
|
||||
newPassword,
|
||||
kekSalt,
|
||||
100000,
|
||||
32,
|
||||
"sha256",
|
||||
(err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derivedKey);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Encrypt the existing DEK with new password-derived KEK
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv);
|
||||
let encryptedDEK = cipher.update(currentDEK);
|
||||
encryptedDEK = Buffer.concat([encryptedDEK, cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const encryptedDEKData = JSON.stringify({
|
||||
iv: iv.toString("hex"),
|
||||
encryptedKey: encryptedDEK.toString("hex"),
|
||||
authTag: authTag.toString("hex"),
|
||||
});
|
||||
|
||||
// Step 3: Hash the new password
|
||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
// Step 4: Update user record atomically
|
||||
// Copy OIDC configuration from OIDC user to target password user
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
password_hash,
|
||||
is_oidc: false,
|
||||
oidc_identifier: null,
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "",
|
||||
name_path: "",
|
||||
scopes: "openid email profile",
|
||||
is_oidc: true, // Enable OIDC login for this account
|
||||
oidc_identifier: oidcUser.oidc_identifier,
|
||||
client_id: oidcUser.client_id,
|
||||
client_secret: oidcUser.client_secret,
|
||||
issuer_url: oidcUser.issuer_url,
|
||||
authorization_url: oidcUser.authorization_url,
|
||||
token_url: oidcUser.token_url,
|
||||
identifier_path: oidcUser.identifier_path,
|
||||
name_path: oidcUser.name_path,
|
||||
scopes: oidcUser.scopes || "openid email profile",
|
||||
})
|
||||
.where(eq(users.id, targetUserId));
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// Step 5: Update KEK salt and encrypted DEK in settings
|
||||
// Revoke all sessions for the OIDC user before deletion
|
||||
await authManager.revokeAllUserSessions(oidcUserId);
|
||||
authManager.logoutUser(oidcUserId);
|
||||
|
||||
// Delete OIDC user's recent activity first (to avoid NOT NULL constraint issues)
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(eq(recentActivity.userId, oidcUserId));
|
||||
|
||||
// Delete the OIDC user (CASCADE will delete related data: hosts, credentials, sessions, etc.)
|
||||
await db.delete(users).where(eq(users.id, oidcUserId));
|
||||
|
||||
// Clean up OIDC user's settings
|
||||
db.$client
|
||||
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.run(`user_kek_salt_${targetUserId}`, kekSaltHex);
|
||||
|
||||
db.$client
|
||||
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.run(`user_encrypted_dek_${targetUserId}`, encryptedDEKData);
|
||||
|
||||
// Step 6: Remove OIDC session duration setting if exists
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key = ?")
|
||||
.run(`user_oidc_session_duration_${targetUserId}`);
|
||||
|
||||
// Step 7: Revoke all existing sessions to force re-login
|
||||
await authManager.revokeAllUserSessions(targetUserId);
|
||||
|
||||
// Step 8: Clear the in-memory DEK
|
||||
authManager.logoutUser(targetUserId);
|
||||
.prepare("DELETE FROM settings WHERE key LIKE ?")
|
||||
.run(`user_%_${oidcUserId}`);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist conversion to disk", saveError, {
|
||||
operation: "convert_oidc_save_failed",
|
||||
targetUserId,
|
||||
authLogger.error("Failed to persist account linking to disk", saveError, {
|
||||
operation: "link_oidc_save_failed",
|
||||
oidcUserId,
|
||||
targetUserId: targetUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`OIDC user converted to password user: ${targetUser.username}`,
|
||||
`OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`,
|
||||
{
|
||||
operation: "convert_oidc_to_password_success",
|
||||
targetUserId,
|
||||
adminUserId,
|
||||
operation: "link_oidc_to_password_success",
|
||||
oidcUserId,
|
||||
oidcUsername: oidcUser.username,
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User ${targetUser.username} has been converted to password authentication. All sessions have been revoked.`,
|
||||
message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to convert OIDC user to password user", err, {
|
||||
operation: "convert_oidc_to_password_failed",
|
||||
targetUserId,
|
||||
authLogger.error("Failed to link OIDC user to password account", err, {
|
||||
operation: "link_oidc_to_password_failed",
|
||||
oidcUserId,
|
||||
targetUsername,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to convert user account",
|
||||
error: "Failed to link accounts",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -704,15 +704,6 @@ class PollingManager {
|
||||
config.statusTimer = setInterval(() => {
|
||||
this.pollHostStatus(host);
|
||||
}, intervalMs);
|
||||
|
||||
statsLogger.debug(
|
||||
`Started status polling for host ${host.id} (interval: ${statsConfig.statusCheckInterval}s)`,
|
||||
{
|
||||
operation: "status_polling_started",
|
||||
hostId: host.id,
|
||||
interval: statsConfig.statusCheckInterval,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
this.statusStore.delete(host.id);
|
||||
statsLogger.debug(`Status polling disabled for host ${host.id}`, {
|
||||
@@ -729,15 +720,6 @@ class PollingManager {
|
||||
config.metricsTimer = setInterval(() => {
|
||||
this.pollHostMetrics(host);
|
||||
}, intervalMs);
|
||||
|
||||
statsLogger.debug(
|
||||
`Started metrics polling for host ${host.id} (interval: ${statsConfig.metricsInterval}s)`,
|
||||
{
|
||||
operation: "metrics_polling_started",
|
||||
hostId: host.id,
|
||||
interval: statsConfig.metricsInterval,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
this.metricsStore.delete(host.id);
|
||||
statsLogger.debug(`Metrics polling disabled for host ${host.id}`, {
|
||||
|
||||
@@ -189,9 +189,23 @@
|
||||
"enableRightClickCopyPaste": "Rechtsklick-Kopieren\/Einfügen aktivieren",
|
||||
"shareIdeas": "Haben Sie Vorschläge welche weiteren SSH-Tools ergänzt werden sollen? Dann teilen Sie diese gerne mit uns auf"
|
||||
},
|
||||
"commandHistory": {
|
||||
"title": "Verlauf",
|
||||
"searchPlaceholder": "Befehle suchen...",
|
||||
"noTerminal": "Kein aktives Terminal",
|
||||
"noTerminalHint": "Öffnen Sie ein Terminal, um dessen Befehlsverlauf anzuzeigen.",
|
||||
"empty": "Noch kein Befehlsverlauf",
|
||||
"emptyHint": "Führen Sie Befehle im aktiven Terminal aus, um einen Verlauf zu erstellen.",
|
||||
"noResults": "Keine Befehle gefunden",
|
||||
"noResultsHint": "Keine Befehle mit \"{{query}}\" gefunden",
|
||||
"deleteSuccess": "Befehl aus Verlauf gelöscht",
|
||||
"deleteFailed": "Befehl konnte nicht gelöscht werden.",
|
||||
"deleteTooltip": "Befehl löschen",
|
||||
"tabHint": "Verwenden Sie Tab im Terminal, um aus dem Befehlsverlauf zu vervollständigen"
|
||||
},
|
||||
"homepage": {
|
||||
"loggedInTitle": "Eingeloggt!",
|
||||
"loggedInMessage": "Sie sind angemeldet! Über die Seitenleiste haben Sie Zugriff auf alle verfügbaren Tools. Erstellen Sie zunächst einen SSH-Host im Tab „SSH-Manager“. Anschließend können Sie sich über die anderen Apps in der Seitenleiste mit diesem Host verbinden.",
|
||||
"loggedInMessage": "Sie sind angemeldet! Über die Seitenleiste haben Sie Zugriff auf alle verfügbaren Tools. Erstellen Sie zunächst einen SSH-Host im Tab SSH-Manager. Anschließend können Sie sich über die anderen Apps in der Seitenleiste mit diesem Host verbinden.",
|
||||
"failedToLoadAlerts": "Warnmeldungen konnten nicht geladen werden",
|
||||
"failedToDismissAlert": "Benachrichtigung konnte nicht geschlossen werden"
|
||||
},
|
||||
@@ -1374,6 +1388,10 @@
|
||||
"local": "Lokal",
|
||||
"external": "Extern (OIDC)",
|
||||
"selectPreferredLanguage": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche",
|
||||
"fileColorCoding": "Dateifarb-Codierung",
|
||||
"fileColorCodingDesc": "Farbcodierung von Dateien nach Typ: Ordner (rot), Dateien (blau), Symlinks (grün)",
|
||||
"commandAutocomplete": "Befehlsautovervollständigung",
|
||||
"commandAutocompleteDesc": "Tab-Taste Autovervollständigung für Terminal-Befehle basierend auf Ihrem Befehlsverlauf aktivieren",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"passwordChangedSuccess": "Passwort erfolgreich geändert! Bitte melden Sie sich erneut an.",
|
||||
"failedToChangePassword": "Passwort konnte nicht geändert werden. Bitte überprüfen Sie Ihr aktuelles Passwort und versuchen Sie es erneut."
|
||||
|
||||
@@ -226,6 +226,20 @@
|
||||
"editTooltip": "Edit this snippet",
|
||||
"deleteTooltip": "Delete this snippet"
|
||||
},
|
||||
"commandHistory": {
|
||||
"title": "History",
|
||||
"searchPlaceholder": "Search commands...",
|
||||
"noTerminal": "No active terminal",
|
||||
"noTerminalHint": "Open a terminal to see its command history.",
|
||||
"empty": "No command history yet",
|
||||
"emptyHint": "Execute commands in the active terminal to build its history.",
|
||||
"noResults": "No commands found",
|
||||
"noResultsHint": "No commands matching \"{{query}}\"",
|
||||
"deleteSuccess": "Command deleted from history",
|
||||
"deleteFailed": "Failed to delete command.",
|
||||
"deleteTooltip": "Delete command",
|
||||
"tabHint": "Use Tab in Terminal to autocomplete from command history"
|
||||
},
|
||||
"homepage": {
|
||||
"loggedInTitle": "Logged in!",
|
||||
"loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.",
|
||||
@@ -482,26 +496,20 @@
|
||||
"confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?",
|
||||
"failedToRevokeSessions": "Failed to revoke sessions",
|
||||
"sessionsRevokedSuccessfully": "Sessions revoked successfully",
|
||||
"convertToPasswordAuth": "Convert to Password Authentication",
|
||||
"convertOIDCToPassword": "Convert {{username}} from OIDC/SSO authentication to password-based authentication. This will allow the user to log in with a username and password instead of through an external provider.",
|
||||
"convertUserDialogTitle": "Convert to Password Authentication",
|
||||
"convertUserDialogDescription": "This action will set a new password, disable OIDC/SSO login, log out all sessions, and preserve all user data.",
|
||||
"convertActionWillSetPassword": "Set a new password for this user",
|
||||
"convertActionWillDisableOIDC": "Disable OIDC/SSO login for this account",
|
||||
"convertActionWillLogout": "Log out all active sessions",
|
||||
"convertActionWillPreserveData": "Preserve all user data (SSH hosts, credentials, etc.)",
|
||||
"convertPasswordLabel": "New Password (min 8 chars)",
|
||||
"convertPasswordPlaceholder": "Enter new password",
|
||||
"convertTotpLabel": "TOTP Code (if user has 2FA enabled)",
|
||||
"convertTotpPlaceholder": "000000",
|
||||
"convertUserButton": "Convert User",
|
||||
"convertingUser": "Converting...",
|
||||
"userConvertedSuccessfully": "User {{username}} has been converted to password authentication. All sessions have been revoked.",
|
||||
"failedToConvertUser": "Failed to convert user account",
|
||||
"convertPasswordRequired": "Password is required",
|
||||
"convertPasswordTooShort": "Password must be at least 8 characters long",
|
||||
"convertTotpRequired": "TOTP code is required for this user",
|
||||
"convertToPasswordTitle": "Convert to password authentication",
|
||||
"linkToPasswordAccount": "Link to Password Account",
|
||||
"linkOIDCDialogTitle": "Link OIDC Account to Password Account",
|
||||
"linkOIDCDialogDescription": "Link {{username}} (OIDC user) to an existing password account. This will enable dual authentication for the password account.",
|
||||
"linkOIDCWarningTitle": "Warning: OIDC User Data Will Be Deleted",
|
||||
"linkOIDCActionDeleteUser": "Delete the OIDC user account and all their data",
|
||||
"linkOIDCActionAddCapability": "Add OIDC login capability to the target password account",
|
||||
"linkOIDCActionDualAuth": "Allow the password account to login with both password and OIDC",
|
||||
"linkTargetUsernameLabel": "Target Password Account Username",
|
||||
"linkTargetUsernamePlaceholder": "Enter username of password account",
|
||||
"linkAccountsButton": "Link Accounts",
|
||||
"linkingAccounts": "Linking...",
|
||||
"accountsLinkedSuccessfully": "OIDC user {{oidcUsername}} has been linked to {{targetUsername}}",
|
||||
"failedToLinkAccounts": "Failed to link accounts",
|
||||
"linkTargetUsernameRequired": "Target username is required",
|
||||
"databaseSecurity": "Database Security",
|
||||
"encryptionStatus": "Encryption Status",
|
||||
"encryptionEnabled": "Encryption Enabled",
|
||||
@@ -889,6 +897,13 @@
|
||||
"searchServers": "Search servers...",
|
||||
"noServerFound": "No server found",
|
||||
"jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server",
|
||||
"quickActions": "Quick Actions",
|
||||
"quickActionsDescription": "Quick actions allow you to create custom buttons that execute SSH snippets on this server. These buttons will appear at the top of the Server Stats page for quick access.",
|
||||
"quickActionsList": "Quick Actions List",
|
||||
"addQuickAction": "Add Quick Action",
|
||||
"quickActionName": "Action name",
|
||||
"noSnippetFound": "No snippet found",
|
||||
"quickActionsOrder": "Quick action buttons will appear in the order listed above on the Server Stats page",
|
||||
"advancedAuthSettings": "Advanced Authentication Settings"
|
||||
},
|
||||
"terminal": {
|
||||
@@ -1370,7 +1385,13 @@
|
||||
"recentSuccessfulLogins": "Recent Successful Logins",
|
||||
"recentFailedAttempts": "Recent Failed Attempts",
|
||||
"noRecentLoginData": "No recent login data",
|
||||
"from": "from"
|
||||
"from": "from",
|
||||
"quickActions": "Quick Actions",
|
||||
"executeQuickAction": "Execute {{name}}",
|
||||
"executingQuickAction": "Executing {{name}}...",
|
||||
"quickActionSuccess": "{{name}} completed successfully",
|
||||
"quickActionFailed": "{{name}} failed",
|
||||
"quickActionError": "Failed to execute {{name}}"
|
||||
},
|
||||
"auth": {
|
||||
"tagline": "SSH SERVER MANAGER",
|
||||
@@ -1553,6 +1574,8 @@
|
||||
"selectPreferredLanguage": "Select your preferred language for the interface",
|
||||
"fileColorCoding": "File Color Coding",
|
||||
"fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)",
|
||||
"commandAutocomplete": "Command Autocomplete",
|
||||
"commandAutocompleteDesc": "Enable Tab key autocomplete suggestions for terminal commands based on your command history",
|
||||
"currentPassword": "Current Password",
|
||||
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
|
||||
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
|
||||
|
||||
@@ -223,6 +223,20 @@
|
||||
"editTooltip": "Modifier cet extrait",
|
||||
"deleteTooltip": "Supprimer cet extrait"
|
||||
},
|
||||
"commandHistory": {
|
||||
"title": "Historique",
|
||||
"searchPlaceholder": "Rechercher des commandes...",
|
||||
"noTerminal": "Aucun terminal actif",
|
||||
"noTerminalHint": "Ouvrez un terminal pour voir son historique de commandes.",
|
||||
"empty": "Aucun historique de commandes",
|
||||
"emptyHint": "Exécutez des commandes dans le terminal actif pour créer un historique.",
|
||||
"noResults": "Aucune commande trouvée",
|
||||
"noResultsHint": "Aucune commande correspondant à \"{{query}}\"",
|
||||
"deleteSuccess": "Commande supprimée de l'historique",
|
||||
"deleteFailed": "Échec de la suppression de la commande.",
|
||||
"deleteTooltip": "Supprimer la commande",
|
||||
"tabHint": "Utilisez Tab dans le terminal pour compléter automatiquement depuis l'historique des commandes"
|
||||
},
|
||||
"homepage": {
|
||||
"loggedInTitle": "Connexion réussie !",
|
||||
"loggedInMessage": "Vous êtes connecté ! Utilisez la barre latérale pour accéder à tous les outils disponibles. Pour commencer, créez un hôte SSH dans l'onglet Gestionnaire SSH. Une fois créé, vous pourrez vous connecter à cet hôte avec les autres applications de la barre latérale.",
|
||||
@@ -1368,6 +1382,10 @@
|
||||
"local": "Local",
|
||||
"external": "Externe (OIDC)",
|
||||
"selectPreferredLanguage": "Choisissez votre langue préférée pour l'interface",
|
||||
"fileColorCoding": "Codage couleur des fichiers",
|
||||
"fileColorCodingDesc": "Codage couleur des fichiers par type : dossiers (rouge), fichiers (bleu), liens symboliques (vert)",
|
||||
"commandAutocomplete": "Autocomplétion des commandes",
|
||||
"commandAutocompleteDesc": "Activer les suggestions d'autocomplétion avec la touche Tab pour les commandes du terminal basées sur votre historique",
|
||||
"currentPassword": "Mot de passe actuel",
|
||||
"passwordChangedSuccess": "Mot de passe modifié avec succès ! Veuillez vous reconnecter.",
|
||||
"failedToChangePassword": "Échec de la modification du mot de passe. Vérifiez votre mot de passe actuel et réessayez."
|
||||
|
||||
@@ -191,6 +191,20 @@
|
||||
"enableRightClickCopyPaste": "Habilitar copiar/colar com botão direito",
|
||||
"shareIdeas": "Tem ideias sobre o que deve vir a seguir nas ferramentas SSH? Compartilhe em"
|
||||
},
|
||||
"commandHistory": {
|
||||
"title": "Histórico",
|
||||
"searchPlaceholder": "Pesquisar comandos...",
|
||||
"noTerminal": "Nenhum terminal ativo",
|
||||
"noTerminalHint": "Abra um terminal para ver seu histórico de comandos.",
|
||||
"empty": "Ainda não há histórico de comandos",
|
||||
"emptyHint": "Execute comandos no terminal ativo para criar um histórico.",
|
||||
"noResults": "Nenhum comando encontrado",
|
||||
"noResultsHint": "Nenhum comando correspondente a \"{{query}}\"",
|
||||
"deleteSuccess": "Comando removido do histórico",
|
||||
"deleteFailed": "Falha ao excluir comando.",
|
||||
"deleteTooltip": "Excluir comando",
|
||||
"tabHint": "Use Tab no Terminal para autocompletar do histórico de comandos"
|
||||
},
|
||||
"homepage": {
|
||||
"loggedInTitle": "Conectado!",
|
||||
"loggedInMessage": "Você está conectado! Use a barra lateral para acessar todas as ferramentas disponíveis. Para começar, crie um Host SSH na aba Gerenciador SSH. Depois de criado, você pode se conectar a esse host usando os outros apps na barra lateral.",
|
||||
@@ -1322,6 +1336,10 @@
|
||||
"local": "Local",
|
||||
"external": "Externo (OIDC)",
|
||||
"selectPreferredLanguage": "Selecione seu idioma preferido para a interface",
|
||||
"fileColorCoding": "Codificação de Cores de Arquivos",
|
||||
"fileColorCodingDesc": "Codificar arquivos por cores por tipo: pastas (vermelho), arquivos (azul), links simbólicos (verde)",
|
||||
"commandAutocomplete": "Autocompletar Comandos",
|
||||
"commandAutocompleteDesc": "Ativar sugestões de autocompletar com a tecla Tab para comandos do terminal baseado no seu histórico",
|
||||
"currentPassword": "Senha Atual",
|
||||
"passwordChangedSuccess": "Senha alterada com sucesso! Por favor, faça login novamente.",
|
||||
"failedToChangePassword": "Falha ao alterar a senha. Por favor, verifique sua senha atual e tente novamente."
|
||||
|
||||
@@ -225,6 +225,20 @@
|
||||
"editTooltip": "Редактировать этот сниппет",
|
||||
"deleteTooltip": "Удалить этот сниппет"
|
||||
},
|
||||
"commandHistory": {
|
||||
"title": "История",
|
||||
"searchPlaceholder": "Поиск команд...",
|
||||
"noTerminal": "Нет активного терминала",
|
||||
"noTerminalHint": "Откройте терминал, чтобы увидеть историю команд.",
|
||||
"empty": "История команд пока пуста",
|
||||
"emptyHint": "Выполните команды в активном терминале, чтобы создать историю.",
|
||||
"noResults": "Команды не найдены",
|
||||
"noResultsHint": "Нет команд, соответствующих \"{{query}}\"",
|
||||
"deleteSuccess": "Команда удалена из истории",
|
||||
"deleteFailed": "Не удалось удалить команду.",
|
||||
"deleteTooltip": "Удалить команду",
|
||||
"tabHint": "Используйте Tab в Терминале для автозаполнения из истории команд"
|
||||
},
|
||||
"homepage": {
|
||||
"loggedInTitle": "Вы вошли в систему!",
|
||||
"loggedInMessage": "Вы вошли в систему! Используйте боковую панель для доступа ко всем доступным инструментам. Чтобы начать, создайте SSH-хост в разделе SSH-менеджера. После создания вы можете подключиться к этому хосту, используя другие приложения на боковой панели.",
|
||||
@@ -1461,6 +1475,10 @@
|
||||
"local": "Локальный",
|
||||
"external": "Внешний (OIDC)",
|
||||
"selectPreferredLanguage": "Выберите предпочитаемый язык интерфейса",
|
||||
"fileColorCoding": "Цветовое кодирование файлов",
|
||||
"fileColorCodingDesc": "Цветовая кодировка файлов по типу: папки (красный), файлы (синий), символические ссылки (зелёный)",
|
||||
"commandAutocomplete": "Автодополнение команд",
|
||||
"commandAutocompleteDesc": "Включить автодополнение команд терминала клавишей Tab на основе вашей истории команд",
|
||||
"currentPassword": "Текущий пароль",
|
||||
"passwordChangedSuccess": "Пароль успешно изменен! Пожалуйста, войдите снова.",
|
||||
"failedToChangePassword": "Не удалось изменить пароль. Пожалуйста, проверьте ваш текущий пароль и попробуйте снова."
|
||||
|
||||
@@ -223,6 +223,20 @@
|
||||
"editTooltip": "编辑此片段",
|
||||
"deleteTooltip": "删除此片段"
|
||||
},
|
||||
"commandHistory": {
|
||||
"title": "历史记录",
|
||||
"searchPlaceholder": "搜索命令...",
|
||||
"noTerminal": "无活动终端",
|
||||
"noTerminalHint": "打开终端以查看其命令历史记录。",
|
||||
"empty": "暂无命令历史记录",
|
||||
"emptyHint": "在活动终端中执行命令以建立历史记录。",
|
||||
"noResults": "未找到命令",
|
||||
"noResultsHint": "没有匹配 \"{{query}}\" 的命令",
|
||||
"deleteSuccess": "命令已从历史记录中删除",
|
||||
"deleteFailed": "删除命令失败。",
|
||||
"deleteTooltip": "删除命令",
|
||||
"tabHint": "在终端中使用 Tab 键从命令历史记录自动完成"
|
||||
},
|
||||
"homepage": {
|
||||
"loggedInTitle": "登录成功!",
|
||||
"loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。",
|
||||
@@ -1494,6 +1508,8 @@
|
||||
"selectPreferredLanguage": "选择您的界面首选语言",
|
||||
"fileColorCoding": "文件颜色编码",
|
||||
"fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)",
|
||||
"commandAutocomplete": "命令自动补全",
|
||||
"commandAutocompleteDesc": "启用基于命令历史记录的 Tab 键终端命令自动补全建议",
|
||||
"currentPassword": "当前密码",
|
||||
"passwordChangedSuccess": "密码修改成功!请重新登录。",
|
||||
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。"
|
||||
|
||||
@@ -9,6 +9,11 @@ export interface JumpHost {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
export interface QuickAction {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
export interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -38,6 +43,7 @@ export interface SSHHost {
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
jumpHosts?: JumpHost[];
|
||||
quickActions?: QuickAction[];
|
||||
statsConfig?: string;
|
||||
terminalConfig?: TerminalConfig;
|
||||
createdAt: string;
|
||||
@@ -48,6 +54,11 @@ export interface JumpHostData {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
export interface QuickActionData {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
export interface SSHHostData {
|
||||
name?: string;
|
||||
ip: string;
|
||||
@@ -70,6 +81,7 @@ export interface SSHHostData {
|
||||
forceKeyboardInteractive?: boolean;
|
||||
tunnelConnections?: TunnelConnection[];
|
||||
jumpHosts?: JumpHostData[];
|
||||
quickActions?: QuickActionData[];
|
||||
statsConfig?: string | Record<string, unknown>;
|
||||
terminalConfig?: TerminalConfig;
|
||||
}
|
||||
@@ -317,6 +329,22 @@ export interface TabContextTab {
|
||||
initialTab?: string;
|
||||
}
|
||||
|
||||
// Split Screen Layout Types
|
||||
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
|
||||
|
||||
export interface SplitConfiguration {
|
||||
layout: SplitLayout;
|
||||
positions: Map<number, number>; // position index -> tab ID
|
||||
}
|
||||
|
||||
export interface SplitLayoutOption {
|
||||
id: SplitLayout;
|
||||
name: string;
|
||||
description: string;
|
||||
cellCount: number;
|
||||
icon: string; // lucide icon name
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONNECTION STATES
|
||||
// ============================================================================
|
||||
|
||||
@@ -31,7 +31,7 @@ function AppContent() {
|
||||
const { currentTab, tabs } = useTabs();
|
||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(300);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
|
||||
|
||||
const lastShiftPressTime = useRef(0);
|
||||
|
||||
@@ -69,6 +69,8 @@ function AppContent() {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
// Clear invalid token
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
@@ -80,6 +82,9 @@ function AppContent() {
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
|
||||
// Clear invalid token on any auth error
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
console.warn("Session expired - please log in again");
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
Trash2,
|
||||
Users,
|
||||
Database,
|
||||
Lock,
|
||||
Link2,
|
||||
Download,
|
||||
Upload,
|
||||
Monitor,
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
getSessions,
|
||||
revokeSession,
|
||||
revokeAllUserSessions,
|
||||
convertOIDCToPassword,
|
||||
linkOIDCToPasswordAccount,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface AdminSettingsProps {
|
||||
@@ -75,7 +75,7 @@ interface AdminSettingsProps {
|
||||
export function AdminSettings({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 300,
|
||||
rightSidebarWidth = 400,
|
||||
}: AdminSettingsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
@@ -107,6 +107,7 @@ export function AdminSettings({
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
}>
|
||||
>([]);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
@@ -147,15 +148,13 @@ export function AdminSettings({
|
||||
>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = React.useState(false);
|
||||
|
||||
const [convertUserDialogOpen, setConvertUserDialogOpen] =
|
||||
React.useState(false);
|
||||
const [convertTargetUser, setConvertTargetUser] = React.useState<{
|
||||
const [linkAccountAlertOpen, setLinkAccountAlertOpen] = React.useState(false);
|
||||
const [linkOidcUser, setLinkOidcUser] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
} | null>(null);
|
||||
const [convertPassword, setConvertPassword] = React.useState("");
|
||||
const [convertTotpCode, setConvertTotpCode] = React.useState("");
|
||||
const [convertLoading, setConvertLoading] = React.useState(false);
|
||||
const [linkTargetUsername, setLinkTargetUsername] = React.useState("");
|
||||
const [linkLoading, setLinkLoading] = React.useState(false);
|
||||
|
||||
const requiresImportPassword = React.useMemo(
|
||||
() => !currentUser?.is_oidc,
|
||||
@@ -655,54 +654,41 @@ export function AdminSettings({
|
||||
);
|
||||
};
|
||||
|
||||
const handleConvertOIDCUser = (user: { id: string; username: string }) => {
|
||||
setConvertTargetUser(user);
|
||||
setConvertPassword("");
|
||||
setConvertTotpCode("");
|
||||
setConvertUserDialogOpen(true);
|
||||
const handleLinkOIDCUser = (user: { id: string; username: string }) => {
|
||||
setLinkOidcUser(user);
|
||||
setLinkTargetUsername("");
|
||||
setLinkAccountAlertOpen(true);
|
||||
};
|
||||
|
||||
const handleConvertSubmit = async () => {
|
||||
if (!convertTargetUser || !convertPassword) {
|
||||
toast.error("Password is required");
|
||||
const handleLinkSubmit = async () => {
|
||||
if (!linkOidcUser || !linkTargetUsername.trim()) {
|
||||
toast.error("Target username is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (convertPassword.length < 8) {
|
||||
toast.error("Password must be at least 8 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
setConvertLoading(true);
|
||||
setLinkLoading(true);
|
||||
try {
|
||||
const result = await convertOIDCToPassword(
|
||||
convertTargetUser.id,
|
||||
convertPassword,
|
||||
convertTotpCode || undefined,
|
||||
const result = await linkOIDCToPasswordAccount(
|
||||
linkOidcUser.id,
|
||||
linkTargetUsername.trim(),
|
||||
);
|
||||
|
||||
toast.success(
|
||||
result.message ||
|
||||
`User ${convertTargetUser.username} converted to password authentication`,
|
||||
`OIDC user ${linkOidcUser.username} linked to ${linkTargetUsername}`,
|
||||
);
|
||||
setConvertUserDialogOpen(false);
|
||||
setConvertPassword("");
|
||||
setConvertTotpCode("");
|
||||
setConvertTargetUser(null);
|
||||
setLinkAccountAlertOpen(false);
|
||||
setLinkTargetUsername("");
|
||||
setLinkOidcUser(null);
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
};
|
||||
if (err.response?.data?.code === "TOTP_REQUIRED") {
|
||||
toast.error("TOTP code is required for this user");
|
||||
} else {
|
||||
toast.error(
|
||||
err.response?.data?.error || "Failed to convert user account",
|
||||
);
|
||||
}
|
||||
toast.error(err.response?.data?.error || "Failed to link accounts");
|
||||
} finally {
|
||||
setConvertLoading(false);
|
||||
setLinkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1095,26 +1081,28 @@ export function AdminSettings({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{user.is_oidc
|
||||
? t("admin.external")
|
||||
: t("admin.local")}
|
||||
{user.is_oidc && user.password_hash
|
||||
? "Dual Auth"
|
||||
: user.is_oidc
|
||||
? t("admin.external")
|
||||
: t("admin.local")}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex gap-2">
|
||||
{user.is_oidc && (
|
||||
{user.is_oidc && !user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleConvertOIDCUser({
|
||||
handleLinkOIDCUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
title="Convert to password authentication"
|
||||
title="Link to password account"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -1505,78 +1493,87 @@ export function AdminSettings({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Convert OIDC to Password Dialog */}
|
||||
<Dialog
|
||||
open={convertUserDialogOpen}
|
||||
onOpenChange={setConvertUserDialogOpen}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Convert to Password Authentication</DialogTitle>
|
||||
<DialogDescription>
|
||||
Convert {convertTargetUser?.username} from OIDC/SSO authentication
|
||||
to password-based authentication. This will allow the user to log
|
||||
in with a username and password instead of through an external
|
||||
provider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Link OIDC to Password Account Dialog */}
|
||||
{linkAccountAlertOpen && (
|
||||
<Dialog
|
||||
open={linkAccountAlertOpen}
|
||||
onOpenChange={setLinkAccountAlertOpen}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
Link OIDC Account to Password Account
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Link{" "}
|
||||
<span className="font-mono text-foreground">
|
||||
{linkOidcUser?.username}
|
||||
</span>{" "}
|
||||
(OIDC user) to an existing password account. This will enable
|
||||
dual authentication for the password account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Alert>
|
||||
<AlertTitle>Important</AlertTitle>
|
||||
<AlertDescription>
|
||||
This action will:
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Set a new password for this user</li>
|
||||
<li>Disable OIDC/SSO login for this account</li>
|
||||
<li>Log out all active sessions</li>
|
||||
<li>Preserve all user data (SSH hosts, credentials, etc.)</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-4 py-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Warning: OIDC User Data Will Be Deleted</AlertTitle>
|
||||
<AlertDescription>
|
||||
This action will:
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Delete the OIDC user account and all their data</li>
|
||||
<li>
|
||||
Add OIDC login capability to the target password account
|
||||
</li>
|
||||
<li>
|
||||
Allow the password account to login with both password and
|
||||
OIDC
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="convert-password">
|
||||
New Password (min 8 chars)
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="convert-password"
|
||||
value={convertPassword}
|
||||
onChange={(e) => setConvertPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
disabled={convertLoading}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="link-target-username"
|
||||
className="text-base font-semibold text-foreground"
|
||||
>
|
||||
Target Password Account Username
|
||||
</Label>
|
||||
<Input
|
||||
id="link-target-username"
|
||||
value={linkTargetUsername}
|
||||
onChange={(e) => setLinkTargetUsername(e.target.value)}
|
||||
placeholder="Enter username of password account"
|
||||
disabled={linkLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && linkTargetUsername.trim()) {
|
||||
handleLinkSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="convert-totp">
|
||||
TOTP Code (if user has 2FA enabled)
|
||||
</Label>
|
||||
<Input
|
||||
id="convert-totp"
|
||||
value={convertTotpCode}
|
||||
onChange={(e) => setConvertTotpCode(e.target.value)}
|
||||
placeholder="000000"
|
||||
disabled={convertLoading}
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConvertUserDialogOpen(false)}
|
||||
disabled={convertLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConvertSubmit} disabled={convertLoading}>
|
||||
{convertLoading ? "Converting..." : "Convert User"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setLinkAccountAlertOpen(false)}
|
||||
disabled={linkLoading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLinkSubmit}
|
||||
disabled={linkLoading || !linkTargetUsername.trim()}
|
||||
variant="destructive"
|
||||
>
|
||||
{linkLoading ? "Linking..." : "Link Accounts"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +122,10 @@ export function CommandPalette({
|
||||
if (adminTab) {
|
||||
setCurrentTab(adminTab.id);
|
||||
} else {
|
||||
const id = addTab({ type: "admin", title: t("commandPalette.adminSettings") });
|
||||
const id = addTab({
|
||||
type: "admin",
|
||||
title: t("commandPalette.adminSettings"),
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
setIsOpen(false);
|
||||
@@ -133,7 +136,10 @@ export function CommandPalette({
|
||||
if (userProfileTab) {
|
||||
setCurrentTab(userProfileTab.id);
|
||||
} else {
|
||||
const id = addTab({ type: "user_profile", title: t("commandPalette.userProfile") });
|
||||
const id = addTab({
|
||||
type: "user_profile",
|
||||
title: t("commandPalette.userProfile"),
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
setIsOpen(false);
|
||||
@@ -288,83 +294,93 @@ export function CommandPalette({
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={t("commandPalette.hosts")}>
|
||||
{hosts.map((host, index) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`host-${index}-${host.id}`}
|
||||
value={`host-${index}-${title}-${host.id}`}
|
||||
onSelect={() => {
|
||||
if (host.enableTerminal) {
|
||||
handleHostTerminalClick(host);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 h-7 border-1 border-dark-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="right"
|
||||
className="w-56 bg-dark-bg border-dark-border text-white"
|
||||
{hosts.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading={t("commandPalette.hosts")}>
|
||||
{hosts.map((host, index) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`host-${index}-${host.id}`}
|
||||
value={`host-${index}-${title}-${host.id}`}
|
||||
onSelect={() => {
|
||||
if (host.enableTerminal) {
|
||||
handleHostTerminalClick(host);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostServerDetailsClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">{t("commandPalette.openServerDetails")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostFileManagerClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">{t("commandPalette.openFileManager")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostEditClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="flex-1">{t("commandPalette.edit")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 h-7 border-1 border-dark-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="right"
|
||||
className="w-56 bg-dark-bg border-dark-border text-white"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostServerDetailsClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.openServerDetails")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostFileManagerClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.openFileManager")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostEditClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.edit")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
<CommandGroup heading={t("commandPalette.links")}>
|
||||
<CommandItem onSelect={handleGitHub}>
|
||||
<Github />
|
||||
|
||||
@@ -62,7 +62,7 @@ export function Dashboard({
|
||||
isTopbarOpen,
|
||||
onSelectView,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 300,
|
||||
rightSidebarWidth = 400,
|
||||
}: DashboardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
|
||||
@@ -19,7 +19,7 @@ export function HostManager({
|
||||
initialTab = "host_viewer",
|
||||
hostConfig,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 300,
|
||||
rightSidebarWidth = 400,
|
||||
}: HostManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
|
||||
@@ -110,12 +110,12 @@ function JumpHostItem({
|
||||
{index + 1}.
|
||||
</span>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTrigger asChild className="flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex-1 justify-between"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedHost
|
||||
? `${selectedHost.name || `${selectedHost.username}@${selectedHost.ip}`}`
|
||||
@@ -123,7 +123,10 @@ function JumpHostItem({
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0">
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("hosts.searchServers")} />
|
||||
<CommandEmpty>{t("hosts.noServerFound")}</CommandEmpty>
|
||||
@@ -133,7 +136,7 @@ function JumpHostItem({
|
||||
.map((host) => (
|
||||
<CommandItem
|
||||
key={host.id}
|
||||
value={`${host.name} ${host.ip} ${host.username}`}
|
||||
value={`${host.name} ${host.ip} ${host.username} ${host.id}`}
|
||||
onSelect={() => {
|
||||
onUpdate(host.id);
|
||||
setOpen(false);
|
||||
@@ -162,7 +165,112 @@ function JumpHostItem({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onRemove}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemove}
|
||||
className="ml-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuickActionItemProps {
|
||||
quickAction: { name: string; snippetId: number };
|
||||
index: number;
|
||||
snippets: Array<{ id: number; name: string; content: string }>;
|
||||
onUpdate: (name: string, snippetId: number) => void;
|
||||
onRemove: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function QuickActionItem({
|
||||
quickAction,
|
||||
index,
|
||||
snippets,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
t,
|
||||
}: QuickActionItemProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selectedSnippet = snippets.find((s) => s.id === quickAction.snippetId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<Input
|
||||
placeholder={t("hosts.quickActionName")}
|
||||
value={quickAction.name}
|
||||
onChange={(e) => onUpdate(e.target.value, quickAction.snippetId)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild className="w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedSnippet
|
||||
? selectedSnippet.name
|
||||
: t("hosts.selectSnippet")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("hosts.searchSnippets")} />
|
||||
<CommandEmpty>{t("hosts.noSnippetFound")}</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-y-auto">
|
||||
{snippets.map((snippet) => (
|
||||
<CommandItem
|
||||
key={snippet.id}
|
||||
value={`${snippet.name} ${snippet.content} ${snippet.id}`}
|
||||
onSelect={() => {
|
||||
onUpdate(quickAction.name, snippet.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
quickAction.snippetId === snippet.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{snippet.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[350px]">
|
||||
{snippet.content}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemove}
|
||||
className="ml-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -198,6 +306,10 @@ interface SSHHost {
|
||||
jumpHosts?: Array<{
|
||||
hostId: number;
|
||||
}>;
|
||||
quickActions?: Array<{
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}>;
|
||||
statsConfig?: StatsConfig;
|
||||
terminalConfig?: TerminalConfig;
|
||||
createdAt: string;
|
||||
@@ -440,6 +552,14 @@ export function HostManagerEditor({
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
quickActions: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
snippetId: z.number().min(1),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.authType === "none") {
|
||||
@@ -528,6 +648,7 @@ export function HostManagerEditor({
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
jumpHosts: [],
|
||||
quickActions: [],
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||
forceKeyboardInteractive: false,
|
||||
@@ -612,6 +733,9 @@ export function HostManagerEditor({
|
||||
jumpHosts: Array.isArray(cleanedHost.jumpHosts)
|
||||
? cleanedHost.jumpHosts
|
||||
: [],
|
||||
quickActions: Array.isArray(cleanedHost.quickActions)
|
||||
? cleanedHost.quickActions
|
||||
: [],
|
||||
statsConfig: parsedStatsConfig,
|
||||
terminalConfig: {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
@@ -670,6 +794,7 @@ export function HostManagerEditor({
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
jumpHosts: [],
|
||||
quickActions: [],
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||
forceKeyboardInteractive: false,
|
||||
@@ -730,6 +855,7 @@ export function HostManagerEditor({
|
||||
defaultPath: data.defaultPath || "/",
|
||||
tunnelConnections: data.tunnelConnections || [],
|
||||
jumpHosts: data.jumpHosts || [],
|
||||
quickActions: data.quickActions || [],
|
||||
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
||||
forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive),
|
||||
@@ -2983,6 +3109,70 @@ export function HostManagerEditor({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("hosts.quickActions")}
|
||||
</h3>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("hosts.quickActionsDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="quickActions"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.quickActionsList")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-3">
|
||||
{field.value.map((quickAction, index) => (
|
||||
<QuickActionItem
|
||||
key={index}
|
||||
quickAction={quickAction}
|
||||
index={index}
|
||||
snippets={snippets}
|
||||
onUpdate={(name, snippetId) => {
|
||||
const newQuickActions = [...field.value];
|
||||
newQuickActions[index] = {
|
||||
name,
|
||||
snippetId,
|
||||
};
|
||||
field.onChange(newQuickActions);
|
||||
}}
|
||||
onRemove={() => {
|
||||
const newQuickActions = field.value.filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
field.onChange(newQuickActions);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
field.onChange([
|
||||
...field.value,
|
||||
{ name: "", snippetId: 0 },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("hosts.addQuickAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.quickActionsOrder")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -1240,6 +1240,9 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
ID: {host.id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
{host.folder && host.folder !== "" && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
executeSnippet,
|
||||
type ServerMetrics,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
@@ -29,6 +30,11 @@ import {
|
||||
} from "./widgets";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface QuickAction {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -37,6 +43,7 @@ interface HostConfig {
|
||||
folder?: string;
|
||||
enableFileManager?: boolean;
|
||||
tunnelConnections?: unknown[];
|
||||
quickActions?: QuickAction[];
|
||||
statsConfig?: string | StatsConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -81,6 +88,9 @@ export function Server({
|
||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
||||
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const statsConfig = React.useMemo((): StatsConfig => {
|
||||
if (!currentHostConfig?.statsConfig) {
|
||||
@@ -450,38 +460,147 @@ export function Server({
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{metricsEnabled && showStatsUI && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto relative">
|
||||
{!metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{enabledWidgets.map((widgetType) => (
|
||||
<div key={widgetType} className="h-[280px]">
|
||||
{renderWidget(widgetType)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(metricsEnabled && showStatsUI) ||
|
||||
(currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0) ? (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 overflow-y-auto relative flex-1 flex flex-col">
|
||||
{currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0 && (
|
||||
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-2">
|
||||
{t("serverStats.quickActions")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentHostConfig.quickActions.map((action, index) => {
|
||||
const isExecuting = executingActions.has(
|
||||
action.snippetId,
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="font-semibold"
|
||||
disabled={isExecuting}
|
||||
onClick={async () => {
|
||||
if (!currentHostConfig) return;
|
||||
|
||||
<SimpleLoader
|
||||
visible={isLoadingMetrics && !metrics}
|
||||
message={t("serverStats.loadingMetrics")}
|
||||
/>
|
||||
setExecutingActions((prev) =>
|
||||
new Set(prev).add(action.snippetId),
|
||||
);
|
||||
toast.loading(
|
||||
t("serverStats.executingQuickAction", {
|
||||
name: action.name,
|
||||
}),
|
||||
{ id: `quick-action-${action.snippetId}` },
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await executeSnippet(
|
||||
action.snippetId,
|
||||
currentHostConfig.id,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
t("serverStats.quickActionSuccess", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: result.output
|
||||
? result.output.substring(0, 200)
|
||||
: undefined,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("serverStats.quickActionFailed", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
result.error ||
|
||||
result.output ||
|
||||
undefined,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
t("serverStats.quickActionError", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
error?.message || "Unknown error",
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setExecutingActions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(action.snippetId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
title={t("serverStats.executeQuickAction", {
|
||||
name: action.name,
|
||||
})}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||
{action.name}
|
||||
</div>
|
||||
) : (
|
||||
action.name
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{metricsEnabled &&
|
||||
showStatsUI &&
|
||||
(!metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{enabledWidgets.map((widgetType) => (
|
||||
<div key={widgetType} className="h-[280px]">
|
||||
{renderWidget(widgetType)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{metricsEnabled && showStatsUI && (
|
||||
<SimpleLoader
|
||||
visible={isLoadingMetrics && !metrics}
|
||||
message={t("serverStats.loadingMetrics")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 && (
|
||||
|
||||
@@ -221,7 +221,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
// Load command history for autocomplete on mount (Stage 3)
|
||||
useEffect(() => {
|
||||
if (hostConfig.id) {
|
||||
// Check if command autocomplete is enabled
|
||||
const autocompleteEnabled =
|
||||
localStorage.getItem("commandAutocomplete") !== "false";
|
||||
|
||||
if (hostConfig.id && autocompleteEnabled) {
|
||||
import("@/ui/main-axios.ts")
|
||||
.then((module) => module.getCommandHistory(hostConfig.id!))
|
||||
.then((history) => {
|
||||
@@ -231,6 +235,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
console.error("Failed to load autocomplete history:", error);
|
||||
autocompleteHistory.current = [];
|
||||
});
|
||||
} else {
|
||||
autocompleteHistory.current = [];
|
||||
}
|
||||
}, [hostConfig.id]);
|
||||
|
||||
@@ -1318,6 +1324,20 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if command autocomplete is enabled in settings
|
||||
const autocompleteEnabled =
|
||||
localStorage.getItem("commandAutocomplete") !== "false";
|
||||
|
||||
if (!autocompleteEnabled) {
|
||||
// If disabled, let the terminal handle Tab normally (send to server)
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: "\t" }),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentCmd = getCurrentCommandRef.current().trim();
|
||||
if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
|
||||
// Filter commands that start with current input
|
||||
@@ -1328,7 +1348,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
cmd !== currentCmd &&
|
||||
cmd.length > currentCmd.length,
|
||||
)
|
||||
.slice(0, 10); // Show up to 10 matches
|
||||
.slice(0, 5); // Show up to 5 matches for better UX
|
||||
|
||||
if (matches.length === 1) {
|
||||
// Only one match - auto-complete directly
|
||||
@@ -1359,21 +1379,31 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const cellWidth =
|
||||
terminal.cols > 0 ? rect.width / terminal.cols : 10;
|
||||
|
||||
// Estimate autocomplete menu height (max-h-[240px] from component)
|
||||
const menuHeight = 240;
|
||||
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
|
||||
const spaceBelow = window.innerHeight - cursorBottomY;
|
||||
const spaceAbove = rect.top + cursorY * cellHeight;
|
||||
// Calculate actual menu height based on number of items
|
||||
// Each item is ~32px (py-1.5), footer is ~32px, max total 240px
|
||||
const itemHeight = 32;
|
||||
const footerHeight = 32;
|
||||
const maxMenuHeight = 240;
|
||||
const estimatedMenuHeight = Math.min(
|
||||
matches.length * itemHeight + footerHeight,
|
||||
maxMenuHeight,
|
||||
);
|
||||
|
||||
// Show above cursor if not enough space below
|
||||
// Get cursor position in viewport coordinates
|
||||
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
|
||||
const cursorTopY = rect.top + cursorY * cellHeight;
|
||||
const spaceBelow = window.innerHeight - cursorBottomY;
|
||||
const spaceAbove = cursorTopY;
|
||||
|
||||
// Show above cursor if not enough space below and more space above
|
||||
const showAbove =
|
||||
spaceBelow < menuHeight && spaceAbove > spaceBelow;
|
||||
spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
|
||||
|
||||
setAutocompletePosition({
|
||||
top: showAbove
|
||||
? rect.top + cursorY * cellHeight - menuHeight
|
||||
? Math.max(0, cursorTopY - estimatedMenuHeight)
|
||||
: cursorBottomY,
|
||||
left: rect.left + cursorX * cellWidth,
|
||||
left: Math.max(0, rect.left + cursorX * cellWidth),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -33,33 +33,44 @@ export function CommandAutocomplete({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate max height for suggestions list to ensure footer is always visible
|
||||
// Footer height is approximately 32px (text + padding + border)
|
||||
const footerHeight = 32;
|
||||
const maxSuggestionsHeight = 240 - footerHeight;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed z-[9999] bg-dark-bg border border-dark-border rounded-md shadow-lg max-h-[240px] overflow-y-auto min-w-[200px] max-w-[600px]"
|
||||
className="fixed z-[9999] bg-dark-bg border border-dark-border rounded-md shadow-lg min-w-[200px] max-w-[600px] flex flex-col"
|
||||
style={{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
maxHeight: "240px",
|
||||
}}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={index === selectedIndex ? selectedRef : null}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
|
||||
"hover:bg-dark-hover",
|
||||
index === selectedIndex && "bg-blue-500/20 text-blue-400",
|
||||
)}
|
||||
onClick={() => onSelect(suggestion)}
|
||||
onMouseEnter={() => {
|
||||
// Optional: update selected index on hover
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
))}
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50">
|
||||
<div
|
||||
className="overflow-y-auto"
|
||||
style={{ maxHeight: `${maxSuggestionsHeight}px` }}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={index === selectedIndex ? selectedRef : null}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
|
||||
"hover:bg-dark-hover",
|
||||
index === selectedIndex && "bg-gray-500/20 text-gray-400",
|
||||
)}
|
||||
onClick={() => onSelect(suggestion)}
|
||||
onMouseEnter={() => {
|
||||
// Optional: update selected index on hover
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50 shrink-0">
|
||||
Tab/Enter to complete • ↑↓ to navigate • Esc to close
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
Search,
|
||||
Loader2,
|
||||
Terminal,
|
||||
LayoutGrid,
|
||||
MonitorCheck,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -67,7 +69,7 @@ interface TabData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SSHUtilitySidebarProps {
|
||||
interface SSHToolsSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSnippetExecute: (content: string) => void;
|
||||
@@ -85,12 +87,21 @@ export function SSHToolsSidebar({
|
||||
setSidebarWidth,
|
||||
initialTab,
|
||||
onTabChange,
|
||||
}: SSHUtilitySidebarProps) {
|
||||
}: SSHToolsSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { tabs, currentTab } = useTabs() as {
|
||||
const {
|
||||
tabs,
|
||||
currentTab,
|
||||
allSplitScreenTab,
|
||||
setSplitScreenTab,
|
||||
setCurrentTab,
|
||||
} = useTabs() as {
|
||||
tabs: TabData[];
|
||||
currentTab: number | null;
|
||||
allSplitScreenTab: number[];
|
||||
setSplitScreenTab: (tabId: number) => void;
|
||||
setCurrentTab: (tabId: number) => void;
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools");
|
||||
|
||||
@@ -141,6 +152,17 @@ export function SSHToolsSidebar({
|
||||
const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0);
|
||||
const commandHistoryScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Split Screen state
|
||||
const [splitMode, setSplitMode] = useState<"none" | "2" | "3" | "4">("none");
|
||||
const [splitAssignments, setSplitAssignments] = useState<Map<number, number>>(
|
||||
new Map(),
|
||||
);
|
||||
const [previewKey, setPreviewKey] = useState(0);
|
||||
const [draggedTabId, setDraggedTabId] = useState<number | null>(null);
|
||||
const [dragOverCellIndex, setDragOverCellIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Resize state
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const startXRef = React.useRef<number | null>(null);
|
||||
@@ -152,6 +174,15 @@ export function SSHToolsSidebar({
|
||||
activeUiTab?.type === "terminal" ? activeUiTab : undefined;
|
||||
const activeTerminalHostId = activeTerminal?.hostConfig?.id;
|
||||
|
||||
// Get splittable tabs (terminal, server, file_manager)
|
||||
const splittableTabs = tabs.filter(
|
||||
(tab: TabData) =>
|
||||
tab.type === "terminal" ||
|
||||
tab.type === "server" ||
|
||||
tab.type === "file_manager" ||
|
||||
tab.type === "user_profile",
|
||||
);
|
||||
|
||||
// Fetch command history
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === "command-history") {
|
||||
@@ -567,6 +598,148 @@ export function SSHToolsSidebar({
|
||||
toast.success(t("snippets.copySuccess", { name: snippet.name }));
|
||||
};
|
||||
|
||||
// Split Screen handlers
|
||||
const handleSplitModeChange = (mode: "none" | "2" | "3" | "4") => {
|
||||
setSplitMode(mode);
|
||||
|
||||
if (mode === "none") {
|
||||
// Clear all splits
|
||||
handleClearSplit();
|
||||
} else {
|
||||
// Clear assignments when changing modes
|
||||
setSplitAssignments(new Map());
|
||||
setPreviewKey((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (tabId: number) => {
|
||||
setDraggedTabId(tabId);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedTabId(null);
|
||||
setDragOverCellIndex(null);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, cellIndex: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverCellIndex(cellIndex);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverCellIndex(null);
|
||||
};
|
||||
|
||||
const handleDrop = (cellIndex: number) => {
|
||||
if (draggedTabId === null) return;
|
||||
|
||||
setSplitAssignments((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
// Remove this tab from any other cell
|
||||
Array.from(newMap.entries()).forEach(([idx, id]) => {
|
||||
if (id === draggedTabId && idx !== cellIndex) {
|
||||
newMap.delete(idx);
|
||||
}
|
||||
});
|
||||
newMap.set(cellIndex, draggedTabId);
|
||||
return newMap;
|
||||
});
|
||||
|
||||
setDraggedTabId(null);
|
||||
setDragOverCellIndex(null);
|
||||
setPreviewKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleRemoveFromCell = (cellIndex: number) => {
|
||||
setSplitAssignments((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(cellIndex);
|
||||
setPreviewKey((prev) => prev + 1);
|
||||
return newMap;
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplySplit = () => {
|
||||
if (splitMode === "none") {
|
||||
handleClearSplit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (splitAssignments.size === 0) {
|
||||
toast.error(
|
||||
t("splitScreen.error.noAssignments", {
|
||||
defaultValue: "Please drag tabs to cells before applying",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredSlots = parseInt(splitMode);
|
||||
|
||||
// Validate: All layout spots must be filled
|
||||
if (splitAssignments.size < requiredSlots) {
|
||||
toast.error(
|
||||
t("splitScreen.error.fillAllSlots", {
|
||||
defaultValue: `Please fill all ${requiredSlots} layout spots before applying`,
|
||||
count: requiredSlots,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build ordered array of tab IDs based on cell index (0, 1, 2, 3)
|
||||
const orderedTabIds: number[] = [];
|
||||
for (let i = 0; i < requiredSlots; i++) {
|
||||
const tabId = splitAssignments.get(i);
|
||||
if (tabId !== undefined) {
|
||||
orderedTabIds.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
// First, clear ALL existing splits
|
||||
const currentSplits = [...allSplitScreenTab];
|
||||
currentSplits.forEach((tabId) => {
|
||||
setSplitScreenTab(tabId); // Toggle off
|
||||
});
|
||||
|
||||
// Then, add only the newly assigned tabs to split IN ORDER
|
||||
orderedTabIds.forEach((tabId) => {
|
||||
setSplitScreenTab(tabId); // Toggle on
|
||||
});
|
||||
|
||||
// Set first assigned tab as active if current tab is not in split
|
||||
if (!orderedTabIds.includes(currentTab ?? 0)) {
|
||||
setCurrentTab(orderedTabIds[0]);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t("splitScreen.success", {
|
||||
defaultValue: "Split screen applied",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearSplit = () => {
|
||||
// Remove all tabs from split screen
|
||||
allSplitScreenTab.forEach((tabId) => {
|
||||
setSplitScreenTab(tabId);
|
||||
});
|
||||
|
||||
setSplitMode("none");
|
||||
setSplitAssignments(new Map());
|
||||
setPreviewKey((prev) => prev + 1);
|
||||
|
||||
toast.success(
|
||||
t("splitScreen.cleared", {
|
||||
defaultValue: "Split screen cleared",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleResetToSingle = () => {
|
||||
handleClearSplit();
|
||||
};
|
||||
|
||||
// Command History handlers
|
||||
const handleCommandSelect = (command: string) => {
|
||||
if (activeTerminal?.terminalRef?.current?.sendInput) {
|
||||
@@ -616,7 +789,7 @@ export function SSHToolsSidebar({
|
||||
<div className="absolute right-5 flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSidebarWidth(300)}
|
||||
onClick={() => setSidebarWidth(400)}
|
||||
className="w-[28px] h-[28px]"
|
||||
title="Reset sidebar width"
|
||||
>
|
||||
@@ -640,7 +813,7 @@ export function SSHToolsSidebar({
|
||||
onValueChange={handleTabChange}
|
||||
className="flex flex-col h-full overflow-hidden"
|
||||
>
|
||||
<TabsList className="w-full grid grid-cols-3 mb-4 flex-shrink-0">
|
||||
<TabsList className="w-full grid grid-cols-4 mb-4 flex-shrink-0">
|
||||
<TabsTrigger value="ssh-tools">
|
||||
{t("sshTools.title")}
|
||||
</TabsTrigger>
|
||||
@@ -650,6 +823,9 @@ export function SSHToolsSidebar({
|
||||
<TabsTrigger value="command-history">
|
||||
{t("commandHistory.title", { defaultValue: "History" })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="split-screen">
|
||||
{t("splitScreen.title", { defaultValue: "Split Screen" })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ssh-tools" className="space-y-4">
|
||||
@@ -755,7 +931,7 @@ export function SSHToolsSidebar({
|
||||
href="https://github.com/Termix-SSH/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
className="gray-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
@@ -832,7 +1008,7 @@ export function SSHToolsSidebar({
|
||||
{snippets.map((snippet) => (
|
||||
<div
|
||||
key={snippet.id}
|
||||
className="bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group"
|
||||
className="bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-gray-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group"
|
||||
>
|
||||
<div className="mb-2">
|
||||
<h3 className="text-sm font-medium text-white mb-1">
|
||||
@@ -843,6 +1019,9 @@ export function SSHToolsSidebar({
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
ID: {snippet.id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded p-2 mb-3">
|
||||
@@ -950,6 +1129,12 @@ export function SSHToolsSidebar({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground bg-muted/30 px-2 py-1.5 rounded">
|
||||
{t("commandHistory.tabHint", {
|
||||
defaultValue:
|
||||
"Use Tab in Terminal to autocomplete from command history",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
@@ -1040,6 +1225,219 @@ export function SSHToolsSidebar({
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="split-screen"
|
||||
className="flex flex-col flex-1 overflow-hidden"
|
||||
>
|
||||
<div className="space-y-4 flex-1 overflow-y-auto overflow-x-hidden pb-4">
|
||||
{/* Split Mode Tabs */}
|
||||
<Tabs
|
||||
value={splitMode}
|
||||
onValueChange={(value) =>
|
||||
handleSplitModeChange(
|
||||
value as "none" | "2" | "3" | "4",
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full grid grid-cols-4">
|
||||
<TabsTrigger value="none">
|
||||
{t("splitScreen.none", { defaultValue: "None" })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="2">
|
||||
{t("splitScreen.twoSplit", {
|
||||
defaultValue: "2-Split",
|
||||
})}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="3">
|
||||
{t("splitScreen.threeSplit", {
|
||||
defaultValue: "3-Split",
|
||||
})}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="4">
|
||||
{t("splitScreen.fourSplit", {
|
||||
defaultValue: "4-Split",
|
||||
})}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Drag-and-Drop Interface */}
|
||||
{splitMode !== "none" && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* Available Tabs List */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{t("splitScreen.availableTabs", {
|
||||
defaultValue: "Available Tabs",
|
||||
})}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{t("splitScreen.dragTabsHint", {
|
||||
defaultValue:
|
||||
"Drag tabs into the grid below to position them",
|
||||
})}
|
||||
</p>
|
||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
||||
{splittableTabs.map((tab) => {
|
||||
const isAssigned = Array.from(
|
||||
splitAssignments.values(),
|
||||
).includes(tab.id);
|
||||
const isDragging = draggedTabId === tab.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
draggable={!isAssigned}
|
||||
onDragStart={() => handleDragStart(tab.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`
|
||||
px-3 py-2 rounded-md text-sm cursor-move transition-all
|
||||
${
|
||||
isAssigned
|
||||
? "bg-dark-bg/50 text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-dark-bg border border-dark-border hover:border-gray-400 hover:bg-dark-bg-input"
|
||||
}
|
||||
${isDragging ? "opacity-50" : ""}
|
||||
`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{tab.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Drop Grid */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{t("splitScreen.layout", {
|
||||
defaultValue: "Layout",
|
||||
})}
|
||||
</label>
|
||||
<div
|
||||
className={`grid gap-2 ${
|
||||
splitMode === "2"
|
||||
? "grid-cols-2"
|
||||
: splitMode === "3"
|
||||
? "grid-cols-2 grid-rows-2"
|
||||
: "grid-cols-2 grid-rows-2"
|
||||
}`}
|
||||
>
|
||||
{Array.from(
|
||||
{ length: parseInt(splitMode) },
|
||||
(_, idx) => {
|
||||
const assignedTabId =
|
||||
splitAssignments.get(idx);
|
||||
const assignedTab = assignedTabId
|
||||
? tabs.find((t) => t.id === assignedTabId)
|
||||
: null;
|
||||
const isHovered = dragOverCellIndex === idx;
|
||||
const isEmpty = !assignedTabId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={() => handleDrop(idx)}
|
||||
className={`
|
||||
relative bg-dark-bg border-2 rounded-md p-3 min-h-[100px]
|
||||
flex flex-col items-center justify-center transition-all
|
||||
${splitMode === "3" && idx === 2 ? "col-span-2" : ""}
|
||||
${
|
||||
isEmpty
|
||||
? "border-dashed border-dark-border"
|
||||
: "border-solid border-gray-400 bg-gray-500/10"
|
||||
}
|
||||
${
|
||||
isHovered && draggedTabId
|
||||
? "border-gray-500 bg-gray-500/20 ring-2 ring-gray-500/50"
|
||||
: ""
|
||||
}
|
||||
`}
|
||||
>
|
||||
{assignedTab ? (
|
||||
<>
|
||||
<span className="text-sm text-white truncate w-full text-center mb-2">
|
||||
{assignedTab.title}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRemoveFromCell(idx)
|
||||
}
|
||||
className="h-6 text-xs hover:bg-red-500/20"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("splitScreen.dropHere", {
|
||||
defaultValue: "Drop tab here",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleApplySplit}
|
||||
className="flex-1"
|
||||
disabled={splitAssignments.size === 0}
|
||||
>
|
||||
{t("splitScreen.apply", {
|
||||
defaultValue: "Apply Split",
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClearSplit}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("splitScreen.clear", {
|
||||
defaultValue: "Clear",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Help Text for None mode */}
|
||||
{splitMode === "none" && (
|
||||
<div className="text-center py-8">
|
||||
<LayoutGrid className="h-12 w-12 mb-4 opacity-20 mx-auto" />
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{t("splitScreen.selectMode", {
|
||||
defaultValue:
|
||||
"Select a split mode to get started",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("splitScreen.helpText", {
|
||||
defaultValue:
|
||||
"Choose how many tabs you want to display at once",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SidebarContent>
|
||||
{isOpen && (
|
||||
|
||||
@@ -555,7 +555,11 @@ export function Auth({
|
||||
const error = urlParams.get("error");
|
||||
|
||||
if (error) {
|
||||
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
|
||||
if (error === "registration_disabled") {
|
||||
toast.error(t("messages.registrationDisabled"));
|
||||
} else {
|
||||
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
|
||||
}
|
||||
setOidcLoading(false);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
|
||||
@@ -42,7 +42,7 @@ interface TerminalViewProps {
|
||||
export function AppView({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 300,
|
||||
rightSidebarWidth = 400,
|
||||
}: TerminalViewProps): React.ReactElement {
|
||||
const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as {
|
||||
tabs: TabData[];
|
||||
@@ -204,16 +204,12 @@ export function AppView({
|
||||
|
||||
const renderTerminalsLayer = () => {
|
||||
const styles: Record<number, React.CSSProperties> = {};
|
||||
const splitTabs = terminalTabs.filter((tab: TabData) =>
|
||||
allSplitScreenTab.includes(tab.id),
|
||||
);
|
||||
// Use allSplitScreenTab order directly - it maintains the order tabs were added
|
||||
const layoutTabs = allSplitScreenTab
|
||||
.map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId))
|
||||
.filter((t): t is TabData => t !== null && t !== undefined);
|
||||
|
||||
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab);
|
||||
const layoutTabs = [
|
||||
mainTab,
|
||||
...splitTabs.filter(
|
||||
(t: TabData) => t && t.id !== (mainTab && (mainTab as TabData).id),
|
||||
),
|
||||
].filter((t): t is TabData => t !== null && t !== undefined);
|
||||
|
||||
if (allSplitScreenTab.length === 0 && mainTab) {
|
||||
const isFileManagerTab = mainTab.type === "file_manager";
|
||||
@@ -358,16 +354,10 @@ export function AppView({
|
||||
};
|
||||
|
||||
const renderSplitOverlays = () => {
|
||||
const splitTabs = terminalTabs.filter((tab: TabData) =>
|
||||
allSplitScreenTab.includes(tab.id),
|
||||
);
|
||||
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab);
|
||||
const layoutTabs = [
|
||||
mainTab,
|
||||
...splitTabs.filter(
|
||||
(t: TabData) => t && t.id !== (mainTab && (mainTab as TabData).id),
|
||||
),
|
||||
].filter((t): t is TabData => t !== null && t !== undefined);
|
||||
// Use allSplitScreenTab order directly - it maintains the order tabs were added
|
||||
const layoutTabs = allSplitScreenTab
|
||||
.map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId))
|
||||
.filter((t): t is TabData => t !== null && t !== undefined);
|
||||
if (allSplitScreenTab.length === 0) return null;
|
||||
|
||||
const handleStyle = {
|
||||
|
||||
@@ -60,9 +60,10 @@ export function TopNavbar({
|
||||
|
||||
const [toolsSidebarOpen, setToolsSidebarOpen] = useState(false);
|
||||
const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false);
|
||||
const [splitScreenTabActive, setSplitScreenTabActive] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => {
|
||||
const saved = localStorage.getItem("rightSidebarWidth");
|
||||
const defaultWidth = 350;
|
||||
const defaultWidth = 400;
|
||||
const savedWidth = saved !== null ? parseInt(saved, 10) : defaultWidth;
|
||||
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
|
||||
const maxWidth = Math.floor(window.innerWidth * 0.3);
|
||||
@@ -132,7 +133,11 @@ export function TopNavbar({
|
||||
};
|
||||
|
||||
const handleTabSplit = (tabId: number) => {
|
||||
setSplitScreenTab(tabId);
|
||||
// Open the sidebar to the split-screen tab
|
||||
setToolsSidebarOpen(true);
|
||||
setCommandHistoryTabActive(false);
|
||||
setSplitScreenTabActive(true);
|
||||
// Optional: could pass tabId to pre-select this tab in the sidebar
|
||||
};
|
||||
|
||||
const handleTabClose = (tabId: number) => {
|
||||
@@ -371,17 +376,7 @@ export function TopNavbar({
|
||||
const isAdmin = tab.type === "admin";
|
||||
const isUserProfile = tab.type === "user_profile";
|
||||
const isSplittable = isTerminal || isServer || isFileManager;
|
||||
const isSplitButtonDisabled =
|
||||
(isActive && !isSplitScreenActive) ||
|
||||
((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
|
||||
const disableSplit =
|
||||
!isSplittable ||
|
||||
isSplitButtonDisabled ||
|
||||
isActive ||
|
||||
currentTabIsHome ||
|
||||
currentTabIsSshManager ||
|
||||
currentTabIsAdmin ||
|
||||
currentTabIsUserProfile;
|
||||
const disableSplit = !isSplittable;
|
||||
const disableActivate =
|
||||
isSplit ||
|
||||
((tab.type === "home" ||
|
||||
@@ -390,7 +385,7 @@ export function TopNavbar({
|
||||
tab.type === "user_profile") &&
|
||||
isSplitScreenActive);
|
||||
const isHome = tab.type === "home";
|
||||
const disableClose = (isSplitScreenActive && isActive) || isHome;
|
||||
const disableClose = isHome;
|
||||
|
||||
const isDraggingThisTab = dragState.draggedIndex === index;
|
||||
const isTheDraggedTab = tab.id === dragState.draggedId;
|
||||
@@ -566,12 +561,17 @@ export function TopNavbar({
|
||||
onSnippetExecute={handleSnippetExecute}
|
||||
sidebarWidth={rightSidebarWidth}
|
||||
setSidebarWidth={setRightSidebarWidth}
|
||||
commandHistory={commandHistory.commandHistory}
|
||||
onSelectCommand={commandHistory.onSelectCommand}
|
||||
onDeleteCommand={commandHistory.onDeleteCommand}
|
||||
isHistoryLoading={commandHistory.isLoading}
|
||||
initialTab={commandHistoryTabActive ? "command-history" : undefined}
|
||||
onTabChange={() => setCommandHistoryTabActive(false)}
|
||||
initialTab={
|
||||
commandHistoryTabActive
|
||||
? "command-history"
|
||||
: splitScreenTabActive
|
||||
? "split-screen"
|
||||
: undefined
|
||||
}
|
||||
onTabChange={() => {
|
||||
setCommandHistoryTabActive(false);
|
||||
setSplitScreenTabActive(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -143,7 +143,7 @@ export function Tab({
|
||||
onClick={!disableActivate ? onActivate : undefined}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
borderBottom: isActive ? "2px solid white" : "none",
|
||||
borderBottom: isActive || isSplit ? "2px solid white" : "none",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
@@ -175,7 +175,10 @@ export function Tab({
|
||||
}
|
||||
>
|
||||
<SeparatorVertical
|
||||
className={cn("h-4 w-4", isSplit && "text-white")}
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
isSplit ? "text-white" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -156,11 +156,32 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
}
|
||||
|
||||
setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
|
||||
setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId));
|
||||
|
||||
// Remove from split screen
|
||||
setAllSplitScreenTab((prev) => {
|
||||
const newSplits = prev.filter((id) => id !== tabId);
|
||||
// Auto-clear split mode if only 1 or fewer tabs remain in split
|
||||
if (newSplits.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
return newSplits;
|
||||
});
|
||||
|
||||
if (currentTab === tabId) {
|
||||
const remainingTabs = tabs.filter((tab) => tab.id !== tabId);
|
||||
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
|
||||
if (remainingTabs.length > 0) {
|
||||
// Try to set current tab to another split tab first, if any remain
|
||||
const remainingSplitTabs = allSplitScreenTab.filter(
|
||||
(id) => id !== tabId,
|
||||
);
|
||||
if (remainingSplitTabs.length > 0) {
|
||||
setCurrentTab(remainingSplitTabs[0]);
|
||||
} else {
|
||||
setCurrentTab(remainingTabs[0].id);
|
||||
}
|
||||
} else {
|
||||
setCurrentTab(1); // Home tab
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,7 +189,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
setAllSplitScreenTab((prev) => {
|
||||
if (prev.includes(tabId)) {
|
||||
return prev.filter((id) => id !== tabId);
|
||||
} else if (prev.length < 3) {
|
||||
} else if (prev.length < 4) {
|
||||
return [...prev, tabId];
|
||||
}
|
||||
return prev;
|
||||
|
||||
@@ -74,7 +74,7 @@ async function handleLogout() {
|
||||
export function UserProfile({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 300,
|
||||
rightSidebarWidth = 400,
|
||||
}: UserProfileProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
@@ -97,6 +97,9 @@ export function UserProfile({
|
||||
const [fileColorCoding, setFileColorCoding] = useState<boolean>(
|
||||
localStorage.getItem("fileColorCoding") !== "false",
|
||||
);
|
||||
const [commandAutocomplete, setCommandAutocomplete] = useState<boolean>(
|
||||
localStorage.getItem("commandAutocomplete") !== "false",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserInfo();
|
||||
@@ -145,6 +148,11 @@ export function UserProfile({
|
||||
window.dispatchEvent(new Event("fileColorCodingChanged"));
|
||||
};
|
||||
|
||||
const handleCommandAutocompleteToggle = (enabled: boolean) => {
|
||||
setCommandAutocomplete(enabled);
|
||||
localStorage.setItem("commandAutocomplete", enabled.toString());
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeleteLoading(true);
|
||||
@@ -363,6 +371,23 @@ export function UserProfile({
|
||||
</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("profile.commandAutocomplete")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.commandAutocompleteDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandAutocomplete}
|
||||
onCheckedChange={handleCommandAutocompleteToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -77,6 +77,7 @@ interface UserInfo {
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
data_unlocked: boolean;
|
||||
password_hash?: string;
|
||||
}
|
||||
|
||||
interface UserCount {
|
||||
@@ -396,10 +397,12 @@ function createApiInstance(
|
||||
errorMessage === "Authentication required";
|
||||
|
||||
if (isSessionExpired || isSessionNotFound) {
|
||||
// Clear token from localStorage
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
// Clear Electron settings cache
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
localStorage.removeItem("jwt");
|
||||
electronSettingsCache.delete("jwt");
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -420,6 +423,12 @@ function createApiInstance(
|
||||
"Authentication error - token may be invalid",
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
// Clear invalid token
|
||||
localStorage.removeItem("jwt");
|
||||
if (isElectron()) {
|
||||
electronSettingsCache.delete("jwt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,6 +882,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
defaultPath: hostData.defaultPath || "/",
|
||||
tunnelConnections: hostData.tunnelConnections || [],
|
||||
jumpHosts: hostData.jumpHosts || [],
|
||||
quickActions: hostData.quickActions || [],
|
||||
statsConfig: hostData.statsConfig
|
||||
? typeof hostData.statsConfig === "string"
|
||||
? hostData.statsConfig
|
||||
@@ -938,6 +948,7 @@ export async function updateSSHHost(
|
||||
defaultPath: hostData.defaultPath || "/",
|
||||
tunnelConnections: hostData.tunnelConnections || [],
|
||||
jumpHosts: hostData.jumpHosts || [],
|
||||
quickActions: hostData.quickActions || [],
|
||||
statsConfig: hostData.statsConfig
|
||||
? typeof hostData.statsConfig === "string"
|
||||
? hostData.statsConfig
|
||||
@@ -2874,6 +2885,21 @@ export async function deleteSnippet(
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSnippet(
|
||||
snippetId: number,
|
||||
hostId: number,
|
||||
): Promise<{ success: boolean; output: string; error?: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/snippets/execute", {
|
||||
snippetId,
|
||||
hostId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "execute snippet");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOMEPAGE API
|
||||
// ============================================================================
|
||||
@@ -2966,10 +2992,17 @@ export async function saveCommandToHistory(
|
||||
/**
|
||||
* Get command history for a specific host
|
||||
* Returns array of unique commands ordered by most recent
|
||||
* @param hostId - The host ID to fetch history for
|
||||
* @param limit - Maximum number of commands to return (default: 100)
|
||||
*/
|
||||
export async function getCommandHistory(hostId: number): Promise<string[]> {
|
||||
export async function getCommandHistory(
|
||||
hostId: number,
|
||||
limit: number = 100,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const response = await authApi.get(`/terminal/command_history/${hostId}`);
|
||||
const response = await authApi.get(`/terminal/command_history/${hostId}`, {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch command history");
|
||||
@@ -3011,25 +3044,23 @@ export async function clearCommandHistory(
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC TO PASSWORD CONVERSION
|
||||
// OIDC ACCOUNT LINKING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert an OIDC user to a password-based user
|
||||
* Link an OIDC user to an existing password account (merges OIDC into password account)
|
||||
*/
|
||||
export async function convertOIDCToPassword(
|
||||
targetUserId: string,
|
||||
newPassword: string,
|
||||
totpCode?: string,
|
||||
export async function linkOIDCToPasswordAccount(
|
||||
oidcUserId: string,
|
||||
targetUsername: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/convert-oidc-to-password", {
|
||||
targetUserId,
|
||||
newPassword,
|
||||
totpCode,
|
||||
const response = await authApi.post("/users/link-oidc-to-password", {
|
||||
oidcUserId,
|
||||
targetUsername,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "convert OIDC user to password");
|
||||
throw handleApiError(error, "link OIDC account to password account");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +495,12 @@ export function Auth({
|
||||
const error = urlParams.get("error");
|
||||
|
||||
if (error) {
|
||||
const errorMessage = `${t("errors.oidcAuthFailed")}: ${error}`;
|
||||
let errorMessage: string;
|
||||
if (error === "registration_disabled") {
|
||||
errorMessage = t("messages.registrationDisabled");
|
||||
} else {
|
||||
errorMessage = `${t("errors.oidcAuthFailed")}: ${error}`;
|
||||
}
|
||||
setError(errorMessage);
|
||||
setOidcLoading(false);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
Reference in New Issue
Block a user