v1.9.0 #437

Merged
LukeGus merged 33 commits from dev-1.9.0 into main 2025-11-17 15:46:05 +00:00
16 changed files with 445 additions and 209 deletions
Showing only changes of commit b43e98073f - Show all commits

View File

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

View File

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

View File

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

View File

@@ -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}`,
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}, []);