Compare commits

...

16 Commits

Author SHA1 Message Date
github-actions[bot]
2cad71b878 chore: add VERSION v0.3.0 2025-10-10 13:10:38 +00:00
Michel Roegl-Brunner
9649f63474 Fix terminal input handling (#106)
* Fix terminal input handling

- Fix stale executionId issue in terminal input handler
- Add proper terminal focus management
- Fix timing issues with input handler registration
- Remove invalid terminal.off() cleanup method
- Add isTerminalReady state to ensure proper initialization order
- Clean up debugging console logs

Fixes issue where terminal input would stop working after script restarts
due to stale executionId being captured in the input handler closure.

* Fix TypeScript build errors

- Fix unsafe argument type for removeEventListener
- Remove unused eslint-disable directive
- Build now passes successfully
2025-10-10 15:02:36 +02:00
Michel Roegl-Brunner
e63958e5eb feat: Add confirmation modal for script installation and server sorting (#105)
- Add confirmation modal when only one server is saved
- Show script name and server details in confirmation view
- Auto-select single server but require user confirmation
- Preserve existing behavior for multiple servers (no pre-selection)
- Sort servers alphabetically by name in all components
- Fix ESLint issues with nullish coalescing operators

Fixes: PROX2 now appears before PROX3 in server lists
2025-10-10 14:40:52 +02:00
Michel Roegl-Brunner
ba5730287f Change release drafter to use minor version 2025-10-10 14:36:35 +02:00
Michel Roegl-Brunner
4faa74b4c5 feat: Server Color Coding System (#103)
* feat: implement server color coding feature

- Add color column to servers table with migration
- Add SERVER_COLOR_CODING_ENABLED environment variable
- Create API route for color coding toggle settings
- Add color field to Server and CreateServerData types
- Update database CRUD operations to handle color field
- Update server API routes to handle color field
- Create colorUtils.ts with contrast calculation function
- Add color coding toggle to GeneralSettingsModal
- Add color picker to ServerForm component (only shown when enabled)
- Apply colors to InstalledScriptsTab (borders and server column)
- Apply colors to ScriptInstallationCard component
- Apply colors to ServerList component
- Fix 'Local' display issue in installed scripts table

* fix: resolve TypeScript errors in color coding implementation

- Fix unsafe argument type errors in GeneralSettingsModal and ServerForm
- Remove unused import in ServerList component

* feat: add color-coded dropdown for server selection

- Create ColorCodedDropdown component with server color indicators
- Replace HTML select with custom dropdown in ExecutionModeModal
- Add color dots next to server names in dropdown options
- Maintain all existing functionality with improved visual design

* fix: generate new execution ID for each script run

- Change executionId from useState to allow updates
- Generate new execution ID in startScript function for each run
- Fixes issue where scripts couldn't be run multiple times without page reload
- Resolves 'Script execution already running' error on subsequent runs

* fix: improve whiptail handling and execution ID generation

- Remove premature terminal clearing for whiptail sessions
- Let whiptail handle its own display without interference
- Generate new execution ID for both initial and manual script runs
- Fix whiptail session state management
- Should resolve blank screen and script restart issues

* fix: revert problematic whiptail changes that broke terminal display

- Remove complex whiptail session handling that caused blank screen
- Simplify output handling to just write data directly to terminal
- Keep execution ID generation fix for multiple script runs
- Remove unused inWhiptailSession state variable
- Terminal should now display output normally again

* fix: remove remaining inWhiptailSession reference

- Remove inWhiptailSession from useEffect dependency array
- Fixes ReferenceError: inWhiptailSession is not defined
- Terminal should now work without JavaScript errors

* debug: add console logging to terminal message handling

- Add debug logs to see what messages are being received
- Help diagnose why terminal shows blank screen
- Will remove debug logs once issue is identified

* fix: prevent WebSocket reconnection loop

- Remove executionId from useEffect dependency arrays
- Fixes terminal constantly reconnecting and showing blank screen
- WebSocket now maintains stable connection during script execution
- Removes debug console logs

* fix: prevent WebSocket reconnection on second script run

- Remove handleMessage from useEffect dependency array
- Fixes loop of START messages and connection blinking on subsequent runs
- WebSocket connection now stable for multiple script executions
- handleMessage recreation no longer triggers WebSocket reconnection

* debug: add logging to identify WebSocket reconnection cause

- Add console logs to useEffect and startScript
- Track what dependencies are changing
- Identify why WebSocket reconnects on second run

* fix: remove isRunning from WebSocket useEffect dependencies

- isRunning state change was causing WebSocket reconnection loop
- Each script start changed isRunning from false to true
- This triggered useEffect to reconnect WebSocket
- Removing isRunning from dependencies breaks the loop
- WebSocket connection now stable during script execution

* feat: preselect SSH mode in execution modal and clean up debug logs

- Preselect SSH execution mode by default since it's the only available option
- Remove debug console logs from Terminal component
- Clean up code for production readiness

* fix: resolve build errors and warnings

- Add missing SettingsModal import to ExecutionModeModal
- Remove unused selectedMode and handleModeChange variables
- Add ESLint disable comments for intentional useEffect dependency exclusions
- Build now passes successfully with no errors or warnings
2025-10-10 14:30:28 +02:00
Michel Roegl-Brunner
aa9e155b0c feat: auto-select single server and remove local execution (#102)
* feat: auto-select single server and remove local execution

- Remove local execution option entirely, all scripts now execute via SSH
- Auto-select and skip modal when exactly one server is configured
- Add server settings button when no servers are configured
- Auto-refresh server list when settings modal closes
- Update modal title from 'Execution Mode' to 'Select Server'

* fix: remove debug messages from WebSocket output

- Remove console.log for WebSocket messages in Terminal component
- Remove debug output from SSH command execution in installedScripts router
- Clean up command output chunk logging and config data logging
- Remove container check result debug messages
- This eliminates unwanted debug messages appearing in terminal output
2025-10-10 13:26:24 +02:00
Michel Roegl-Brunner
d819cd79fe feat: Add card/list view toggle with enhanced list view (#101)
* feat: Add card/list view toggle with enhanced list view

- Add ViewToggle component with grid/list icons and active state styling
- Create ScriptCardList component with horizontal layout design
- Add view-mode API endpoint for GET/POST operations to persist view preference
- Update ScriptsGrid and DownloadedScriptsTab with view mode state and conditional rendering
- Enhance list view with additional information:
  - Categories with tag icon
  - Creation date with calendar icon
  - OS and version with computer icon
  - Default port with terminal icon
  - Script ID with info icon
- View preference persists across page reloads
- Same view mode applies to both Available and Downloaded scripts pages
- List view shows same information as card view but in compact horizontal layout

* fix: Resolve TypeScript/ESLint build errors

- Fix unsafe argument type errors in view mode loading
- Use proper type guards for viewMode validation
- Replace logical OR with nullish coalescing operator
- Add explicit type casting for API response validation
2025-10-10 13:04:57 +02:00
Michel Roegl-Brunner
c618fef2ef feat: Add script count badges to tab navigation (#100)
* feat: add script count badges to tab navigation

- Add script counts to Available Scripts, Downloaded Scripts, and Installed Scripts tabs
- Counts are calculated from API data and displayed as small badges
- Available scripts count shows total GitHub scripts
- Downloaded scripts count shows scripts that have local versions
- Installed scripts count shows total installed script records
- Badges use muted styling to blend with the UI

* fix: use nullish coalescing operator for safer null handling

- Replace logical OR (||) with nullish coalescing (??) for better null/undefined safety
- Fixes ESLint error: @typescript-eslint/prefer-nullish-coalescing
- Ensures build passes successfully
2025-10-10 12:52:53 +02:00
Michel Roegl-Brunner
6265ffeab5 feat: Implement comprehensive authentication system (#99)
* feat: implement JWT-based authentication system

- Add bcrypt password hashing and JWT token generation
- Create blocking auth modals for login and setup
- Add authentication management to General Settings
- Implement API routes for login, verify, setup, and credential management
- Add AuthProvider and AuthGuard components
- Support first-time setup and persistent authentication
- Store credentials securely in .env file

* feat: add option to skip enabling auth during setup

- Add toggle in SetupModal to choose whether to enable authentication immediately
- Users can set up credentials but keep authentication disabled initially
- Authentication can be enabled/disabled later through General Settings
- Maintains flexibility for users who want to configure auth gradually

* fix: allow proceeding without password when auth is disabled

- Make password fields optional when authentication is disabled in setup
- Update button validation to only require password when auth is enabled
- Modify API to handle optional password parameter
- Update hasCredentials logic to work with username-only setup
- Users can now complete setup with just username when auth is disabled
- Password can be added later when enabling authentication

* feat: don't store credentials when authentication is disabled

- When auth is disabled, no username or password is stored
- Setup modal only requires credentials when authentication is enabled
- Disabling authentication clears all stored credentials
- Users can skip authentication entirely without storing any data
- Clean separation between enabled/disabled authentication states

* feat: add setup completed flag to prevent modal on every load

- Add AUTH_SETUP_COMPLETED flag to track when user has completed setup
- Setup modal only appears when setupCompleted is false
- Both enabled and disabled auth setups mark setup as completed
- Clean .env file when authentication is disabled (no empty credential lines)
- Prevents setup modal from appearing on every page load after user decision

* fix: add missing Authentication tab button in settings modal

- Authentication tab button was missing from the tabs navigation
- Users couldn't access authentication settings
- Added Authentication tab button with proper styling and click handler
- Authentication settings are now accessible through the settings modal

* fix: properly load and display authentication settings

- Add setupCompleted state variable to track setup status
- Update loadAuthCredentials to include setupCompleted field
- Fix authentication status display logic to show correct state
- Show proper status when auth is disabled but setup is completed
- Enable toggle only when setup is completed (not just when credentials exist)
- Settings now correctly reflect the actual authentication state

* fix: handle empty FILTERS environment variable

- Add check for empty or invalid FILTERS JSON before parsing
- Prevents 'Unexpected end of JSON input' error when FILTERS is empty
- Return null filters instead of throwing parse error
- Clean up empty FILTERS line from .env file
- Fixes console error when loading settings modal

* fix: load authentication credentials when settings modal opens

- Add loadAuthCredentials() call to useEffect when modal opens
- Authentication settings were not loading because the function wasn't being called
- Now properly loads auth configuration when settings modal is opened
- Settings will display the correct authentication status and state

* fix: prevent multiple JWT secret generation with caching

- Add JWT secret caching to prevent race conditions
- Multiple API calls were generating duplicate JWT secrets
- Now caches secret after first generation/read
- Clean up duplicate JWT_SECRET lines from .env file
- Prevents .env file from being cluttered with multiple secrets

* feat: auto-login user after setup with authentication enabled

- When user sets up authentication with credentials, automatically log them in
- Prevents need to manually log in after setup completion
- Setup modal now calls login API after successful setup when auth is enabled
- AuthGuard no longer reloads page after setup, just refreshes config
- Seamless user experience from setup to authenticated state

* fix: resolve console errors and improve auth flow

- Fix 401 Unauthorized error by checking setup status before auth verification
- AuthProvider now checks if setup is completed before attempting to verify auth
- Prevents unnecessary auth verification calls when no credentials exist
- Add webpack polling configuration to fix WebSocket HMR issues
- Improves development experience when accessing from different IPs
- Eliminates console errors during initial setup flow

* fix: resolve build errors and linting issues

- Fix TypeScript ESLint error: use optional chain expression in auth.ts
- Fix React Hook warning: add missing 'isRunning' dependency to useEffect in Terminal.tsx
- Build now compiles successfully without any errors or warnings
- All linting rules are now satisfied
2025-10-10 12:45:45 +02:00
Michel Roegl-Brunner
608a7ac78c fix: resolve Next.js warnings and SVG path errors (#98)
* fix: move viewport metadata to separate export

- Remove viewport from metadata export in layout.tsx
- Add separate viewport export following Next.js 14+ conventions
- Fixes unsupported metadata viewport warning

* fix: resolve Next.js warnings and SVG path errors

- Move viewport metadata to separate export in layout.tsx (Next.js 14+ compliance)
- Fix malformed SVG arc command in CategorySidebar.tsx key icon
- Add allowedDevOrigins configuration for cross-origin requests from local networks
- Support all private network ranges without hardcoding specific IPs
2025-10-10 11:54:29 +02:00
Michel Roegl-Brunner
ff1ab35b46 feat: Add SSH key authentication and custom port support (#97)
* feat: Add SSH key authentication and custom port support

- Add SSH key authentication support with three modes: password, key, or both
- Add custom SSH port support (defaults to 22)
- Create SSHKeyInput component with file upload and paste modes
- Update database schema with auth_type, ssh_key, ssh_key_passphrase, and ssh_port columns
- Update TypeScript interfaces to support new authentication fields
- Update SSH services to handle key authentication and custom ports
- Update ServerForm with authentication type selection and SSH port field
- Update API routes with validation for new fields
- Add proper cleanup for temporary SSH key files
- Support for encrypted SSH keys with passphrase protection
- Maintain backward compatibility with existing password-only servers

* fix: Resolve TypeScript build errors and improve type safety

- Replace || operators with ?? (nullish coalescing) for better type safety
- Add proper null checks for password fields in SSH services
- Fix JSDoc type annotations for better TypeScript inference
- Update error object types to use Record<keyof CreateServerData, string>
- Ensure all SSH authentication methods handle optional fields correctly
2025-10-10 11:54:15 +02:00
dependabot[bot]
e8be9e7214 build(deps-dev): Bump @types/node from 24.7.0 to 24.7.1 (#96) 2025-10-09 22:04:20 +02:00
github-actions[bot]
cfcd09611e chore: add VERSION v0.2.5 (#90)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-09 07:54:19 +00:00
Michel Roegl-Brunner
4ed3e42148 feat: Add sorting functionality to installed scripts table (#88)
* feat: Add sorting functionality to installed scripts table

- Add sortable columns for Script Name, Container ID, Server, Status, and Installation Date
- Implement clickable table headers with visual sort indicators
- Add ascending/descending toggle functionality
- Maintain existing search and filter functionality
- Default sort by Script Name (ascending)
- Handle null values gracefully in sorting logic

* fix: Replace logical OR with nullish coalescing operator

- Fix TypeScript ESLint errors in InstalledScriptsTab.tsx
- Replace || with ?? for safer null/undefined handling
- Build now passes successfully
2025-10-09 09:51:33 +02:00
Michel Roegl-Brunner
a09f331d5f add restart command for service 2025-10-09 09:34:36 +02:00
github-actions[bot]
36beb427c0 chore: add VERSION v0.2.4 (#87)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-09 07:20:38 +00:00
47 changed files with 3793 additions and 449 deletions

View File

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

View File

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

View File

@@ -1 +1 @@
0.2.3
0.3.0

View File

@@ -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
View File

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

View File

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

View 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}</>;
}

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

View 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;
}

View File

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

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

View File

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

View File

@@ -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 &quot;{scriptName}&quot;?
</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 &quot;{scriptName}&quot; 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 &quot;{scriptName}&quot;
</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}
/>
</>
);
}

View File

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

View File

@@ -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;
@@ -26,6 +28,8 @@ export function InstalledScriptsTab() {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
const [serverFilter, setServerFilter] = useState<string>('all');
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('script_name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
@@ -154,20 +158,58 @@ export function InstalledScriptsTab() {
}
}, [scripts.length, serversData?.servers, cleanupMutation]);
// Filter scripts based on search and filters
const filteredScripts = scripts.filter((script: InstalledScript) => {
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(script.container_id?.includes(searchTerm) ?? false) ||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
const matchesServer = serverFilter === 'all' ||
(serverFilter === 'local' && !script.server_name) ||
(script.server_name === serverFilter);
return matchesSearch && matchesStatus && matchesServer;
});
// Filter and sort scripts
const filteredScripts = scripts
.filter((script: InstalledScript) => {
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(script.container_id?.includes(searchTerm) ?? false) ||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
const matchesServer = serverFilter === 'all' ||
(serverFilter === 'local' && !script.server_name) ||
(script.server_name === serverFilter);
return matchesSearch && matchesStatus && matchesServer;
})
.sort((a: InstalledScript, b: InstalledScript) => {
let aValue: any;
let bValue: any;
switch (sortField) {
case 'script_name':
aValue = a.script_name.toLowerCase();
bValue = b.script_name.toLowerCase();
break;
case 'container_id':
aValue = a.container_id ?? '';
bValue = b.container_id ?? '';
break;
case 'server_name':
aValue = a.server_name ?? 'Local';
bValue = b.server_name ?? 'Local';
break;
case 'status':
aValue = a.status;
bValue = b.status;
break;
case 'installation_date':
aValue = new Date(a.installation_date).getTime();
bValue = new Date(b.installation_date).getTime();
break;
default:
return 0;
}
if (aValue < bValue) {
return sortDirection === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return sortDirection === 'asc' ? 1 : -1;
}
return 0;
});
// Get unique servers for filter
const uniqueServers: string[] = [];
@@ -298,6 +340,15 @@ export function InstalledScriptsTab() {
setAutoDetectServerId('');
};
const handleSort = (field: 'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date') => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
@@ -652,20 +703,70 @@ export function InstalledScriptsTab() {
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Script Name
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('script_name')}
>
<div className="flex items-center space-x-1">
<span>Script Name</span>
{sortField === 'script_name' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Container ID
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('container_id')}
>
<div className="flex items-center space-x-1">
<span>Container ID</span>
{sortField === 'container_id' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Server
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('server_name')}
>
<div className="flex items-center space-x-1">
<span>Server</span>
{sortField === 'server_name' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Status
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('status')}
>
<div className="flex items-center space-x-1">
<span>Status</span>
{sortField === 'status' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Installation Date
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('installation_date')}
>
<div className="flex items-center space-x-1">
<span>Installation Date</span>
{sortField === 'installation_date' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions
@@ -674,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">
@@ -712,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">

View 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-----&#10;b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABFwAAAAdzc2gtcn...&#10;-----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>
);
}

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

View File

@@ -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 */}

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

View 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
}
}

View File

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

View File

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

View File

@@ -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
View 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
View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -592,6 +592,7 @@ start_application() {
if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then
log "Service was running before update, re-enabling and starting systemd service..."
if systemctl enable --now pvescriptslocal.service; then
systemctl restart pvescriptslocal.service
log_success "Service enabled and started successfully"
# Wait a moment and check if it's running
sleep 2
@@ -703,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)