Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cad71b878 | ||
|
|
9649f63474 | ||
|
|
e63958e5eb | ||
|
|
ba5730287f | ||
|
|
4faa74b4c5 | ||
|
|
aa9e155b0c | ||
|
|
d819cd79fe | ||
|
|
c618fef2ef | ||
|
|
6265ffeab5 | ||
|
|
608a7ac78c | ||
|
|
ff1ab35b46 | ||
|
|
e8be9e7214 | ||
|
|
cfcd09611e |
@@ -21,3 +21,8 @@ WEBSOCKET_PORT="3001"
|
||||
GITHUB_TOKEN=
|
||||
SAVE_FILTER=false
|
||||
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
|
||||
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
|
||||
tag-template: 'v$NEXT_PATCH_VERSION'
|
||||
name-template: 'v$NEXT_MINOR_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
|
||||
tag-template: 'v$NEXT_MINOR_VERSION'
|
||||
|
||||
# Exclude PRs with this label from release notes
|
||||
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;
|
||||
|
||||
159
package-lock.json
generated
159
package-lock.json
generated
@@ -19,9 +19,11 @@
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.545.0",
|
||||
"next": "^15.5.3",
|
||||
"node-pty": "^1.0.0",
|
||||
@@ -42,8 +44,10 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@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-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
@@ -2986,6 +2990,13 @@
|
||||
"@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": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
@@ -3043,10 +3054,28 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "24.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz",
|
||||
"integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==",
|
||||
"version": "24.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
|
||||
"integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.14.0"
|
||||
@@ -4259,6 +4288,15 @@
|
||||
"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": {
|
||||
"version": "12.4.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
|
||||
@@ -4385,6 +4423,12 @@
|
||||
"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": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
@@ -4954,6 +4998,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.5.232",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.232.tgz",
|
||||
@@ -7166,6 +7219,40 @@
|
||||
"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": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
@@ -7182,6 +7269,27 @@
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -7481,6 +7589,42 @@
|
||||
"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": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -7488,6 +7632,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -7731,7 +7881,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nan": {
|
||||
|
||||
@@ -33,9 +33,11 @@
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.545.0",
|
||||
"next": "^15.5.3",
|
||||
"node-pty": "^1.0.0",
|
||||
@@ -56,8 +58,10 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@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-dom": "^19.0.0",
|
||||
"@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: (
|
||||
<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>
|
||||
),
|
||||
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 { api } from '~/trpc/react';
|
||||
import { ScriptCard } from './ScriptCard';
|
||||
import { ScriptCardList } from './ScriptCardList';
|
||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||
import { CategorySidebar } from './CategorySidebar';
|
||||
import { FilterBar, type FilterState } from './FilterBar';
|
||||
import { ViewToggle } from './ViewToggle';
|
||||
import { Button } from './ui/button';
|
||||
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 [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
searchQuery: '',
|
||||
showUpdatable: null,
|
||||
@@ -40,7 +43,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
{ 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(() => {
|
||||
const loadSettings = async () => {
|
||||
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) {
|
||||
console.error('Error loading settings:', error);
|
||||
} finally {
|
||||
@@ -96,6 +109,29 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [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
|
||||
const categories = React.useMemo((): string[] => {
|
||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||
@@ -367,25 +403,8 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Category Sidebar */}
|
||||
@@ -412,6 +431,12 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
isLoadingFilters={isLoadingFilters}
|
||||
/>
|
||||
|
||||
{/* View Toggle */}
|
||||
<ViewToggle
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* Scripts Grid */}
|
||||
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
|
||||
<div className="text-center py-12">
|
||||
@@ -446,25 +471,47 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{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 (
|
||||
<ScriptCard
|
||||
key={uniqueKey}
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
viewMode === 'card' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{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 (
|
||||
<ScriptCard
|
||||
key={uniqueKey}
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Server } from '../../types/server';
|
||||
import { Button } from './ui/button';
|
||||
import { ColorCodedDropdown } from './ColorCodedDropdown';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
|
||||
|
||||
interface ExecutionModeModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,8 +18,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedMode, setSelectedMode] = useState<'local' | 'ssh'>('local');
|
||||
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -24,6 +27,20 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
}
|
||||
}, [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 () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -33,7 +50,11 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
throw new Error('Failed to fetch servers');
|
||||
}
|
||||
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) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
@@ -42,167 +63,175 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
};
|
||||
|
||||
const handleExecute = () => {
|
||||
if (selectedMode === 'ssh' && !selectedServer) {
|
||||
if (!selectedServer) {
|
||||
setError('Please select a server for SSH execution');
|
||||
return;
|
||||
}
|
||||
|
||||
onExecute(selectedMode, selectedServer ?? undefined);
|
||||
onExecute('ssh', selectedServer);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleModeChange = (mode: 'local' | 'ssh') => {
|
||||
setSelectedMode(mode);
|
||||
if (mode === 'local') {
|
||||
setSelectedServer(null);
|
||||
}
|
||||
|
||||
const handleServerSelect = (server: Server | null) => {
|
||||
setSelectedServer(server);
|
||||
};
|
||||
|
||||
|
||||
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-between p-6 border-b border-border">
|
||||
<h2 className="text-xl font-bold text-foreground">Execution Mode</h2>
|
||||
<Button
|
||||
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">
|
||||
<>
|
||||
<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-between p-6 border-b border-border">
|
||||
<h2 className="text-xl font-bold text-foreground">Select Server</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
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'}
|
||||
<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">
|
||||
{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>
|
||||
|
||||
{/* Server Settings Modal */}
|
||||
<SettingsModal
|
||||
isOpen={settingsModalOpen}
|
||||
onClose={handleSettingsModalClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,23 @@ interface 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 [saveFilter, setSaveFilter] = useState(false);
|
||||
const [savedFilters, setSavedFilters] = useState<any>(null);
|
||||
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -25,6 +35,8 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
void loadGithubToken();
|
||||
void loadSaveFilter();
|
||||
void loadSavedFilters();
|
||||
void loadAuthCredentials();
|
||||
void loadColorCodingSetting();
|
||||
}
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
@@ -185,6 +320,18 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
>
|
||||
GitHub
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -237,6 +384,16 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
</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>
|
||||
)}
|
||||
@@ -301,6 +458,134 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
</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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Terminal } from './Terminal';
|
||||
import { StatusBadge } from './Badge';
|
||||
import { Button } from './ui/button';
|
||||
import { ScriptInstallationCard } from './ScriptInstallationCard';
|
||||
import { getContrastColor } from '../../lib/colorUtils';
|
||||
|
||||
interface InstalledScript {
|
||||
id: number;
|
||||
@@ -17,6 +18,7 @@ interface InstalledScript {
|
||||
server_ip: string | null;
|
||||
server_user: string | null;
|
||||
server_password: string | null;
|
||||
server_color: string | null;
|
||||
installation_date: string;
|
||||
status: 'in_progress' | 'success' | 'failed';
|
||||
output_log: string | null;
|
||||
@@ -773,7 +775,11 @@ export function InstalledScriptsTab() {
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-gray-200">
|
||||
{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">
|
||||
{editingScriptId === script.id ? (
|
||||
<div className="space-y-2">
|
||||
@@ -811,8 +817,14 @@ export function InstalledScriptsTab() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{script.server_name ?? 'Local'}
|
||||
<span
|
||||
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>
|
||||
</td>
|
||||
<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 { StatusBadge } from './Badge';
|
||||
import { getContrastColor } from '../../lib/colorUtils';
|
||||
|
||||
interface InstalledScript {
|
||||
id: number;
|
||||
@@ -13,6 +14,7 @@ interface InstalledScript {
|
||||
server_ip: string | null;
|
||||
server_user: string | null;
|
||||
server_password: string | null;
|
||||
server_color: string | null;
|
||||
installation_date: string;
|
||||
status: 'in_progress' | 'success' | 'failed';
|
||||
output_log: string | null;
|
||||
@@ -50,7 +52,10 @@ export function ScriptInstallationCard({
|
||||
};
|
||||
|
||||
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 */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -102,9 +107,15 @@ export function ScriptInstallationCard({
|
||||
{/* Server */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{script.server_name ?? 'Local'}
|
||||
</div>
|
||||
<span
|
||||
className="text-sm px-3 py-1 rounded inline-block"
|
||||
style={{
|
||||
backgroundColor: script.server_color ?? 'transparent',
|
||||
color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
|
||||
}}
|
||||
>
|
||||
{script.server_name ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Installation Date */}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { ScriptCard } from './ScriptCard';
|
||||
import { ScriptCardList } from './ScriptCardList';
|
||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||
import { CategorySidebar } from './CategorySidebar';
|
||||
import { FilterBar, type FilterState } from './FilterBar';
|
||||
import { ViewToggle } from './ViewToggle';
|
||||
import { Button } from './ui/button';
|
||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||
|
||||
@@ -19,6 +21,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
searchQuery: '',
|
||||
showUpdatable: null,
|
||||
@@ -37,7 +40,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
{ 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(() => {
|
||||
const loadSettings = async () => {
|
||||
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) {
|
||||
console.error('Error loading settings:', error);
|
||||
} finally {
|
||||
@@ -93,6 +106,29 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [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
|
||||
const categories = React.useMemo((): string[] => {
|
||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||
@@ -399,6 +435,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
isLoadingFilters={isLoadingFilters}
|
||||
/>
|
||||
|
||||
{/* View Toggle */}
|
||||
<ViewToggle
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||
<div className="hidden mb-8">
|
||||
<div className="relative max-w-md mx-auto">
|
||||
@@ -474,25 +516,47 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{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 (
|
||||
<ScriptCard
|
||||
key={uniqueKey}
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
viewMode === 'card' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{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 (
|
||||
<ScriptCard
|
||||
key={uniqueKey}
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { CreateServerData } from '../../types/server';
|
||||
import { Button } from './ui/button';
|
||||
import { SSHKeyInput } from './SSHKeyInput';
|
||||
|
||||
interface ServerFormProps {
|
||||
onSubmit: (data: CreateServerData) => void;
|
||||
@@ -18,13 +19,35 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
ip: '',
|
||||
user: '',
|
||||
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 newErrors: Partial<CreateServerData> = {};
|
||||
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Server name is required';
|
||||
@@ -44,12 +67,36 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
newErrors.user = 'Username is required';
|
||||
}
|
||||
|
||||
if (!formData.password.trim()) {
|
||||
newErrors.password = 'Password is required';
|
||||
// Validate SSH port
|
||||
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);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -57,13 +104,23 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
if (validateForm()) {
|
||||
onSubmit(formData);
|
||||
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) => (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||
// 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 (
|
||||
<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>
|
||||
<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>}
|
||||
</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>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Password *
|
||||
Password {formData.auth_type === 'both' ? '(Optional)' : '*'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={formData.password}
|
||||
value={formData.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 ${
|
||||
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>}
|
||||
</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">
|
||||
{isEditing && onCancel && (
|
||||
|
||||
@@ -85,7 +85,11 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{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 ? (
|
||||
<div>
|
||||
<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,
|
||||
user: server.user,
|
||||
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}
|
||||
isEditing={true}
|
||||
|
||||
@@ -31,7 +31,11 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
throw new Error('Failed to fetch servers');
|
||||
}
|
||||
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) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} 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 [showMobileInput, setShowMobileInput] = useState(false);
|
||||
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
|
||||
const [inWhiptailSession, setInWhiptailSession] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isStopped, setIsStopped] = useState(false);
|
||||
const [isTerminalReady, setIsTerminalReady] = useState(false);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<any>(null);
|
||||
const fitAddonRef = useRef<any>(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 hasConnectedRef = useRef<boolean>(false);
|
||||
|
||||
@@ -53,22 +54,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
break;
|
||||
case 'output':
|
||||
// Write directly to terminal - xterm.js handles ANSI codes natively
|
||||
// Detect whiptail sessions and clear immediately
|
||||
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);
|
||||
}
|
||||
xtermRef.current.write(message.data);
|
||||
break;
|
||||
case 'error':
|
||||
// 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;
|
||||
case 'end':
|
||||
// Reset whiptail session
|
||||
setInWhiptailSession(false);
|
||||
setIsRunning(false);
|
||||
|
||||
// Check if this is an LXC creation script
|
||||
const isLxcCreation = scriptPath.includes('ct/') ||
|
||||
@@ -107,10 +92,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
} else {
|
||||
xtermRef.current.writeln(`${prefix}✅ ${message.data}`);
|
||||
}
|
||||
setIsRunning(false);
|
||||
break;
|
||||
}
|
||||
}, [scriptPath, containerId, scriptName, inWhiptailSession]);
|
||||
}, [scriptPath, containerId, scriptName]);
|
||||
|
||||
// Ensure we're on the client side
|
||||
useEffect(() => {
|
||||
@@ -198,6 +182,20 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
// Ensure cursor is properly positioned
|
||||
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);
|
||||
|
||||
// Fit after a small delay to ensure proper sizing
|
||||
@@ -231,18 +229,10 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
// Store references
|
||||
xtermRef.current = terminal;
|
||||
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 () => {
|
||||
terminal.dispose();
|
||||
@@ -254,18 +244,51 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
void initTerminal();
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (terminalElement && (terminalElement as any).resizeHandler) {
|
||||
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
|
||||
}
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (terminalElement && (terminalElement as any).resizeHandler) {
|
||||
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
|
||||
}
|
||||
if (terminalElement && (terminalElement as any).focusHandler) {
|
||||
terminalElement.removeEventListener('click', (terminalElement as any).focusHandler as (this: HTMLDivElement, ev: PointerEvent) => any);
|
||||
}
|
||||
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(() => {
|
||||
// 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
|
||||
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 = {
|
||||
action: 'start',
|
||||
scriptPath,
|
||||
executionId,
|
||||
executionId: newExecutionId,
|
||||
mode,
|
||||
server,
|
||||
isUpdate,
|
||||
@@ -314,7 +341,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data as string) as TerminalMessage;
|
||||
console.log('WebSocket message received:', message);
|
||||
handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
@@ -346,15 +372,19 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
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 = () => {
|
||||
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);
|
||||
wsRef.current.send(JSON.stringify({
|
||||
action: 'start',
|
||||
scriptPath,
|
||||
executionId,
|
||||
executionId: newExecutionId,
|
||||
mode,
|
||||
server,
|
||||
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 { 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
|
||||
if (!name || !ip || !user || !password) {
|
||||
if (!name || !ip || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ error: 'Missing required fields: name, ip, and user are required' },
|
||||
{ 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();
|
||||
|
||||
// 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(
|
||||
{
|
||||
|
||||
@@ -20,18 +20,67 @@ export async function GET() {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
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
|
||||
if (!name || !ip || !user || !password) {
|
||||
if (!name || !ip || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ error: 'Missing required fields: name, ip, and user are required' },
|
||||
{ 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 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(
|
||||
{
|
||||
|
||||
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 {
|
||||
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
|
||||
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 { type Metadata } from "next";
|
||||
import { type Metadata, type Viewport } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
import { AuthProvider } from "./_components/AuthProvider";
|
||||
import { AuthGuard } from "./_components/AuthGuard";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PVE Scripts local",
|
||||
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
|
||||
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
|
||||
icons: [
|
||||
{ rel: "icon", url: "/favicon.png", type: "image/png" },
|
||||
{ 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({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains-mono",
|
||||
@@ -40,7 +47,13 @@ export default function RootLayout({
|
||||
className="bg-background text-foreground transition-colors"
|
||||
suppressHydrationWarning={true}
|
||||
>
|
||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||
<TRPCReactProvider>
|
||||
<AuthProvider>
|
||||
<AuthGuard>
|
||||
{children}
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
</TRPCReactProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -12,12 +12,41 @@ import { SettingsButton } from './_components/SettingsButton';
|
||||
import { VersionDisplay } from './_components/VersionDisplay';
|
||||
import { Button } from './_components/ui/button';
|
||||
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { api } from '~/trpc/react';
|
||||
|
||||
export default function Home() {
|
||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
|
||||
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 = () => {
|
||||
if (terminalRef.current) {
|
||||
// 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" />
|
||||
<span className="hidden sm:inline">Available Scripts</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
|
||||
variant="ghost"
|
||||
@@ -96,6 +128,9 @@ export default function Home() {
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Downloaded Scripts</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
|
||||
variant="ghost"
|
||||
@@ -109,6 +144,9 @@ export default function Home() {
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Installed Scripts</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>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
16
src/env.js
16
src/env.js
@@ -25,6 +25,14 @@ export const env = createEnv({
|
||||
WEBSOCKET_PORT: z.string().default("3001"),
|
||||
// GitHub Configuration
|
||||
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,
|
||||
// GitHub Configuration
|
||||
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,
|
||||
},
|
||||
/**
|
||||
|
||||
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,
|
||||
command,
|
||||
(data: string) => {
|
||||
console.log('Command output chunk:', data);
|
||||
commandOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
@@ -276,7 +275,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
},
|
||||
(exitCode: number) => {
|
||||
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
|
||||
const configFiles = commandOutput.split('\n')
|
||||
@@ -306,8 +304,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
server as any,
|
||||
readCommand,
|
||||
(configData: string) => {
|
||||
console.log('Config data for', containerId, ':', configData.substring(0, 300) + '...');
|
||||
|
||||
// Parse config file for hostname
|
||||
const lines = configData.split('\n');
|
||||
let hostname = '';
|
||||
@@ -316,7 +312,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.startsWith('hostname:')) {
|
||||
hostname = trimmedLine.substring(9).trim();
|
||||
console.log('Found hostname for', containerId, ':', hostname);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -368,7 +363,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
|
||||
// Get existing scripts to check for duplicates
|
||||
const existingScripts = db.getAllInstalledScripts();
|
||||
console.log('Existing scripts in database:', existingScripts.length);
|
||||
|
||||
// Create installed script records for detected containers (skip duplicates)
|
||||
const createdScripts = [];
|
||||
@@ -504,7 +498,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
server as any,
|
||||
checkCommand,
|
||||
(data: string) => {
|
||||
console.log(`Container check result for ${scriptData.script_name}:`, data.trim());
|
||||
resolve(data.trim() === 'exists');
|
||||
},
|
||||
(error: string) => {
|
||||
|
||||
@@ -163,12 +163,22 @@ export const scriptsRouter = createTRPCRouter({
|
||||
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') ?? [];
|
||||
|
||||
// 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 {
|
||||
...card,
|
||||
categories: script?.categories ?? [],
|
||||
categoryNames: categoryNames,
|
||||
// Add date_created from script
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -16,12 +16,68 @@ class DatabaseService {
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
ip 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,
|
||||
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
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS installed_scripts (
|
||||
@@ -53,12 +109,12 @@ class DatabaseService {
|
||||
* @param {import('../types/server').CreateServerData} 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(`
|
||||
INSERT INTO servers (name, ip, user, password)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color)
|
||||
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() {
|
||||
@@ -79,13 +135,13 @@ class DatabaseService {
|
||||
* @param {import('../types/server').CreateServerData} 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(`
|
||||
UPDATE servers
|
||||
SET name = ?, ip = ?, user = ?, password = ?
|
||||
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, color = ?
|
||||
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.ip as server_ip,
|
||||
s.user as server_user,
|
||||
s.password as server_password
|
||||
s.password as server_password,
|
||||
s.color as server_color
|
||||
FROM installed_scripts inst
|
||||
LEFT JOIN servers s ON inst.server_id = s.id
|
||||
ORDER BY inst.installation_date DESC
|
||||
|
||||
@@ -1,16 +1,131 @@
|
||||
import { spawn } from 'child_process';
|
||||
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
|
||||
* @property {string} ip - Server IP address
|
||||
* @property {string} user - Username
|
||||
* @property {string} password - Password
|
||||
* @property {string} [password] - Password (optional)
|
||||
* @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 {
|
||||
/**
|
||||
* 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
|
||||
* @param {Server} server - Server configuration
|
||||
@@ -21,7 +136,8 @@ class SSHExecutionService {
|
||||
* @returns {Promise<Object>} Process information
|
||||
*/
|
||||
async executeScript(server, scriptPath, onData, onError, onExit) {
|
||||
const { ip, user, password } = server;
|
||||
/** @type {string|null} */
|
||||
let tempKeyPath = null;
|
||||
|
||||
try {
|
||||
await this.transferScriptsFolder(server, onData, onError);
|
||||
@@ -29,46 +145,37 @@ class SSHExecutionService {
|
||||
return new Promise((resolve, reject) => {
|
||||
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
|
||||
|
||||
// Use ptySpawn for proper terminal emulation and color support
|
||||
const sshCommand = ptySpawn('sshpass', [
|
||||
'-p', password,
|
||||
'ssh',
|
||||
'-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'
|
||||
try {
|
||||
// Create temporary key file if using key authentication
|
||||
if (server.auth_type === 'key' || server.auth_type === 'both') {
|
||||
tempKeyPath = this.createTempKeyFile(server);
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
sshCommand.onData((data) => {
|
||||
@@ -82,8 +189,34 @@ class SSHExecutionService {
|
||||
|
||||
resolve({
|
||||
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) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -100,20 +233,49 @@ class SSHExecutionService {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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) => {
|
||||
const rsyncCommand = spawn('rsync', [
|
||||
'-avz',
|
||||
'--delete',
|
||||
'--exclude=*.log',
|
||||
'--exclude=*.tmp',
|
||||
'--rsh=sshpass -p ' + password + ' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null',
|
||||
'scripts/',
|
||||
`${user}@${ip}:/tmp/scripts/`
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
try {
|
||||
// Create temporary key file if using key authentication
|
||||
if (auth_type === 'key' || auth_type === 'both') {
|
||||
if (ssh_key) {
|
||||
tempKeyPath = this.createTempKeyFile(server);
|
||||
}
|
||||
}
|
||||
|
||||
// Build rsync command based on authentication type
|
||||
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) => {
|
||||
// Ensure proper UTF-8 encoding for ANSI colors
|
||||
@@ -128,6 +290,17 @@ class SSHExecutionService {
|
||||
});
|
||||
|
||||
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) {
|
||||
resolve();
|
||||
} else {
|
||||
@@ -136,8 +309,32 @@ class SSHExecutionService {
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
} 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
|
||||
*/
|
||||
async executeCommand(server, command, onData, onError, onExit) {
|
||||
const { ip, user, password } = server;
|
||||
/** @type {string|null} */
|
||||
let tempKeyPath = null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Use ptySpawn for proper terminal emulation and color support
|
||||
const sshCommand = ptySpawn('sshpass', [
|
||||
'-p', password,
|
||||
'ssh',
|
||||
'-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',
|
||||
`${user}@${ip}`,
|
||||
command
|
||||
], {
|
||||
name: 'xterm-color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: process.cwd(),
|
||||
env: process.env
|
||||
});
|
||||
try {
|
||||
// Create temporary key file if using key authentication
|
||||
if (server.auth_type === 'key' || server.auth_type === 'both') {
|
||||
tempKeyPath = this.createTempKeyFile(server);
|
||||
}
|
||||
|
||||
// Build SSH command based on authentication type
|
||||
const { command: sshCommandName, args } = this.buildSSHCommand(server, tempKeyPath);
|
||||
|
||||
// Add the command to execute to the args
|
||||
args.push(command);
|
||||
|
||||
// Use ptySpawn for proper terminal emulation and color support
|
||||
const sshCommand = ptySpawn(sshCommandName, args, {
|
||||
name: 'xterm-color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: process.cwd(),
|
||||
env: process.env
|
||||
});
|
||||
|
||||
sshCommand.onData((data) => {
|
||||
onData(data);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 { writeFileSync, unlinkSync, chmodSync } from 'fs';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
class SSHService {
|
||||
/**
|
||||
@@ -10,38 +11,42 @@ class SSHService {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
async testConnection(server) {
|
||||
const { ip, user, password } = server;
|
||||
const { auth_type = 'password' } = server;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = 15000; // 15 seconds timeout for login test
|
||||
let resolved = false;
|
||||
|
||||
// Try sshpass first if available
|
||||
this.testWithSshpass(server).then(result => {
|
||||
// Choose authentication method based on auth_type
|
||||
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) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
}).catch(() => {
|
||||
// If sshpass fails, try expect
|
||||
this.testWithExpect(server).then(result => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
}).catch(() => {
|
||||
// If both fail, return error
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve({
|
||||
success: false,
|
||||
message: 'SSH login test requires sshpass or expect - neither available or working',
|
||||
details: {
|
||||
method: 'no_auth_tools'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// If primary method fails, return error
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve({
|
||||
success: false,
|
||||
message: `SSH login test failed for ${auth_type} authentication`,
|
||||
details: {
|
||||
method: 'auth_failed',
|
||||
auth_type: auth_type
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Set up overall timeout
|
||||
@@ -64,7 +69,11 @@ class SSHService {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
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) => {
|
||||
const timeout = 10000;
|
||||
@@ -73,6 +82,7 @@ class SSHService {
|
||||
const sshCommand = spawn('sshpass', [
|
||||
'-p', password,
|
||||
'ssh',
|
||||
'-p', ssh_port.toString(),
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
@@ -156,7 +166,7 @@ class SSHService {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
async testWithExpect(server) {
|
||||
const { ip, user, password } = server;
|
||||
const { ip, user, password, ssh_port = 22 } = server;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = 10000;
|
||||
@@ -164,7 +174,7 @@ class SSHService {
|
||||
|
||||
const expectScript = `#!/usr/bin/expect -f
|
||||
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 {
|
||||
"password:" {
|
||||
send "${password}\r"
|
||||
@@ -428,13 +438,14 @@ expect {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
async testSSHConnection(server) {
|
||||
const { ip, user } = server;
|
||||
const { ip, user, ssh_port = 22 } = server;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = 5000;
|
||||
let resolved = false;
|
||||
|
||||
const sshCommand = spawn('ssh', [
|
||||
'-p', ssh_port.toString(),
|
||||
'-o', 'ConnectTimeout=5',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-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
|
||||
|
||||
@@ -57,6 +57,9 @@ export interface ScriptCard {
|
||||
categories?: number[];
|
||||
categoryNames?: string[];
|
||||
date_created?: string;
|
||||
os?: string;
|
||||
version?: string;
|
||||
interface_port?: number | null;
|
||||
}
|
||||
|
||||
export interface GitHubFile {
|
||||
|
||||
@@ -3,7 +3,12 @@ export interface Server {
|
||||
name: string;
|
||||
ip: 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;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -12,7 +17,12 @@ export interface CreateServerData {
|
||||
name: string;
|
||||
ip: 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 {
|
||||
|
||||
@@ -704,7 +704,7 @@ main() {
|
||||
if [ -f "package.json" ] && [ -f "server.js" ]; then
|
||||
app_dir="$(pwd)"
|
||||
else
|
||||
# Try multiple common locations
|
||||
# Try multiple common locations:
|
||||
for search_path in /opt /root /home /usr/local; do
|
||||
if [ -d "$search_path" ]; then
|
||||
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