fix: More bug fixes and QOL fixes
This commit is contained in:
60
package-lock.json
generated
60
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}} успешно переименован",
|
||||
|
||||
@@ -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": "文件为空",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -204,27 +204,59 @@ export function Dashboard({
|
||||
|
||||
setServerStatsLoading(true);
|
||||
const serversWithStats = await Promise.all(
|
||||
hosts.slice(0, 50).map(async (host: { id: number; name: string }) => {
|
||||
try {
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu.percent,
|
||||
ram: metrics.memory.percent,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
}),
|
||||
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,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu.percent,
|
||||
ram: metrics.memory.percent,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
(config) => config !== currentHostName,
|
||||
);
|
||||
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")}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!res.is_admin,
|
||||
username: res.username || null,
|
||||
userId: res.userId || null,
|
||||
});
|
||||
|
||||
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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,17 +30,29 @@ const AppContent: FC = () => {
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
setAuthLoading(true);
|
||||
// Don't optimistically set isAuthenticated before checking
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user