fix: More bug fixes and QOL fixes

This commit is contained in:
LukeGus
2025-11-13 00:07:01 -06:00
parent bd7f9730b0
commit e564748d01
33 changed files with 611 additions and 245 deletions

60
package-lock.json generated
View File

@@ -154,6 +154,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -439,6 +440,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz",
"integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -487,6 +489,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
@@ -513,6 +516,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
@@ -540,6 +544,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
@@ -740,6 +745,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@@ -816,6 +822,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
@@ -837,6 +844,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -1164,6 +1172,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -1550,7 +1559,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1572,7 +1580,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -2529,7 +2536,8 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz",
"integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@lezer/cpp": {
"version": "1.1.3",
@@ -2569,6 +2577,7 @@
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@lezer/common": "^1.3.0"
}
@@ -2600,6 +2609,7 @@
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
@@ -2622,6 +2632,7 @@
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@lezer/common": "^1.0.0"
}
@@ -4845,6 +4856,7 @@
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -5002,6 +5014,7 @@
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz",
"integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -5124,6 +5137,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz",
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -5166,6 +5180,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -5176,6 +5191,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -5343,6 +5359,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -5719,7 +5736,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/7zip-bin": {
"version": "5.2.0",
@@ -5754,6 +5772,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6176,6 +6195,7 @@
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -6310,6 +6330,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -7313,6 +7334,7 @@
"integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"env-paths": "^2.2.1",
"import-fresh": "^3.3.0",
@@ -7389,8 +7411,7 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -7849,6 +7870,7 @@
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.0.12",
"builder-util": "26.0.11",
@@ -7946,8 +7968,7 @@
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
"integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true
"license": "(MPL-2.0 OR Apache-2.0)"
},
"node_modules/dot-prop": {
"version": "5.3.0",
@@ -8299,7 +8320,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -8320,7 +8340,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -8336,7 +8355,6 @@
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"license": "MIT",
"peer": true,
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
@@ -8347,7 +8365,6 @@
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4.0.0"
}
@@ -8610,6 +8627,7 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10208,6 +10226,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -11749,7 +11768,6 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
@@ -13777,7 +13795,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -13795,7 +13812,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -14247,6 +14263,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14256,6 +14273,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -14282,6 +14300,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -14429,6 +14448,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -14637,7 +14657,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -15958,7 +15979,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -15999,7 +16019,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -16014,7 +16033,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -16119,6 +16137,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16324,6 +16343,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16736,6 +16756,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16827,6 +16848,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

View File

@@ -1498,17 +1498,13 @@ app.get(
if (status.hasUnencryptedDb) {
try {
unencryptedSize = fs.statSync(dbPath).size;
} catch (error) {
databaseLogger.debug("Operation failed, continuing", { error });
}
} catch (error) {}
}
if (status.hasEncryptedDb) {
try {
encryptedSize = fs.statSync(encryptedDbPath).size;
} catch (error) {
databaseLogger.debug("Operation failed, continuing", { error });
}
} catch (error) {}
}
res.json({

View File

@@ -908,6 +908,29 @@ router.delete(
},
);
// Notify stats server to stop polling this host
try {
const axios = (await import("axios")).default;
const statsPort = process.env.STATS_PORT || 30005;
await axios.post(
`http://localhost:${statsPort}/host-deleted`,
{ hostId: numericHostId },
{
headers: {
Authorization: req.headers.authorization || "",
Cookie: req.headers.cookie || "",
},
timeout: 5000,
},
);
} catch (err) {
sshLogger.warn("Failed to notify stats server of host deletion", {
operation: "host_delete",
hostId: numericHostId,
error: err instanceof Error ? err.message : String(err),
});
}
res.json({ message: "SSH host deleted" });
} catch (err) {
sshLogger.error("Failed to delete SSH host from database", err, {
@@ -1630,6 +1653,39 @@ router.delete(
deletedCount: hostsToDelete.length,
});
// Notify stats server to stop polling these hosts
try {
const axios = (await import("axios")).default;
const statsPort = process.env.STATS_PORT || 30005;
for (const host of hostsToDelete) {
try {
await axios.post(
`http://localhost:${statsPort}/host-deleted`,
{ hostId: host.id },
{
headers: {
Authorization: req.headers.authorization || "",
Cookie: req.headers.cookie || "",
},
timeout: 5000,
},
);
} catch (err) {
sshLogger.warn("Failed to notify stats server of host deletion", {
operation: "folder_hosts_delete",
hostId: host.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
} catch (err) {
sshLogger.warn("Failed to notify stats server of folder deletion", {
operation: "folder_hosts_delete",
folderName,
error: err instanceof Error ? err.message : String(err),
});
}
res.json({
message: "All hosts in folder deleted successfully",
deletedCount: hostsToDelete.length,

View File

@@ -1030,9 +1030,7 @@ router.post("/login", async (req, res) => {
if (kekSalt.length === 0) {
await authManager.registerUser(userRecord.id, password);
}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", { error });
}
} catch (error) {}
const deviceInfo = parseUserAgent(req);
const dataUnlocked = await authManager.authenticateUser(

View File

@@ -261,6 +261,7 @@ interface SSHSession {
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number; // Track number of active operations to prevent cleanup mid-operation
}
interface PendingTOTPSession {
@@ -285,11 +286,24 @@ const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
// Don't cleanup if there are active operations
if (session.activeOperations > 0) {
fileLogger.warn(
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
{
operation: "cleanup_deferred",
sessionId,
activeOperations: session.activeOperations,
},
);
// Reschedule cleanup
scheduleSessionCleanup(sessionId);
return;
}
try {
session.client.end();
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
} catch (error) {}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
@@ -563,6 +577,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0, // Initialize active operations counter
};
scheduleSessionCleanup(sessionId);
res.json({ status: "success", message: "SSH connection established" });
@@ -917,6 +932,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
client: session.client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0, // Initialize active operations counter
};
scheduleSessionCleanup(sessionId);
@@ -1060,10 +1076,12 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
}
sshConn.lastActive = Date.now();
sshConn.activeOperations++; // Track operation start
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
if (err) {
sshConn.activeOperations--; // Decrement on error
fileLogger.error("SSH listFiles error:", err);
return res.status(500).json({ error: err.message });
}
@@ -1080,6 +1098,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
});
stream.on("close", (code) => {
sshConn.activeOperations--; // Decrement when operation completes
if (code !== 0) {
fileLogger.error(
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,

View File

@@ -368,9 +368,7 @@ class SSHConnectionPool {
if (!conn.inUse && now - conn.lastUsed > maxAge) {
try {
conn.client.end();
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
} catch (error) {}
return false;
}
return true;
@@ -390,9 +388,7 @@ class SSHConnectionPool {
for (const conn of connections) {
try {
conn.client.end();
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
} catch (error) {}
}
}
this.connections.clear();
@@ -430,9 +426,7 @@ class RequestQueue {
if (request) {
try {
await request();
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
} catch (error) {}
}
}
@@ -663,6 +657,7 @@ class PollingManager {
async startPollingForHost(host: SSHHostWithCredentials): Promise<void> {
const statsConfig = this.parseStatsConfig(host.statsConfig);
const existingConfig = this.pollingConfigs.get(host.id);
// Always clear existing timers first
@@ -696,6 +691,9 @@ class PollingManager {
statsConfig,
};
// Set the config FIRST to prevent race conditions with old timers
this.pollingConfigs.set(host.id, config);
if (statsConfig.statusCheckEnabled) {
const intervalMs = statsConfig.statusCheckInterval * 1000;
@@ -715,6 +713,7 @@ class PollingManager {
if (statsConfig.metricsEnabled) {
const intervalMs = statsConfig.metricsInterval * 1000;
// Poll immediately, but only if metrics are actually enabled
this.pollHostMetrics(host);
config.metricsTimer = setInterval(() => {
@@ -728,6 +727,7 @@ class PollingManager {
});
}
// Update with the new timers
this.pollingConfigs.set(host.id, config);
}
@@ -750,14 +750,18 @@ class PollingManager {
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
// Double-check that metrics are still enabled before collecting
// Always get the LATEST config from the Map, not from the closure
const config = this.pollingConfigs.get(host.id);
if (!config || !config.statsConfig.metricsEnabled) {
return;
}
// Use the host from the config to ensure we have the latest version
const currentHost = config.host;
try {
const metrics = await collectMetrics(host);
this.metricsStore.set(host.id, {
const metrics = await collectMetrics(currentHost);
this.metricsStore.set(currentHost.id, {
data: metrics,
timestamp: Date.now(),
});
@@ -765,13 +769,13 @@ class PollingManager {
const errorMessage =
error instanceof Error ? error.message : String(error);
// Only log errors if metrics collection is actually enabled
// Don't spam logs with errors for hosts that have metrics disabled
if (config.statsConfig.metricsEnabled) {
// Re-check config after the async operation in case it was disabled during collection
const latestConfig = this.pollingConfigs.get(currentHost.id);
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
statsLogger.warn("Failed to collect metrics for host", {
operation: "metrics_poll_failed",
hostId: host.id,
hostName: host.name,
hostId: currentHost.id,
hostName: currentHost.name,
error: errorMessage,
});
}
@@ -1330,9 +1334,7 @@ function tcpPing(
settled = true;
try {
socket.destroy();
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
} catch (error) {}
resolve(result);
};
@@ -1438,6 +1440,34 @@ app.post("/host-updated", async (req, res) => {
}
});
app.post("/host-deleted", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId } = req.body;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
try {
pollingManager.stopPollingForHost(hostId, true);
res.json({ message: "Host polling stopped" });
} catch (error) {
statsLogger.error("Failed to stop polling for host", error, {
operation: "host_deleted",
hostId,
userId,
});
res.status(500).json({ error: "Failed to stop polling" });
}
});
app.get("/metrics/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;

View File

@@ -323,6 +323,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
let isConnecting = false;
let isConnected = false;
let isCleaningUp = false;
let isShellInitializing = false; // Track shell initialization to prevent cleanup mid-setup
ws.on("close", () => {
const userWs = userConnections.get(userId);
@@ -680,9 +681,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.on("ready", () => {
clearTimeout(connectionTimeout);
// Capture the connection reference immediately and verify it's still valid
const conn = sshConn;
if (!conn || isCleaningUp) {
// Additional check: verify the connection is still active before proceeding
if (!conn || isCleaningUp || !sshConn) {
sshLogger.warn(
"SSH connection was cleaned up before shell could be created",
{
@@ -692,6 +695,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
port,
username,
isCleaningUp,
connNull: !conn,
sshConnNull: !sshConn,
},
);
ws.send(
@@ -704,9 +709,30 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
// Mark that we're initializing the shell to prevent cleanup
isShellInitializing = true;
isConnecting = false;
isConnected = true;
// Verify connection is still valid right before shell() call
if (!sshConn) {
sshLogger.error(
"SSH connection became null right before shell creation",
{
operation: "ssh_shell",
hostId: id,
},
);
ws.send(
JSON.stringify({
type: "error",
message: "SSH connection lost during setup",
}),
);
isShellInitializing = false;
return;
}
conn.shell(
{
rows: data.rows,
@@ -714,6 +740,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
term: "xterm-256color",
} as PseudoTtyOptions,
(err, stream) => {
// Shell initialization complete, clear the flag
isShellInitializing = false;
if (err) {
sshLogger.error("Shell error", err, {
operation: "ssh_shell",
@@ -1235,6 +1264,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (isCleaningUp) {
return;
}
// Don't cleanup if we're in the middle of shell initialization
if (isShellInitializing) {
sshLogger.warn(
"Cleanup attempted during shell initialization, deferring",
{
operation: "cleanup_deferred",
userId,
},
);
// Retry cleanup after a short delay
setTimeout(() => cleanupSSH(timeoutId), 100);
return;
}
isCleaningUp = true;
if (timeoutId) {
@@ -1283,9 +1327,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
function setupPingInterval() {
// More frequent keepalive to prevent idle disconnections
pingInterval = setInterval(() => {
if (sshConn && sshStream) {
try {
// Send null byte as keepalive
sshStream.write("\x00");
} catch (e: unknown) {
sshLogger.error(
@@ -1294,7 +1340,13 @@ wss.on("connection", async (ws: WebSocket, req) => {
);
cleanupSSH();
}
} else if (!sshConn || !sshStream) {
// If connection or stream is lost, clear the interval
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
}, 60000);
}
}, 30000); // Reduced from 60s to 30s for more reliable keepalive
}
});

View File

@@ -217,9 +217,7 @@ function cleanupTunnelResources(
if (verification?.timeout) clearTimeout(verification.timeout);
try {
verification?.conn.end();
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
} catch (error) {}
tunnelVerifications.delete(tunnelName);
}
@@ -284,9 +282,7 @@ function handleDisconnect(
const verification = tunnelVerifications.get(tunnelName);
if (verification?.timeout) clearTimeout(verification.timeout);
verification?.conn.end();
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
} catch (error) {}
tunnelVerifications.delete(tunnelName);
}
@@ -642,9 +638,7 @@ async function connectSSHTunnel(
try {
conn.end();
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
} catch (error) {}
activeTunnels.delete(tunnelName);
@@ -784,9 +778,7 @@ async function connectSSHTunnel(
const verification = tunnelVerifications.get(tunnelName);
if (verification?.timeout) clearTimeout(verification.timeout);
verification?.conn.end();
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
} catch (error) {}
tunnelVerifications.delete(tunnelName);
}

View File

@@ -21,11 +21,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
if (persistentConfig.parsed) {
Object.assign(process.env, persistentConfig.parsed);
}
} catch (error) {
systemLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
} catch (error) {}
let version = "unknown";

View File

@@ -233,11 +233,7 @@ IP.3 = 0.0.0.0
let envContent = "";
try {
envContent = await fs.readFile(this.ENV_FILE, "utf8");
} catch (error) {
systemLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
} catch (error) {}
let updatedContent = envContent;
let hasChanges = false;

View File

@@ -632,11 +632,7 @@ class DatabaseFileEncryption {
try {
fs.accessSync(envPath, fs.constants.R_OK);
result.environment.envFileReadable = true;
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
} catch (error) {}
}
if (

View File

@@ -82,11 +82,7 @@ export class LazyFieldEncryption {
legacyFieldName,
);
return decrypted;
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
} catch (error) {}
}
const sensitiveFields = [
@@ -178,11 +174,7 @@ export class LazyFieldEncryption {
wasPlaintext: false,
wasLegacyEncryption: true,
};
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
} catch (error) {}
}
return {
encrypted: fieldValue,

View File

@@ -85,11 +85,7 @@ function detectKeyTypeFromContent(keyContent: string): string {
} else if (decodedString.includes("1.3.101.112")) {
return "ssh-ed25519";
}
} catch (error) {
sshLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
} catch (error) {}
if (content.length < 800) {
return "ssh-ed25519";
@@ -145,11 +141,7 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
} else if (decodedString.includes("1.3.101.112")) {
return "ssh-ed25519";
}
} catch (error) {
sshLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
} catch (error) {}
if (content.length < 400) {
return "ssh-ed25519";
@@ -251,11 +243,7 @@ export function parseSSHKey(
useSSH2 = true;
}
} catch (error) {
sshLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
} catch (error) {}
}
if (!useSSH2) {
@@ -281,11 +269,7 @@ export function parseSSHKey(
success: true,
};
}
} catch (error) {
sshLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
} catch (error) {}
return {
privateKey: privateKeyData,

View File

@@ -51,15 +51,7 @@ class SystemCrypto {
},
);
}
} catch (fileError) {
// OK: .env file not found or unreadable, will generate new JWT secret
databaseLogger.debug(
".env file not accessible, will generate new JWT secret",
{
operation: "jwt_env_not_found",
},
);
}
} catch (fileError) {}
await this.generateAndGuideUser();
} catch (error) {
@@ -110,15 +102,7 @@ class SystemCrypto {
return;
} else {
}
} catch (fileError) {
// OK: .env file not found or unreadable, will generate new database key
databaseLogger.debug(
".env file not accessible, will generate new database key",
{
operation: "db_key_env_not_found",
},
);
}
} catch (fileError) {}
await this.generateAndGuideDatabaseKey();
} catch (error) {
@@ -156,11 +140,7 @@ class SystemCrypto {
process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1];
return;
}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
} catch (error) {}
await this.generateAndGuideInternalAuthToken();
} catch (error) {

View File

@@ -915,6 +915,8 @@
"copy": "Kopieren",
"cut": "Ausschneiden",
"paste": "Einfügen",
"copyPath": "Pfad kopieren",
"copyPaths": "Pfade kopieren",
"delete": "Löschen",
"properties": "Eigenschaften",
"refresh": "Aktualisieren",
@@ -924,6 +926,9 @@
"deleteFiles": "{{count}} Element(e) löschen",
"filesCopiedToClipboard": "{{count}} Element(e) in die Zwischenablage kopiert",
"filesCutToClipboard": "{{count}} Element(e) in die Zwischenablage ausschneiden",
"pathCopiedToClipboard": "Pfad in Zwischenablage kopiert",
"pathsCopiedToClipboard": "{{count}} Pfade in Zwischenablage kopiert",
"failedToCopyPath": "Fehler beim Kopieren des Pfades in die Zwischenablage",
"movedItems": "{{count}} Element(e) verschoben",
"failedToDeleteItem": "Das Löschen des Elements ist fehlgeschlagen.",
"itemRenamedSuccessfully": "{{type}} erfolgreich umbenannt",

View File

@@ -1040,6 +1040,8 @@
"copy": "Copy",
"cut": "Cut",
"paste": "Paste",
"copyPath": "Copy Path",
"copyPaths": "Copy Paths",
"delete": "Delete",
"properties": "Properties",
"preview": "Preview",
@@ -1050,6 +1052,9 @@
"deleteFiles": "Delete {{count}} items",
"filesCopiedToClipboard": "{{count}} items copied to clipboard",
"filesCutToClipboard": "{{count}} items cut to clipboard",
"pathCopiedToClipboard": "Path copied to clipboard",
"pathsCopiedToClipboard": "{{count}} paths copied to clipboard",
"failedToCopyPath": "Failed to copy path to clipboard",
"movedItems": "Moved {{count}} items",
"failedToDeleteItem": "Failed to delete item",
"itemRenamedSuccessfully": "{{type}} renamed successfully",

View File

@@ -910,6 +910,8 @@
"copy": "Copier",
"cut": "Couper",
"paste": "Coller",
"copyPath": "Copier le chemin",
"copyPaths": "Copier les chemins",
"delete": "Supprimer",
"properties": "Propriétés",
"refresh": "Actualiser",
@@ -919,6 +921,9 @@
"deleteFiles": "Supprimer {{count}} éléments",
"filesCopiedToClipboard": "{{count}} éléments copiés dans le presse-papiers",
"filesCutToClipboard": "{{count}} éléments coupés dans le presse-papiers",
"pathCopiedToClipboard": "Chemin copié dans le presse-papiers",
"pathsCopiedToClipboard": "{{count}} chemins copiés dans le presse-papiers",
"failedToCopyPath": "Échec de la copie du chemin dans le presse-papiers",
"movedItems": "{{count}} éléments déplacés",
"failedToDeleteItem": "Échec de la suppression de l'élément",
"itemRenamedSuccessfully": "{{type}} renommé avec succès",

View File

@@ -861,6 +861,8 @@
"copy": "Copiar",
"cut": "Recortar",
"paste": "Colar",
"copyPath": "Copiar caminho",
"copyPaths": "Copiar caminhos",
"delete": "Excluir",
"properties": "Propriedades",
"preview": "Visualizar",
@@ -871,6 +873,9 @@
"deleteFiles": "Excluir {{count}} itens",
"filesCopiedToClipboard": "{{count}} itens copiados para a área de transferência",
"filesCutToClipboard": "{{count}} itens recortados para a área de transferência",
"pathCopiedToClipboard": "Caminho copiado para a área de transferência",
"pathsCopiedToClipboard": "{{count}} caminhos copiados para a área de transferência",
"failedToCopyPath": "Falha ao copiar caminho para a área de transferência",
"movedItems": "{{count}} itens movidos",
"failedToDeleteItem": "Falha ao excluir item",
"itemRenamedSuccessfully": "{{type}} renomeado com sucesso",

View File

@@ -978,6 +978,8 @@
"copy": "Копировать",
"cut": "Вырезать",
"paste": "Вставить",
"copyPath": "Копировать путь",
"copyPaths": "Копировать пути",
"delete": "Удалить",
"properties": "Свойства",
"preview": "Просмотр",
@@ -988,6 +990,9 @@
"deleteFiles": "Удалить {{count}} элементов",
"filesCopiedToClipboard": "{{count}} элементов скопировано в буфер обмена",
"filesCutToClipboard": "{{count}} элементов вырезано в буфер обмена",
"pathCopiedToClipboard": "Путь скопирован в буфер обмена",
"pathsCopiedToClipboard": "{{count}} путей скопировано в буфер обмена",
"failedToCopyPath": "Не удалось скопировать путь в буфер обмена",
"movedItems": "Перемещено {{count}} элементов",
"failedToDeleteItem": "Не удалось удалить элемент",
"itemRenamedSuccessfully": "{{type}} успешно переименован",

View File

@@ -1025,7 +1025,11 @@
"noSSHConnection": "无SSH连接可用",
"enterFolderName": "输入文件夹名称:",
"enterFileName": "输入文件名称:",
"copy": "复制",
"cut": "剪切",
"paste": "粘贴",
"copyPath": "复制路径",
"copyPaths": "复制路径",
"properties": "属性",
"refresh": "刷新",
"downloadFiles": "下载 {{count}} 个文件",
@@ -1034,6 +1038,9 @@
"deleteFiles": "删除 {{count}} 个项目",
"filesCopiedToClipboard": "{{count}} 个项目已复制到剪贴板",
"filesCutToClipboard": "{{count}} 个项目已剪切到剪贴板",
"pathCopiedToClipboard": "路径已复制到剪贴板",
"pathsCopiedToClipboard": "{{count}} 个路径已复制到剪贴板",
"failedToCopyPath": "复制路径到剪贴板失败",
"movedItems": "已移动 {{count}} 个项目",
"unknownSize": "未知大小",
"fileIsEmpty": "文件为空",

View File

@@ -63,6 +63,7 @@ function AppContent() {
useEffect(() => {
const checkAuth = () => {
setAuthLoading(true);
// Don't optimistically set isAuthenticated before checking
getUserInfo()
.then((meRes) => {
if (typeof meRes === "string" || !meRes.username) {
@@ -163,13 +164,34 @@ function AppContent() {
const showAdmin = currentTabData?.type === "admin";
const showProfile = currentTabData?.type === "user_profile";
if (authLoading) {
return (
<div
className="h-screen w-screen flex items-center justify-center bg-dark-bg-darkest"
style={{
backgroundImage: `repeating-linear-gradient(
225deg,
transparent,
transparent 35px,
rgba(255, 255, 255, 0.03) 35px,
rgba(255, 255, 255, 0.03) 37px
)`,
}}
>
<div className="text-center">
<div className="w-16 h-16 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto" />
</div>
</div>
);
}
return (
<div className="h-screen w-screen overflow-hidden">
<CommandPalette
isOpen={isCommandPaletteOpen}
setIsOpen={setIsCommandPaletteOpen}
/>
{!isAuthenticated && !authLoading && (
{!isAuthenticated && (
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
<Dashboard
onSelectView={handleSelectView}

View File

@@ -15,6 +15,7 @@ import { PasswordInput } from "@/components/ui/password-input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert";
import React, { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
@@ -52,6 +53,8 @@ export function CredentialEditor({
const [detectedKeyType, setDetectedKeyType] = useState<string | null>(null);
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState<string | null>(null);
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
string | null
@@ -60,6 +63,11 @@ export function CredentialEditor({
useState(false);
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Clear error when tab changes
useEffect(() => {
setFormError(null);
}, [activeTab]);
useEffect(() => {
const fetchData = async () => {
try {
@@ -320,6 +328,8 @@ export function CredentialEditor({
const onSubmit = async (data: FormData) => {
try {
setFormError(null);
if (!data.name || data.name.trim() === "") {
data.name = data.username;
}
@@ -378,6 +388,28 @@ export function CredentialEditor({
}
};
const handleFormError = () => {
const errors = form.formState.errors;
if (
errors.name ||
errors.username ||
errors.description ||
errors.folder ||
errors.tags
) {
setActiveTab("general");
} else if (
errors.password ||
errors.key ||
errors.publicKey ||
errors.keyPassword ||
errors.keyType
) {
setActiveTab("authentication");
}
};
const [tagInput, setTagInput] = useState("");
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
@@ -427,11 +459,20 @@ export function CredentialEditor({
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
className="flex flex-col flex-1 min-h-0 h-full"
>
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<Tabs defaultValue="general" className="w-full">
{formError && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList>
<TabsTrigger value="general">
{t("credentials.general")}

View File

@@ -204,8 +204,32 @@ export function Dashboard({
setServerStatsLoading(true);
const serversWithStats = await Promise.all(
hosts.slice(0, 50).map(async (host: { id: number; name: string }) => {
hosts
.slice(0, 50)
.map(
async (host: {
id: number;
name: string;
statsConfig?: string | { metricsEnabled?: boolean };
}) => {
try {
// Parse statsConfig if it's a string
let statsConfig: { metricsEnabled?: boolean } = {
metricsEnabled: true,
};
if (host.statsConfig) {
if (typeof host.statsConfig === "string") {
statsConfig = JSON.parse(host.statsConfig);
} else {
statsConfig = host.statsConfig;
}
}
// Skip if metrics are disabled
if (statsConfig.metricsEnabled === false) {
return null;
}
const metrics = await getServerMetricsById(host.id);
return {
id: host.id,
@@ -221,10 +245,18 @@ export function Dashboard({
ram: null,
};
}
}),
},
),
);
const validServerStats = serversWithStats.filter(
(server) => server.cpu !== null && server.ram !== null,
(
server,
): server is {
id: number;
name: string;
cpu: number | null;
ram: number | null;
} => server !== null && server.cpu !== null && server.ram !== null,
);
setServerStats(validServerStats);
setServerStatsLoading(false);
@@ -339,7 +371,7 @@ export function Dashboard({
</div>
) : (
<div
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex"
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex min-w-0"
style={{
marginLeft: leftMarginPx,
marginRight: rightSidebarOpen
@@ -352,19 +384,19 @@ export function Dashboard({
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
}}
>
<div className="flex flex-col relative z-10 w-full h-full">
<div className="flex flex-row items-center justify-between w-full px-3 mt-3">
<div className="text-2xl text-white font-semibold">
<div className="flex flex-col relative z-10 w-full h-full min-w-0">
<div className="flex flex-row items-center justify-between w-full px-3 mt-3 min-w-0 flex-wrap gap-2">
<div className="text-2xl text-white font-semibold shrink-0">
{t("dashboard.title")}
</div>
<div className="flex flex-row gap-3">
<div className="flex flex-col items-center gap-4 justify-center mr-5">
<p className="text-muted-foreground text-sm">
<div className="flex flex-row gap-3 flex-wrap min-w-0">
<div className="flex flex-col items-center gap-4 justify-center mr-5 min-w-0 shrink">
<p className="text-muted-foreground text-sm whitespace-nowrap">
Press <Kbd>LShift</Kbd> twice to open the command palette
</p>
</div>
<Button
className="font-semibold"
className="font-semibold shrink-0"
variant="outline"
onClick={() =>
window.open(
@@ -376,7 +408,7 @@ export function Dashboard({
{t("dashboard.github")}
</Button>
<Button
className="font-semibold"
className="font-semibold shrink-0"
variant="outline"
onClick={() =>
window.open(
@@ -388,7 +420,7 @@ export function Dashboard({
{t("dashboard.support")}
</Button>
<Button
className="font-semibold"
className="font-semibold shrink-0"
variant="outline"
onClick={() =>
window.open(
@@ -400,7 +432,7 @@ export function Dashboard({
{t("dashboard.discord")}
</Button>
<Button
className="font-semibold"
className="font-semibold shrink-0"
variant="outline"
onClick={() =>
window.open("https://github.com/sponsors/LukeGus", "_blank")
@@ -413,23 +445,23 @@ export function Dashboard({
<Separator className="mt-3 p-0.25" />
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0">
<div className="flex flex-row flex-1 gap-4 min-h-0">
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0 min-w-0">
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<Server className="mr-3" />
{t("dashboard.serverOverview")}
</p>
<div className="bg-dark-bg w-full h-auto border-2 border-dark-border rounded-md px-3 py-3">
<div className="flex flex-row items-center justify-between mb-3">
<div className="flex flex-row items-center">
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<History
size={20}
color="#FFFFFF"
className="shrink-0"
/>
<p className="ml-2 leading-none">
<p className="ml-2 leading-none truncate">
{t("dashboard.version")}
</p>
</div>
@@ -451,14 +483,14 @@ export function Dashboard({
</div>
</div>
<div className="flex flex-row items-center justify-between mb-5">
<div className="flex flex-row items-center">
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Clock
size={20}
color="#FFFFFF"
className="shrink-0"
/>
<p className="ml-2 leading-none">
<p className="ml-2 leading-none truncate">
{t("dashboard.uptime")}
</p>
</div>
@@ -470,14 +502,14 @@ export function Dashboard({
</div>
</div>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Database
size={20}
color="#FFFFFF"
className="shrink-0"
/>
<p className="ml-2 leading-none">
<p className="ml-2 leading-none truncate">
{t("dashboard.database")}
</p>
</div>
@@ -494,14 +526,14 @@ export function Dashboard({
</div>
</div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
<div className="flex flex-row items-center">
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Server
size={16}
color="#FFFFFF"
className="mr-3 shrink-0"
/>
<p className="m-0 leading-none">
<p className="m-0 leading-none truncate">
{t("dashboard.totalServers")}
</p>
</div>
@@ -509,14 +541,14 @@ export function Dashboard({
{totalServers}
</p>
</div>
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
<div className="flex flex-row items-center">
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Network
size={16}
color="#FFFFFF"
className="mr-3 shrink-0"
/>
<p className="m-0 leading-none">
<p className="m-0 leading-none truncate">
{t("dashboard.totalTunnels")}
</p>
</div>
@@ -526,14 +558,14 @@ export function Dashboard({
</div>
</div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
<div className="flex flex-row items-center">
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Key
size={16}
color="#FFFFFF"
className="mr-3 shrink-0"
/>
<p className="m-0 leading-none">
<p className="m-0 leading-none truncate">
{t("dashboard.totalCredentials")}
</p>
</div>
@@ -544,7 +576,7 @@ export function Dashboard({
</div>
</div>
</div>
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<div className="flex flex-row items-center justify-between mb-3 mt-1">
<p className="text-xl font-semibold flex flex-row items-center">
@@ -561,7 +593,7 @@ export function Dashboard({
</Button>
</div>
<div
className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{recentActivityLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
@@ -577,7 +609,7 @@ export function Dashboard({
<Button
key={item.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg"
className="border-2 !border-dark-border bg-dark-bg min-w-0"
onClick={() => handleActivityClick(item)}
>
{item.type === "terminal" ? (
@@ -595,17 +627,17 @@ export function Dashboard({
</div>
</div>
</div>
<div className="flex flex-row flex-1 gap-4 min-h-0">
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<FastForward className="mr-3" />
{t("dashboard.quickActions")}
</p>
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-y-auto overflow-x-hidden">
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden">
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
onClick={handleAddHost}
>
<Server
@@ -618,7 +650,7 @@ export function Dashboard({
</Button>
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
onClick={handleAddCredential}
>
<Key
@@ -632,7 +664,7 @@ export function Dashboard({
{isAdmin && (
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
onClick={handleOpenAdminSettings}
>
<Settings
@@ -646,7 +678,7 @@ export function Dashboard({
)}
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
onClick={handleOpenUserProfile}
>
<User
@@ -660,14 +692,14 @@ export function Dashboard({
</div>
</div>
</div>
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<ChartLine className="mr-3" />
{t("dashboard.serverStats")}
</p>
<div
className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{serverStatsLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
@@ -683,7 +715,7 @@ export function Dashboard({
<Button
key={server.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg h-auto p-3"
className="border-2 !border-dark-border bg-dark-bg h-auto p-3 min-w-0"
>
<div className="flex flex-col w-full">
<div className="flex flex-row items-center mb-2">

View File

@@ -900,6 +900,26 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
);
}
function handleCopyPath(files: FileItem[]) {
if (files.length === 0) return;
const paths = files.map((file) => file.path).join("\n");
navigator.clipboard.writeText(paths).then(
() => {
toast.success(
files.length === 1
? t("fileManager.pathCopiedToClipboard")
: t("fileManager.pathsCopiedToClipboard", { count: files.length }),
);
},
(err) => {
console.error("Failed to copy path to clipboard:", err);
toast.error(t("fileManager.failedToCopyPath"));
},
);
}
async function handlePasteFiles() {
if (!clipboard || !sshSessionId) return;
@@ -2064,6 +2084,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onProperties={handleOpenPermissionsDialog}
onExtractArchive={handleExtractArchive}
onCompress={handleOpenCompressDialog}
onCopyPath={handleCopyPath}
/>
</div>
</div>

View File

@@ -63,6 +63,7 @@ interface ContextMenuProps {
currentPath?: string;
onExtractArchive?: (file: FileItem) => void;
onCompress?: (files: FileItem[]) => void;
onCopyPath?: (files: FileItem[]) => void;
}
interface MenuItem {
@@ -104,6 +105,7 @@ export function FileManagerContextMenu({
currentPath,
onExtractArchive,
onCompress,
onCopyPath,
}: ContextMenuProps) {
const { t } = useTranslation();
const [menuPosition, setMenuPosition] = useState({ x, y });
@@ -365,7 +367,18 @@ export function FileManagerContextMenu({
});
}
if ((isSingleFile && onRename) || onCopy || onCut) {
if (onCopyPath) {
menuItems.push({
icon: <Clipboard className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.copyPaths")
: t("fileManager.copyPath"),
action: () => onCopyPath(files),
shortcut: "Ctrl+Shift+P",
});
}
if ((isSingleFile && onRename) || onCopy || onCut || onCopyPath) {
menuItems.push({ separator: true } as MenuItem);
}

View File

@@ -72,13 +72,14 @@ export function HostManager({
};
const handleTabChange = (value: string) => {
setActiveTab(value);
if (value !== "add_host") {
// Only clear editing state when leaving the respective tabs, not when entering them
if (activeTab === "add_host" && value !== "add_host") {
setEditingHost(null);
}
if (value !== "add_credential") {
if (activeTab === "add_credential" && value !== "add_credential") {
setEditingCredential(null);
}
setActiveTab(value);
};
const topMarginPx = isTopbarOpen ? 74 : 26;

View File

@@ -345,6 +345,13 @@ export function HostManagerEditor({
"upload",
);
const isSubmittingRef = useRef(false);
const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState<string | null>(null);
// Clear error when tab changes
useEffect(() => {
setFormError(null);
}, [activeTab]);
const [statusIntervalUnit, setStatusIntervalUnit] = useState<
"seconds" | "minutes"
@@ -817,6 +824,7 @@ export function HostManagerEditor({
const onSubmit = async (data: FormData) => {
try {
isSubmittingRef.current = true;
setFormError(null);
if (!data.name || data.name.trim() === "") {
data.name = `${data.username}@${data.ip}`;
@@ -828,12 +836,16 @@ export function HostManagerEditor({
if (statusInterval < 5 || statusInterval > 3600) {
toast.error(t("hosts.intervalValidation"));
setActiveTab("statistics");
setFormError(t("hosts.intervalValidation"));
isSubmittingRef.current = false;
return;
}
if (metricsInterval < 5 || metricsInterval > 3600) {
toast.error(t("hosts.intervalValidation"));
setActiveTab("statistics");
setFormError(t("hosts.intervalValidation"));
isSubmittingRef.current = false;
return;
}
@@ -943,13 +955,47 @@ export function HostManagerEditor({
);
notifyHostCreatedOrUpdated(savedHost.id);
}
} catch {
} catch (error) {
toast.error(t("hosts.failedToSaveHost"));
console.error("Failed to save host:", error);
} finally {
isSubmittingRef.current = false;
}
};
// Handle form validation errors
const handleFormError = () => {
const errors = form.formState.errors;
// Determine which tab contains the error
if (
errors.ip ||
errors.port ||
errors.username ||
errors.name ||
errors.folder ||
errors.tags ||
errors.pin ||
errors.password ||
errors.key ||
errors.keyPassword ||
errors.keyType ||
errors.credentialId ||
errors.forceKeyboardInteractive ||
errors.jumpHosts
) {
setActiveTab("general");
} else if (errors.enableTerminal || errors.terminalConfig) {
setActiveTab("terminal");
} else if (errors.enableTunnel || errors.tunnelConnections) {
setActiveTab("tunnel");
} else if (errors.enableFileManager || errors.defaultPath) {
setActiveTab("file_manager");
} else if (errors.statsConfig) {
setActiveTab("statistics");
}
};
const [tagInput, setTagInput] = useState("");
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
@@ -1038,12 +1084,26 @@ export function HostManagerEditor({
const getFilteredSshConfigs = (index: number) => {
const value = form.watch(`tunnelConnections.${index}.endpointHost`);
const currentHostName =
form.watch("name") || `${form.watch("username")}@${form.watch("ip")}`;
const currentHostId = editingHost?.id;
let filtered = sshConfigurations.filter(
let filtered = sshConfigurations;
// Filter out the current host being edited (by ID, not by name)
if (currentHostId) {
const currentHostName = hosts.find((h) => h.id === currentHostId)?.name;
if (currentHostName) {
filtered = sshConfigurations.filter(
(config) => config !== currentHostName,
);
}
} else {
// If creating a new host, filter by the name being entered
const currentHostName =
form.watch("name") || `${form.watch("username")}@${form.watch("ip")}`;
filtered = sshConfigurations.filter(
(config) => config !== currentHostName,
);
}
if (value) {
filtered = filtered.filter((config) =>
@@ -1099,12 +1159,21 @@ export function HostManagerEditor({
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
className="flex flex-col flex-1 min-h-0 h-full"
>
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<div className="pr-4">
<Tabs defaultValue="general" className="w-full">
{formError && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList>
<TabsTrigger value="general">
{t("hosts.general")}

View File

@@ -199,9 +199,6 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
await fetchHosts();
await fetchFolderMetadata();
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
refreshServerPolling();
} catch (error) {
console.error("Failed to delete hosts in folder:", error);
toast.error(t("hosts.failedToDeleteHostsInFolder"));

View File

@@ -117,6 +117,7 @@ export function Auth({
const [totpTempToken, setTotpTempToken] = useState("");
const [totpLoading, setTotpLoading] = useState(false);
const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false);
const totpInputRef = React.useRef<HTMLInputElement>(null);
const [showServerConfig, setShowServerConfig] = useState<boolean | null>(
null,
@@ -156,6 +157,12 @@ export function Auth({
setInternalLoggedIn(loggedIn);
}, [loggedIn]);
useEffect(() => {
if (totpRequired && totpInputRef.current) {
totpInputRef.current.focus();
}
}, [totpRequired]);
useEffect(() => {
getRegistrationAllowed().then((res) => {
setRegistrationAllowed(res.allowed);
@@ -479,20 +486,17 @@ export function Auth({
}
}
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!res.is_admin);
setUsername(res.username || null);
setUserId(res.userId || null);
setDbError(null);
setTimeout(() => {
onAuthSuccess({
isAdmin: !!res.is_admin,
username: res.username || null,
userId: res.userId || null,
});
}, 100);
setInternalLoggedIn(true);
setTotpRequired(false);
@@ -916,6 +920,7 @@ export function Auth({
<div className="flex flex-col gap-2">
<Label htmlFor="totp-code">{t("auth.verifyCode")}</Label>
<Input
ref={totpInputRef}
id="totp-code"
type="text"
placeholder="000000"

View File

@@ -49,13 +49,24 @@ interface TabProviderProps {
export function TabProvider({ children }: TabProviderProps) {
const { t } = useTranslation();
const [tabs, setTabs] = useState<Tab[]>([
{ id: 1, type: "home", title: t("nav.home") },
const [tabs, setTabs] = useState<Tab[]>(() => [
{ id: 1, type: "home", title: "Home" },
]);
const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(2);
// Update home tab title when translation changes
React.useEffect(() => {
setTabs((prev) =>
prev.map((tab) =>
tab.id === 1 && tab.type === "home"
? { ...tab, title: t("nav.home") }
: tab,
),
);
}, [t]);
function computeUniqueTitle(
tabType: Tab["type"],
desiredTitle: string | undefined,

View File

@@ -396,7 +396,7 @@ function createApiInstance(
errorMessage === "Invalid token" ||
errorMessage === "Authentication required";
if (isSessionExpired || isSessionNotFound) {
if (isSessionExpired || isSessionNotFound || isInvalidToken) {
// Clear token from localStorage
localStorage.removeItem("jwt");
@@ -405,29 +405,19 @@ function createApiInstance(
electronSettingsCache.delete("jwt");
}
// Clear cookie
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=/;";
}
// Only show toast and reload for explicit session expiration
// Let the auth check in DesktopApp.tsx handle the redirect silently
if (isSessionExpired && typeof window !== "undefined") {
console.warn("Session expired - please log in again");
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(
"Authentication error - token may be invalid",
errorMessage,
);
// Clear invalid token
localStorage.removeItem("jwt");
if (isElectron()) {
electronSettingsCache.delete("jwt");
}
}
}

View File

@@ -30,17 +30,29 @@ const AppContent: FC = () => {
useEffect(() => {
const checkAuth = () => {
setAuthLoading(true);
// Don't optimistically set isAuthenticated before checking
getUserInfo()
.then((meRes) => {
if (typeof meRes === "string" || !meRes.username) {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
// Clear invalid token
localStorage.removeItem("jwt");
} else {
setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
}
})
.catch((err) => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
// Clear invalid token on any auth error
localStorage.removeItem("jwt");
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again");
@@ -113,7 +125,10 @@ const AppContent: FC = () => {
if (authLoading) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-dark-bg-darkest">
<p className="text-white">{t("common.loading")}</p>
<div className="text-center">
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
}

View File

@@ -118,11 +118,18 @@ export function Auth({
const [totpTempToken, setTotpTempToken] = useState("");
const [totpLoading, setTotpLoading] = useState(false);
const [mobileAuthSuccess, setMobileAuthSuccess] = useState(false);
const totpInputRef = React.useRef<HTMLInputElement>(null);
useEffect(() => {
setInternalLoggedIn(loggedIn);
}, [loggedIn]);
useEffect(() => {
if (totpRequired && totpInputRef.current) {
totpInputRef.current.focus();
}
}, [totpRequired]);
useEffect(() => {
getRegistrationAllowed().then((res) => {
setRegistrationAllowed(res.allowed);
@@ -648,6 +655,7 @@ export function Auth({
<div className="flex flex-col gap-2">
<Label htmlFor="totp-code">{t("auth.verifyCode")}</Label>
<Input
ref={totpInputRef}
id="totp-code"
type="text"
placeholder="000000"