v1.8.0 #429
@@ -34,7 +34,6 @@ http {
|
|||||||
ssl_certificate_key ${SSL_KEY_PATH};
|
ssl_certificate_key ${SSL_KEY_PATH};
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
add_header X-Frame-Options DENY always;
|
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
@@ -49,6 +48,15 @@ http {
|
|||||||
log_not_found off;
|
log_not_found off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/users/sessions(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location ~ ^/users(/.*)?$ {
|
location ~ ^/users(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ http {
|
|||||||
log_not_found off;
|
log_not_found off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/users/sessions(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location ~ ^/users(/.*)?$ {
|
location ~ ^/users(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -62,10 +62,11 @@ function createWindow() {
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
webSecurity: true,
|
webSecurity: false,
|
||||||
preload: path.join(__dirname, "preload.js"),
|
preload: path.join(__dirname, "preload.js"),
|
||||||
partition: "persist:termix",
|
partition: "persist:termix",
|
||||||
allowRunningInsecureContent: false,
|
allowRunningInsecureContent: true,
|
||||||
|
webviewTag: true,
|
||||||
},
|
},
|
||||||
show: false,
|
show: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,10 +57,6 @@ app.use(
|
|||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (allowedOrigins.includes(origin)) {
|
|
||||||
return callback(null, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (origin.startsWith("https://")) {
|
if (origin.startsWith("https://")) {
|
||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
@@ -69,6 +65,10 @@ app.use(
|
|||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (allowedOrigins.includes(origin)) {
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
callback(new Error("Not allowed by CORS"));
|
callback(new Error("Not allowed by CORS"));
|
||||||
},
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
@@ -172,6 +172,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
keyPassword,
|
keyPassword,
|
||||||
authType,
|
authType,
|
||||||
credentialId,
|
credentialId,
|
||||||
|
userProvidedPassword,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -264,44 +265,31 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
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-sha256",
|
||||||
"diffie-hellman-group14-sha1",
|
"diffie-hellman-group14-sha1",
|
||||||
"diffie-hellman-group-exchange-sha1",
|
|
||||||
"diffie-hellman-group1-sha1",
|
"diffie-hellman-group1-sha1",
|
||||||
],
|
"diffie-hellman-group-exchange-sha256",
|
||||||
serverHostKey: [
|
"diffie-hellman-group-exchange-sha1",
|
||||||
"ssh-ed25519",
|
"ecdh-sha2-nistp256",
|
||||||
"ecdsa-sha2-nistp521",
|
"ecdh-sha2-nistp384",
|
||||||
"ecdsa-sha2-nistp384",
|
"ecdh-sha2-nistp521",
|
||||||
"ecdsa-sha2-nistp256",
|
|
||||||
"rsa-sha2-512",
|
|
||||||
"rsa-sha2-256",
|
|
||||||
"ssh-rsa",
|
|
||||||
"ssh-dss",
|
|
||||||
],
|
],
|
||||||
cipher: [
|
cipher: [
|
||||||
"chacha20-poly1305@openssh.com",
|
|
||||||
"aes256-gcm@openssh.com",
|
|
||||||
"aes128-gcm@openssh.com",
|
|
||||||
"aes256-ctr",
|
|
||||||
"aes192-ctr",
|
|
||||||
"aes128-ctr",
|
"aes128-ctr",
|
||||||
"aes256-cbc",
|
"aes192-ctr",
|
||||||
"aes192-cbc",
|
"aes256-ctr",
|
||||||
|
"aes128-gcm@openssh.com",
|
||||||
|
"aes256-gcm@openssh.com",
|
||||||
"aes128-cbc",
|
"aes128-cbc",
|
||||||
|
"aes192-cbc",
|
||||||
|
"aes256-cbc",
|
||||||
"3des-cbc",
|
"3des-cbc",
|
||||||
],
|
],
|
||||||
hmac: [
|
hmac: [
|
||||||
"hmac-sha2-512-etm@openssh.com",
|
|
||||||
"hmac-sha2-256-etm@openssh.com",
|
"hmac-sha2-256-etm@openssh.com",
|
||||||
"hmac-sha2-512",
|
"hmac-sha2-512-etm@openssh.com",
|
||||||
"hmac-sha2-256",
|
"hmac-sha2-256",
|
||||||
|
"hmac-sha2-512",
|
||||||
"hmac-sha1",
|
"hmac-sha1",
|
||||||
"hmac-md5",
|
"hmac-md5",
|
||||||
],
|
],
|
||||||
@@ -309,8 +297,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let authMethodNotAvailable = false;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
resolvedCredentials.authType === "key" &&
|
resolvedCredentials.authType === "key" &&
|
||||||
resolvedCredentials.sshKey &&
|
resolvedCredentials.sshKey &&
|
||||||
@@ -348,33 +334,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "Password required for password authentication" });
|
.json({ error: "Password required for password authentication" });
|
||||||
}
|
}
|
||||||
config.password = resolvedCredentials.password;
|
|
||||||
|
if (userProvidedPassword) {
|
||||||
|
config.password = resolvedCredentials.password;
|
||||||
|
}
|
||||||
} else if (resolvedCredentials.authType === "none") {
|
} else if (resolvedCredentials.authType === "none") {
|
||||||
config.authHandler = (
|
|
||||||
methodsLeft: string[] | null,
|
|
||||||
partialSuccess: boolean,
|
|
||||||
callback: (nextMethod: string | false) => void,
|
|
||||||
) => {
|
|
||||||
if (methodsLeft && methodsLeft.length > 0) {
|
|
||||||
if (methodsLeft.includes("keyboard-interactive")) {
|
|
||||||
callback("keyboard-interactive");
|
|
||||||
} else {
|
|
||||||
authMethodNotAvailable = true;
|
|
||||||
fileLogger.error(
|
|
||||||
"Server does not support keyboard-interactive auth",
|
|
||||||
{
|
|
||||||
operation: "ssh_auth_handler_no_keyboard",
|
|
||||||
hostId,
|
|
||||||
sessionId,
|
|
||||||
methodsAvailable: methodsLeft,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
callback(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
callback(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
fileLogger.warn(
|
fileLogger.warn(
|
||||||
"No valid authentication method provided for file manager",
|
"No valid authentication method provided for file manager",
|
||||||
@@ -451,36 +415,26 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
client.on("error", (err) => {
|
client.on("error", (err) => {
|
||||||
if (responseSent) return;
|
if (responseSent) return;
|
||||||
responseSent = true;
|
responseSent = true;
|
||||||
|
fileLogger.error("SSH connection failed for file manager", {
|
||||||
|
operation: "file_connect",
|
||||||
|
sessionId,
|
||||||
|
hostId,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
|
||||||
if (authMethodNotAvailable && resolvedCredentials.authType === "none") {
|
if (
|
||||||
res.status(200).json({
|
|
||||||
status: "auth_required",
|
|
||||||
message:
|
|
||||||
"The server does not support keyboard-interactive authentication. Please provide credentials.",
|
|
||||||
reason: "no_keyboard",
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
resolvedCredentials.authType === "none" &&
|
resolvedCredentials.authType === "none" &&
|
||||||
(err.message.includes("All configured authentication methods failed") ||
|
(err.message.includes("authentication") ||
|
||||||
err.message.includes("No supported authentication methods available") ||
|
err.message.includes("All configured authentication methods failed"))
|
||||||
err.message.includes("authentication methods failed"))
|
|
||||||
) {
|
) {
|
||||||
res.status(200).json({
|
res.json({
|
||||||
status: "auth_required",
|
status: "auth_required",
|
||||||
message:
|
|
||||||
"The server does not support keyboard-interactive authentication. Please provide credentials.",
|
|
||||||
reason: "no_keyboard",
|
reason: "no_keyboard",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
fileLogger.error("SSH connection failed for file manager", {
|
|
||||||
operation: "file_connect",
|
|
||||||
sessionId,
|
|
||||||
hostId,
|
|
||||||
ip,
|
|
||||||
port,
|
|
||||||
username,
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
res.status(500).json({ status: "error", message: err.message });
|
res.status(500).json({ status: "error", message: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -564,13 +518,43 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
/password/i.test(p.prompt),
|
/password/i.test(p.prompt),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
resolvedCredentials.authType === "none" &&
|
||||||
|
passwordPromptIndex !== -1
|
||||||
|
) {
|
||||||
|
if (responseSent) return;
|
||||||
|
responseSent = true;
|
||||||
|
|
||||||
|
client.end();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: "auth_required",
|
||||||
|
reason: "no_keyboard",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||||
if (responseSent) {
|
if (responseSent) {
|
||||||
|
const responses = prompts.map((p) => {
|
||||||
|
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||||
|
return resolvedCredentials.password;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
finish(responses);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
responseSent = true;
|
responseSent = true;
|
||||||
|
|
||||||
if (pendingTOTPSessions[sessionId]) {
|
if (pendingTOTPSessions[sessionId]) {
|
||||||
|
const responses = prompts.map((p) => {
|
||||||
|
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||||
|
return resolvedCredentials.password;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
finish(responses);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2446,6 +2430,15 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
|||||||
: code;
|
: code;
|
||||||
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
|
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
|
||||||
|
|
||||||
|
fileLogger.info("File execution completed", {
|
||||||
|
operation: "execute_file",
|
||||||
|
sessionId,
|
||||||
|
filePath,
|
||||||
|
exitCode: actualExitCode,
|
||||||
|
outputLength: cleanOutput.length,
|
||||||
|
errorLength: errorOutput.length,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
exitCode: actualExitCode,
|
exitCode: actualExitCode,
|
||||||
|
|||||||
@@ -64,47 +64,21 @@ const wss = new WebSocketServer({
|
|||||||
const token = url.query.token as string;
|
const token = url.query.token as string;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
sshLogger.warn("WebSocket connection rejected: missing token", {
|
|
||||||
operation: "websocket_auth_reject",
|
|
||||||
reason: "missing_token",
|
|
||||||
ip: info.req.socket.remoteAddress,
|
|
||||||
});
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await authManager.verifyJWTToken(token);
|
const payload = await authManager.verifyJWTToken(token);
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
sshLogger.warn("WebSocket connection rejected: invalid token", {
|
|
||||||
operation: "websocket_auth_reject",
|
|
||||||
reason: "invalid_token",
|
|
||||||
ip: info.req.socket.remoteAddress,
|
|
||||||
});
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.pendingTOTP) {
|
if (payload.pendingTOTP) {
|
||||||
sshLogger.warn(
|
|
||||||
"WebSocket connection rejected: TOTP verification pending",
|
|
||||||
{
|
|
||||||
operation: "websocket_auth_reject",
|
|
||||||
reason: "totp_pending",
|
|
||||||
userId: payload.userId,
|
|
||||||
ip: info.req.socket.remoteAddress,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingConnections = userConnections.get(payload.userId);
|
const existingConnections = userConnections.get(payload.userId);
|
||||||
if (existingConnections && existingConnections.size >= 3) {
|
if (existingConnections && existingConnections.size >= 3) {
|
||||||
sshLogger.warn("WebSocket connection rejected: too many connections", {
|
|
||||||
operation: "websocket_auth_reject",
|
|
||||||
reason: "connection_limit",
|
|
||||||
userId: payload.userId,
|
|
||||||
currentConnections: existingConnections.size,
|
|
||||||
ip: info.req.socket.remoteAddress,
|
|
||||||
});
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,28 +101,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
const token = url.query.token as string;
|
const token = url.query.token as string;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
sshLogger.warn(
|
|
||||||
"WebSocket connection rejected: missing token in connection",
|
|
||||||
{
|
|
||||||
operation: "websocket_connection_reject",
|
|
||||||
reason: "missing_token",
|
|
||||||
ip: req.socket.remoteAddress,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
ws.close(1008, "Authentication required");
|
ws.close(1008, "Authentication required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await authManager.verifyJWTToken(token);
|
const payload = await authManager.verifyJWTToken(token);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
sshLogger.warn(
|
|
||||||
"WebSocket connection rejected: invalid token in connection",
|
|
||||||
{
|
|
||||||
operation: "websocket_connection_reject",
|
|
||||||
reason: "invalid_token",
|
|
||||||
ip: req.socket.remoteAddress,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
ws.close(1008, "Authentication required");
|
ws.close(1008, "Authentication required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -169,11 +127,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
const dataKey = userCrypto.getUserDataKey(userId);
|
const dataKey = userCrypto.getUserDataKey(userId);
|
||||||
if (!dataKey) {
|
if (!dataKey) {
|
||||||
sshLogger.warn("WebSocket connection rejected: data locked", {
|
|
||||||
operation: "websocket_data_locked",
|
|
||||||
userId,
|
|
||||||
ip: req.socket.remoteAddress,
|
|
||||||
});
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "error",
|
type: "error",
|
||||||
@@ -213,11 +166,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
ws.on("message", (msg: RawData) => {
|
ws.on("message", (msg: RawData) => {
|
||||||
const currentDataKey = userCrypto.getUserDataKey(userId);
|
const currentDataKey = userCrypto.getUserDataKey(userId);
|
||||||
if (!currentDataKey) {
|
if (!currentDataKey) {
|
||||||
sshLogger.warn("WebSocket message rejected: data access expired", {
|
|
||||||
operation: "websocket_message_rejected",
|
|
||||||
userId,
|
|
||||||
reason: "data_access_expired",
|
|
||||||
});
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "error",
|
type: "error",
|
||||||
@@ -371,6 +319,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
if (credentialsData.password) {
|
if (credentialsData.password) {
|
||||||
credentialsData.hostConfig.password = credentialsData.password;
|
credentialsData.hostConfig.password = credentialsData.password;
|
||||||
credentialsData.hostConfig.authType = "password";
|
credentialsData.hostConfig.authType = "password";
|
||||||
|
(credentialsData.hostConfig as any).userProvidedPassword = true;
|
||||||
} else if (credentialsData.sshKey) {
|
} else if (credentialsData.sshKey) {
|
||||||
credentialsData.hostConfig.key = credentialsData.sshKey;
|
credentialsData.hostConfig.key = credentialsData.sshKey;
|
||||||
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
|
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
|
||||||
@@ -776,13 +725,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
sshConn.on("close", () => {
|
sshConn.on("close", () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
sshLogger.warn("SSH connection closed by server", {
|
|
||||||
operation: "ssh_close",
|
|
||||||
hostId: id,
|
|
||||||
ip,
|
|
||||||
port,
|
|
||||||
hadStream: !!sshStream,
|
|
||||||
});
|
|
||||||
cleanupSSH(connectionTimeout);
|
cleanupSSH(connectionTimeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -795,15 +737,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||||
finish: (responses: string[]) => void,
|
finish: (responses: string[]) => void,
|
||||||
) => {
|
) => {
|
||||||
if (resolvedCredentials.authType === "none") {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "keyboard_interactive_available",
|
|
||||||
message: "Keyboard-interactive authentication is available",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const promptTexts = prompts.map((p) => p.prompt);
|
const promptTexts = prompts.map((p) => p.prompt);
|
||||||
const totpPromptIndex = prompts.findIndex((p) =>
|
const totpPromptIndex = prompts.findIndex((p) =>
|
||||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||||
@@ -854,7 +787,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||||
if (keyboardInteractiveResponded && totpPromptSent) {
|
if (keyboardInteractiveResponded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
keyboardInteractiveResponded = true;
|
keyboardInteractiveResponded = true;
|
||||||
@@ -898,7 +831,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
host: ip,
|
host: ip,
|
||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
tryKeyboard: resolvedCredentials.authType === "none",
|
tryKeyboard: true,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
readyTimeout: 60000,
|
readyTimeout: 60000,
|
||||||
@@ -964,22 +897,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (resolvedCredentials.authType === "none") {
|
if (resolvedCredentials.authType === "none") {
|
||||||
connectConfig.authHandler = (
|
|
||||||
methodsLeft: string[] | null,
|
|
||||||
partialSuccess: boolean,
|
|
||||||
callback: (nextMethod: string | false) => void,
|
|
||||||
) => {
|
|
||||||
if (methodsLeft && methodsLeft.length > 0) {
|
|
||||||
if (methodsLeft.includes("keyboard-interactive")) {
|
|
||||||
callback("keyboard-interactive");
|
|
||||||
} else {
|
|
||||||
authMethodNotAvailable = true;
|
|
||||||
callback(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
callback(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else if (resolvedCredentials.authType === "password") {
|
} else if (resolvedCredentials.authType === "password") {
|
||||||
if (!resolvedCredentials.password) {
|
if (!resolvedCredentials.password) {
|
||||||
sshLogger.error(
|
sshLogger.error(
|
||||||
@@ -994,7 +911,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
connectConfig.password = resolvedCredentials.password;
|
|
||||||
|
if ((hostConfig as any).userProvidedPassword) {
|
||||||
|
connectConfig.password = resolvedCredentials.password;
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
resolvedCredentials.authType === "key" &&
|
resolvedCredentials.authType === "key" &&
|
||||||
resolvedCredentials.key
|
resolvedCredentials.key
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ function AppContent() {
|
|||||||
setAuthLoading(true);
|
setAuthLoading(true);
|
||||||
getUserInfo()
|
getUserInfo()
|
||||||
.then((meRes) => {
|
.then((meRes) => {
|
||||||
// Check if response is actually HTML (Vite dev server page)
|
|
||||||
if (typeof meRes === "string" || !meRes.username) {
|
if (typeof meRes === "string" || !meRes.username) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
|
|||||||
@@ -1344,6 +1344,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
authType: credentials.password ? "password" : "key",
|
authType: credentials.password ? "password" : "key",
|
||||||
credentialId: currentHost.credentialId,
|
credentialId: currentHost.credentialId,
|
||||||
userId: currentHost.userId,
|
userId: currentHost.userId,
|
||||||
|
userProvidedPassword: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.requires_totp) {
|
if (result?.requires_totp) {
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
|
import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
|
||||||
import { getCookie, getUserInfo } from "@/ui/main-axios.ts";
|
import { getCookie, getUserInfo } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
webview: React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLElement>,
|
||||||
|
HTMLElement
|
||||||
|
> & {
|
||||||
|
src?: string;
|
||||||
|
partition?: string;
|
||||||
|
allowpopups?: string;
|
||||||
|
ref?: React.Ref<any>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ElectronLoginFormProps {
|
interface ElectronLoginFormProps {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
onAuthSuccess: () => void;
|
onAuthSuccess: () => void;
|
||||||
@@ -20,10 +36,12 @@ export function ElectronLoginForm({
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const webviewRef = useRef<any>(null);
|
||||||
const hasAuthenticatedRef = useRef(false);
|
const hasAuthenticatedRef = useRef(false);
|
||||||
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
||||||
const hasLoadedOnce = useRef(false);
|
const hasLoadedOnce = useRef(false);
|
||||||
|
const urlCheckInterval = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const loadTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMessage = async (event: MessageEvent) => {
|
const handleMessage = async (event: MessageEvent) => {
|
||||||
@@ -57,7 +75,17 @@ export function ElectronLoginForm({
|
|||||||
await getUserInfo();
|
await getUserInfo();
|
||||||
} catch (verifyErr) {
|
} catch (verifyErr) {
|
||||||
localStorage.removeItem("jwt");
|
localStorage.removeItem("jwt");
|
||||||
throw new Error("Invalid or expired authentication token");
|
const errorMsg =
|
||||||
|
verifyErr instanceof Error
|
||||||
|
? verifyErr.message
|
||||||
|
: "Failed to verify authentication";
|
||||||
|
console.error("Authentication verification failed:", verifyErr);
|
||||||
|
throw new Error(
|
||||||
|
errorMsg.includes("registration") ||
|
||||||
|
errorMsg.includes("allowed")
|
||||||
|
? "Authentication failed. Please check your server connection and try again."
|
||||||
|
: errorMsg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
@@ -85,159 +113,186 @@ export function ElectronLoginForm({
|
|||||||
}, [serverUrl, isAuthenticating, onAuthSuccess, t]);
|
}, [serverUrl, isAuthenticating, onAuthSuccess, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const iframe = iframeRef.current;
|
const checkWebviewUrl = () => {
|
||||||
if (!iframe) return;
|
const webview = webviewRef.current;
|
||||||
|
if (!webview) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const webviewUrl = webview.getURL();
|
||||||
|
if (webviewUrl && webviewUrl !== currentUrl) {
|
||||||
|
setCurrentUrl(webviewUrl);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
urlCheckInterval.current = setInterval(checkWebviewUrl, 500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (urlCheckInterval.current) {
|
||||||
|
clearInterval(urlCheckInterval.current);
|
||||||
|
urlCheckInterval.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [currentUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const webview = webviewRef.current;
|
||||||
|
if (!webview) return;
|
||||||
|
|
||||||
|
loadTimeout.current = setTimeout(() => {
|
||||||
|
if (!hasLoadedOnce.current && loading) {
|
||||||
|
setLoading(false);
|
||||||
|
setError(
|
||||||
|
"Unable to connect to server. Please check the server URL and try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
|
if (loadTimeout.current) {
|
||||||
|
clearTimeout(loadTimeout.current);
|
||||||
|
loadTimeout.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
hasLoadedOnce.current = true;
|
hasLoadedOnce.current = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (iframe.contentWindow) {
|
const webviewUrl = webview.getURL();
|
||||||
setCurrentUrl(iframe.contentWindow.location.href);
|
setCurrentUrl(webviewUrl || serverUrl);
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCurrentUrl(serverUrl);
|
setCurrentUrl(serverUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const injectedScript = `
|
||||||
const injectedScript = `
|
(function() {
|
||||||
(function() {
|
window.IS_ELECTRON = true;
|
||||||
window.IS_ELECTRON = true;
|
if (typeof window.electronAPI === 'undefined') {
|
||||||
if (typeof window.electronAPI === 'undefined') {
|
window.electronAPI = { isElectron: true };
|
||||||
window.electronAPI = { isElectron: true };
|
}
|
||||||
|
|
||||||
|
let hasNotified = false;
|
||||||
|
|
||||||
|
function postJWTToParent(token, source) {
|
||||||
|
if (hasNotified) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
hasNotified = true;
|
||||||
|
|
||||||
let hasNotified = false;
|
|
||||||
|
|
||||||
function postJWTToParent(token, source) {
|
|
||||||
if (hasNotified) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hasNotified = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
window.parent.postMessage({
|
|
||||||
type: 'AUTH_SUCCESS',
|
|
||||||
token: token,
|
|
||||||
source: source,
|
|
||||||
platform: 'desktop',
|
|
||||||
timestamp: Date.now()
|
|
||||||
}, '*');
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAuthData() {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem('jwt');
|
|
||||||
sessionStorage.removeItem('jwt');
|
|
||||||
|
|
||||||
const cookies = document.cookie.split(';');
|
|
||||||
for (let i = 0; i < cookies.length; i++) {
|
|
||||||
const cookie = cookies[i];
|
|
||||||
const eqPos = cookie.indexOf('=');
|
|
||||||
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
|
|
||||||
if (name === 'jwt') {
|
|
||||||
document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/';
|
|
||||||
document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=' + window.location.hostname;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('message', function(event) {
|
|
||||||
try {
|
|
||||||
if (event.data && typeof event.data === 'object') {
|
|
||||||
if (event.data.type === 'CLEAR_AUTH_DATA') {
|
|
||||||
clearAuthData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function checkAuth() {
|
|
||||||
try {
|
|
||||||
const localToken = localStorage.getItem('jwt');
|
|
||||||
if (localToken && localToken.length > 20) {
|
|
||||||
postJWTToParent(localToken, 'localStorage');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionToken = sessionStorage.getItem('jwt');
|
|
||||||
if (sessionToken && sessionToken.length > 20) {
|
|
||||||
postJWTToParent(sessionToken, 'sessionStorage');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookies = document.cookie;
|
|
||||||
if (cookies && cookies.length > 0) {
|
|
||||||
const cookieArray = cookies.split('; ');
|
|
||||||
const tokenCookie = cookieArray.find(row => row.startsWith('jwt='));
|
|
||||||
|
|
||||||
if (tokenCookie) {
|
|
||||||
const token = tokenCookie.split('=')[1];
|
|
||||||
if (token && token.length > 20) {
|
|
||||||
postJWTToParent(token, 'cookie');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalSetItem = localStorage.setItem;
|
|
||||||
localStorage.setItem = function(key, value) {
|
|
||||||
originalSetItem.apply(this, arguments);
|
|
||||||
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
|
|
||||||
setTimeout(() => checkAuth(), 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const originalSessionSetItem = sessionStorage.setItem;
|
|
||||||
sessionStorage.setItem = function(key, value) {
|
|
||||||
originalSessionSetItem.apply(this, arguments);
|
|
||||||
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
|
|
||||||
setTimeout(() => checkAuth(), 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
if (hasNotified) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (checkAuth()) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
}, 300000);
|
|
||||||
|
|
||||||
setTimeout(() => checkAuth(), 500);
|
|
||||||
})();
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (iframe.contentWindow) {
|
|
||||||
try {
|
try {
|
||||||
iframe.contentWindow.eval(injectedScript);
|
window.parent.postMessage({
|
||||||
} catch (evalError) {
|
type: 'AUTH_SUCCESS',
|
||||||
iframe.contentWindow.postMessage(
|
token: token,
|
||||||
{ type: "INJECT_SCRIPT", script: injectedScript },
|
source: source,
|
||||||
"*",
|
platform: 'desktop',
|
||||||
);
|
timestamp: Date.now()
|
||||||
|
}, '*');
|
||||||
|
} catch (e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {}
|
|
||||||
} catch (err) {}
|
function clearAuthData() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('jwt');
|
||||||
|
sessionStorage.removeItem('jwt');
|
||||||
|
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i];
|
||||||
|
const eqPos = cookie.indexOf('=');
|
||||||
|
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
|
||||||
|
if (name === 'jwt') {
|
||||||
|
document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/';
|
||||||
|
document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=' + window.location.hostname;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', function(event) {
|
||||||
|
try {
|
||||||
|
if (event.data && typeof event.data === 'object') {
|
||||||
|
if (event.data.type === 'CLEAR_AUTH_DATA') {
|
||||||
|
clearAuthData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function checkAuth() {
|
||||||
|
try {
|
||||||
|
const localToken = localStorage.getItem('jwt');
|
||||||
|
if (localToken && localToken.length > 20) {
|
||||||
|
postJWTToParent(localToken, 'localStorage');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = sessionStorage.getItem('jwt');
|
||||||
|
if (sessionToken && sessionToken.length > 20) {
|
||||||
|
postJWTToParent(sessionToken, 'sessionStorage');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = document.cookie;
|
||||||
|
if (cookies && cookies.length > 0) {
|
||||||
|
const cookieArray = cookies.split('; ');
|
||||||
|
const tokenCookie = cookieArray.find(row => row.startsWith('jwt='));
|
||||||
|
|
||||||
|
if (tokenCookie) {
|
||||||
|
const token = tokenCookie.split('=')[1];
|
||||||
|
if (token && token.length > 20) {
|
||||||
|
postJWTToParent(token, 'cookie');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalSetItem = localStorage.setItem;
|
||||||
|
localStorage.setItem = function(key, value) {
|
||||||
|
originalSetItem.apply(this, arguments);
|
||||||
|
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
|
||||||
|
setTimeout(() => checkAuth(), 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalSessionSetItem = sessionStorage.setItem;
|
||||||
|
sessionStorage.setItem = function(key, value) {
|
||||||
|
originalSessionSetItem.apply(this, arguments);
|
||||||
|
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
|
||||||
|
setTimeout(() => checkAuth(), 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
if (hasNotified) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (checkAuth()) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}, 300000);
|
||||||
|
|
||||||
|
setTimeout(() => checkAuth(), 500);
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
webview.executeJavaScript(injectedScript);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to inject authentication script:", err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
@@ -247,18 +302,27 @@ export function ElectronLoginForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
iframe.addEventListener("load", handleLoad);
|
webview.addEventListener("did-finish-load", handleLoad);
|
||||||
iframe.addEventListener("error", handleError);
|
webview.addEventListener("did-fail-load", handleError);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
iframe.removeEventListener("load", handleLoad);
|
webview.removeEventListener("did-finish-load", handleLoad);
|
||||||
iframe.removeEventListener("error", handleError);
|
webview.removeEventListener("did-fail-load", handleError);
|
||||||
|
if (loadTimeout.current) {
|
||||||
|
clearTimeout(loadTimeout.current);
|
||||||
|
loadTimeout.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [t]);
|
}, [t, loading, serverUrl]);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
if (iframeRef.current) {
|
if (webviewRef.current) {
|
||||||
iframeRef.current.src = serverUrl;
|
if (loadTimeout.current) {
|
||||||
|
clearTimeout(loadTimeout.current);
|
||||||
|
loadTimeout.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
webviewRef.current.src = serverUrl;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
@@ -336,14 +400,13 @@ export function ElectronLoginForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<iframe
|
<webview
|
||||||
ref={iframeRef}
|
ref={webviewRef}
|
||||||
src={serverUrl}
|
src={serverUrl}
|
||||||
className="w-full h-full border-0"
|
className="w-full h-full border-0"
|
||||||
title="Server Authentication"
|
partition="persist:termix"
|
||||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation allow-modals allow-downloads"
|
allowpopups="false"
|
||||||
allow="clipboard-read; clipboard-write; cross-origin-isolated; camera; microphone; geolocation; storage-access"
|
style={{ width: "100%", height: "100%" }}
|
||||||
credentialless={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -136,11 +136,8 @@ export function SSHAuthDialog({
|
|||||||
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
|
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-dark-bg">
|
||||||
className="absolute inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
<Card className="w-full max-w-2xl mx-4 border-2">
|
||||||
style={{ backgroundColor: `${backgroundColor}dd` }}
|
|
||||||
>
|
|
||||||
<Card className="w-full max-w-2xl mx-4 shadow-2xl">
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield className="w-5 h-5" />
|
<Shield className="w-5 h-5" />
|
||||||
|
|||||||
Reference in New Issue
Block a user