v1.9.0 #437
@@ -456,6 +456,11 @@ const migrateSchema = () => {
|
||||
"credential_id",
|
||||
"INTEGER REFERENCES ssh_credentials(id)",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"override_credential_username",
|
||||
"INTEGER",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||
|
||||
@@ -72,6 +72,9 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
autostartKeyPassword: text("autostart_key_password"),
|
||||
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id),
|
||||
overrideCredentialUsername: integer("override_credential_username", {
|
||||
mode: "boolean",
|
||||
}),
|
||||
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
recentActivity,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
@@ -225,6 +226,7 @@ router.post(
|
||||
authMethod,
|
||||
authType,
|
||||
credentialId,
|
||||
overrideCredentialUsername,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
@@ -264,6 +266,7 @@ router.post(
|
||||
username,
|
||||
authType: effectiveAuthType,
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
enableTunnel: enableTunnel ? 1 : 0,
|
||||
@@ -323,6 +326,7 @@ router.post(
|
||||
: []
|
||||
: [],
|
||||
pin: !!createdHost.pin,
|
||||
overrideCredentialUsername: !!createdHost.overrideCredentialUsername,
|
||||
enableTerminal: !!createdHost.enableTerminal,
|
||||
enableTunnel: !!createdHost.enableTunnel,
|
||||
tunnelConnections: createdHost.tunnelConnections
|
||||
@@ -349,6 +353,27 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const fetch = (await import("node-fetch")).default;
|
||||
const token =
|
||||
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
|
||||
await fetch("http://localhost:30005/refresh", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { Cookie: `jwt=${token}` }),
|
||||
},
|
||||
});
|
||||
} catch (refreshError) {
|
||||
sshLogger.warn("Failed to refresh server stats polling", {
|
||||
operation: "stats_refresh_after_create",
|
||||
error:
|
||||
refreshError instanceof Error
|
||||
? refreshError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
res.json(resolvedHost);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to save SSH host to database", err, {
|
||||
@@ -415,6 +440,7 @@ router.put(
|
||||
authMethod,
|
||||
authType,
|
||||
credentialId,
|
||||
overrideCredentialUsername,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
@@ -455,6 +481,7 @@ router.put(
|
||||
username,
|
||||
authType: effectiveAuthType,
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
enableTunnel: enableTunnel ? 1 : 0,
|
||||
@@ -532,6 +559,7 @@ router.put(
|
||||
: []
|
||||
: [],
|
||||
pin: !!updatedHost.pin,
|
||||
overrideCredentialUsername: !!updatedHost.overrideCredentialUsername,
|
||||
enableTerminal: !!updatedHost.enableTerminal,
|
||||
enableTunnel: !!updatedHost.enableTunnel,
|
||||
tunnelConnections: updatedHost.tunnelConnections
|
||||
@@ -558,6 +586,27 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const fetch = (await import("node-fetch")).default;
|
||||
const token =
|
||||
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
|
||||
await fetch("http://localhost:30005/refresh", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { Cookie: `jwt=${token}` }),
|
||||
},
|
||||
});
|
||||
} catch (refreshError) {
|
||||
sshLogger.warn("Failed to refresh server stats polling", {
|
||||
operation: "stats_refresh_after_update",
|
||||
error:
|
||||
refreshError instanceof Error
|
||||
? refreshError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
res.json(resolvedHost);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to update SSH host in database", err, {
|
||||
@@ -585,6 +634,18 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
sshLogger.warn("User data not unlocked for SSH host fetch", {
|
||||
operation: "host_fetch",
|
||||
userId,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await SimpleDBOps.select(
|
||||
db.select().from(sshData).where(eq(sshData.userId, userId)),
|
||||
@@ -603,6 +664,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
||||
: []
|
||||
: [],
|
||||
pin: !!row.pin,
|
||||
overrideCredentialUsername: !!row.overrideCredentialUsername,
|
||||
enableTerminal: !!row.enableTerminal,
|
||||
enableTunnel: !!row.enableTunnel,
|
||||
tunnelConnections: row.tunnelConnections
|
||||
@@ -649,6 +711,19 @@ router.get(
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||
}
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
sshLogger.warn("User data not unlocked for SSH host fetch by ID", {
|
||||
operation: "host_fetch_by_id",
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await db
|
||||
.select()
|
||||
@@ -674,6 +749,7 @@ router.get(
|
||||
: []
|
||||
: [],
|
||||
pin: !!host.pin,
|
||||
overrideCredentialUsername: !!host.overrideCredentialUsername,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: host.tunnelConnections
|
||||
@@ -848,6 +924,15 @@ router.delete(
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(
|
||||
and(
|
||||
eq(recentActivity.userId, userId),
|
||||
eq(recentActivity.hostId, numericHostId),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(sshData)
|
||||
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
|
||||
@@ -1267,7 +1352,9 @@ async function resolveHostCredentials(
|
||||
const credential = credentials[0];
|
||||
return {
|
||||
...host,
|
||||
username: credential.username,
|
||||
username: host.overrideCredentialUsername
|
||||
? host.username
|
||||
: credential.username,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
password: credential.password,
|
||||
key: credential.key,
|
||||
@@ -1446,8 +1533,10 @@ router.post(
|
||||
username: hostData.username,
|
||||
password: hostData.authType === "password" ? hostData.password : null,
|
||||
authType: hostData.authType,
|
||||
credentialId:
|
||||
hostData.authType === "credential" ? hostData.credentialId : null,
|
||||
credentialId: hostData.credentialId || null,
|
||||
overrideCredentialUsername: hostData.overrideCredentialUsername
|
||||
? 1
|
||||
: 0,
|
||||
key: hostData.authType === "key" ? hostData.key : null,
|
||||
keyPassword:
|
||||
hostData.authType === "key"
|
||||
|
||||
@@ -226,6 +226,16 @@ router.post("/create", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist user to disk", saveError, {
|
||||
operation: "user_create_save_failed",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`Traditional user created: ${username} (is_admin: ${isFirstUser})`,
|
||||
{
|
||||
@@ -785,6 +795,16 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist OIDC user to disk", saveError, {
|
||||
operation: "oidc_user_create_save_failed",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
|
||||
user = await db.select().from(users).where(eq(users.id, id));
|
||||
} else {
|
||||
await db
|
||||
@@ -836,6 +856,9 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Clear any existing JWT cookie first to prevent conflicts
|
||||
res.clearCookie("jwt", authManager.getSecureCookieOptions(req));
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
||||
.redirect(redirectUrl.toString());
|
||||
@@ -1653,6 +1676,16 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
.set({ is_admin: true })
|
||||
.where(eq(users.username, username));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist admin promotion to disk", saveError, {
|
||||
operation: "make_admin_save_failed",
|
||||
username,
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`User ${username} made admin by ${adminUser[0].username}`,
|
||||
);
|
||||
@@ -1702,6 +1735,16 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
.set({ is_admin: false })
|
||||
.where(eq(users.username, username));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist admin removal to disk", saveError, {
|
||||
operation: "remove_admin_save_failed",
|
||||
username,
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`Admin status removed from ${username} by ${adminUser[0].username}`,
|
||||
);
|
||||
|
||||
@@ -845,7 +845,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
||||
sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH listFiles error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
|
||||
@@ -455,6 +455,13 @@ class PollingManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) {
|
||||
this.pollingConfigs.delete(host.id);
|
||||
this.statusStore.delete(host.id);
|
||||
this.metricsStore.delete(host.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const config: HostPollingConfig = {
|
||||
host,
|
||||
statsConfig,
|
||||
@@ -514,7 +521,7 @@ class PollingManager {
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
stopPollingForHost(hostId: number): void {
|
||||
stopPollingForHost(hostId: number, clearData = true): void {
|
||||
const config = this.pollingConfigs.get(hostId);
|
||||
if (config) {
|
||||
if (config.statusTimer) {
|
||||
@@ -524,8 +531,10 @@ class PollingManager {
|
||||
clearInterval(config.metricsTimer);
|
||||
}
|
||||
this.pollingConfigs.delete(hostId);
|
||||
this.statusStore.delete(hostId);
|
||||
this.metricsStore.delete(hostId);
|
||||
if (clearData) {
|
||||
this.statusStore.delete(hostId);
|
||||
this.metricsStore.delete(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,11 +563,23 @@ class PollingManager {
|
||||
}
|
||||
|
||||
async refreshHostPolling(userId: string): Promise<void> {
|
||||
const hosts = await fetchAllHosts(userId);
|
||||
const currentHostIds = new Set(hosts.map((h) => h.id));
|
||||
|
||||
for (const hostId of this.pollingConfigs.keys()) {
|
||||
this.stopPollingForHost(hostId);
|
||||
this.stopPollingForHost(hostId, false);
|
||||
}
|
||||
|
||||
await this.initializePolling(userId);
|
||||
for (const hostId of this.statusStore.keys()) {
|
||||
if (!currentHostIds.has(hostId)) {
|
||||
this.statusStore.delete(hostId);
|
||||
this.metricsStore.delete(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const host of hosts) {
|
||||
await this.startPollingForHost(host);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
|
||||
@@ -152,6 +152,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let totpPromptSent = false;
|
||||
let isKeyboardInteractive = false;
|
||||
let keyboardInteractiveResponded = false;
|
||||
let isConnecting = false;
|
||||
let isConnected = false;
|
||||
let isCleaningUp = false;
|
||||
|
||||
ws.on("close", () => {
|
||||
const userWs = userConnections.get(userId);
|
||||
@@ -417,10 +420,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isConnecting || isConnected) {
|
||||
sshLogger.warn("Connection already in progress or established", {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
isConnecting,
|
||||
isConnected,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isConnecting = true;
|
||||
sshConn = new Client();
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (sshConn) {
|
||||
if (sshConn && isConnecting && !isConnected) {
|
||||
sshLogger.error("SSH connection timeout", undefined, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
@@ -433,7 +447,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
);
|
||||
cleanupSSH(connectionTimeout);
|
||||
}
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||
let authMethodNotAvailable = false;
|
||||
@@ -498,7 +512,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshConn.on("ready", () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
if (!sshConn) {
|
||||
const conn = sshConn;
|
||||
|
||||
if (!conn || isCleaningUp) {
|
||||
sshLogger.warn(
|
||||
"SSH connection was cleaned up before shell could be created",
|
||||
{
|
||||
@@ -507,6 +523,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
isCleaningUp,
|
||||
},
|
||||
);
|
||||
ws.send(
|
||||
@@ -519,7 +536,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.shell(
|
||||
isConnecting = false;
|
||||
isConnected = true;
|
||||
|
||||
conn.shell(
|
||||
{
|
||||
rows: data.rows,
|
||||
cols: data.cols,
|
||||
@@ -836,9 +856,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 60000,
|
||||
readyTimeout: 120000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
timeout: 120000,
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
@@ -982,6 +1003,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
|
||||
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
|
||||
if (isCleaningUp) {
|
||||
return;
|
||||
}
|
||||
isCleaningUp = true;
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
@@ -1019,6 +1045,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
isKeyboardInteractive = false;
|
||||
keyboardInteractiveResponded = false;
|
||||
keyboardInteractiveFinish = null;
|
||||
isConnecting = false;
|
||||
isConnected = false;
|
||||
|
||||
setTimeout(() => {
|
||||
isCleaningUp = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function setupPingInterval() {
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
"orCreateNewFolder": "Or create new folder",
|
||||
"addTag": "Add tag",
|
||||
"saving": "Saving...",
|
||||
"credentialId": "Credential ID",
|
||||
"overview": "Overview",
|
||||
"security": "Security",
|
||||
"usage": "Usage",
|
||||
@@ -782,7 +783,9 @@
|
||||
"noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.",
|
||||
"noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally.",
|
||||
"forceKeyboardInteractive": "Force Keyboard-Interactive",
|
||||
"forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA)."
|
||||
"forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA).",
|
||||
"overrideCredentialUsername": "Override Credential Username",
|
||||
"overrideCredentialUsernameDesc": "Use a different username than the one stored in the credential. This allows you to use the same credential with different usernames."
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface SSHHost {
|
||||
autostartKeyPassword?: string;
|
||||
|
||||
credentialId?: number;
|
||||
overrideCredentialUsername?: boolean;
|
||||
userId?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
@@ -52,6 +53,7 @@ export interface SSHHostData {
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number | null;
|
||||
overrideCredentialUsername?: boolean;
|
||||
enableTerminal?: boolean;
|
||||
enableTunnel?: boolean;
|
||||
enableFileManager?: boolean;
|
||||
|
||||
@@ -640,6 +640,9 @@ export function CredentialsManager({
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
ID: {credential.id}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.authType === "password"
|
||||
? t("credentials.password")
|
||||
|
||||
@@ -527,41 +527,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error);
|
||||
|
||||
const isTextFile =
|
||||
file.type.startsWith("text/") ||
|
||||
file.type === "application/json" ||
|
||||
file.type === "application/javascript" ||
|
||||
file.type === "application/xml" ||
|
||||
file.type === "image/svg+xml" ||
|
||||
file.name.match(
|
||||
/\.(txt|json|js|ts|jsx|tsx|css|scss|less|html|htm|xml|svg|yaml|yml|md|markdown|mdown|mkdn|mdx|py|java|c|cpp|h|sh|bash|zsh|bat|ps1|toml|ini|conf|config|sql|vue|svelte)$/i,
|
||||
);
|
||||
|
||||
if (isTextFile) {
|
||||
reader.onload = () => {
|
||||
if (reader.result) {
|
||||
resolve(reader.result as string);
|
||||
} else {
|
||||
reject(new Error("Failed to read text file content"));
|
||||
reader.onload = () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(reader.result);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} else {
|
||||
reader.onload = () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(reader.result);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to read binary file"));
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to read file"));
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
await uploadSSHFile(
|
||||
|
||||
@@ -217,6 +217,7 @@ export function HostManagerEditor({
|
||||
pin: z.boolean().default(false),
|
||||
authType: z.enum(["password", "key", "credential", "none"]),
|
||||
credentialId: z.number().optional().nullable(),
|
||||
overrideCredentialUsername: z.boolean().optional(),
|
||||
password: z.string().optional(),
|
||||
key: z.any().optional().nullable(),
|
||||
keyPassword: z.string().optional(),
|
||||
@@ -389,6 +390,7 @@ export function HostManagerEditor({
|
||||
pin: false,
|
||||
authType: "password" as const,
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: false,
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
@@ -407,7 +409,8 @@ export function HostManagerEditor({
|
||||
useEffect(() => {
|
||||
if (authTab === "credential") {
|
||||
const currentCredentialId = form.getValues("credentialId");
|
||||
if (currentCredentialId) {
|
||||
const overrideUsername = form.getValues("overrideCredentialUsername");
|
||||
if (currentCredentialId && !overrideUsername) {
|
||||
const selectedCredential = credentials.find(
|
||||
(c) => c.id === currentCredentialId,
|
||||
);
|
||||
@@ -464,6 +467,9 @@ export function HostManagerEditor({
|
||||
pin: Boolean(cleanedHost.pin),
|
||||
authType: defaultAuthType as "password" | "key" | "credential" | "none",
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: Boolean(
|
||||
cleanedHost.overrideCredentialUsername,
|
||||
),
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
@@ -512,6 +518,7 @@ export function HostManagerEditor({
|
||||
pin: false,
|
||||
authType: "password" as const,
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: false,
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
@@ -574,6 +581,7 @@ export function HostManagerEditor({
|
||||
tags: data.tags || [],
|
||||
pin: Boolean(data.pin),
|
||||
authType: data.authType,
|
||||
overrideCredentialUsername: Boolean(data.overrideCredentialUsername),
|
||||
enableTerminal: Boolean(data.enableTerminal),
|
||||
enableTunnel: Boolean(data.enableTunnel),
|
||||
enableFileManager: Boolean(data.enableFileManager),
|
||||
@@ -882,17 +890,28 @@ export function HostManagerEditor({
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t("hosts.username")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.username")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
render={({ field }) => {
|
||||
const isCredentialAuth = authTab === "credential";
|
||||
const hasCredential = !!form.watch("credentialId");
|
||||
const overrideEnabled = !!form.watch(
|
||||
"overrideCredentialUsername",
|
||||
);
|
||||
const shouldDisable =
|
||||
isCredentialAuth && hasCredential && !overrideEnabled;
|
||||
|
||||
return (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t("hosts.username")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.username")}
|
||||
disabled={shouldDisable}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">
|
||||
@@ -1263,29 +1282,60 @@ export function HostManagerEditor({
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="credential">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="credentialId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<CredentialSelector
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
onCredentialSelect={(credential) => {
|
||||
if (credential) {
|
||||
form.setValue(
|
||||
"username",
|
||||
credential.username,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("hosts.credentialDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="credentialId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<CredentialSelector
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
onCredentialSelect={(credential) => {
|
||||
if (
|
||||
credential &&
|
||||
!form.getValues(
|
||||
"overrideCredentialUsername",
|
||||
)
|
||||
) {
|
||||
form.setValue(
|
||||
"username",
|
||||
credential.username,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("hosts.credentialDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("credentialId") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="overrideCredentialUsername"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>
|
||||
{t("hosts.overrideCredentialUsername")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.overrideCredentialUsernameDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="none">
|
||||
<Alert className="mt-2">
|
||||
|
||||
@@ -112,6 +112,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
|
||||
useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const isReadyRef = useRef<boolean>(false);
|
||||
const isFittingRef = useRef(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
@@ -157,6 +158,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
isVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
isReadyRef.current = isReady;
|
||||
}, [isReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwtToken = getCookie("jwt");
|
||||
@@ -507,6 +512,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}),
|
||||
);
|
||||
terminal.onData((data) => {
|
||||
if (data === "\x00" || data === "\u0000") {
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
});
|
||||
|
||||
@@ -915,15 +923,21 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
element?.addEventListener("keydown", handleMacKeyboard, true);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const handleResize = () => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
if (!isVisibleRef.current || !isReady) return;
|
||||
if (!isVisibleRef.current || !isReadyRef.current) return;
|
||||
performFit();
|
||||
}, 50);
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
|
||||
if (xtermRef.current) {
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
setVisible(true);
|
||||
|
||||
@@ -936,6 +950,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
setIsReady(false);
|
||||
isFittingRef.current = false;
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", handleResize);
|
||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||
element?.removeEventListener("keydown", handleMacKeyboard, true);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
|
||||
@@ -558,65 +558,61 @@ export function Auth({
|
||||
if (success) {
|
||||
setOidcLoading(true);
|
||||
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
if (isInElectronWebView()) {
|
||||
const token = getCookie("jwt") || localStorage.getItem("jwt");
|
||||
if (token) {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
token: token,
|
||||
source: "oidc_callback",
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
setTimeout(() => window.location.reload(), 100);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error("Error posting auth success message:", e);
|
||||
// Clear the success parameter first to prevent re-processing
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
setTimeout(() => {
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
if (isInElectronWebView()) {
|
||||
const token = getCookie("jwt") || localStorage.getItem("jwt");
|
||||
if (token) {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
token: token,
|
||||
source: "oidc_callback",
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
setTimeout(() => window.location.reload(), 100);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error("Error posting auth success message:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
setInternalLoggedIn(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get user info after OIDC callback:", err);
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
})
|
||||
.finally(() => {
|
||||
setOidcLoading(false);
|
||||
});
|
||||
setInternalLoggedIn(true);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setOidcLoading(false);
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
}, [
|
||||
onAuthSuccess,
|
||||
|
||||
@@ -318,34 +318,37 @@ function createApiInstance(
|
||||
const errorMessage = (error.response?.data as Record<string, unknown>)
|
||||
?.error;
|
||||
const isSessionExpired = errorCode === "SESSION_EXPIRED";
|
||||
const isSessionNotFound = errorCode === "SESSION_NOT_FOUND";
|
||||
const isInvalidToken =
|
||||
errorCode === "AUTH_REQUIRED" ||
|
||||
errorMessage === "Invalid token" ||
|
||||
errorMessage === "Authentication required";
|
||||
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
localStorage.removeItem("jwt");
|
||||
}
|
||||
if (isSessionExpired || isSessionNotFound) {
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
localStorage.removeItem("jwt");
|
||||
}
|
||||
|
||||
if (
|
||||
(isSessionExpired || isInvalidToken) &&
|
||||
typeof window !== "undefined"
|
||||
) {
|
||||
if (typeof window !== "undefined") {
|
||||
console.warn("Session expired or not found - please log in again");
|
||||
|
||||
document.cookie =
|
||||
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
|
||||
import("sonner").then(({ toast }) => {
|
||||
toast.warning("Session expired. Please log in again.");
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
}
|
||||
} else if (isInvalidToken && typeof window !== "undefined") {
|
||||
console.warn(
|
||||
"Session expired or invalid token - please log in again",
|
||||
"Authentication error - token may be invalid",
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
document.cookie =
|
||||
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
|
||||
import("sonner").then(({ toast }) => {
|
||||
toast.warning("Session expired. Please log in again.");
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,6 +795,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
keyType: hostData.authType === "key" ? hostData.keyType : null,
|
||||
credentialId:
|
||||
hostData.authType === "credential" ? hostData.credentialId : null,
|
||||
overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername),
|
||||
enableTerminal: Boolean(hostData.enableTerminal),
|
||||
enableTunnel: Boolean(hostData.enableTunnel),
|
||||
enableFileManager: Boolean(hostData.enableFileManager),
|
||||
@@ -855,6 +859,7 @@ export async function updateSSHHost(
|
||||
keyType: hostData.authType === "key" ? hostData.keyType : null,
|
||||
credentialId:
|
||||
hostData.authType === "credential" ? hostData.credentialId : null,
|
||||
overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername),
|
||||
enableTerminal: Boolean(hostData.enableTerminal),
|
||||
enableTunnel: Boolean(hostData.enableTunnel),
|
||||
enableFileManager: Boolean(hostData.enableFileManager),
|
||||
|
||||
@@ -504,55 +504,45 @@ export function Auth({
|
||||
setOidcLoading(true);
|
||||
setError(null);
|
||||
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
postJWTToWebView();
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
if (isReactNativeWebView()) {
|
||||
setMobileAuthSuccess(true);
|
||||
setTimeout(() => {
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
postJWTToWebView();
|
||||
|
||||
if (isReactNativeWebView()) {
|
||||
setMobileAuthSuccess(true);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoggedIn(true);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get user info after OIDC callback:", err);
|
||||
setError(t("errors.failedUserInfo"));
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
})
|
||||
.finally(() => {
|
||||
setOidcLoading(false);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoggedIn(true);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setError(t("errors.failedUserInfo"));
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setOidcLoading(false);
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user