Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cad71b878 | ||
|
|
9649f63474 | ||
|
|
e63958e5eb | ||
|
|
ba5730287f | ||
|
|
4faa74b4c5 | ||
|
|
aa9e155b0c | ||
|
|
d819cd79fe | ||
|
|
c618fef2ef | ||
|
|
6265ffeab5 | ||
|
|
608a7ac78c | ||
|
|
ff1ab35b46 | ||
|
|
e8be9e7214 | ||
|
|
cfcd09611e | ||
|
|
4ed3e42148 | ||
|
|
a09f331d5f | ||
|
|
36beb427c0 |
@@ -21,3 +21,8 @@ WEBSOCKET_PORT="3001"
|
|||||||
GITHUB_TOKEN=
|
GITHUB_TOKEN=
|
||||||
SAVE_FILTER=false
|
SAVE_FILTER=false
|
||||||
FILTERS=
|
FILTERS=
|
||||||
|
AUTH_USERNAME=
|
||||||
|
AUTH_PASSWORD_HASH=
|
||||||
|
AUTH_ENABLED=false
|
||||||
|
AUTH_SETUP_COMPLETED=false
|
||||||
|
JWT_SECRET=
|
||||||
4
.github/release-drafter.yml
vendored
4
.github/release-drafter.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
# Template for release drafts
|
# Template for release drafts
|
||||||
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
|
name-template: 'v$NEXT_MINOR_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
|
||||||
tag-template: 'v$NEXT_PATCH_VERSION'
|
tag-template: 'v$NEXT_MINOR_VERSION'
|
||||||
|
|
||||||
# Exclude PRs with this label from release notes
|
# Exclude PRs with this label from release notes
|
||||||
exclude-labels:
|
exclude-labels:
|
||||||
|
|||||||
@@ -18,6 +18,40 @@ const config = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// Allow cross-origin requests from local network ranges
|
||||||
|
allowedDevOrigins: [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://127.0.0.1:3000',
|
||||||
|
'http://[::1]:3000',
|
||||||
|
'http://10.*',
|
||||||
|
'http://172.16.*',
|
||||||
|
'http://172.17.*',
|
||||||
|
'http://172.18.*',
|
||||||
|
'http://172.19.*',
|
||||||
|
'http://172.20.*',
|
||||||
|
'http://172.21.*',
|
||||||
|
'http://172.22.*',
|
||||||
|
'http://172.23.*',
|
||||||
|
'http://172.24.*',
|
||||||
|
'http://172.25.*',
|
||||||
|
'http://172.26.*',
|
||||||
|
'http://172.27.*',
|
||||||
|
'http://172.28.*',
|
||||||
|
'http://172.29.*',
|
||||||
|
'http://172.30.*',
|
||||||
|
'http://172.31.*',
|
||||||
|
'http://192.168.*',
|
||||||
|
],
|
||||||
|
|
||||||
|
webpack: (config, { dev, isServer }) => {
|
||||||
|
if (dev && !isServer) {
|
||||||
|
config.watchOptions = {
|
||||||
|
poll: 1000,
|
||||||
|
aggregateTimeout: 300,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
159
package-lock.json
generated
159
package-lock.json
generated
@@ -19,9 +19,11 @@
|
|||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.3",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
@@ -42,8 +44,10 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.8",
|
"@types/better-sqlite3": "^7.6.8",
|
||||||
"@types/node": "^24.3.1",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^24.7.1",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
@@ -2986,6 +2990,13 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcryptjs": {
|
||||||
|
"version": "2.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
|
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/better-sqlite3": {
|
"node_modules/@types/better-sqlite3": {
|
||||||
"version": "7.6.13",
|
"version": "7.6.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
@@ -3043,10 +3054,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.7.0",
|
"version": "24.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
|
||||||
"integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==",
|
"integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.14.0"
|
"undici-types": "~7.14.0"
|
||||||
@@ -4259,6 +4288,15 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "12.4.1",
|
"version": "12.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
|
||||||
@@ -4385,6 +4423,12 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/cac": {
|
"node_modules/cac": {
|
||||||
"version": "6.7.14",
|
"version": "6.7.14",
|
||||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
@@ -4954,6 +4998,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.232",
|
"version": "1.5.232",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.232.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.232.tgz",
|
||||||
@@ -7166,6 +7219,40 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||||
|
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^3.2.2",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonwebtoken/node_modules/semver": {
|
||||||
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsx-ast-utils": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@@ -7182,6 +7269,27 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^1.4.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -7481,6 +7589,42 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -7488,6 +7632,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -7731,7 +7881,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nan": {
|
"node_modules/nan": {
|
||||||
|
|||||||
@@ -33,9 +33,11 @@
|
|||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.3",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
@@ -56,8 +58,10 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.8",
|
"@types/better-sqlite3": "^7.6.8",
|
||||||
"@types/node": "^24.3.1",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^24.7.1",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
|
|||||||
73
src/app/_components/AuthGuard.tsx
Normal file
73
src/app/_components/AuthGuard.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, type ReactNode } from 'react';
|
||||||
|
import { useAuth } from './AuthProvider';
|
||||||
|
import { AuthModal } from './AuthModal';
|
||||||
|
import { SetupModal } from './SetupModal';
|
||||||
|
|
||||||
|
interface AuthGuardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthConfig {
|
||||||
|
username: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
hasCredentials: boolean;
|
||||||
|
setupCompleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: AuthGuardProps) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null);
|
||||||
|
const [configLoading, setConfigLoading] = useState(true);
|
||||||
|
const [setupCompleted, setSetupCompleted] = useState(false);
|
||||||
|
|
||||||
|
const handleSetupComplete = async () => {
|
||||||
|
setSetupCompleted(true);
|
||||||
|
// Refresh auth config without reloading the page
|
||||||
|
await fetchAuthConfig();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAuthConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/auth-credentials');
|
||||||
|
if (response.ok) {
|
||||||
|
const config = await response.json() as AuthConfig;
|
||||||
|
setAuthConfig(config);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching auth config:', error);
|
||||||
|
} finally {
|
||||||
|
setConfigLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchAuthConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show loading while checking auth status
|
||||||
|
if (isLoading || configLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-4"></div>
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show setup modal if setup has not been completed yet
|
||||||
|
if (authConfig && !authConfig.setupCompleted && !setupCompleted) {
|
||||||
|
return <SetupModal isOpen={true} onComplete={handleSetupComplete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show auth modal if auth is enabled but user is not authenticated
|
||||||
|
if (authConfig && authConfig.enabled && !isAuthenticated) {
|
||||||
|
return <AuthModal isOpen={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render children if authenticated or auth is disabled
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
111
src/app/_components/AuthModal.tsx
Normal file
111
src/app/_components/AuthModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { useAuth } from './AuthProvider';
|
||||||
|
import { Lock, User, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AuthModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthModal({ isOpen }: AuthModalProps) {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const success = await login(username, password);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setError('Invalid username or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Lock className="h-8 w-8 text-blue-600" />
|
||||||
|
<h2 className="text-2xl font-bold text-card-foreground">Authentication Required</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-muted-foreground text-center mb-6">
|
||||||
|
Please enter your credentials to access the application.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-800 border border-red-200 rounded-md">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !username.trim() || !password.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing In...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/app/_components/AuthProvider.tsx
Normal file
119
src/app/_components/AuthProvider.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
username: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
// First check if setup is completed
|
||||||
|
const setupResponse = await fetch('/api/settings/auth-credentials');
|
||||||
|
if (setupResponse.ok) {
|
||||||
|
const setupData = await setupResponse.json() as { setupCompleted: boolean; enabled: boolean };
|
||||||
|
|
||||||
|
// If setup is not completed or auth is disabled, don't verify
|
||||||
|
if (!setupData.setupCompleted || !setupData.enabled) {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsername(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only verify authentication if setup is completed and auth is enabled
|
||||||
|
const response = await fetch('/api/auth/verify');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { username: string };
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setUsername(data.username);
|
||||||
|
} else {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsername(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking auth:', error);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsername(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (username: string, password: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { username: string };
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setUsername(data.username);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.error('Login failed:', errorData.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
// Clear the auth cookie by setting it to expire
|
||||||
|
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsername(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
isAuthenticated,
|
||||||
|
username,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
checkAuth,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ const CategoryIcon = ({ iconName, className = "w-5 h-5" }: { iconName: string; c
|
|||||||
),
|
),
|
||||||
key: (
|
key: (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1721 9z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
archive: (
|
archive: (
|
||||||
|
|||||||
125
src/app/_components/ColorCodedDropdown.tsx
Normal file
125
src/app/_components/ColorCodedDropdown.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import type { Server } from '../../types/server';
|
||||||
|
|
||||||
|
interface ColorCodedDropdownProps {
|
||||||
|
servers: Server[];
|
||||||
|
selectedServer: Server | null;
|
||||||
|
onServerSelect: (server: Server | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorCodedDropdown({
|
||||||
|
servers,
|
||||||
|
selectedServer,
|
||||||
|
onServerSelect,
|
||||||
|
placeholder = "Select a server...",
|
||||||
|
disabled = false
|
||||||
|
}: ColorCodedDropdownProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleServerClick = (server: Server) => {
|
||||||
|
onServerSelect(server);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSelection = () => {
|
||||||
|
onServerSelect(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* Dropdown Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary bg-background text-foreground text-left flex items-center justify-between ${
|
||||||
|
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedServer ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{selectedServer.color && (
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: selectedServer.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedServer.name} ({selectedServer.ip}) - {selectedServer.user}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
placeholder
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-card border border-border rounded-md shadow-lg max-h-60 overflow-auto">
|
||||||
|
{/* Clear Selection Option */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearSelection}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{placeholder}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Server Options */}
|
||||||
|
{servers
|
||||||
|
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
|
||||||
|
.map((server) => (
|
||||||
|
<button
|
||||||
|
key={server.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleServerClick(server)}
|
||||||
|
className={`w-full px-3 py-2 text-left text-sm transition-colors flex items-center gap-2 ${
|
||||||
|
selectedServer?.id === server.id
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-foreground hover:bg-accent hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{server.color && (
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: server.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="truncate">
|
||||||
|
{server.name} ({server.ip}) - {server.user}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { ScriptCard } from './ScriptCard';
|
import { ScriptCard } from './ScriptCard';
|
||||||
|
import { ScriptCardList } from './ScriptCardList';
|
||||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||||
import { CategorySidebar } from './CategorySidebar';
|
import { CategorySidebar } from './CategorySidebar';
|
||||||
import { FilterBar, type FilterState } from './FilterBar';
|
import { FilterBar, type FilterState } from './FilterBar';
|
||||||
|
import { ViewToggle } from './ViewToggle';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
showUpdatable: null,
|
showUpdatable: null,
|
||||||
@@ -40,7 +43,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
{ enabled: !!selectedSlug }
|
{ enabled: !!selectedSlug }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load SAVE_FILTER setting and saved filters on component mount
|
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -63,6 +66,16 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load view mode
|
||||||
|
const viewModeResponse = await fetch('/api/settings/view-mode');
|
||||||
|
if (viewModeResponse.ok) {
|
||||||
|
const viewModeData = await viewModeResponse.json();
|
||||||
|
const viewMode = viewModeData.viewMode;
|
||||||
|
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
|
||||||
|
setViewMode(viewMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,6 +109,29 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
||||||
|
|
||||||
|
// Save view mode when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoadingFilters) return;
|
||||||
|
|
||||||
|
const saveViewMode = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings/view-mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ viewMode }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving view mode:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the save operation
|
||||||
|
const timeoutId = setTimeout(() => void saveViewMode(), 300);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [viewMode, isLoadingFilters]);
|
||||||
|
|
||||||
// Extract categories from metadata
|
// Extract categories from metadata
|
||||||
const categories = React.useMemo((): string[] => {
|
const categories = React.useMemo((): string[] => {
|
||||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||||
@@ -367,25 +403,8 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header with Stats */}
|
|
||||||
<div className="bg-card rounded-lg shadow p-6">
|
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-4">Downloaded Scripts</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
|
||||||
<div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-blue-400">{downloadedScripts.length}</div>
|
|
||||||
<div className="text-sm text-blue-300">Total Downloaded</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-500/10 border border-green-500/20 p-4 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-green-400">{filterCounts.updatableCount}</div>
|
|
||||||
<div className="text-sm text-green-300">Updatable</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-500/10 border border-purple-500/20 p-4 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-purple-400">{filteredScripts.length}</div>
|
|
||||||
<div className="text-sm text-purple-300">Filtered Results</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||||
{/* Category Sidebar */}
|
{/* Category Sidebar */}
|
||||||
@@ -412,6 +431,12 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
isLoadingFilters={isLoadingFilters}
|
isLoadingFilters={isLoadingFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<ViewToggle
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Scripts Grid */}
|
{/* Scripts Grid */}
|
||||||
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
|
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@@ -446,25 +471,47 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
viewMode === 'card' ? (
|
||||||
{filteredScripts.map((script, index) => {
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
// Add validation to ensure script has required properties
|
{filteredScripts.map((script, index) => {
|
||||||
if (!script || typeof script !== 'object') {
|
// Add validation to ensure script has required properties
|
||||||
return null;
|
if (!script || typeof script !== 'object') {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
// Create a unique key by combining slug, name, and index to handle duplicates
|
|
||||||
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
|
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||||
return (
|
|
||||||
<ScriptCard
|
return (
|
||||||
key={uniqueKey}
|
<ScriptCard
|
||||||
script={script}
|
key={uniqueKey}
|
||||||
onClick={handleCardClick}
|
script={script}
|
||||||
/>
|
onClick={handleCardClick}
|
||||||
);
|
/>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredScripts.map((script, index) => {
|
||||||
|
// Add validation to ensure script has required properties
|
||||||
|
if (!script || typeof script !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
|
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScriptCardList
|
||||||
|
key={uniqueKey}
|
||||||
|
script={script}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScriptDetailModal
|
<ScriptDetailModal
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { Server } from '../../types/server';
|
import type { Server } from '../../types/server';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { ColorCodedDropdown } from './ColorCodedDropdown';
|
||||||
|
import { SettingsModal } from './SettingsModal';
|
||||||
|
|
||||||
|
|
||||||
interface ExecutionModeModalProps {
|
interface ExecutionModeModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -15,8 +18,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedMode, setSelectedMode] = useState<'local' | 'ssh'>('local');
|
|
||||||
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
||||||
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -24,6 +27,20 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Auto-select server when exactly one server is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !loading && servers.length === 1) {
|
||||||
|
setSelectedServer(servers[0] ?? null);
|
||||||
|
}
|
||||||
|
}, [isOpen, loading, servers]);
|
||||||
|
|
||||||
|
// Refresh servers when settings modal closes
|
||||||
|
const handleSettingsModalClose = () => {
|
||||||
|
setSettingsModalOpen(false);
|
||||||
|
// Refetch servers to reflect any changes made in settings
|
||||||
|
void fetchServers();
|
||||||
|
};
|
||||||
|
|
||||||
const fetchServers = async () => {
|
const fetchServers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -33,7 +50,11 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
throw new Error('Failed to fetch servers');
|
throw new Error('Failed to fetch servers');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setServers(data as Server[]);
|
// Sort servers by name alphabetically
|
||||||
|
const sortedServers = (data as Server[]).sort((a, b) =>
|
||||||
|
(a.name ?? '').localeCompare(b.name ?? '')
|
||||||
|
);
|
||||||
|
setServers(sortedServers);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -42,167 +63,175 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExecute = () => {
|
const handleExecute = () => {
|
||||||
if (selectedMode === 'ssh' && !selectedServer) {
|
if (!selectedServer) {
|
||||||
setError('Please select a server for SSH execution');
|
setError('Please select a server for SSH execution');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onExecute(selectedMode, selectedServer ?? undefined);
|
onExecute('ssh', selectedServer);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModeChange = (mode: 'local' | 'ssh') => {
|
|
||||||
setSelectedMode(mode);
|
const handleServerSelect = (server: Server | null) => {
|
||||||
if (mode === 'local') {
|
setSelectedServer(server);
|
||||||
setSelectedServer(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
<>
|
||||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
{/* Header */}
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
{/* Header */}
|
||||||
<h2 className="text-xl font-bold text-foreground">Execution Mode</h2>
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
<Button
|
<h2 className="text-xl font-bold text-foreground">Select Server</h2>
|
||||||
onClick={onClose}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
|
||||||
Where would you like to execute "{scriptName}"?
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Execution Mode Selection */}
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
|
|
||||||
|
|
||||||
{/* SSH Execution */}
|
|
||||||
<div
|
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
|
||||||
selectedMode === 'ssh'
|
|
||||||
? 'border-primary bg-primary/10'
|
|
||||||
: 'border-border hover:border-primary/50'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleModeChange('ssh')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
id="ssh"
|
|
||||||
name="executionMode"
|
|
||||||
value="ssh"
|
|
||||||
checked={selectedMode === 'ssh'}
|
|
||||||
onChange={() => handleModeChange('ssh')}
|
|
||||||
className="h-4 w-4 text-primary focus:ring-primary border-border"
|
|
||||||
/>
|
|
||||||
<label htmlFor="ssh" className="ml-3 flex-1 cursor-pointer">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h4 className="text-sm font-medium text-foreground">SSH Execution</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">Run the script on a remote server</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Server Selection (only for SSH mode) */}
|
|
||||||
{selectedMode === 'ssh' && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
|
|
||||||
Select Server
|
|
||||||
</label>
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
|
|
||||||
</div>
|
|
||||||
) : servers.length === 0 ? (
|
|
||||||
<div className="text-center py-4 text-muted-foreground">
|
|
||||||
<p className="text-sm">No servers configured</p>
|
|
||||||
<p className="text-xs mt-1">Add servers in Settings to use SSH execution</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
id="server"
|
|
||||||
value={selectedServer?.id ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const serverId = parseInt(e.target.value);
|
|
||||||
const server = servers.find(s => s.id === serverId);
|
|
||||||
setSelectedServer(server ?? null);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary bg-background text-foreground"
|
|
||||||
>
|
|
||||||
<option value="">Select a server...</option>
|
|
||||||
{servers.map((server) => (
|
|
||||||
<option key={server.id} value={server.id}>
|
|
||||||
{server.name} ({server.ip}) - {server.user}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex justify-end space-x-3">
|
|
||||||
<Button
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="default"
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Cancel
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</Button>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
<Button
|
</svg>
|
||||||
onClick={handleExecute}
|
|
||||||
disabled={selectedMode === 'ssh' && !selectedServer}
|
|
||||||
variant="default"
|
|
||||||
size="default"
|
|
||||||
className={selectedMode === 'ssh' && !selectedServer ? 'bg-gray-400 cursor-not-allowed' : ''}
|
|
||||||
>
|
|
||||||
{selectedMode === 'local' ? 'Run Locally' : 'Run on Server'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
|
||||||
|
</div>
|
||||||
|
) : servers.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<p className="text-sm">No servers configured</p>
|
||||||
|
<p className="text-xs mt-1">Add servers in Settings to execute scripts</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setSettingsModalOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3"
|
||||||
|
>
|
||||||
|
Open Server Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : servers.length === 1 ? (
|
||||||
|
/* Single Server Confirmation View */
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
Install Script Confirmation
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Do you want to install "{scriptName}" on the following server?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
|
{selectedServer?.name ?? 'Unnamed Server'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{selectedServer?.ip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExecute}
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Multiple Servers Selection View */
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
Select server to execute "{scriptName}"
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Selection */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Select Server
|
||||||
|
</label>
|
||||||
|
<ColorCodedDropdown
|
||||||
|
servers={servers}
|
||||||
|
selectedServer={selectedServer}
|
||||||
|
onServerSelect={handleServerSelect}
|
||||||
|
placeholder="Select a server..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExecute}
|
||||||
|
disabled={!selectedServer}
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
className={!selectedServer ? 'bg-gray-400 cursor-not-allowed' : ''}
|
||||||
|
>
|
||||||
|
Run on Server
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Server Settings Modal */}
|
||||||
|
<SettingsModal
|
||||||
|
isOpen={settingsModalOpen}
|
||||||
|
onClose={handleSettingsModalClose}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,23 @@ interface GeneralSettingsModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
|
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'github'>('general');
|
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth'>('general');
|
||||||
const [githubToken, setGithubToken] = useState('');
|
const [githubToken, setGithubToken] = useState('');
|
||||||
const [saveFilter, setSaveFilter] = useState(false);
|
const [saveFilter, setSaveFilter] = useState(false);
|
||||||
const [savedFilters, setSavedFilters] = useState<any>(null);
|
const [savedFilters, setSavedFilters] = useState<any>(null);
|
||||||
|
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Auth state
|
||||||
|
const [authUsername, setAuthUsername] = useState('');
|
||||||
|
const [authPassword, setAuthPassword] = useState('');
|
||||||
|
const [authConfirmPassword, setAuthConfirmPassword] = useState('');
|
||||||
|
const [authEnabled, setAuthEnabled] = useState(false);
|
||||||
|
const [authHasCredentials, setAuthHasCredentials] = useState(false);
|
||||||
|
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
|
||||||
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
|
|
||||||
// Load existing settings when modal opens
|
// Load existing settings when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -25,6 +35,8 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
|||||||
void loadGithubToken();
|
void loadGithubToken();
|
||||||
void loadSaveFilter();
|
void loadSaveFilter();
|
||||||
void loadSavedFilters();
|
void loadSavedFilters();
|
||||||
|
void loadAuthCredentials();
|
||||||
|
void loadColorCodingSetting();
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@@ -138,6 +150,129 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadColorCodingSetting = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/color-coding');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setColorCodingEnabled(Boolean(data.enabled));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading color coding setting:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveColorCodingSetting = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/color-coding', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setColorCodingEnabled(enabled);
|
||||||
|
setMessage({ type: 'success', text: 'Color coding setting saved successfully' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save color coding setting' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving color coding setting:', error);
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save color coding setting' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAuthCredentials = async () => {
|
||||||
|
setAuthLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/auth-credentials');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean };
|
||||||
|
setAuthUsername(data.username ?? '');
|
||||||
|
setAuthEnabled(data.enabled ?? false);
|
||||||
|
setAuthHasCredentials(data.hasCredentials ?? false);
|
||||||
|
setAuthSetupCompleted(data.setupCompleted ?? false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading auth credentials:', error);
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAuthCredentials = async () => {
|
||||||
|
if (authPassword !== authConfirmPassword) {
|
||||||
|
setMessage({ type: 'error', text: 'Passwords do not match' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthLoading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/auth-credentials', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: authUsername,
|
||||||
|
password: authPassword,
|
||||||
|
enabled: authEnabled
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Authentication credentials updated successfully!' });
|
||||||
|
setAuthPassword('');
|
||||||
|
setAuthConfirmPassword('');
|
||||||
|
void loadAuthCredentials();
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save credentials' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save credentials' });
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAuthEnabled = async (enabled: boolean) => {
|
||||||
|
setAuthLoading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/auth-credentials', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setAuthEnabled(enabled);
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully!`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setMessage({ type: 'error', text: errorData.error ?? 'Failed to update auth status' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to update auth status' });
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -185,6 +320,18 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
|||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('auth')}
|
||||||
|
variant="ghost"
|
||||||
|
size="null"
|
||||||
|
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||||
|
activeTab === 'auth'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Authentication
|
||||||
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -237,6 +384,16 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Server Color Coding</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Enable color coding for servers to visually distinguish them throughout the application.</p>
|
||||||
|
<Toggle
|
||||||
|
checked={colorCodingEnabled}
|
||||||
|
onCheckedChange={saveColorCodingSetting}
|
||||||
|
label="Enable server color coding"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -301,6 +458,134 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'auth' && (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Authentication Settings</h3>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||||
|
Configure authentication to secure access to your application.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Authentication Status</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{authSetupCompleted
|
||||||
|
? (authHasCredentials
|
||||||
|
? `Authentication is ${authEnabled ? 'enabled' : 'disabled'}. Current username: ${authUsername}`
|
||||||
|
: `Authentication is ${authEnabled ? 'enabled' : 'disabled'}. No credentials configured.`)
|
||||||
|
: 'Authentication setup has not been completed yet.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Enable Authentication</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{authEnabled
|
||||||
|
? 'Authentication is required on every page load'
|
||||||
|
: 'Authentication is optional'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={authEnabled}
|
||||||
|
onCheckedChange={toggleAuthEnabled}
|
||||||
|
disabled={authLoading || !authSetupCompleted}
|
||||||
|
label="Enable authentication"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Change your username and password for authentication.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="auth-username" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="auth-username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter username"
|
||||||
|
value={authUsername}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthUsername(e.target.value)}
|
||||||
|
disabled={authLoading}
|
||||||
|
className="w-full"
|
||||||
|
minLength={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="auth-password" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="auth-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter new password"
|
||||||
|
value={authPassword}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthPassword(e.target.value)}
|
||||||
|
disabled={authLoading}
|
||||||
|
className="w-full"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="auth-confirm-password" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="auth-confirm-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
value={authConfirmPassword}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthConfirmPassword(e.target.value)}
|
||||||
|
disabled={authLoading}
|
||||||
|
className="w-full"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`p-3 rounded-md text-sm ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-green-50 text-green-800 border border-green-200'
|
||||||
|
: 'bg-red-50 text-red-800 border border-red-200'
|
||||||
|
}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={saveAuthCredentials}
|
||||||
|
disabled={authLoading || !authUsername.trim() || !authPassword.trim() || !authConfirmPassword.trim()}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{authLoading ? 'Saving...' : 'Update Credentials'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={loadAuthCredentials}
|
||||||
|
disabled={authLoading}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{authLoading ? 'Loading...' : 'Refresh'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Terminal } from './Terminal';
|
|||||||
import { StatusBadge } from './Badge';
|
import { StatusBadge } from './Badge';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { ScriptInstallationCard } from './ScriptInstallationCard';
|
import { ScriptInstallationCard } from './ScriptInstallationCard';
|
||||||
|
import { getContrastColor } from '../../lib/colorUtils';
|
||||||
|
|
||||||
interface InstalledScript {
|
interface InstalledScript {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -17,6 +18,7 @@ interface InstalledScript {
|
|||||||
server_ip: string | null;
|
server_ip: string | null;
|
||||||
server_user: string | null;
|
server_user: string | null;
|
||||||
server_password: string | null;
|
server_password: string | null;
|
||||||
|
server_color: string | null;
|
||||||
installation_date: string;
|
installation_date: string;
|
||||||
status: 'in_progress' | 'success' | 'failed';
|
status: 'in_progress' | 'success' | 'failed';
|
||||||
output_log: string | null;
|
output_log: string | null;
|
||||||
@@ -26,6 +28,8 @@ export function InstalledScriptsTab() {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
|
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
|
||||||
const [serverFilter, setServerFilter] = useState<string>('all');
|
const [serverFilter, setServerFilter] = useState<string>('all');
|
||||||
|
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('script_name');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
|
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
|
||||||
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
|
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
|
||||||
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
|
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
|
||||||
@@ -154,20 +158,58 @@ export function InstalledScriptsTab() {
|
|||||||
}
|
}
|
||||||
}, [scripts.length, serversData?.servers, cleanupMutation]);
|
}, [scripts.length, serversData?.servers, cleanupMutation]);
|
||||||
|
|
||||||
// Filter scripts based on search and filters
|
// Filter and sort scripts
|
||||||
const filteredScripts = scripts.filter((script: InstalledScript) => {
|
const filteredScripts = scripts
|
||||||
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
.filter((script: InstalledScript) => {
|
||||||
(script.container_id?.includes(searchTerm) ?? false) ||
|
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
|
(script.container_id?.includes(searchTerm) ?? false) ||
|
||||||
|
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
|
||||||
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
|
|
||||||
|
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
|
||||||
const matchesServer = serverFilter === 'all' ||
|
|
||||||
(serverFilter === 'local' && !script.server_name) ||
|
const matchesServer = serverFilter === 'all' ||
|
||||||
(script.server_name === serverFilter);
|
(serverFilter === 'local' && !script.server_name) ||
|
||||||
|
(script.server_name === serverFilter);
|
||||||
return matchesSearch && matchesStatus && matchesServer;
|
|
||||||
});
|
return matchesSearch && matchesStatus && matchesServer;
|
||||||
|
})
|
||||||
|
.sort((a: InstalledScript, b: InstalledScript) => {
|
||||||
|
let aValue: any;
|
||||||
|
let bValue: any;
|
||||||
|
|
||||||
|
switch (sortField) {
|
||||||
|
case 'script_name':
|
||||||
|
aValue = a.script_name.toLowerCase();
|
||||||
|
bValue = b.script_name.toLowerCase();
|
||||||
|
break;
|
||||||
|
case 'container_id':
|
||||||
|
aValue = a.container_id ?? '';
|
||||||
|
bValue = b.container_id ?? '';
|
||||||
|
break;
|
||||||
|
case 'server_name':
|
||||||
|
aValue = a.server_name ?? 'Local';
|
||||||
|
bValue = b.server_name ?? 'Local';
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
aValue = a.status;
|
||||||
|
bValue = b.status;
|
||||||
|
break;
|
||||||
|
case 'installation_date':
|
||||||
|
aValue = new Date(a.installation_date).getTime();
|
||||||
|
bValue = new Date(b.installation_date).getTime();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aValue < bValue) {
|
||||||
|
return sortDirection === 'asc' ? -1 : 1;
|
||||||
|
}
|
||||||
|
if (aValue > bValue) {
|
||||||
|
return sortDirection === 'asc' ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
// Get unique servers for filter
|
// Get unique servers for filter
|
||||||
const uniqueServers: string[] = [];
|
const uniqueServers: string[] = [];
|
||||||
@@ -298,6 +340,15 @@ export function InstalledScriptsTab() {
|
|||||||
setAutoDetectServerId('');
|
setAutoDetectServerId('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: 'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date') => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString();
|
return new Date(dateString).toLocaleString();
|
||||||
@@ -652,20 +703,70 @@ export function InstalledScriptsTab() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-muted">
|
<thead className="bg-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<th
|
||||||
Script Name
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||||
|
onClick={() => handleSort('script_name')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span>Script Name</span>
|
||||||
|
{sortField === 'script_name' && (
|
||||||
|
<span className="text-primary">
|
||||||
|
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<th
|
||||||
Container ID
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||||
|
onClick={() => handleSort('container_id')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span>Container ID</span>
|
||||||
|
{sortField === 'container_id' && (
|
||||||
|
<span className="text-primary">
|
||||||
|
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<th
|
||||||
Server
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||||
|
onClick={() => handleSort('server_name')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span>Server</span>
|
||||||
|
{sortField === 'server_name' && (
|
||||||
|
<span className="text-primary">
|
||||||
|
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<th
|
||||||
Status
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||||
|
onClick={() => handleSort('status')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span>Status</span>
|
||||||
|
{sortField === 'status' && (
|
||||||
|
<span className="text-primary">
|
||||||
|
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<th
|
||||||
Installation Date
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||||
|
onClick={() => handleSort('installation_date')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span>Installation Date</span>
|
||||||
|
{sortField === 'installation_date' && (
|
||||||
|
<span className="text-primary">
|
||||||
|
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
@@ -674,7 +775,11 @@ export function InstalledScriptsTab() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-card divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-gray-200">
|
||||||
{filteredScripts.map((script) => (
|
{filteredScripts.map((script) => (
|
||||||
<tr key={script.id} className="hover:bg-accent">
|
<tr
|
||||||
|
key={script.id}
|
||||||
|
className="hover:bg-accent"
|
||||||
|
style={{ borderLeft: `4px solid ${script.server_color ?? 'transparent'}` }}
|
||||||
|
>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
{editingScriptId === script.id ? (
|
{editingScriptId === script.id ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -712,8 +817,14 @@ export function InstalledScriptsTab() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span
|
||||||
{script.server_name ?? 'Local'}
|
className="text-sm px-3 py-1 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: script.server_color ?? 'transparent',
|
||||||
|
color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{script.server_name ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
|||||||
191
src/app/_components/SSHKeyInput.tsx
Normal file
191
src/app/_components/SSHKeyInput.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
interface SSHKeyInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHKeyInputProps) {
|
||||||
|
const [inputMode, setInputMode] = useState<'upload' | 'paste'>('upload');
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const validateSSHKey = (keyContent: string): boolean => {
|
||||||
|
const trimmed = keyContent.trim();
|
||||||
|
return (
|
||||||
|
trimmed.includes('BEGIN') &&
|
||||||
|
trimmed.includes('PRIVATE KEY') &&
|
||||||
|
trimmed.includes('END') &&
|
||||||
|
trimmed.includes('PRIVATE KEY')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (file: File) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const content = e.target?.result as string;
|
||||||
|
if (validateSSHKey(content)) {
|
||||||
|
onChange(content);
|
||||||
|
onError?.('');
|
||||||
|
} else {
|
||||||
|
onError?.('Invalid SSH key format. Please ensure the file contains a valid private key.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
onError?.('Failed to read the file. Please try again.');
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileUpload(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
|
const file = event.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileUpload(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasteChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const content = event.target.value;
|
||||||
|
onChange(content);
|
||||||
|
|
||||||
|
if (content.trim() && !validateSSHKey(content)) {
|
||||||
|
onError?.('Invalid SSH key format. Please ensure the content is a valid private key.');
|
||||||
|
} else {
|
||||||
|
onError?.('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKeyFingerprint = (keyContent: string): string => {
|
||||||
|
// This is a simplified fingerprint - in a real implementation,
|
||||||
|
// you might want to use a library to generate proper SSH key fingerprints
|
||||||
|
if (!keyContent.trim()) return '';
|
||||||
|
|
||||||
|
const lines = keyContent.trim().split('\n');
|
||||||
|
const keyLine = lines.find(line =>
|
||||||
|
line.includes('BEGIN') && line.includes('PRIVATE KEY')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (keyLine) {
|
||||||
|
const keyType = keyLine.includes('RSA') ? 'RSA' :
|
||||||
|
keyLine.includes('ED25519') ? 'ED25519' :
|
||||||
|
keyLine.includes('ECDSA') ? 'ECDSA' : 'Unknown';
|
||||||
|
return `${keyType} key (${keyContent.length} characters)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown key type';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={inputMode === 'upload' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setInputMode('upload')}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={inputMode === 'paste' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setInputMode('paste')}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Paste Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Upload Mode */}
|
||||||
|
{inputMode === 'upload' && (
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||||
|
isDragOver
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/50'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pem,.key,.id_rsa,.id_ed25519,.id_ecdsa"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-lg">📁</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Drag and drop your SSH private key here, or click to browse
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Supported formats: RSA, ED25519, ECDSA (.pem, .key, .id_rsa, etc.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Paste Mode */}
|
||||||
|
{inputMode === 'paste' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">
|
||||||
|
Paste your SSH private key:
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={handlePasteChange}
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABFwAAAAdzc2gtcn... -----END OPENSSH PRIVATE KEY-----"
|
||||||
|
className="w-full h-32 px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring font-mono text-xs"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Key Information */}
|
||||||
|
{value && (
|
||||||
|
<div className="p-3 bg-muted rounded-md">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium">Key detected:</span> {getKeyFingerprint(value)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
⚠️ Keep your private keys secure. This key will be stored in the database.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/app/_components/ScriptCardList.tsx
Normal file
164
src/app/_components/ScriptCardList.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import type { ScriptCard } from '~/types/script';
|
||||||
|
import { TypeBadge, UpdateableBadge } from './Badge';
|
||||||
|
|
||||||
|
interface ScriptCardListProps {
|
||||||
|
script: ScriptCard;
|
||||||
|
onClick: (script: ScriptCard) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScriptCardList({ script, onClick }: ScriptCardListProps) {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
setImageError(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString?: string) => {
|
||||||
|
if (!dateString) return 'Unknown';
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryNames = () => {
|
||||||
|
if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized';
|
||||||
|
return script.categoryNames.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary"
|
||||||
|
onClick={() => onClick(script)}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{script.logo && !imageError ? (
|
||||||
|
<Image
|
||||||
|
src={script.logo}
|
||||||
|
alt={`${script.name} logo`}
|
||||||
|
width={56}
|
||||||
|
height={56}
|
||||||
|
className="w-14 h-14 rounded-lg object-contain"
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-muted-foreground text-lg font-semibold">
|
||||||
|
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Header Row */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-xl font-semibold text-foreground truncate mb-2">
|
||||||
|
{script.name || 'Unnamed Script'}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<TypeBadge type={script.type ?? 'unknown'} />
|
||||||
|
{script.updateable && <UpdateableBadge />}
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${
|
||||||
|
script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`}></div>
|
||||||
|
<span className={`text-sm font-medium ${
|
||||||
|
script.isDownloaded ? 'text-green-700' : 'text-destructive'
|
||||||
|
}`}>
|
||||||
|
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Website link */}
|
||||||
|
{script.website && (
|
||||||
|
<a
|
||||||
|
href={script.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center space-x-1 ml-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span>Website</span>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
|
||||||
|
{script.description || 'No description available'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Metadata Row */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
<span>Categories: {getCategoryNames()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span>Created: {formatDate(script.date_created)}</span>
|
||||||
|
</div>
|
||||||
|
{(script.os ?? script.version) && (
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{script.os && script.version
|
||||||
|
? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}`
|
||||||
|
: script.os
|
||||||
|
? script.os.charAt(0).toUpperCase() + script.os.slice(1)
|
||||||
|
: script.version
|
||||||
|
? `Version ${script.version}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{script.interface_port && (
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span>Port: {script.interface_port}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>ID: {script.slug || 'unknown'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { StatusBadge } from './Badge';
|
import { StatusBadge } from './Badge';
|
||||||
|
import { getContrastColor } from '../../lib/colorUtils';
|
||||||
|
|
||||||
interface InstalledScript {
|
interface InstalledScript {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -13,6 +14,7 @@ interface InstalledScript {
|
|||||||
server_ip: string | null;
|
server_ip: string | null;
|
||||||
server_user: string | null;
|
server_user: string | null;
|
||||||
server_password: string | null;
|
server_password: string | null;
|
||||||
|
server_color: string | null;
|
||||||
installation_date: string;
|
installation_date: string;
|
||||||
status: 'in_progress' | 'success' | 'failed';
|
status: 'in_progress' | 'success' | 'failed';
|
||||||
output_log: string | null;
|
output_log: string | null;
|
||||||
@@ -50,7 +52,10 @@ export function ScriptInstallationCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow">
|
<div
|
||||||
|
className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
style={{ borderLeft: `4px solid ${script.server_color ?? 'transparent'}` }}
|
||||||
|
>
|
||||||
{/* Header with Script Name and Status */}
|
{/* Header with Script Name and Status */}
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -102,9 +107,15 @@ export function ScriptInstallationCard({
|
|||||||
{/* Server */}
|
{/* Server */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
|
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<span
|
||||||
{script.server_name ?? 'Local'}
|
className="text-sm px-3 py-1 rounded inline-block"
|
||||||
</div>
|
style={{
|
||||||
|
backgroundColor: script.server_color ?? 'transparent',
|
||||||
|
color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{script.server_name ?? '-'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Installation Date */}
|
{/* Installation Date */}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { ScriptCard } from './ScriptCard';
|
import { ScriptCard } from './ScriptCard';
|
||||||
|
import { ScriptCardList } from './ScriptCardList';
|
||||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||||
import { CategorySidebar } from './CategorySidebar';
|
import { CategorySidebar } from './CategorySidebar';
|
||||||
import { FilterBar, type FilterState } from './FilterBar';
|
import { FilterBar, type FilterState } from './FilterBar';
|
||||||
|
import { ViewToggle } from './ViewToggle';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
showUpdatable: null,
|
showUpdatable: null,
|
||||||
@@ -37,7 +40,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
{ enabled: !!selectedSlug }
|
{ enabled: !!selectedSlug }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load SAVE_FILTER setting and saved filters on component mount
|
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -60,6 +63,16 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load view mode
|
||||||
|
const viewModeResponse = await fetch('/api/settings/view-mode');
|
||||||
|
if (viewModeResponse.ok) {
|
||||||
|
const viewModeData = await viewModeResponse.json();
|
||||||
|
const viewMode = viewModeData.viewMode;
|
||||||
|
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
|
||||||
|
setViewMode(viewMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -93,6 +106,29 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
||||||
|
|
||||||
|
// Save view mode when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoadingFilters) return;
|
||||||
|
|
||||||
|
const saveViewMode = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings/view-mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ viewMode }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving view mode:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the save operation
|
||||||
|
const timeoutId = setTimeout(() => void saveViewMode(), 300);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [viewMode, isLoadingFilters]);
|
||||||
|
|
||||||
// Extract categories from metadata
|
// Extract categories from metadata
|
||||||
const categories = React.useMemo((): string[] => {
|
const categories = React.useMemo((): string[] => {
|
||||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||||
@@ -399,6 +435,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
isLoadingFilters={isLoadingFilters}
|
isLoadingFilters={isLoadingFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<ViewToggle
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||||
<div className="hidden mb-8">
|
<div className="hidden mb-8">
|
||||||
<div className="relative max-w-md mx-auto">
|
<div className="relative max-w-md mx-auto">
|
||||||
@@ -474,25 +516,47 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
viewMode === 'card' ? (
|
||||||
{filteredScripts.map((script, index) => {
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
// Add validation to ensure script has required properties
|
{filteredScripts.map((script, index) => {
|
||||||
if (!script || typeof script !== 'object') {
|
// Add validation to ensure script has required properties
|
||||||
return null;
|
if (!script || typeof script !== 'object') {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
// Create a unique key by combining slug, name, and index to handle duplicates
|
|
||||||
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
|
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||||
return (
|
|
||||||
<ScriptCard
|
return (
|
||||||
key={uniqueKey}
|
<ScriptCard
|
||||||
script={script}
|
key={uniqueKey}
|
||||||
onClick={handleCardClick}
|
script={script}
|
||||||
/>
|
onClick={handleCardClick}
|
||||||
);
|
/>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredScripts.map((script, index) => {
|
||||||
|
// Add validation to ensure script has required properties
|
||||||
|
if (!script || typeof script !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
|
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScriptCardList
|
||||||
|
key={uniqueKey}
|
||||||
|
script={script}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScriptDetailModal
|
<ScriptDetailModal
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { CreateServerData } from '../../types/server';
|
import type { CreateServerData } from '../../types/server';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { SSHKeyInput } from './SSHKeyInput';
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
onSubmit: (data: CreateServerData) => void;
|
onSubmit: (data: CreateServerData) => void;
|
||||||
@@ -18,13 +19,35 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
ip: '',
|
ip: '',
|
||||||
user: '',
|
user: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
auth_type: 'password',
|
||||||
|
ssh_key: '',
|
||||||
|
ssh_key_passphrase: '',
|
||||||
|
ssh_port: 22,
|
||||||
|
color: '#3b82f6',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Partial<CreateServerData>>({});
|
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
|
||||||
|
const [sshKeyError, setSshKeyError] = useState<string>('');
|
||||||
|
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColorCodingSetting = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/color-coding');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setColorCodingEnabled(Boolean(data.enabled));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading color coding setting:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadColorCodingSetting();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors: Partial<CreateServerData> = {};
|
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
|
||||||
|
|
||||||
if (!formData.name.trim()) {
|
if (!formData.name.trim()) {
|
||||||
newErrors.name = 'Server name is required';
|
newErrors.name = 'Server name is required';
|
||||||
@@ -44,12 +67,36 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
newErrors.user = 'Username is required';
|
newErrors.user = 'Username is required';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.password.trim()) {
|
// Validate SSH port
|
||||||
newErrors.password = 'Password is required';
|
if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) {
|
||||||
|
newErrors.ssh_port = 'SSH port must be between 1 and 65535';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate authentication based on auth_type
|
||||||
|
const authType = formData.auth_type ?? 'password';
|
||||||
|
|
||||||
|
if (authType === 'password' || authType === 'both') {
|
||||||
|
if (!formData.password?.trim()) {
|
||||||
|
newErrors.password = 'Password is required for password authentication';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === 'key' || authType === 'both') {
|
||||||
|
if (!formData.ssh_key?.trim()) {
|
||||||
|
newErrors.ssh_key = 'SSH key is required for key authentication';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if at least one authentication method is provided
|
||||||
|
if (authType === 'both') {
|
||||||
|
if (!formData.password?.trim() && !formData.ssh_key?.trim()) {
|
||||||
|
newErrors.password = 'At least one authentication method (password or SSH key) is required';
|
||||||
|
newErrors.ssh_key = 'At least one authentication method (password or SSH key) is required';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -57,13 +104,23 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
if (validateForm()) {
|
if (validateForm()) {
|
||||||
onSubmit(formData);
|
onSubmit(formData);
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
setFormData({ name: '', ip: '', user: '', password: '' });
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
ip: '',
|
||||||
|
user: '',
|
||||||
|
password: '',
|
||||||
|
auth_type: 'password',
|
||||||
|
ssh_key: '',
|
||||||
|
ssh_key_passphrase: '',
|
||||||
|
ssh_port: 22,
|
||||||
|
color: '#3b82f6'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (field: keyof CreateServerData) => (
|
const handleChange = (field: keyof CreateServerData) => (
|
||||||
e: React.ChangeEvent<HTMLInputElement>
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||||
) => {
|
) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||||
// Clear error when user starts typing
|
// Clear error when user starts typing
|
||||||
@@ -72,8 +129,15 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSSHKeyChange = (value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, ssh_key: value }));
|
||||||
|
if (errors.ssh_key) {
|
||||||
|
setErrors(prev => ({ ...prev, ssh_key: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
@@ -126,14 +190,72 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
|
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
SSH Port
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="ssh_port"
|
||||||
|
value={formData.ssh_port ?? 22}
|
||||||
|
onChange={handleChange('ssh_port')}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||||
|
errors.ssh_port ? 'border-destructive' : 'border-border'
|
||||||
|
}`}
|
||||||
|
placeholder="22"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
/>
|
||||||
|
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
Authentication Type *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="auth_type"
|
||||||
|
value={formData.auth_type ?? 'password'}
|
||||||
|
onChange={handleChange('auth_type')}
|
||||||
|
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||||
|
>
|
||||||
|
<option value="password">Password Only</option>
|
||||||
|
<option value="key">SSH Key Only</option>
|
||||||
|
<option value="both">Both Password & SSH Key</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{colorCodingEnabled && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
Server Color
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="color"
|
||||||
|
value={formData.color ?? '#3b82f6'}
|
||||||
|
onChange={handleChange('color')}
|
||||||
|
className="w-20 h-10 rounded cursor-pointer border border-border"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Choose a color to identify this server
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Authentication */}
|
||||||
|
{(formData.auth_type === 'password' || formData.auth_type === 'both') && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
Password *
|
Password {formData.auth_type === 'both' ? '(Optional)' : '*'}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
value={formData.password}
|
value={formData.password ?? ''}
|
||||||
onChange={handleChange('password')}
|
onChange={handleChange('password')}
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||||
errors.password ? 'border-destructive' : 'border-border'
|
errors.password ? 'border-destructive' : 'border-border'
|
||||||
@@ -142,7 +264,42 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
/>
|
/>
|
||||||
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
|
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* SSH Key Authentication */}
|
||||||
|
{(formData.auth_type === 'key' || formData.auth_type === 'both') && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
SSH Private Key {formData.auth_type === 'both' ? '(Optional)' : '*'}
|
||||||
|
</label>
|
||||||
|
<SSHKeyInput
|
||||||
|
value={formData.ssh_key ?? ''}
|
||||||
|
onChange={handleSSHKeyChange}
|
||||||
|
onError={setSshKeyError}
|
||||||
|
/>
|
||||||
|
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
|
||||||
|
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
SSH Key Passphrase (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="ssh_key_passphrase"
|
||||||
|
value={formData.ssh_key_passphrase ?? ''}
|
||||||
|
onChange={handleChange('ssh_key_passphrase')}
|
||||||
|
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||||
|
placeholder="Enter passphrase for encrypted key"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Only required if your SSH key is encrypted with a passphrase
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
|
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
|
||||||
{isEditing && onCancel && (
|
{isEditing && onCancel && (
|
||||||
|
|||||||
@@ -85,7 +85,11 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{servers.map((server) => (
|
{servers.map((server) => (
|
||||||
<div key={server.id} className="bg-card border border-border rounded-lg p-4 shadow-sm">
|
<div
|
||||||
|
key={server.id}
|
||||||
|
className="bg-card border border-border rounded-lg p-4 shadow-sm"
|
||||||
|
style={{ borderLeft: `4px solid ${server.color ?? 'transparent'}` }}
|
||||||
|
>
|
||||||
{editingId === server.id ? (
|
{editingId === server.id ? (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-lg font-medium text-foreground mb-4">Edit Server</h4>
|
<h4 className="text-lg font-medium text-foreground mb-4">Edit Server</h4>
|
||||||
@@ -95,6 +99,11 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
ip: server.ip,
|
ip: server.ip,
|
||||||
user: server.user,
|
user: server.user,
|
||||||
password: server.password,
|
password: server.password,
|
||||||
|
auth_type: server.auth_type,
|
||||||
|
ssh_key: server.ssh_key,
|
||||||
|
ssh_key_passphrase: server.ssh_key_passphrase,
|
||||||
|
ssh_port: server.ssh_port,
|
||||||
|
color: server.color,
|
||||||
}}
|
}}
|
||||||
onSubmit={handleUpdate}
|
onSubmit={handleUpdate}
|
||||||
isEditing={true}
|
isEditing={true}
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
throw new Error('Failed to fetch servers');
|
throw new Error('Failed to fetch servers');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setServers(data as Server[]);
|
// Sort servers by name alphabetically
|
||||||
|
const sortedServers = (data as Server[]).sort((a, b) =>
|
||||||
|
(a.name ?? '').localeCompare(b.name ?? '')
|
||||||
|
);
|
||||||
|
setServers(sortedServers);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
204
src/app/_components/SetupModal.tsx
Normal file
204
src/app/_components/SetupModal.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Toggle } from './ui/toggle';
|
||||||
|
import { Lock, User, Shield, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SetupModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [enableAuth, setEnableAuth] = useState(true);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Only validate passwords if authentication is enabled
|
||||||
|
if (enableAuth && password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: enableAuth ? username : undefined,
|
||||||
|
password: enableAuth ? password : undefined,
|
||||||
|
enabled: enableAuth
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// If authentication is enabled, automatically log in the user
|
||||||
|
if (enableAuth) {
|
||||||
|
const loginResponse = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loginResponse.ok) {
|
||||||
|
// Login successful, complete setup
|
||||||
|
onComplete();
|
||||||
|
} else {
|
||||||
|
// Setup succeeded but login failed, still complete setup
|
||||||
|
console.warn('Setup completed but auto-login failed');
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Authentication disabled, just complete setup
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json() as { error: string };
|
||||||
|
setError(errorData.error ?? 'Failed to setup authentication');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Setup error:', error);
|
||||||
|
setError('Failed to setup authentication');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield className="h-8 w-8 text-green-600" />
|
||||||
|
<h2 className="text-2xl font-bold text-card-foreground">Setup Authentication</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-muted-foreground text-center mb-6">
|
||||||
|
Set up authentication to secure your application. This will be required for future access.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-username" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="setup-username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Choose a username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required={enableAuth}
|
||||||
|
minLength={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-password" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="setup-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Choose a password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required={enableAuth}
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm-password" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required={enableAuth}
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-foreground mb-1">Enable Authentication</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{enableAuth
|
||||||
|
? 'Authentication will be required on every page load'
|
||||||
|
: 'Authentication will be optional (can be enabled later in settings)'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={enableAuth}
|
||||||
|
onCheckedChange={setEnableAuth}
|
||||||
|
disabled={isLoading}
|
||||||
|
label="Enable authentication"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-800 border border-red-200 rounded-md">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
(enableAuth && (!username.trim() || !password.trim() || !confirmPassword.trim()))
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Setting Up...' : 'Complete Setup'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,14 +27,15 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
const [mobileInput, setMobileInput] = useState('');
|
const [mobileInput, setMobileInput] = useState('');
|
||||||
const [showMobileInput, setShowMobileInput] = useState(false);
|
const [showMobileInput, setShowMobileInput] = useState(false);
|
||||||
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
|
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
|
||||||
const [inWhiptailSession, setInWhiptailSession] = useState(false);
|
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const [isStopped, setIsStopped] = useState(false);
|
const [isStopped, setIsStopped] = useState(false);
|
||||||
|
const [isTerminalReady, setIsTerminalReady] = useState(false);
|
||||||
const terminalRef = useRef<HTMLDivElement>(null);
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
const xtermRef = useRef<any>(null);
|
const xtermRef = useRef<any>(null);
|
||||||
const fitAddonRef = useRef<any>(null);
|
const fitAddonRef = useRef<any>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const [executionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
const inputHandlerRef = useRef<((data: string) => void) | null>(null);
|
||||||
|
const [executionId, setExecutionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||||
const isConnectingRef = useRef<boolean>(false);
|
const isConnectingRef = useRef<boolean>(false);
|
||||||
const hasConnectedRef = useRef<boolean>(false);
|
const hasConnectedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
@@ -53,22 +54,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
break;
|
break;
|
||||||
case 'output':
|
case 'output':
|
||||||
// Write directly to terminal - xterm.js handles ANSI codes natively
|
// Write directly to terminal - xterm.js handles ANSI codes natively
|
||||||
// Detect whiptail sessions and clear immediately
|
xtermRef.current.write(message.data);
|
||||||
if (message.data.includes('whiptail') || message.data.includes('dialog') || message.data.includes('Choose an option')) {
|
|
||||||
setInWhiptailSession(true);
|
|
||||||
// Clear terminal immediately when whiptail starts
|
|
||||||
xtermRef.current.clear();
|
|
||||||
xtermRef.current.write('\x1b[2J\x1b[H');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for screen clearing sequences and handle them properly
|
|
||||||
if (message.data.includes('\x1b[2J') || message.data.includes('\x1b[H\x1b[2J')) {
|
|
||||||
// This is a clear screen sequence, ensure it's processed correctly
|
|
||||||
xtermRef.current.write(message.data);
|
|
||||||
} else {
|
|
||||||
// Let xterm handle all ANSI sequences naturally
|
|
||||||
xtermRef.current.write(message.data);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
// Check if this looks like ANSI terminal output (contains escape codes)
|
// Check if this looks like ANSI terminal output (contains escape codes)
|
||||||
@@ -87,8 +73,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'end':
|
case 'end':
|
||||||
// Reset whiptail session
|
setIsRunning(false);
|
||||||
setInWhiptailSession(false);
|
|
||||||
|
|
||||||
// Check if this is an LXC creation script
|
// Check if this is an LXC creation script
|
||||||
const isLxcCreation = scriptPath.includes('ct/') ||
|
const isLxcCreation = scriptPath.includes('ct/') ||
|
||||||
@@ -107,10 +92,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
} else {
|
} else {
|
||||||
xtermRef.current.writeln(`${prefix}✅ ${message.data}`);
|
xtermRef.current.writeln(`${prefix}✅ ${message.data}`);
|
||||||
}
|
}
|
||||||
setIsRunning(false);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [scriptPath, containerId, scriptName, inWhiptailSession]);
|
}, [scriptPath, containerId, scriptName]);
|
||||||
|
|
||||||
// Ensure we're on the client side
|
// Ensure we're on the client side
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -198,6 +182,20 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
terminal.refresh(0, terminal.rows - 1);
|
terminal.refresh(0, terminal.rows - 1);
|
||||||
// Ensure cursor is properly positioned
|
// Ensure cursor is properly positioned
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
|
|
||||||
|
// Force focus on the terminal element
|
||||||
|
terminalElement.focus();
|
||||||
|
terminalElement.click();
|
||||||
|
|
||||||
|
// Add click handler to ensure terminal stays focused
|
||||||
|
const focusHandler = () => {
|
||||||
|
terminal.focus();
|
||||||
|
terminalElement.focus();
|
||||||
|
};
|
||||||
|
terminalElement.addEventListener('click', focusHandler);
|
||||||
|
|
||||||
|
// Store the handler for cleanup
|
||||||
|
(terminalElement as any).focusHandler = focusHandler;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Fit after a small delay to ensure proper sizing
|
// Fit after a small delay to ensure proper sizing
|
||||||
@@ -231,18 +229,10 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
// Store references
|
// Store references
|
||||||
xtermRef.current = terminal;
|
xtermRef.current = terminal;
|
||||||
fitAddonRef.current = fitAddon;
|
fitAddonRef.current = fitAddon;
|
||||||
|
|
||||||
|
// Mark terminal as ready
|
||||||
|
setIsTerminalReady(true);
|
||||||
|
|
||||||
// Handle terminal input
|
|
||||||
terminal.onData((data) => {
|
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
||||||
const message = {
|
|
||||||
action: 'input',
|
|
||||||
executionId,
|
|
||||||
input: data
|
|
||||||
};
|
|
||||||
wsRef.current.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
@@ -254,18 +244,51 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
void initTerminal();
|
void initTerminal();
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (terminalElement && (terminalElement as any).resizeHandler) {
|
if (terminalElement && (terminalElement as any).resizeHandler) {
|
||||||
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
|
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
|
||||||
}
|
}
|
||||||
if (xtermRef.current) {
|
if (terminalElement && (terminalElement as any).focusHandler) {
|
||||||
xtermRef.current.dispose();
|
terminalElement.removeEventListener('click', (terminalElement as any).focusHandler as (this: HTMLDivElement, ev: PointerEvent) => any);
|
||||||
xtermRef.current = null;
|
}
|
||||||
fitAddonRef.current = null;
|
if (xtermRef.current) {
|
||||||
|
xtermRef.current.dispose();
|
||||||
|
xtermRef.current = null;
|
||||||
|
fitAddonRef.current = null;
|
||||||
|
setIsTerminalReady(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isClient, isMobile]);
|
||||||
|
|
||||||
|
// Handle terminal input with current executionId
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTerminalReady || !xtermRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminal = xtermRef.current;
|
||||||
|
|
||||||
|
const handleData = (data: string) => {
|
||||||
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
|
const message = {
|
||||||
|
action: 'input',
|
||||||
|
executionId,
|
||||||
|
input: data
|
||||||
|
};
|
||||||
|
wsRef.current.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [executionId, isClient, inWhiptailSession, isMobile]);
|
|
||||||
|
// Store the handler reference
|
||||||
|
inputHandlerRef.current = handleData;
|
||||||
|
terminal.onData(handleData);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Clear the handler reference
|
||||||
|
inputHandlerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [executionId, isTerminalReady]); // Depend on terminal ready state
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prevent multiple connections in React Strict Mode
|
// Prevent multiple connections in React Strict Mode
|
||||||
@@ -298,10 +321,14 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
|
|
||||||
// Only auto-start on initial connection, not on reconnections
|
// Only auto-start on initial connection, not on reconnections
|
||||||
if (isInitialConnection && !isRunning) {
|
if (isInitialConnection && !isRunning) {
|
||||||
|
// Generate a new execution ID for the initial run
|
||||||
|
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
setExecutionId(newExecutionId);
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
action: 'start',
|
action: 'start',
|
||||||
scriptPath,
|
scriptPath,
|
||||||
executionId,
|
executionId: newExecutionId,
|
||||||
mode,
|
mode,
|
||||||
server,
|
server,
|
||||||
isUpdate,
|
isUpdate,
|
||||||
@@ -314,7 +341,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data as string) as TerminalMessage;
|
const message = JSON.parse(event.data as string) as TerminalMessage;
|
||||||
console.log('WebSocket message received:', message);
|
|
||||||
handleMessage(message);
|
handleMessage(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing WebSocket message:', error);
|
console.error('Error parsing WebSocket message:', error);
|
||||||
@@ -346,15 +372,19 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
wsRef.current.close();
|
wsRef.current.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile]);
|
}, [scriptPath, mode, server, isUpdate, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const startScript = () => {
|
const startScript = () => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
||||||
|
// Generate a new execution ID for each script run
|
||||||
|
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
setExecutionId(newExecutionId);
|
||||||
|
|
||||||
setIsStopped(false);
|
setIsStopped(false);
|
||||||
wsRef.current.send(JSON.stringify({
|
wsRef.current.send(JSON.stringify({
|
||||||
action: 'start',
|
action: 'start',
|
||||||
scriptPath,
|
scriptPath,
|
||||||
executionId,
|
executionId: newExecutionId,
|
||||||
mode,
|
mode,
|
||||||
server,
|
server,
|
||||||
isUpdate,
|
isUpdate,
|
||||||
|
|||||||
45
src/app/_components/ViewToggle.tsx
Normal file
45
src/app/_components/ViewToggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Grid3X3, List } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ViewToggleProps {
|
||||||
|
viewMode: 'card' | 'list';
|
||||||
|
onViewModeChange: (mode: 'card' | 'list') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="flex items-center space-x-1 bg-muted rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
onClick={() => onViewModeChange('card')}
|
||||||
|
variant={viewMode === 'card' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className={`flex items-center space-x-2 ${
|
||||||
|
viewMode === 'card'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Grid3X3 className="h-4 w-4" />
|
||||||
|
<span className="text-sm">Card View</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className={`flex items-center space-x-2 ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
<span className="text-sm">List View</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/app/api/auth/login/route.ts
Normal file
66
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { comparePassword, generateToken, getAuthConfig } from '~/lib/auth';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password } = await request.json() as { username: string; password: string };
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username and password are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
|
||||||
|
if (!authConfig.hasCredentials) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Authentication not configured' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username !== authConfig.username) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid credentials' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPassword = await comparePassword(password, authConfig.passwordHash!);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid credentials' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateToken(username);
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Login successful',
|
||||||
|
username
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set httpOnly cookie
|
||||||
|
response.cookies.set('auth-token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during login:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/app/api/auth/setup/route.ts
Normal file
94
src/app/api/auth/setup/route.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { updateAuthCredentials, getAuthConfig, setSetupCompleted } from '~/lib/auth';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password, enabled } = await request.json() as { username?: string; password?: string; enabled?: boolean };
|
||||||
|
|
||||||
|
// If authentication is disabled, we don't need any credentials
|
||||||
|
if (enabled === false) {
|
||||||
|
// Just set AUTH_ENABLED to false without storing credentials
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_ENABLED
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||||
|
if (enabledRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set setup completed flag
|
||||||
|
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=.*$/m;
|
||||||
|
if (setupCompletedRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(setupCompletedRegex, 'AUTH_SETUP_COMPLETED=true');
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_SETUP_COMPLETED=true\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any empty AUTH_USERNAME or AUTH_PASSWORD_HASH lines
|
||||||
|
envContent = envContent.replace(/^AUTH_USERNAME=\s*$/m, '');
|
||||||
|
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=\s*$/m, '');
|
||||||
|
envContent = envContent.replace(/\n\n+/g, '\n');
|
||||||
|
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Authentication disabled successfully'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If authentication is enabled, require username and password
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username is required when authentication is enabled' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < 3) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username must be at least 3 characters long' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password || password.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password must be at least 6 characters long' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if credentials already exist
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
if (authConfig.hasCredentials) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Authentication is already configured' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAuthCredentials(username, password, enabled ?? true);
|
||||||
|
setSetupCompleted();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Authentication setup completed successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during setup:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/auth/verify/route.ts
Normal file
37
src/app/api/auth/verify/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { verifyToken } from '~/lib/auth';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const token = request.cookies.get('auth-token')?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No token provided' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = verifyToken(token);
|
||||||
|
|
||||||
|
if (!decoded) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid token' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
username: decoded.username,
|
||||||
|
authenticated: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying token:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,16 +52,55 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, ip, user, password }: CreateServerData = body;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!name || !ip || !user || !password) {
|
if (!name || !ip || !user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Missing required fields' },
|
{ error: 'Missing required fields: name, ip, and user are required' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate SSH port
|
||||||
|
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH port must be between 1 and 65535' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate authentication based on auth_type
|
||||||
|
const authType = auth_type ?? 'password';
|
||||||
|
|
||||||
|
if (authType === 'password' || authType === 'both') {
|
||||||
|
if (!password?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password is required for password authentication' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === 'key' || authType === 'both') {
|
||||||
|
if (!ssh_key?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH key is required for key authentication' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if at least one authentication method is provided
|
||||||
|
if (authType === 'both') {
|
||||||
|
if (!password?.trim() && !ssh_key?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'At least one authentication method (password or SSH key) is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Check if server exists
|
// Check if server exists
|
||||||
@@ -73,7 +112,17 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.updateServer(id, { name, ip, user, password });
|
const result = db.updateServer(id, {
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
auth_type: authType,
|
||||||
|
ssh_key,
|
||||||
|
ssh_key_passphrase,
|
||||||
|
ssh_port: ssh_port ?? 22,
|
||||||
|
color
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,18 +20,67 @@ export async function GET() {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, ip, user, password }: CreateServerData = body;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!name || !ip || !user || !password) {
|
if (!name || !ip || !user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Missing required fields' },
|
{ error: 'Missing required fields: name, ip, and user are required' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate SSH port
|
||||||
|
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH port must be between 1 and 65535' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate authentication based on auth_type
|
||||||
|
const authType = auth_type ?? 'password';
|
||||||
|
|
||||||
|
if (authType === 'password' || authType === 'both') {
|
||||||
|
if (!password?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password is required for password authentication' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === 'key' || authType === 'both') {
|
||||||
|
if (!ssh_key?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH key is required for key authentication' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if at least one authentication method is provided
|
||||||
|
if (authType === 'both') {
|
||||||
|
if (!password?.trim() && !ssh_key?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'At least one authentication method (password or SSH key) is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const result = db.createServer({ name, ip, user, password });
|
const result = db.createServer({
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
auth_type: authType,
|
||||||
|
ssh_key,
|
||||||
|
ssh_key_passphrase,
|
||||||
|
ssh_port: ssh_port ?? 22,
|
||||||
|
color
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
117
src/app/api/settings/auth-credentials/route.ts
Normal file
117
src/app/api/settings/auth-credentials/route.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
username: authConfig.username,
|
||||||
|
enabled: authConfig.enabled,
|
||||||
|
hasCredentials: authConfig.hasCredentials,
|
||||||
|
setupCompleted: authConfig.setupCompleted,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading auth credentials:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to read auth configuration' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password, enabled } = await request.json() as { username: string; password: string; enabled?: boolean };
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username and password are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < 3) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username must be at least 3 characters long' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password must be at least 6 characters long' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAuthCredentials(username, password, enabled ?? false);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Authentication credentials updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating auth credentials:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update auth credentials' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { enabled } = await request.json() as { enabled: boolean };
|
||||||
|
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Enabled flag must be a boolean' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// When enabling, just update the flag
|
||||||
|
updateAuthEnabled(enabled);
|
||||||
|
} else {
|
||||||
|
// When disabling, clear all credentials and set flag to false
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove AUTH_USERNAME and AUTH_PASSWORD_HASH
|
||||||
|
envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, '');
|
||||||
|
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, '');
|
||||||
|
|
||||||
|
// Update or add AUTH_ENABLED
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||||
|
if (enabledRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty lines
|
||||||
|
envContent = envContent.replace(/\n\n+/g, '\n');
|
||||||
|
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating auth enabled status:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update auth status' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/settings/color-coding/route.ts
Normal file
75
src/app/api/settings/color-coding/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { enabled } = await request.json();
|
||||||
|
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Enabled must be a boolean value' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SERVER_COLOR_CODING_ENABLED already exists
|
||||||
|
const colorCodingRegex = /^SERVER_COLOR_CODING_ENABLED=.*$/m;
|
||||||
|
const colorCodingMatch = colorCodingRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (colorCodingMatch) {
|
||||||
|
// Replace existing SERVER_COLOR_CODING_ENABLED
|
||||||
|
envContent = envContent.replace(colorCodingRegex, `SERVER_COLOR_CODING_ENABLED=${enabled}`);
|
||||||
|
} else {
|
||||||
|
// Add new SERVER_COLOR_CODING_ENABLED
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `SERVER_COLOR_CODING_ENABLED=${enabled}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Color coding setting saved successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving color coding setting:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to save color coding setting' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return NextResponse.json({ enabled: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
|
||||||
|
// Extract SERVER_COLOR_CODING_ENABLED
|
||||||
|
const colorCodingRegex = /^SERVER_COLOR_CODING_ENABLED=(.*)$/m;
|
||||||
|
const colorCodingMatch = colorCodingRegex.exec(envContent);
|
||||||
|
const enabled = colorCodingMatch ? colorCodingMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||||
|
|
||||||
|
return NextResponse.json({ enabled });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading color coding setting:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to read color coding setting' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,7 +81,14 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filters = JSON.parse(filtersMatch[1]!);
|
const filtersJson = filtersMatch[1]?.trim();
|
||||||
|
|
||||||
|
// Check if filters JSON is empty or invalid
|
||||||
|
if (!filtersJson || filtersJson === '') {
|
||||||
|
return NextResponse.json({ filters: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = JSON.parse(filtersJson);
|
||||||
|
|
||||||
// Validate the parsed filters
|
// Validate the parsed filters
|
||||||
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
|
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
|
||||||
|
|||||||
81
src/app/api/settings/view-mode/route.ts
Normal file
81
src/app/api/settings/view-mode/route.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { viewMode } = await request.json();
|
||||||
|
|
||||||
|
if (!viewMode || !['card', 'list'].includes(viewMode as string)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'View mode must be either "card" or "list"' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if VIEW_MODE already exists
|
||||||
|
const viewModeRegex = /^VIEW_MODE=.*$/m;
|
||||||
|
const viewModeMatch = viewModeRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (viewModeMatch) {
|
||||||
|
// Replace existing VIEW_MODE
|
||||||
|
envContent = envContent.replace(viewModeRegex, `VIEW_MODE=${viewMode}`);
|
||||||
|
} else {
|
||||||
|
// Add new VIEW_MODE
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `VIEW_MODE=${viewMode}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'View mode saved successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving view mode:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to save view mode' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return NextResponse.json({ viewMode: 'card' }); // Default to card view
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read .env file and extract VIEW_MODE
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
const viewModeRegex = /^VIEW_MODE=(.*)$/m;
|
||||||
|
const viewModeMatch = viewModeRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (!viewModeMatch) {
|
||||||
|
return NextResponse.json({ viewMode: 'card' }); // Default to card view
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewMode = viewModeMatch[1]?.trim();
|
||||||
|
|
||||||
|
// Validate the view mode
|
||||||
|
if (!viewMode || !['card', 'list'].includes(viewMode)) {
|
||||||
|
return NextResponse.json({ viewMode: 'card' }); // Default to card view
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ viewMode });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading view mode:', error);
|
||||||
|
return NextResponse.json({ viewMode: 'card' }); // Default to card view
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import "~/styles/globals.css";
|
import "~/styles/globals.css";
|
||||||
|
|
||||||
import { type Metadata } from "next";
|
import { type Metadata, type Viewport } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
|
|
||||||
import { TRPCReactProvider } from "~/trpc/react";
|
import { TRPCReactProvider } from "~/trpc/react";
|
||||||
|
import { AuthProvider } from "./_components/AuthProvider";
|
||||||
|
import { AuthGuard } from "./_components/AuthGuard";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "PVE Scripts local",
|
title: "PVE Scripts local",
|
||||||
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
|
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
|
||||||
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
|
|
||||||
icons: [
|
icons: [
|
||||||
{ rel: "icon", url: "/favicon.png", type: "image/png" },
|
{ rel: "icon", url: "/favicon.png", type: "image/png" },
|
||||||
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
|
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
|
||||||
@@ -16,6 +17,12 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
const geist = Geist({
|
const geist = Geist({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-jetbrains-mono",
|
variable: "--font-jetbrains-mono",
|
||||||
@@ -40,7 +47,13 @@ export default function RootLayout({
|
|||||||
className="bg-background text-foreground transition-colors"
|
className="bg-background text-foreground transition-colors"
|
||||||
suppressHydrationWarning={true}
|
suppressHydrationWarning={true}
|
||||||
>
|
>
|
||||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
<TRPCReactProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthGuard>
|
||||||
|
{children}
|
||||||
|
</AuthGuard>
|
||||||
|
</AuthProvider>
|
||||||
|
</TRPCReactProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,12 +12,41 @@ import { SettingsButton } from './_components/SettingsButton';
|
|||||||
import { VersionDisplay } from './_components/VersionDisplay';
|
import { VersionDisplay } from './_components/VersionDisplay';
|
||||||
import { Button } from './_components/ui/button';
|
import { Button } from './_components/ui/button';
|
||||||
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
|
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||||
|
import { api } from '~/trpc/react';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
|
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
|
||||||
const terminalRef = useRef<HTMLDivElement>(null);
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Fetch data for script counts
|
||||||
|
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
|
const { data: localScriptsData } = api.scripts.getCtScripts.useQuery();
|
||||||
|
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||||
|
|
||||||
|
// Calculate script counts
|
||||||
|
const scriptCounts = {
|
||||||
|
available: scriptCardsData?.success ? scriptCardsData.cards?.length ?? 0 : 0,
|
||||||
|
downloaded: (() => {
|
||||||
|
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
|
||||||
|
|
||||||
|
// Count scripts that are both in GitHub data and have local versions
|
||||||
|
const githubScripts = scriptCardsData.cards ?? [];
|
||||||
|
const localScripts = localScriptsData.scripts ?? [];
|
||||||
|
|
||||||
|
return githubScripts.filter(script => {
|
||||||
|
if (!script?.name) return false;
|
||||||
|
return localScripts.some(local => {
|
||||||
|
if (!local?.name) return false;
|
||||||
|
const localName = local.name.replace(/\.sh$/, '');
|
||||||
|
return localName.toLowerCase() === script.name.toLowerCase() ||
|
||||||
|
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
|
||||||
|
});
|
||||||
|
}).length;
|
||||||
|
})(),
|
||||||
|
installed: installedScriptsData?.scripts?.length ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
const scrollToTerminal = () => {
|
const scrollToTerminal = () => {
|
||||||
if (terminalRef.current) {
|
if (terminalRef.current) {
|
||||||
// Get the element's position and scroll with a small offset for better mobile experience
|
// Get the element's position and scroll with a small offset for better mobile experience
|
||||||
@@ -83,6 +112,9 @@ export default function Home() {
|
|||||||
<Package className="h-4 w-4" />
|
<Package className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Available Scripts</span>
|
<span className="hidden sm:inline">Available Scripts</span>
|
||||||
<span className="sm:hidden">Available</span>
|
<span className="sm:hidden">Available</span>
|
||||||
|
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||||
|
{scriptCounts.available}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -96,6 +128,9 @@ export default function Home() {
|
|||||||
<HardDrive className="h-4 w-4" />
|
<HardDrive className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Downloaded Scripts</span>
|
<span className="hidden sm:inline">Downloaded Scripts</span>
|
||||||
<span className="sm:hidden">Downloaded</span>
|
<span className="sm:hidden">Downloaded</span>
|
||||||
|
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||||
|
{scriptCounts.downloaded}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -109,6 +144,9 @@ export default function Home() {
|
|||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Installed Scripts</span>
|
<span className="hidden sm:inline">Installed Scripts</span>
|
||||||
<span className="sm:hidden">Installed</span>
|
<span className="sm:hidden">Installed</span>
|
||||||
|
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||||
|
{scriptCounts.installed}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
16
src/env.js
16
src/env.js
@@ -25,6 +25,14 @@ export const env = createEnv({
|
|||||||
WEBSOCKET_PORT: z.string().default("3001"),
|
WEBSOCKET_PORT: z.string().default("3001"),
|
||||||
// GitHub Configuration
|
// GitHub Configuration
|
||||||
GITHUB_TOKEN: z.string().optional(),
|
GITHUB_TOKEN: z.string().optional(),
|
||||||
|
// Authentication Configuration
|
||||||
|
AUTH_USERNAME: z.string().optional(),
|
||||||
|
AUTH_PASSWORD_HASH: z.string().optional(),
|
||||||
|
AUTH_ENABLED: z.string().optional(),
|
||||||
|
AUTH_SETUP_COMPLETED: z.string().optional(),
|
||||||
|
JWT_SECRET: z.string().optional(),
|
||||||
|
// Server Color Coding Configuration
|
||||||
|
SERVER_COLOR_CODING_ENABLED: z.string().optional(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +64,14 @@ export const env = createEnv({
|
|||||||
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
|
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
|
||||||
// GitHub Configuration
|
// GitHub Configuration
|
||||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||||
|
// Authentication Configuration
|
||||||
|
AUTH_USERNAME: process.env.AUTH_USERNAME,
|
||||||
|
AUTH_PASSWORD_HASH: process.env.AUTH_PASSWORD_HASH,
|
||||||
|
AUTH_ENABLED: process.env.AUTH_ENABLED,
|
||||||
|
AUTH_SETUP_COMPLETED: process.env.AUTH_SETUP_COMPLETED,
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
// Server Color Coding Configuration
|
||||||
|
SERVER_COLOR_CODING_ENABLED: process.env.SERVER_COLOR_CODING_ENABLED,
|
||||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|||||||
240
src/lib/auth.ts
Normal file
240
src/lib/auth.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 10;
|
||||||
|
const JWT_EXPIRY = '7d'; // 7 days
|
||||||
|
|
||||||
|
// Cache for JWT secret to avoid multiple file reads
|
||||||
|
let jwtSecretCache: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or generate JWT secret
|
||||||
|
*/
|
||||||
|
export function getJwtSecret(): string {
|
||||||
|
// Return cached secret if available
|
||||||
|
if (jwtSecretCache) {
|
||||||
|
return jwtSecretCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if JWT_SECRET already exists
|
||||||
|
const jwtSecretRegex = /^JWT_SECRET=(.*)$/m;
|
||||||
|
const jwtSecretMatch = jwtSecretRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (jwtSecretMatch?.[1]?.trim()) {
|
||||||
|
jwtSecretCache = jwtSecretMatch[1].trim();
|
||||||
|
return jwtSecretCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new secret
|
||||||
|
const newSecret = randomBytes(64).toString('hex');
|
||||||
|
|
||||||
|
// Add to .env file
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `JWT_SECRET=${newSecret}\n`;
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
// Cache the new secret
|
||||||
|
jwtSecretCache = newSecret;
|
||||||
|
|
||||||
|
return newSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a password using bcrypt
|
||||||
|
*/
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare a password with a hash
|
||||||
|
*/
|
||||||
|
export async function comparePassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a JWT token
|
||||||
|
*/
|
||||||
|
export function generateToken(username: string): string {
|
||||||
|
const secret = getJwtSecret();
|
||||||
|
return jwt.sign({ username }, secret, { expiresIn: JWT_EXPIRY });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a JWT token
|
||||||
|
*/
|
||||||
|
export function verifyToken(token: string): { username: string } | null {
|
||||||
|
try {
|
||||||
|
const secret = getJwtSecret();
|
||||||
|
const decoded = jwt.verify(token, secret) as { username: string };
|
||||||
|
return decoded;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read auth configuration from .env
|
||||||
|
*/
|
||||||
|
export function getAuthConfig(): {
|
||||||
|
username: string | null;
|
||||||
|
passwordHash: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
hasCredentials: boolean;
|
||||||
|
setupCompleted: boolean;
|
||||||
|
} {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return {
|
||||||
|
username: null,
|
||||||
|
passwordHash: null,
|
||||||
|
enabled: false,
|
||||||
|
hasCredentials: false,
|
||||||
|
setupCompleted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
|
||||||
|
// Extract AUTH_USERNAME
|
||||||
|
const usernameRegex = /^AUTH_USERNAME=(.*)$/m;
|
||||||
|
const usernameMatch = usernameRegex.exec(envContent);
|
||||||
|
const username = usernameMatch ? usernameMatch[1]?.trim() : null;
|
||||||
|
|
||||||
|
// Extract AUTH_PASSWORD_HASH
|
||||||
|
const passwordHashRegex = /^AUTH_PASSWORD_HASH=(.*)$/m;
|
||||||
|
const passwordHashMatch = passwordHashRegex.exec(envContent);
|
||||||
|
const passwordHash = passwordHashMatch ? passwordHashMatch[1]?.trim() : null;
|
||||||
|
|
||||||
|
// Extract AUTH_ENABLED
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=(.*)$/m;
|
||||||
|
const enabledMatch = enabledRegex.exec(envContent);
|
||||||
|
const enabled = enabledMatch ? enabledMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||||
|
|
||||||
|
// Extract AUTH_SETUP_COMPLETED
|
||||||
|
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=(.*)$/m;
|
||||||
|
const setupCompletedMatch = setupCompletedRegex.exec(envContent);
|
||||||
|
const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||||
|
|
||||||
|
const hasCredentials = !!(username && passwordHash);
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: username ?? null,
|
||||||
|
passwordHash: passwordHash ?? null,
|
||||||
|
enabled,
|
||||||
|
hasCredentials,
|
||||||
|
setupCompleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update auth credentials in .env
|
||||||
|
*/
|
||||||
|
export async function updateAuthCredentials(
|
||||||
|
username: string,
|
||||||
|
password?: string,
|
||||||
|
enabled?: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the password if provided
|
||||||
|
const passwordHash = password ? await hashPassword(password) : null;
|
||||||
|
|
||||||
|
// Update or add AUTH_USERNAME
|
||||||
|
const usernameRegex = /^AUTH_USERNAME=.*$/m;
|
||||||
|
if (usernameRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(usernameRegex, `AUTH_USERNAME=${username}`);
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_USERNAME=${username}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_PASSWORD_HASH only if password is provided
|
||||||
|
if (passwordHash) {
|
||||||
|
const passwordHashRegex = /^AUTH_PASSWORD_HASH=.*$/m;
|
||||||
|
if (passwordHashRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(passwordHashRegex, `AUTH_PASSWORD_HASH=${passwordHash}`);
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_PASSWORD_HASH=${passwordHash}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_ENABLED if provided
|
||||||
|
if (enabled !== undefined) {
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||||
|
if (enabledRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`);
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set AUTH_SETUP_COMPLETED flag in .env
|
||||||
|
*/
|
||||||
|
export function setSetupCompleted(): void {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_SETUP_COMPLETED
|
||||||
|
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=.*$/m;
|
||||||
|
if (setupCompletedRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(setupCompletedRegex, 'AUTH_SETUP_COMPLETED=true');
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_SETUP_COMPLETED=true\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update AUTH_ENABLED flag in .env
|
||||||
|
*/
|
||||||
|
export function updateAuthEnabled(enabled: boolean): void {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_ENABLED
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||||
|
if (enabledRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`);
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
}
|
||||||
|
|
||||||
35
src/lib/colorUtils.ts
Normal file
35
src/lib/colorUtils.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Calculate the appropriate text color (black or white) for a given background color
|
||||||
|
* to ensure optimal readability based on luminance
|
||||||
|
*/
|
||||||
|
export function getContrastColor(hexColor: string): 'black' | 'white' {
|
||||||
|
if (!hexColor || hexColor.length !== 7 || !hexColor.startsWith('#')) {
|
||||||
|
return 'black'; // Default to black for invalid colors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the # and convert to RGB
|
||||||
|
const r = parseInt(hexColor.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hexColor.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hexColor.slice(5, 7), 16);
|
||||||
|
|
||||||
|
// Calculate relative luminance using the standard formula
|
||||||
|
// https://www.w3.org/WAI/GL/wiki/Relative_luminance
|
||||||
|
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||||
|
|
||||||
|
// Return black for light backgrounds, white for dark backgrounds
|
||||||
|
return luminance > 0.5 ? 'black' : 'white';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a color string is a valid hex color
|
||||||
|
*/
|
||||||
|
export function isValidHexColor(color: string): boolean {
|
||||||
|
return /^#[0-9A-F]{6}$/i.test(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a default color for servers that don't have one set
|
||||||
|
*/
|
||||||
|
export function getDefaultServerColor(): string {
|
||||||
|
return '#3b82f6'; // Blue-500 from Tailwind
|
||||||
|
}
|
||||||
@@ -268,7 +268,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
server as any,
|
server as any,
|
||||||
command,
|
command,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
console.log('Command output chunk:', data);
|
|
||||||
commandOutput += data;
|
commandOutput += data;
|
||||||
},
|
},
|
||||||
(error: string) => {
|
(error: string) => {
|
||||||
@@ -276,7 +275,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
(exitCode: number) => {
|
(exitCode: number) => {
|
||||||
console.log('Command exit code:', exitCode);
|
console.log('Command exit code:', exitCode);
|
||||||
console.log('Full command output:', commandOutput);
|
|
||||||
|
|
||||||
// Parse the complete output to get config file paths that contain community-script tag
|
// Parse the complete output to get config file paths that contain community-script tag
|
||||||
const configFiles = commandOutput.split('\n')
|
const configFiles = commandOutput.split('\n')
|
||||||
@@ -306,8 +304,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
server as any,
|
server as any,
|
||||||
readCommand,
|
readCommand,
|
||||||
(configData: string) => {
|
(configData: string) => {
|
||||||
console.log('Config data for', containerId, ':', configData.substring(0, 300) + '...');
|
|
||||||
|
|
||||||
// Parse config file for hostname
|
// Parse config file for hostname
|
||||||
const lines = configData.split('\n');
|
const lines = configData.split('\n');
|
||||||
let hostname = '';
|
let hostname = '';
|
||||||
@@ -316,7 +312,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
if (trimmedLine.startsWith('hostname:')) {
|
if (trimmedLine.startsWith('hostname:')) {
|
||||||
hostname = trimmedLine.substring(9).trim();
|
hostname = trimmedLine.substring(9).trim();
|
||||||
console.log('Found hostname for', containerId, ':', hostname);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,7 +363,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Get existing scripts to check for duplicates
|
// Get existing scripts to check for duplicates
|
||||||
const existingScripts = db.getAllInstalledScripts();
|
const existingScripts = db.getAllInstalledScripts();
|
||||||
console.log('Existing scripts in database:', existingScripts.length);
|
|
||||||
|
|
||||||
// Create installed script records for detected containers (skip duplicates)
|
// Create installed script records for detected containers (skip duplicates)
|
||||||
const createdScripts = [];
|
const createdScripts = [];
|
||||||
@@ -504,7 +498,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
server as any,
|
server as any,
|
||||||
checkCommand,
|
checkCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
console.log(`Container check result for ${scriptData.script_name}:`, data.trim());
|
|
||||||
resolve(data.trim() === 'exists');
|
resolve(data.trim() === 'exists');
|
||||||
},
|
},
|
||||||
(error: string) => {
|
(error: string) => {
|
||||||
|
|||||||
@@ -163,12 +163,22 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
const script = scripts.find(s => s.slug === card.slug);
|
const script = scripts.find(s => s.slug === card.slug);
|
||||||
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
|
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
|
||||||
|
|
||||||
|
// Extract OS and version from first install method
|
||||||
|
const firstInstallMethod = script?.install_methods?.[0];
|
||||||
|
const os = firstInstallMethod?.resources?.os;
|
||||||
|
const version = firstInstallMethod?.resources?.version;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...card,
|
...card,
|
||||||
categories: script?.categories ?? [],
|
categories: script?.categories ?? [],
|
||||||
categoryNames: categoryNames,
|
categoryNames: categoryNames,
|
||||||
// Add date_created from script
|
// Add date_created from script
|
||||||
date_created: script?.date_created,
|
date_created: script?.date_created,
|
||||||
|
// Add OS and version from install methods
|
||||||
|
os: os,
|
||||||
|
version: version,
|
||||||
|
// Add interface port
|
||||||
|
interface_port: script?.interface_port,
|
||||||
} as ScriptCard;
|
} as ScriptCard;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,68 @@ class DatabaseService {
|
|||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
ip TEXT NOT NULL,
|
ip TEXT NOT NULL,
|
||||||
user TEXT NOT NULL,
|
user TEXT NOT NULL,
|
||||||
password TEXT NOT NULL,
|
password TEXT,
|
||||||
|
auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both')),
|
||||||
|
ssh_key TEXT,
|
||||||
|
ssh_key_passphrase TEXT,
|
||||||
|
ssh_port INTEGER DEFAULT 22,
|
||||||
|
color TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migration: Add new columns to existing servers table
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both'))
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN ssh_key TEXT
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN ssh_key_passphrase TEXT
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN ssh_port INTEGER DEFAULT 22
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN color TEXT
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing servers to have auth_type='password' if not set
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Update existing servers to have ssh_port=22 if not set
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
// Create installed_scripts table if it doesn't exist
|
// Create installed_scripts table if it doesn't exist
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS installed_scripts (
|
CREATE TABLE IF NOT EXISTS installed_scripts (
|
||||||
@@ -53,12 +109,12 @@ class DatabaseService {
|
|||||||
* @param {import('../types/server').CreateServerData} serverData
|
* @param {import('../types/server').CreateServerData} serverData
|
||||||
*/
|
*/
|
||||||
createServer(serverData) {
|
createServer(serverData) {
|
||||||
const { name, ip, user, password } = serverData;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData;
|
||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
INSERT INTO servers (name, ip, user, password)
|
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
return stmt.run(name, ip, user, password);
|
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllServers() {
|
getAllServers() {
|
||||||
@@ -79,13 +135,13 @@ class DatabaseService {
|
|||||||
* @param {import('../types/server').CreateServerData} serverData
|
* @param {import('../types/server').CreateServerData} serverData
|
||||||
*/
|
*/
|
||||||
updateServer(id, serverData) {
|
updateServer(id, serverData) {
|
||||||
const { name, ip, user, password } = serverData;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData;
|
||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
UPDATE servers
|
UPDATE servers
|
||||||
SET name = ?, ip = ?, user = ?, password = ?
|
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, color = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
return stmt.run(name, ip, user, password, id);
|
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,7 +179,8 @@ class DatabaseService {
|
|||||||
s.name as server_name,
|
s.name as server_name,
|
||||||
s.ip as server_ip,
|
s.ip as server_ip,
|
||||||
s.user as server_user,
|
s.user as server_user,
|
||||||
s.password as server_password
|
s.password as server_password,
|
||||||
|
s.color as server_color
|
||||||
FROM installed_scripts inst
|
FROM installed_scripts inst
|
||||||
LEFT JOIN servers s ON inst.server_id = s.id
|
LEFT JOIN servers s ON inst.server_id = s.id
|
||||||
ORDER BY inst.installation_date DESC
|
ORDER BY inst.installation_date DESC
|
||||||
|
|||||||
@@ -1,16 +1,131 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { spawn as ptySpawn } from 'node-pty';
|
import { spawn as ptySpawn } from 'node-pty';
|
||||||
|
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Server
|
* @typedef {Object} Server
|
||||||
* @property {string} ip - Server IP address
|
* @property {string} ip - Server IP address
|
||||||
* @property {string} user - Username
|
* @property {string} user - Username
|
||||||
* @property {string} password - Password
|
* @property {string} [password] - Password (optional)
|
||||||
* @property {string} name - Server name
|
* @property {string} name - Server name
|
||||||
|
* @property {string} [auth_type] - Authentication type ('password', 'key', 'both')
|
||||||
|
* @property {string} [ssh_key] - SSH private key content
|
||||||
|
* @property {string} [ssh_key_passphrase] - SSH key passphrase
|
||||||
|
* @property {number} [ssh_port] - SSH port (default: 22)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class SSHExecutionService {
|
class SSHExecutionService {
|
||||||
|
/**
|
||||||
|
* Create a temporary SSH key file for authentication
|
||||||
|
* @param {Server} server - Server configuration
|
||||||
|
* @returns {string} Path to temporary key file
|
||||||
|
*/
|
||||||
|
createTempKeyFile(server) {
|
||||||
|
const { ssh_key } = server;
|
||||||
|
if (!ssh_key) {
|
||||||
|
throw new Error('SSH key not provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
|
||||||
|
const tempKeyPath = join(tempDir, 'private_key');
|
||||||
|
|
||||||
|
writeFileSync(tempKeyPath, ssh_key);
|
||||||
|
chmodSync(tempKeyPath, 0o600); // Set proper permissions
|
||||||
|
|
||||||
|
return tempKeyPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SSH command arguments based on authentication type
|
||||||
|
* @param {Server} server - Server configuration
|
||||||
|
* @param {string|null} [tempKeyPath=null] - Path to temporary key file (if using key auth)
|
||||||
|
* @returns {{command: string, args: string[]}} Command and arguments for SSH
|
||||||
|
*/
|
||||||
|
buildSSHCommand(server, tempKeyPath = null) {
|
||||||
|
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_port = 22 } = server;
|
||||||
|
|
||||||
|
const baseArgs = [
|
||||||
|
'-t',
|
||||||
|
'-p', ssh_port.toString(),
|
||||||
|
'-o', 'ConnectTimeout=10',
|
||||||
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
|
'-o', 'LogLevel=ERROR',
|
||||||
|
'-o', 'RequestTTY=yes',
|
||||||
|
'-o', 'SetEnv=TERM=xterm-256color',
|
||||||
|
'-o', 'SetEnv=COLUMNS=120',
|
||||||
|
'-o', 'SetEnv=LINES=30',
|
||||||
|
'-o', 'SetEnv=COLORTERM=truecolor',
|
||||||
|
'-o', 'SetEnv=FORCE_COLOR=1',
|
||||||
|
'-o', 'SetEnv=NO_COLOR=0',
|
||||||
|
'-o', 'SetEnv=CLICOLOR=1',
|
||||||
|
'-o', 'SetEnv=CLICOLOR_FORCE=1'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (auth_type === 'key') {
|
||||||
|
// SSH key authentication
|
||||||
|
if (tempKeyPath) {
|
||||||
|
baseArgs.push('-i', tempKeyPath);
|
||||||
|
baseArgs.push('-o', 'PasswordAuthentication=no');
|
||||||
|
baseArgs.push('-o', 'PubkeyAuthentication=yes');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ssh_key_passphrase) {
|
||||||
|
return {
|
||||||
|
command: 'sshpass',
|
||||||
|
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
command: 'ssh',
|
||||||
|
args: [...baseArgs, `${user}@${ip}`]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (auth_type === 'both') {
|
||||||
|
// Try SSH key first, then password
|
||||||
|
if (tempKeyPath) {
|
||||||
|
baseArgs.push('-i', tempKeyPath);
|
||||||
|
baseArgs.push('-o', 'PasswordAuthentication=yes');
|
||||||
|
baseArgs.push('-o', 'PubkeyAuthentication=yes');
|
||||||
|
|
||||||
|
if (ssh_key_passphrase) {
|
||||||
|
return {
|
||||||
|
command: 'sshpass',
|
||||||
|
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
command: 'ssh',
|
||||||
|
args: [...baseArgs, `${user}@${ip}`]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to password
|
||||||
|
if (password) {
|
||||||
|
return {
|
||||||
|
command: 'sshpass',
|
||||||
|
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error('Password is required for password authentication');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Password authentication (default)
|
||||||
|
if (password) {
|
||||||
|
return {
|
||||||
|
command: 'sshpass',
|
||||||
|
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error('Password is required for password authentication');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a script on a remote server via SSH
|
* Execute a script on a remote server via SSH
|
||||||
* @param {Server} server - Server configuration
|
* @param {Server} server - Server configuration
|
||||||
@@ -21,7 +136,8 @@ class SSHExecutionService {
|
|||||||
* @returns {Promise<Object>} Process information
|
* @returns {Promise<Object>} Process information
|
||||||
*/
|
*/
|
||||||
async executeScript(server, scriptPath, onData, onError, onExit) {
|
async executeScript(server, scriptPath, onData, onError, onExit) {
|
||||||
const { ip, user, password } = server;
|
/** @type {string|null} */
|
||||||
|
let tempKeyPath = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.transferScriptsFolder(server, onData, onError);
|
await this.transferScriptsFolder(server, onData, onError);
|
||||||
@@ -29,46 +145,37 @@ class SSHExecutionService {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
|
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
|
||||||
|
|
||||||
// Use ptySpawn for proper terminal emulation and color support
|
try {
|
||||||
const sshCommand = ptySpawn('sshpass', [
|
// Create temporary key file if using key authentication
|
||||||
'-p', password,
|
if (server.auth_type === 'key' || server.auth_type === 'both') {
|
||||||
'ssh',
|
tempKeyPath = this.createTempKeyFile(server);
|
||||||
'-t',
|
|
||||||
'-o', 'ConnectTimeout=10',
|
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
|
||||||
'-o', 'LogLevel=ERROR',
|
|
||||||
'-o', 'PasswordAuthentication=yes',
|
|
||||||
'-o', 'PubkeyAuthentication=no',
|
|
||||||
'-o', 'RequestTTY=yes',
|
|
||||||
'-o', 'SetEnv=TERM=xterm-256color',
|
|
||||||
'-o', 'SetEnv=COLUMNS=120',
|
|
||||||
'-o', 'SetEnv=LINES=30',
|
|
||||||
'-o', 'SetEnv=COLORTERM=truecolor',
|
|
||||||
'-o', 'SetEnv=FORCE_COLOR=1',
|
|
||||||
'-o', 'SetEnv=NO_COLOR=0',
|
|
||||||
'-o', 'SetEnv=CLICOLOR=1',
|
|
||||||
'-o', 'SetEnv=CLICOLOR_FORCE=1',
|
|
||||||
`${user}@${ip}`,
|
|
||||||
`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`
|
|
||||||
], {
|
|
||||||
name: 'xterm-256color',
|
|
||||||
cols: 120,
|
|
||||||
rows: 30,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TERM: 'xterm-256color',
|
|
||||||
COLUMNS: '120',
|
|
||||||
LINES: '30',
|
|
||||||
SHELL: '/bin/bash',
|
|
||||||
COLORTERM: 'truecolor',
|
|
||||||
FORCE_COLOR: '1',
|
|
||||||
NO_COLOR: '0',
|
|
||||||
CLICOLOR: '1',
|
|
||||||
CLICOLOR_FORCE: '1'
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// Build SSH command based on authentication type
|
||||||
|
const { command, args } = this.buildSSHCommand(server, tempKeyPath);
|
||||||
|
|
||||||
|
// Add the script execution command to the args
|
||||||
|
args.push(`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`);
|
||||||
|
|
||||||
|
// Use ptySpawn for proper terminal emulation and color support
|
||||||
|
const sshCommand = ptySpawn(command, args, {
|
||||||
|
name: 'xterm-256color',
|
||||||
|
cols: 120,
|
||||||
|
rows: 30,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
COLUMNS: '120',
|
||||||
|
LINES: '30',
|
||||||
|
SHELL: '/bin/bash',
|
||||||
|
COLORTERM: 'truecolor',
|
||||||
|
FORCE_COLOR: '1',
|
||||||
|
NO_COLOR: '0',
|
||||||
|
CLICOLOR: '1',
|
||||||
|
CLICOLOR_FORCE: '1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Use pty's onData method which handles both stdout and stderr combined
|
// Use pty's onData method which handles both stdout and stderr combined
|
||||||
sshCommand.onData((data) => {
|
sshCommand.onData((data) => {
|
||||||
@@ -82,8 +189,34 @@ class SSHExecutionService {
|
|||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
process: sshCommand,
|
process: sshCommand,
|
||||||
kill: () => sshCommand.kill('SIGTERM')
|
kill: () => {
|
||||||
|
sshCommand.kill('SIGTERM');
|
||||||
|
// Clean up temporary key file
|
||||||
|
if (tempKeyPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempKeyPath);
|
||||||
|
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||||
|
rmdirSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up temporary key file on error
|
||||||
|
if (tempKeyPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempKeyPath);
|
||||||
|
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||||
|
rmdirSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
@@ -100,20 +233,49 @@ class SSHExecutionService {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async transferScriptsFolder(server, onData, onError) {
|
async transferScriptsFolder(server, onData, onError) {
|
||||||
const { ip, user, password } = server;
|
const { ip, user, password, auth_type = 'password', ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
|
||||||
|
/** @type {string|null} */
|
||||||
|
let tempKeyPath = null;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const rsyncCommand = spawn('rsync', [
|
try {
|
||||||
'-avz',
|
// Create temporary key file if using key authentication
|
||||||
'--delete',
|
if (auth_type === 'key' || auth_type === 'both') {
|
||||||
'--exclude=*.log',
|
if (ssh_key) {
|
||||||
'--exclude=*.tmp',
|
tempKeyPath = this.createTempKeyFile(server);
|
||||||
'--rsh=sshpass -p ' + password + ' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null',
|
}
|
||||||
'scripts/',
|
}
|
||||||
`${user}@${ip}:/tmp/scripts/`
|
|
||||||
], {
|
// Build rsync command based on authentication type
|
||||||
stdio: ['pipe', 'pipe', 'pipe']
|
let rshCommand;
|
||||||
});
|
if (auth_type === 'key' && tempKeyPath) {
|
||||||
|
if (ssh_key_passphrase) {
|
||||||
|
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
|
} else {
|
||||||
|
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
|
}
|
||||||
|
} else if (auth_type === 'both' && tempKeyPath) {
|
||||||
|
if (ssh_key_passphrase) {
|
||||||
|
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
|
} else {
|
||||||
|
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to password authentication
|
||||||
|
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsyncCommand = spawn('rsync', [
|
||||||
|
'-avz',
|
||||||
|
'--delete',
|
||||||
|
'--exclude=*.log',
|
||||||
|
'--exclude=*.tmp',
|
||||||
|
`--rsh=${rshCommand}`,
|
||||||
|
'scripts/',
|
||||||
|
`${user}@${ip}:/tmp/scripts/`
|
||||||
|
], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
|
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
|
||||||
// Ensure proper UTF-8 encoding for ANSI colors
|
// Ensure proper UTF-8 encoding for ANSI colors
|
||||||
@@ -128,6 +290,17 @@ class SSHExecutionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rsyncCommand.on('close', (code) => {
|
rsyncCommand.on('close', (code) => {
|
||||||
|
// Clean up temporary key file
|
||||||
|
if (tempKeyPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempKeyPath);
|
||||||
|
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||||
|
unlinkSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
@@ -136,8 +309,32 @@ class SSHExecutionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rsyncCommand.on('error', (error) => {
|
rsyncCommand.on('error', (error) => {
|
||||||
|
// Clean up temporary key file on error
|
||||||
|
if (tempKeyPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempKeyPath);
|
||||||
|
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||||
|
unlinkSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up temporary key file on error
|
||||||
|
if (tempKeyPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempKeyPath);
|
||||||
|
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||||
|
unlinkSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,47 +348,79 @@ class SSHExecutionService {
|
|||||||
* @returns {Promise<Object>} Process information
|
* @returns {Promise<Object>} Process information
|
||||||
*/
|
*/
|
||||||
async executeCommand(server, command, onData, onError, onExit) {
|
async executeCommand(server, command, onData, onError, onExit) {
|
||||||
const { ip, user, password } = server;
|
/** @type {string|null} */
|
||||||
|
let tempKeyPath = null;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Use ptySpawn for proper terminal emulation and color support
|
try {
|
||||||
const sshCommand = ptySpawn('sshpass', [
|
// Create temporary key file if using key authentication
|
||||||
'-p', password,
|
if (server.auth_type === 'key' || server.auth_type === 'both') {
|
||||||
'ssh',
|
tempKeyPath = this.createTempKeyFile(server);
|
||||||
'-t',
|
}
|
||||||
'-o', 'ConnectTimeout=10',
|
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
// Build SSH command based on authentication type
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
const { command: sshCommandName, args } = this.buildSSHCommand(server, tempKeyPath);
|
||||||
'-o', 'LogLevel=ERROR',
|
|
||||||
'-o', 'PasswordAuthentication=yes',
|
// Add the command to execute to the args
|
||||||
'-o', 'PubkeyAuthentication=no',
|
args.push(command);
|
||||||
'-o', 'RequestTTY=yes',
|
|
||||||
'-o', 'SetEnv=TERM=xterm-256color',
|
// Use ptySpawn for proper terminal emulation and color support
|
||||||
'-o', 'SetEnv=COLUMNS=120',
|
const sshCommand = ptySpawn(sshCommandName, args, {
|
||||||
'-o', 'SetEnv=LINES=30',
|
name: 'xterm-color',
|
||||||
'-o', 'SetEnv=COLORTERM=truecolor',
|
cols: 120,
|
||||||
'-o', 'SetEnv=FORCE_COLOR=1',
|
rows: 30,
|
||||||
'-o', 'SetEnv=NO_COLOR=0',
|
cwd: process.cwd(),
|
||||||
'-o', 'SetEnv=CLICOLOR=1',
|
env: process.env
|
||||||
`${user}@${ip}`,
|
});
|
||||||
command
|
|
||||||
], {
|
|
||||||
name: 'xterm-color',
|
|
||||||
cols: 120,
|
|
||||||
rows: 30,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
env: process.env
|
|
||||||
});
|
|
||||||
|
|
||||||
sshCommand.onData((data) => {
|
sshCommand.onData((data) => {
|
||||||
onData(data);
|
onData(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
sshCommand.onExit((e) => {
|
sshCommand.onExit((e) => {
|
||||||
|
// Clean up temporary key file
|
||||||
|
if (tempKeyPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempKeyPath);
|
||||||
|
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||||
|
unlinkSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
onExit(e.exitCode);
|
onExit(e.exitCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
resolve({ process: sshCommand });
|
resolve({
|
||||||
|
process: sshCommand,
|
||||||
|
kill: () => {
|
||||||
|
sshCommand.kill('SIGTERM');
|
||||||
|
// Clean up temporary key file
|
||||||
|
if (tempKeyPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempKeyPath);
|
||||||
|
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||||
|
rmdirSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up temporary key file on error
|
||||||
|
if (tempKeyPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempKeyPath);
|
||||||
|
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||||
|
unlinkSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { writeFileSync, unlinkSync, chmodSync } from 'fs';
|
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
class SSHService {
|
class SSHService {
|
||||||
/**
|
/**
|
||||||
@@ -10,38 +11,42 @@ class SSHService {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testConnection(server) {
|
async testConnection(server) {
|
||||||
const { ip, user, password } = server;
|
const { auth_type = 'password' } = server;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const timeout = 15000; // 15 seconds timeout for login test
|
const timeout = 15000; // 15 seconds timeout for login test
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
|
||||||
// Try sshpass first if available
|
// Choose authentication method based on auth_type
|
||||||
this.testWithSshpass(server).then(result => {
|
let authPromise;
|
||||||
|
if (auth_type === 'key') {
|
||||||
|
authPromise = this.testWithSSHKey(server);
|
||||||
|
} else if (auth_type === 'both') {
|
||||||
|
// Try SSH key first, then password
|
||||||
|
authPromise = this.testWithSSHKey(server).catch(() => this.testWithSshpass(server));
|
||||||
|
} else {
|
||||||
|
// Default to password authentication
|
||||||
|
authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
authPromise.then(result => {
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
resolve(result);
|
resolve(result);
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// If sshpass fails, try expect
|
// If primary method fails, return error
|
||||||
this.testWithExpect(server).then(result => {
|
if (!resolved) {
|
||||||
if (!resolved) {
|
resolved = true;
|
||||||
resolved = true;
|
resolve({
|
||||||
resolve(result);
|
success: false,
|
||||||
}
|
message: `SSH login test failed for ${auth_type} authentication`,
|
||||||
}).catch(() => {
|
details: {
|
||||||
// If both fail, return error
|
method: 'auth_failed',
|
||||||
if (!resolved) {
|
auth_type: auth_type
|
||||||
resolved = true;
|
}
|
||||||
resolve({
|
});
|
||||||
success: false,
|
}
|
||||||
message: 'SSH login test requires sshpass or expect - neither available or working',
|
|
||||||
details: {
|
|
||||||
method: 'no_auth_tools'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up overall timeout
|
// Set up overall timeout
|
||||||
@@ -64,7 +69,11 @@ class SSHService {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testWithSshpass(server) {
|
async testWithSshpass(server) {
|
||||||
const { ip, user, password } = server;
|
const { ip, user, password, ssh_port = 22 } = server;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
throw new Error('Password is required for password authentication');
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = 10000;
|
const timeout = 10000;
|
||||||
@@ -73,6 +82,7 @@ class SSHService {
|
|||||||
const sshCommand = spawn('sshpass', [
|
const sshCommand = spawn('sshpass', [
|
||||||
'-p', password,
|
'-p', password,
|
||||||
'ssh',
|
'ssh',
|
||||||
|
'-p', ssh_port.toString(),
|
||||||
'-o', 'ConnectTimeout=10',
|
'-o', 'ConnectTimeout=10',
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
@@ -156,7 +166,7 @@ class SSHService {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testWithExpect(server) {
|
async testWithExpect(server) {
|
||||||
const { ip, user, password } = server;
|
const { ip, user, password, ssh_port = 22 } = server;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = 10000;
|
const timeout = 10000;
|
||||||
@@ -164,7 +174,7 @@ class SSHService {
|
|||||||
|
|
||||||
const expectScript = `#!/usr/bin/expect -f
|
const expectScript = `#!/usr/bin/expect -f
|
||||||
set timeout 10
|
set timeout 10
|
||||||
spawn ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
|
spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
|
||||||
expect {
|
expect {
|
||||||
"password:" {
|
"password:" {
|
||||||
send "${password}\r"
|
send "${password}\r"
|
||||||
@@ -428,13 +438,14 @@ expect {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testSSHConnection(server) {
|
async testSSHConnection(server) {
|
||||||
const { ip, user } = server;
|
const { ip, user, ssh_port = 22 } = server;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const timeout = 5000;
|
const timeout = 5000;
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
|
||||||
const sshCommand = spawn('ssh', [
|
const sshCommand = spawn('ssh', [
|
||||||
|
'-p', ssh_port.toString(),
|
||||||
'-o', 'ConnectTimeout=5',
|
'-o', 'ConnectTimeout=5',
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
@@ -523,6 +534,148 @@ expect {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SSH connection using SSH key authentication
|
||||||
|
* @param {import('../types/server').Server} server - Server configuration
|
||||||
|
* @returns {Promise<Object>} Connection test result
|
||||||
|
*/
|
||||||
|
async testWithSSHKey(server) {
|
||||||
|
const { ip, user, ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
|
||||||
|
|
||||||
|
if (!ssh_key) {
|
||||||
|
throw new Error('SSH key not provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = 10000;
|
||||||
|
let resolved = false;
|
||||||
|
let tempKeyPath = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create temporary key file
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
|
||||||
|
tempKeyPath = join(tempDir, 'private_key');
|
||||||
|
|
||||||
|
// Write the private key to temporary file
|
||||||
|
writeFileSync(tempKeyPath, ssh_key);
|
||||||
|
chmodSync(tempKeyPath, 0o600); // Set proper permissions
|
||||||
|
|
||||||
|
// Build SSH command
|
||||||
|
const sshArgs = [
|
||||||
|
'-i', tempKeyPath,
|
||||||
|
'-p', ssh_port.toString(),
|
||||||
|
'-o', 'ConnectTimeout=10',
|
||||||
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
|
'-o', 'LogLevel=ERROR',
|
||||||
|
'-o', 'PasswordAuthentication=no',
|
||||||
|
'-o', 'PubkeyAuthentication=yes',
|
||||||
|
`${user}@${ip}`,
|
||||||
|
'echo "SSH_LOGIN_SUCCESS"'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Use sshpass if passphrase is provided
|
||||||
|
let command, args;
|
||||||
|
if (ssh_key_passphrase) {
|
||||||
|
command = 'sshpass';
|
||||||
|
args = ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...sshArgs];
|
||||||
|
} else {
|
||||||
|
command = 'ssh';
|
||||||
|
args = sshArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshCommand = spawn(command, args, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
sshCommand.kill('SIGTERM');
|
||||||
|
reject(new Error('SSH key login timeout'));
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
let errorOutput = '';
|
||||||
|
|
||||||
|
sshCommand.stdout.on('data', (data) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
sshCommand.stderr.on('data', (data) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
sshCommand.on('close', (code) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (code === 0 && output.includes('SSH_LOGIN_SUCCESS')) {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
message: 'SSH key authentication successful - credentials verified',
|
||||||
|
details: {
|
||||||
|
server: server.name || 'Unknown',
|
||||||
|
ip: ip,
|
||||||
|
user: user,
|
||||||
|
method: 'ssh_key_verified'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let errorMessage = 'SSH key authentication failed';
|
||||||
|
|
||||||
|
if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) {
|
||||||
|
errorMessage = 'SSH key authentication failed - check key and permissions';
|
||||||
|
} else if (errorOutput.includes('Connection refused')) {
|
||||||
|
errorMessage = 'Connection refused - server may be down or SSH not running';
|
||||||
|
} else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) {
|
||||||
|
errorMessage = 'Host not found - check IP address';
|
||||||
|
} else if (errorOutput.includes('Connection timed out')) {
|
||||||
|
errorMessage = 'Connection timeout - server may be unreachable';
|
||||||
|
} else if (errorOutput.includes('Load key') || errorOutput.includes('invalid format')) {
|
||||||
|
errorMessage = 'Invalid SSH key format';
|
||||||
|
} else if (errorOutput.includes('Enter passphrase')) {
|
||||||
|
errorMessage = 'SSH key passphrase required but not provided';
|
||||||
|
} else {
|
||||||
|
errorMessage = `SSH key authentication failed: ${errorOutput.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(errorMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sshCommand.on('error', (error) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temporary key file
|
||||||
|
if (tempKeyPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempKeyPath);
|
||||||
|
// Also remove the temp directory
|
||||||
|
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||||
|
rmdirSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export interface ScriptCard {
|
|||||||
categories?: number[];
|
categories?: number[];
|
||||||
categoryNames?: string[];
|
categoryNames?: string[];
|
||||||
date_created?: string;
|
date_created?: string;
|
||||||
|
os?: string;
|
||||||
|
version?: string;
|
||||||
|
interface_port?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubFile {
|
export interface GitHubFile {
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ export interface Server {
|
|||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password?: string;
|
||||||
|
auth_type?: 'password' | 'key' | 'both';
|
||||||
|
ssh_key?: string;
|
||||||
|
ssh_key_passphrase?: string;
|
||||||
|
ssh_port?: number;
|
||||||
|
color?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -12,7 +17,12 @@ export interface CreateServerData {
|
|||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password?: string;
|
||||||
|
auth_type?: 'password' | 'key' | 'both';
|
||||||
|
ssh_key?: string;
|
||||||
|
ssh_key_passphrase?: string;
|
||||||
|
ssh_port?: number;
|
||||||
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateServerData extends CreateServerData {
|
export interface UpdateServerData extends CreateServerData {
|
||||||
|
|||||||
@@ -592,6 +592,7 @@ start_application() {
|
|||||||
if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then
|
if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then
|
||||||
log "Service was running before update, re-enabling and starting systemd service..."
|
log "Service was running before update, re-enabling and starting systemd service..."
|
||||||
if systemctl enable --now pvescriptslocal.service; then
|
if systemctl enable --now pvescriptslocal.service; then
|
||||||
|
systemctl restart pvescriptslocal.service
|
||||||
log_success "Service enabled and started successfully"
|
log_success "Service enabled and started successfully"
|
||||||
# Wait a moment and check if it's running
|
# Wait a moment and check if it's running
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -703,7 +704,7 @@ main() {
|
|||||||
if [ -f "package.json" ] && [ -f "server.js" ]; then
|
if [ -f "package.json" ] && [ -f "server.js" ]; then
|
||||||
app_dir="$(pwd)"
|
app_dir="$(pwd)"
|
||||||
else
|
else
|
||||||
# Try multiple common locations
|
# Try multiple common locations:
|
||||||
for search_path in /opt /root /home /usr/local; do
|
for search_path in /opt /root /home /usr/local; do
|
||||||
if [ -d "$search_path" ]; then
|
if [ -d "$search_path" ]; then
|
||||||
app_dir=$(find "$search_path" -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
|
app_dir=$(find "$search_path" -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
|
||||||
|
|||||||
Reference in New Issue
Block a user