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= GITHUB_TOKEN=
SAVE_FILTER=false SAVE_FILTER=false
FILTERS= 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 # Template for release drafts
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION name-template: 'v$NEXT_MINOR_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
tag-template: 'v$NEXT_PATCH_VERSION' tag-template: 'v$NEXT_MINOR_VERSION'
# Exclude PRs with this label from release notes # Exclude PRs with this label from release notes
exclude-labels: exclude-labels:

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; export default config;

159
package-lock.json generated
View File

@@ -19,9 +19,11 @@
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^12.4.1", "better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"next": "^15.5.3", "next": "^15.5.3",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
@@ -42,8 +44,10 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.8", "@types/better-sqlite3": "^7.6.8",
"@types/node": "^24.3.1", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.7.1",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
@@ -2986,6 +2990,13 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/better-sqlite3": { "node_modules/@types/better-sqlite3": {
"version": "7.6.13", "version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -3043,10 +3054,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.7.0", "version": "24.7.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
"integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", "integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.14.0" "undici-types": "~7.14.0"
@@ -4259,6 +4288,15 @@
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "12.4.1", "version": "12.4.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
@@ -4385,6 +4423,12 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/cac": { "node_modules/cac": {
"version": "6.7.14", "version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -4954,6 +4998,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.232", "version": "1.5.232",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.232.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.232.tgz",
@@ -7166,6 +7219,40 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jsx-ast-utils": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -7182,6 +7269,27 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7481,6 +7589,42 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -7488,6 +7632,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -7731,7 +7881,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nan": { "node_modules/nan": {

View File

@@ -33,9 +33,11 @@
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^12.4.1", "better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"next": "^15.5.3", "next": "^15.5.3",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
@@ -56,8 +58,10 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.8", "@types/better-sqlite3": "^7.6.8",
"@types/node": "^24.3.1", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.7.1",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",

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: ( key: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1721 9z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z" />
</svg> </svg>
), ),
archive: ( archive: (

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 React, { useState, useRef, useEffect } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { ScriptCard } from './ScriptCard'; import { ScriptCard } from './ScriptCard';
import { ScriptCardList } from './ScriptCardList';
import { ScriptDetailModal } from './ScriptDetailModal'; import { ScriptDetailModal } from './ScriptDetailModal';
import { CategorySidebar } from './CategorySidebar'; import { CategorySidebar } from './CategorySidebar';
import { FilterBar, type FilterState } from './FilterBar'; import { FilterBar, type FilterState } from './FilterBar';
import { ViewToggle } from './ViewToggle';
import { Button } from './ui/button'; import { Button } from './ui/button';
import type { ScriptCard as ScriptCardType } from '~/types/script'; import type { ScriptCard as ScriptCardType } from '~/types/script';
@@ -22,6 +24,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
const [selectedSlug, setSelectedSlug] = useState<string | null>(null); const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [filters, setFilters] = useState<FilterState>({ const [filters, setFilters] = useState<FilterState>({
searchQuery: '', searchQuery: '',
showUpdatable: null, showUpdatable: null,
@@ -40,7 +43,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
{ enabled: !!selectedSlug } { enabled: !!selectedSlug }
); );
// Load SAVE_FILTER setting and saved filters on component mount // Load SAVE_FILTER setting, saved filters, and view mode on component mount
useEffect(() => { useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
try { try {
@@ -63,6 +66,16 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
} }
} }
} }
// Load view mode
const viewModeResponse = await fetch('/api/settings/view-mode');
if (viewModeResponse.ok) {
const viewModeData = await viewModeResponse.json();
const viewMode = viewModeData.viewMode;
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
setViewMode(viewMode);
}
}
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
} finally { } finally {
@@ -96,6 +109,29 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [filters, saveFiltersEnabled, isLoadingFilters]); }, [filters, saveFiltersEnabled, isLoadingFilters]);
// Save view mode when it changes
useEffect(() => {
if (isLoadingFilters) return;
const saveViewMode = async () => {
try {
await fetch('/api/settings/view-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ viewMode }),
});
} catch (error) {
console.error('Error saving view mode:', error);
}
};
// Debounce the save operation
const timeoutId = setTimeout(() => void saveViewMode(), 300);
return () => clearTimeout(timeoutId);
}, [viewMode, isLoadingFilters]);
// Extract categories from metadata // Extract categories from metadata
const categories = React.useMemo((): string[] => { const categories = React.useMemo((): string[] => {
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return []; if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
@@ -367,25 +403,8 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header with Stats */}
<div className="bg-card rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-foreground mb-4">Downloaded Scripts</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-400">{downloadedScripts.length}</div>
<div className="text-sm text-blue-300">Total Downloaded</div>
</div>
<div className="bg-green-500/10 border border-green-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-400">{filterCounts.updatableCount}</div>
<div className="text-sm text-green-300">Updatable</div>
</div>
<div className="bg-purple-500/10 border border-purple-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-purple-400">{filteredScripts.length}</div>
<div className="text-sm text-purple-300">Filtered Results</div>
</div>
</div>
</div>
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6"> <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
{/* Category Sidebar */} {/* Category Sidebar */}
@@ -412,6 +431,12 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
isLoadingFilters={isLoadingFilters} isLoadingFilters={isLoadingFilters}
/> />
{/* View Toggle */}
<ViewToggle
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{/* Scripts Grid */} {/* Scripts Grid */}
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? ( {filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
<div className="text-center py-12"> <div className="text-center py-12">
@@ -446,25 +471,47 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
</div> </div>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> viewMode === 'card' ? (
{filteredScripts.map((script, index) => { <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
// Add validation to ensure script has required properties {filteredScripts.map((script, index) => {
if (!script || typeof script !== 'object') { // Add validation to ensure script has required properties
return null; if (!script || typeof script !== 'object') {
} return null;
}
// Create a unique key by combining slug, name, and index to handle duplicates
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`; // Create a unique key by combining slug, name, and index to handle duplicates
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<ScriptCard return (
key={uniqueKey} <ScriptCard
script={script} key={uniqueKey}
onClick={handleCardClick} script={script}
/> onClick={handleCardClick}
); />
})} );
</div> })}
</div>
) : (
<div className="space-y-3">
{filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties
if (!script || typeof script !== 'object') {
return null;
}
// Create a unique key by combining slug, name, and index to handle duplicates
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<ScriptCardList
key={uniqueKey}
script={script}
onClick={handleCardClick}
/>
);
})}
</div>
)
)} )}
<ScriptDetailModal <ScriptDetailModal

View File

@@ -3,6 +3,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { Server } from '../../types/server'; import type { Server } from '../../types/server';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ColorCodedDropdown } from './ColorCodedDropdown';
import { SettingsModal } from './SettingsModal';
interface ExecutionModeModalProps { interface ExecutionModeModalProps {
isOpen: boolean; isOpen: boolean;
@@ -15,8 +18,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
const [servers, setServers] = useState<Server[]>([]); const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedMode, setSelectedMode] = useState<'local' | 'ssh'>('local');
const [selectedServer, setSelectedServer] = useState<Server | null>(null); const [selectedServer, setSelectedServer] = useState<Server | null>(null);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -24,6 +27,20 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
} }
}, [isOpen]); }, [isOpen]);
// Auto-select server when exactly one server is available
useEffect(() => {
if (isOpen && !loading && servers.length === 1) {
setSelectedServer(servers[0] ?? null);
}
}, [isOpen, loading, servers]);
// Refresh servers when settings modal closes
const handleSettingsModalClose = () => {
setSettingsModalOpen(false);
// Refetch servers to reflect any changes made in settings
void fetchServers();
};
const fetchServers = async () => { const fetchServers = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -33,7 +50,11 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
throw new Error('Failed to fetch servers'); throw new Error('Failed to fetch servers');
} }
const data = await response.json(); const data = await response.json();
setServers(data as Server[]); // Sort servers by name alphabetically
const sortedServers = (data as Server[]).sort((a, b) =>
(a.name ?? '').localeCompare(b.name ?? '')
);
setServers(sortedServers);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred'); setError(err instanceof Error ? err.message : 'An error occurred');
} finally { } finally {
@@ -42,167 +63,175 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
}; };
const handleExecute = () => { const handleExecute = () => {
if (selectedMode === 'ssh' && !selectedServer) { if (!selectedServer) {
setError('Please select a server for SSH execution'); setError('Please select a server for SSH execution');
return; return;
} }
onExecute(selectedMode, selectedServer ?? undefined); onExecute('ssh', selectedServer);
onClose(); onClose();
}; };
const handleModeChange = (mode: 'local' | 'ssh') => {
setSelectedMode(mode); const handleServerSelect = (server: Server | null) => {
if (mode === 'local') { setSelectedServer(server);
setSelectedServer(null);
}
}; };
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4"> <>
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
{/* Header */} <div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
<div className="flex items-center justify-between p-6 border-b border-border"> {/* Header */}
<h2 className="text-xl font-bold text-foreground">Execution Mode</h2> <div className="flex items-center justify-between p-6 border-b border-border">
<Button <h2 className="text-xl font-bold text-foreground">Select Server</h2>
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
{/* Content */}
<div className="p-6">
<div className="mb-6">
<h3 className="text-lg font-medium text-foreground mb-2">
Where would you like to execute &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">
<Button <Button
onClick={onClose} onClick={onClose}
variant="outline" variant="ghost"
size="default" size="icon"
className="text-muted-foreground hover:text-foreground"
> >
Cancel <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</Button> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<Button </svg>
onClick={handleExecute}
disabled={selectedMode === 'ssh' && !selectedServer}
variant="default"
size="default"
className={selectedMode === 'ssh' && !selectedServer ? 'bg-gray-400 cursor-not-allowed' : ''}
>
{selectedMode === 'local' ? 'Run Locally' : 'Run on Server'}
</Button> </Button>
</div> </div>
{/* Content */}
<div className="p-6">
{error && (
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-destructive">{error}</p>
</div>
</div>
</div>
)}
{loading ? (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
</div>
) : servers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">No servers configured</p>
<p className="text-xs mt-1">Add servers in Settings to execute scripts</p>
<Button
onClick={() => setSettingsModalOpen(true)}
variant="outline"
size="sm"
className="mt-3"
>
Open Server Settings
</Button>
</div>
) : servers.length === 1 ? (
/* Single Server Confirmation View */
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium text-foreground mb-2">
Install Script Confirmation
</h3>
<p className="text-sm text-muted-foreground">
Do you want to install &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> </div>
</div>
{/* Server Settings Modal */}
<SettingsModal
isOpen={settingsModalOpen}
onClose={handleSettingsModalClose}
/>
</>
); );
} }

View File

@@ -11,13 +11,23 @@ interface GeneralSettingsModalProps {
} }
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) { export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
const [activeTab, setActiveTab] = useState<'general' | 'github'>('general'); const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth'>('general');
const [githubToken, setGithubToken] = useState(''); const [githubToken, setGithubToken] = useState('');
const [saveFilter, setSaveFilter] = useState(false); const [saveFilter, setSaveFilter] = useState(false);
const [savedFilters, setSavedFilters] = useState<any>(null); const [savedFilters, setSavedFilters] = useState<any>(null);
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Auth state
const [authUsername, setAuthUsername] = useState('');
const [authPassword, setAuthPassword] = useState('');
const [authConfirmPassword, setAuthConfirmPassword] = useState('');
const [authEnabled, setAuthEnabled] = useState(false);
const [authHasCredentials, setAuthHasCredentials] = useState(false);
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
// Load existing settings when modal opens // Load existing settings when modal opens
useEffect(() => { useEffect(() => {
@@ -25,6 +35,8 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
void loadGithubToken(); void loadGithubToken();
void loadSaveFilter(); void loadSaveFilter();
void loadSavedFilters(); void loadSavedFilters();
void loadAuthCredentials();
void loadColorCodingSetting();
} }
}, [isOpen]); }, [isOpen]);
@@ -138,6 +150,129 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
} }
}; };
const loadColorCodingSetting = async () => {
try {
const response = await fetch('/api/settings/color-coding');
if (response.ok) {
const data = await response.json();
setColorCodingEnabled(Boolean(data.enabled));
}
} catch (error) {
console.error('Error loading color coding setting:', error);
}
};
const saveColorCodingSetting = async (enabled: boolean) => {
try {
const response = await fetch('/api/settings/color-coding', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setColorCodingEnabled(enabled);
setMessage({ type: 'success', text: 'Color coding setting saved successfully' });
setTimeout(() => setMessage(null), 3000);
} else {
setMessage({ type: 'error', text: 'Failed to save color coding setting' });
setTimeout(() => setMessage(null), 3000);
}
} catch (error) {
console.error('Error saving color coding setting:', error);
setMessage({ type: 'error', text: 'Failed to save color coding setting' });
setTimeout(() => setMessage(null), 3000);
}
};
const loadAuthCredentials = async () => {
setAuthLoading(true);
try {
const response = await fetch('/api/settings/auth-credentials');
if (response.ok) {
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean };
setAuthUsername(data.username ?? '');
setAuthEnabled(data.enabled ?? false);
setAuthHasCredentials(data.hasCredentials ?? false);
setAuthSetupCompleted(data.setupCompleted ?? false);
}
} catch (error) {
console.error('Error loading auth credentials:', error);
} finally {
setAuthLoading(false);
}
};
const saveAuthCredentials = async () => {
if (authPassword !== authConfirmPassword) {
setMessage({ type: 'error', text: 'Passwords do not match' });
return;
}
setAuthLoading(true);
setMessage(null);
try {
const response = await fetch('/api/settings/auth-credentials', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: authUsername,
password: authPassword,
enabled: authEnabled
}),
});
if (response.ok) {
setMessage({ type: 'success', text: 'Authentication credentials updated successfully!' });
setAuthPassword('');
setAuthConfirmPassword('');
void loadAuthCredentials();
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save credentials' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to save credentials' });
} finally {
setAuthLoading(false);
}
};
const toggleAuthEnabled = async (enabled: boolean) => {
setAuthLoading(true);
setMessage(null);
try {
const response = await fetch('/api/settings/auth-credentials', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setAuthEnabled(enabled);
setMessage({
type: 'success',
text: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully!`
});
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to update auth status' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to update auth status' });
} finally {
setAuthLoading(false);
}
};
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
@@ -185,6 +320,18 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
> >
GitHub GitHub
</Button> </Button>
<Button
onClick={() => setActiveTab('auth')}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
activeTab === 'auth'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Authentication
</Button>
</nav> </nav>
</div> </div>
@@ -237,6 +384,16 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div> </div>
)} )}
</div> </div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Server Color Coding</h4>
<p className="text-sm text-muted-foreground mb-4">Enable color coding for servers to visually distinguish them throughout the application.</p>
<Toggle
checked={colorCodingEnabled}
onCheckedChange={saveColorCodingSetting}
label="Enable server color coding"
/>
</div>
</div> </div>
</div> </div>
)} )}
@@ -301,6 +458,134 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div> </div>
</div> </div>
)} )}
{activeTab === 'auth' && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Authentication Settings</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure authentication to secure access to your application.
</p>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Authentication Status</h4>
<p className="text-sm text-muted-foreground mb-4">
{authSetupCompleted
? (authHasCredentials
? `Authentication is ${authEnabled ? 'enabled' : 'disabled'}. Current username: ${authUsername}`
: `Authentication is ${authEnabled ? 'enabled' : 'disabled'}. No credentials configured.`)
: 'Authentication setup has not been completed yet.'
}
</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Enable Authentication</p>
<p className="text-xs text-muted-foreground">
{authEnabled
? 'Authentication is required on every page load'
: 'Authentication is optional'
}
</p>
</div>
<Toggle
checked={authEnabled}
onCheckedChange={toggleAuthEnabled}
disabled={authLoading || !authSetupCompleted}
label="Enable authentication"
/>
</div>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
<p className="text-sm text-muted-foreground mb-4">
Change your username and password for authentication.
</p>
<div className="space-y-3">
<div>
<label htmlFor="auth-username" className="block text-sm font-medium text-foreground mb-1">
Username
</label>
<Input
id="auth-username"
type="text"
placeholder="Enter username"
value={authUsername}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthUsername(e.target.value)}
disabled={authLoading}
className="w-full"
minLength={3}
/>
</div>
<div>
<label htmlFor="auth-password" className="block text-sm font-medium text-foreground mb-1">
New Password
</label>
<Input
id="auth-password"
type="password"
placeholder="Enter new password"
value={authPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthPassword(e.target.value)}
disabled={authLoading}
className="w-full"
minLength={6}
/>
</div>
<div>
<label htmlFor="auth-confirm-password" className="block text-sm font-medium text-foreground mb-1">
Confirm Password
</label>
<Input
id="auth-confirm-password"
type="password"
placeholder="Confirm new password"
value={authConfirmPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthConfirmPassword(e.target.value)}
disabled={authLoading}
className="w-full"
minLength={6}
/>
</div>
{message && (
<div className={`p-3 rounded-md text-sm ${
message.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}>
{message.text}
</div>
)}
<div className="flex gap-2">
<Button
onClick={saveAuthCredentials}
disabled={authLoading || !authUsername.trim() || !authPassword.trim() || !authConfirmPassword.trim()}
className="flex-1"
>
{authLoading ? 'Saving...' : 'Update Credentials'}
</Button>
<Button
onClick={loadAuthCredentials}
disabled={authLoading}
variant="outline"
>
{authLoading ? 'Loading...' : 'Refresh'}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@ import { Terminal } from './Terminal';
import { StatusBadge } from './Badge'; import { StatusBadge } from './Badge';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ScriptInstallationCard } from './ScriptInstallationCard'; import { ScriptInstallationCard } from './ScriptInstallationCard';
import { getContrastColor } from '../../lib/colorUtils';
interface InstalledScript { interface InstalledScript {
id: number; id: number;
@@ -17,6 +18,7 @@ interface InstalledScript {
server_ip: string | null; server_ip: string | null;
server_user: string | null; server_user: string | null;
server_password: string | null; server_password: string | null;
server_color: string | null;
installation_date: string; installation_date: string;
status: 'in_progress' | 'success' | 'failed'; status: 'in_progress' | 'success' | 'failed';
output_log: string | null; output_log: string | null;
@@ -26,6 +28,8 @@ export function InstalledScriptsTab() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all'); const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
const [serverFilter, setServerFilter] = useState<string>('all'); const [serverFilter, setServerFilter] = useState<string>('all');
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('script_name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null); const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
const [editingScriptId, setEditingScriptId] = useState<number | null>(null); const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' }); const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
@@ -154,20 +158,58 @@ export function InstalledScriptsTab() {
} }
}, [scripts.length, serversData?.servers, cleanupMutation]); }, [scripts.length, serversData?.servers, cleanupMutation]);
// Filter scripts based on search and filters // Filter and sort scripts
const filteredScripts = scripts.filter((script: InstalledScript) => { const filteredScripts = scripts
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) || .filter((script: InstalledScript) => {
(script.container_id?.includes(searchTerm) ?? false) || const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false); (script.container_id?.includes(searchTerm) ?? false) ||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
const matchesServer = serverFilter === 'all' ||
(serverFilter === 'local' && !script.server_name) || const matchesServer = serverFilter === 'all' ||
(script.server_name === serverFilter); (serverFilter === 'local' && !script.server_name) ||
(script.server_name === serverFilter);
return matchesSearch && matchesStatus && matchesServer;
}); return matchesSearch && matchesStatus && matchesServer;
})
.sort((a: InstalledScript, b: InstalledScript) => {
let aValue: any;
let bValue: any;
switch (sortField) {
case 'script_name':
aValue = a.script_name.toLowerCase();
bValue = b.script_name.toLowerCase();
break;
case 'container_id':
aValue = a.container_id ?? '';
bValue = b.container_id ?? '';
break;
case 'server_name':
aValue = a.server_name ?? 'Local';
bValue = b.server_name ?? 'Local';
break;
case 'status':
aValue = a.status;
bValue = b.status;
break;
case 'installation_date':
aValue = new Date(a.installation_date).getTime();
bValue = new Date(b.installation_date).getTime();
break;
default:
return 0;
}
if (aValue < bValue) {
return sortDirection === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return sortDirection === 'asc' ? 1 : -1;
}
return 0;
});
// Get unique servers for filter // Get unique servers for filter
const uniqueServers: string[] = []; const uniqueServers: string[] = [];
@@ -298,6 +340,15 @@ export function InstalledScriptsTab() {
setAutoDetectServerId(''); setAutoDetectServerId('');
}; };
const handleSort = (field: 'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date') => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString(); return new Date(dateString).toLocaleString();
@@ -652,20 +703,70 @@ export function InstalledScriptsTab() {
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-muted"> <thead className="bg-muted">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"> <th
Script Name className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('script_name')}
>
<div className="flex items-center space-x-1">
<span>Script Name</span>
{sortField === 'script_name' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"> <th
Container ID className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('container_id')}
>
<div className="flex items-center space-x-1">
<span>Container ID</span>
{sortField === 'container_id' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"> <th
Server className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('server_name')}
>
<div className="flex items-center space-x-1">
<span>Server</span>
{sortField === 'server_name' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"> <th
Status className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('status')}
>
<div className="flex items-center space-x-1">
<span>Status</span>
{sortField === 'status' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"> <th
Installation Date className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('installation_date')}
>
<div className="flex items-center space-x-1">
<span>Installation Date</span>
{sortField === 'installation_date' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions Actions
@@ -674,7 +775,11 @@ export function InstalledScriptsTab() {
</thead> </thead>
<tbody className="bg-card divide-y divide-gray-200"> <tbody className="bg-card divide-y divide-gray-200">
{filteredScripts.map((script) => ( {filteredScripts.map((script) => (
<tr key={script.id} className="hover:bg-accent"> <tr
key={script.id}
className="hover:bg-accent"
style={{ borderLeft: `4px solid ${script.server_color ?? 'transparent'}` }}
>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? ( {editingScriptId === script.id ? (
<div className="space-y-2"> <div className="space-y-2">
@@ -712,8 +817,14 @@ export function InstalledScriptsTab() {
)} )}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-muted-foreground"> <span
{script.server_name ?? 'Local'} className="text-sm px-3 py-1 rounded"
style={{
backgroundColor: script.server_color ?? 'transparent',
color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
}}
>
{script.server_name ?? '-'}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">

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 { Button } from './ui/button';
import { StatusBadge } from './Badge'; import { StatusBadge } from './Badge';
import { getContrastColor } from '../../lib/colorUtils';
interface InstalledScript { interface InstalledScript {
id: number; id: number;
@@ -13,6 +14,7 @@ interface InstalledScript {
server_ip: string | null; server_ip: string | null;
server_user: string | null; server_user: string | null;
server_password: string | null; server_password: string | null;
server_color: string | null;
installation_date: string; installation_date: string;
status: 'in_progress' | 'success' | 'failed'; status: 'in_progress' | 'success' | 'failed';
output_log: string | null; output_log: string | null;
@@ -50,7 +52,10 @@ export function ScriptInstallationCard({
}; };
return ( return (
<div className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"> <div
className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
style={{ borderLeft: `4px solid ${script.server_color ?? 'transparent'}` }}
>
{/* Header with Script Name and Status */} {/* Header with Script Name and Status */}
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -102,9 +107,15 @@ export function ScriptInstallationCard({
{/* Server */} {/* Server */}
<div> <div>
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div> <div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
<div className="text-sm text-muted-foreground"> <span
{script.server_name ?? 'Local'} className="text-sm px-3 py-1 rounded inline-block"
</div> style={{
backgroundColor: script.server_color ?? 'transparent',
color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
}}
>
{script.server_name ?? '-'}
</span>
</div> </div>
{/* Installation Date */} {/* Installation Date */}

View File

@@ -3,9 +3,11 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { ScriptCard } from './ScriptCard'; import { ScriptCard } from './ScriptCard';
import { ScriptCardList } from './ScriptCardList';
import { ScriptDetailModal } from './ScriptDetailModal'; import { ScriptDetailModal } from './ScriptDetailModal';
import { CategorySidebar } from './CategorySidebar'; import { CategorySidebar } from './CategorySidebar';
import { FilterBar, type FilterState } from './FilterBar'; import { FilterBar, type FilterState } from './FilterBar';
import { ViewToggle } from './ViewToggle';
import { Button } from './ui/button'; import { Button } from './ui/button';
import type { ScriptCard as ScriptCardType } from '~/types/script'; import type { ScriptCard as ScriptCardType } from '~/types/script';
@@ -19,6 +21,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [filters, setFilters] = useState<FilterState>({ const [filters, setFilters] = useState<FilterState>({
searchQuery: '', searchQuery: '',
showUpdatable: null, showUpdatable: null,
@@ -37,7 +40,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
{ enabled: !!selectedSlug } { enabled: !!selectedSlug }
); );
// Load SAVE_FILTER setting and saved filters on component mount // Load SAVE_FILTER setting, saved filters, and view mode on component mount
useEffect(() => { useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
try { try {
@@ -60,6 +63,16 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
} }
} }
} }
// Load view mode
const viewModeResponse = await fetch('/api/settings/view-mode');
if (viewModeResponse.ok) {
const viewModeData = await viewModeResponse.json();
const viewMode = viewModeData.viewMode;
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
setViewMode(viewMode);
}
}
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
} finally { } finally {
@@ -93,6 +106,29 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [filters, saveFiltersEnabled, isLoadingFilters]); }, [filters, saveFiltersEnabled, isLoadingFilters]);
// Save view mode when it changes
useEffect(() => {
if (isLoadingFilters) return;
const saveViewMode = async () => {
try {
await fetch('/api/settings/view-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ viewMode }),
});
} catch (error) {
console.error('Error saving view mode:', error);
}
};
// Debounce the save operation
const timeoutId = setTimeout(() => void saveViewMode(), 300);
return () => clearTimeout(timeoutId);
}, [viewMode, isLoadingFilters]);
// Extract categories from metadata // Extract categories from metadata
const categories = React.useMemo((): string[] => { const categories = React.useMemo((): string[] => {
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return []; if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
@@ -399,6 +435,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
isLoadingFilters={isLoadingFilters} isLoadingFilters={isLoadingFilters}
/> />
{/* View Toggle */}
<ViewToggle
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */} {/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
<div className="hidden mb-8"> <div className="hidden mb-8">
<div className="relative max-w-md mx-auto"> <div className="relative max-w-md mx-auto">
@@ -474,25 +516,47 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
</div> </div>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> viewMode === 'card' ? (
{filteredScripts.map((script, index) => { <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
// Add validation to ensure script has required properties {filteredScripts.map((script, index) => {
if (!script || typeof script !== 'object') { // Add validation to ensure script has required properties
return null; if (!script || typeof script !== 'object') {
} return null;
}
// Create a unique key by combining slug, name, and index to handle duplicates
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`; // Create a unique key by combining slug, name, and index to handle duplicates
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<ScriptCard return (
key={uniqueKey} <ScriptCard
script={script} key={uniqueKey}
onClick={handleCardClick} script={script}
/> onClick={handleCardClick}
); />
})} );
</div> })}
</div>
) : (
<div className="space-y-3">
{filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties
if (!script || typeof script !== 'object') {
return null;
}
// Create a unique key by combining slug, name, and index to handle duplicates
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<ScriptCardList
key={uniqueKey}
script={script}
onClick={handleCardClick}
/>
);
})}
</div>
)
)} )}
<ScriptDetailModal <ScriptDetailModal

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import type { CreateServerData } from '../../types/server'; import type { CreateServerData } from '../../types/server';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { SSHKeyInput } from './SSHKeyInput';
interface ServerFormProps { interface ServerFormProps {
onSubmit: (data: CreateServerData) => void; onSubmit: (data: CreateServerData) => void;
@@ -18,13 +19,35 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
ip: '', ip: '',
user: '', user: '',
password: '', password: '',
auth_type: 'password',
ssh_key: '',
ssh_key_passphrase: '',
ssh_port: 22,
color: '#3b82f6',
} }
); );
const [errors, setErrors] = useState<Partial<CreateServerData>>({}); const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
const [sshKeyError, setSshKeyError] = useState<string>('');
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
useEffect(() => {
const loadColorCodingSetting = async () => {
try {
const response = await fetch('/api/settings/color-coding');
if (response.ok) {
const data = await response.json();
setColorCodingEnabled(Boolean(data.enabled));
}
} catch (error) {
console.error('Error loading color coding setting:', error);
}
};
void loadColorCodingSetting();
}, []);
const validateForm = (): boolean => { const validateForm = (): boolean => {
const newErrors: Partial<CreateServerData> = {}; const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
if (!formData.name.trim()) { if (!formData.name.trim()) {
newErrors.name = 'Server name is required'; newErrors.name = 'Server name is required';
@@ -44,12 +67,36 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
newErrors.user = 'Username is required'; newErrors.user = 'Username is required';
} }
if (!formData.password.trim()) { // Validate SSH port
newErrors.password = 'Password is required'; if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) {
newErrors.ssh_port = 'SSH port must be between 1 and 65535';
}
// Validate authentication based on auth_type
const authType = formData.auth_type ?? 'password';
if (authType === 'password' || authType === 'both') {
if (!formData.password?.trim()) {
newErrors.password = 'Password is required for password authentication';
}
}
if (authType === 'key' || authType === 'both') {
if (!formData.ssh_key?.trim()) {
newErrors.ssh_key = 'SSH key is required for key authentication';
}
}
// Check if at least one authentication method is provided
if (authType === 'both') {
if (!formData.password?.trim() && !formData.ssh_key?.trim()) {
newErrors.password = 'At least one authentication method (password or SSH key) is required';
newErrors.ssh_key = 'At least one authentication method (password or SSH key) is required';
}
} }
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0 && !sshKeyError;
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -57,13 +104,23 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
if (validateForm()) { if (validateForm()) {
onSubmit(formData); onSubmit(formData);
if (!isEditing) { if (!isEditing) {
setFormData({ name: '', ip: '', user: '', password: '' }); setFormData({
name: '',
ip: '',
user: '',
password: '',
auth_type: 'password',
ssh_key: '',
ssh_key_passphrase: '',
ssh_port: 22,
color: '#3b82f6'
});
} }
} }
}; };
const handleChange = (field: keyof CreateServerData) => ( const handleChange = (field: keyof CreateServerData) => (
e: React.ChangeEvent<HTMLInputElement> e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => { ) => {
setFormData(prev => ({ ...prev, [field]: e.target.value })); setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Clear error when user starts typing // Clear error when user starts typing
@@ -72,8 +129,15 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
} }
}; };
const handleSSHKeyChange = (value: string) => {
setFormData(prev => ({ ...prev, ssh_key: value }));
if (errors.ssh_key) {
setErrors(prev => ({ ...prev, ssh_key: undefined }));
}
};
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1"> <label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
@@ -126,14 +190,72 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>} {errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
</div> </div>
<div>
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
SSH Port
</label>
<input
type="number"
id="ssh_port"
value={formData.ssh_port ?? 22}
onChange={handleChange('ssh_port')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ssh_port ? 'border-destructive' : 'border-border'
}`}
placeholder="22"
min="1"
max="65535"
/>
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
</div>
<div>
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
Authentication Type *
</label>
<select
id="auth_type"
value={formData.auth_type ?? 'password'}
onChange={handleChange('auth_type')}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
>
<option value="password">Password Only</option>
<option value="key">SSH Key Only</option>
<option value="both">Both Password & SSH Key</option>
</select>
</div>
{colorCodingEnabled && (
<div>
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
Server Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id="color"
value={formData.color ?? '#3b82f6'}
onChange={handleChange('color')}
className="w-20 h-10 rounded cursor-pointer border border-border"
/>
<span className="text-sm text-muted-foreground">
Choose a color to identify this server
</span>
</div>
</div>
)}
</div>
{/* Password Authentication */}
{(formData.auth_type === 'password' || formData.auth_type === 'both') && (
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1"> <label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
Password * Password {formData.auth_type === 'both' ? '(Optional)' : '*'}
</label> </label>
<input <input
type="password" type="password"
id="password" id="password"
value={formData.password} value={formData.password ?? ''}
onChange={handleChange('password')} onChange={handleChange('password')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.password ? 'border-destructive' : 'border-border' errors.password ? 'border-destructive' : 'border-border'
@@ -142,7 +264,42 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
/> />
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>} {errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
</div> </div>
</div> )}
{/* SSH Key Authentication */}
{(formData.auth_type === 'key' || formData.auth_type === 'both') && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
SSH Private Key {formData.auth_type === 'both' ? '(Optional)' : '*'}
</label>
<SSHKeyInput
value={formData.ssh_key ?? ''}
onChange={handleSSHKeyChange}
onError={setSshKeyError}
/>
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
</div>
<div>
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
SSH Key Passphrase (Optional)
</label>
<input
type="password"
id="ssh_key_passphrase"
value={formData.ssh_key_passphrase ?? ''}
onChange={handleChange('ssh_key_passphrase')}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
placeholder="Enter passphrase for encrypted key"
/>
<p className="mt-1 text-xs text-muted-foreground">
Only required if your SSH key is encrypted with a passphrase
</p>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4"> <div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
{isEditing && onCancel && ( {isEditing && onCancel && (

View File

@@ -85,7 +85,11 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{servers.map((server) => ( {servers.map((server) => (
<div key={server.id} className="bg-card border border-border rounded-lg p-4 shadow-sm"> <div
key={server.id}
className="bg-card border border-border rounded-lg p-4 shadow-sm"
style={{ borderLeft: `4px solid ${server.color ?? 'transparent'}` }}
>
{editingId === server.id ? ( {editingId === server.id ? (
<div> <div>
<h4 className="text-lg font-medium text-foreground mb-4">Edit Server</h4> <h4 className="text-lg font-medium text-foreground mb-4">Edit Server</h4>
@@ -95,6 +99,11 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
ip: server.ip, ip: server.ip,
user: server.user, user: server.user,
password: server.password, password: server.password,
auth_type: server.auth_type,
ssh_key: server.ssh_key,
ssh_key_passphrase: server.ssh_key_passphrase,
ssh_port: server.ssh_port,
color: server.color,
}} }}
onSubmit={handleUpdate} onSubmit={handleUpdate}
isEditing={true} isEditing={true}

View File

@@ -31,7 +31,11 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
throw new Error('Failed to fetch servers'); throw new Error('Failed to fetch servers');
} }
const data = await response.json(); const data = await response.json();
setServers(data as Server[]); // Sort servers by name alphabetically
const sortedServers = (data as Server[]).sort((a, b) =>
(a.name ?? '').localeCompare(b.name ?? '')
);
setServers(sortedServers);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred'); setError(err instanceof Error ? err.message : 'An error occurred');
} finally { } finally {

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 [mobileInput, setMobileInput] = useState('');
const [showMobileInput, setShowMobileInput] = useState(false); const [showMobileInput, setShowMobileInput] = useState(false);
const [lastInputSent, setLastInputSent] = useState<string | null>(null); const [lastInputSent, setLastInputSent] = useState<string | null>(null);
const [inWhiptailSession, setInWhiptailSession] = useState(false);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [isStopped, setIsStopped] = useState(false); const [isStopped, setIsStopped] = useState(false);
const [isTerminalReady, setIsTerminalReady] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<any>(null); const xtermRef = useRef<any>(null);
const fitAddonRef = useRef<any>(null); const fitAddonRef = useRef<any>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const [executionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); const inputHandlerRef = useRef<((data: string) => void) | null>(null);
const [executionId, setExecutionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
const isConnectingRef = useRef<boolean>(false); const isConnectingRef = useRef<boolean>(false);
const hasConnectedRef = useRef<boolean>(false); const hasConnectedRef = useRef<boolean>(false);
@@ -53,22 +54,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
break; break;
case 'output': case 'output':
// Write directly to terminal - xterm.js handles ANSI codes natively // Write directly to terminal - xterm.js handles ANSI codes natively
// Detect whiptail sessions and clear immediately xtermRef.current.write(message.data);
if (message.data.includes('whiptail') || message.data.includes('dialog') || message.data.includes('Choose an option')) {
setInWhiptailSession(true);
// Clear terminal immediately when whiptail starts
xtermRef.current.clear();
xtermRef.current.write('\x1b[2J\x1b[H');
}
// Check for screen clearing sequences and handle them properly
if (message.data.includes('\x1b[2J') || message.data.includes('\x1b[H\x1b[2J')) {
// This is a clear screen sequence, ensure it's processed correctly
xtermRef.current.write(message.data);
} else {
// Let xterm handle all ANSI sequences naturally
xtermRef.current.write(message.data);
}
break; break;
case 'error': case 'error':
// Check if this looks like ANSI terminal output (contains escape codes) // Check if this looks like ANSI terminal output (contains escape codes)
@@ -87,8 +73,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
} }
break; break;
case 'end': case 'end':
// Reset whiptail session setIsRunning(false);
setInWhiptailSession(false);
// Check if this is an LXC creation script // Check if this is an LXC creation script
const isLxcCreation = scriptPath.includes('ct/') || const isLxcCreation = scriptPath.includes('ct/') ||
@@ -107,10 +92,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
} else { } else {
xtermRef.current.writeln(`${prefix}${message.data}`); xtermRef.current.writeln(`${prefix}${message.data}`);
} }
setIsRunning(false);
break; break;
} }
}, [scriptPath, containerId, scriptName, inWhiptailSession]); }, [scriptPath, containerId, scriptName]);
// Ensure we're on the client side // Ensure we're on the client side
useEffect(() => { useEffect(() => {
@@ -198,6 +182,20 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
terminal.refresh(0, terminal.rows - 1); terminal.refresh(0, terminal.rows - 1);
// Ensure cursor is properly positioned // Ensure cursor is properly positioned
terminal.focus(); terminal.focus();
// Force focus on the terminal element
terminalElement.focus();
terminalElement.click();
// Add click handler to ensure terminal stays focused
const focusHandler = () => {
terminal.focus();
terminalElement.focus();
};
terminalElement.addEventListener('click', focusHandler);
// Store the handler for cleanup
(terminalElement as any).focusHandler = focusHandler;
}, 100); }, 100);
// Fit after a small delay to ensure proper sizing // Fit after a small delay to ensure proper sizing
@@ -231,18 +229,10 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
// Store references // Store references
xtermRef.current = terminal; xtermRef.current = terminal;
fitAddonRef.current = fitAddon; fitAddonRef.current = fitAddon;
// Mark terminal as ready
setIsTerminalReady(true);
// Handle terminal input
terminal.onData((data) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const message = {
action: 'input',
executionId,
input: data
};
wsRef.current.send(JSON.stringify(message));
}
});
return () => { return () => {
terminal.dispose(); terminal.dispose();
@@ -254,18 +244,51 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
void initTerminal(); void initTerminal();
}, 50); }, 50);
return () => { return () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (terminalElement && (terminalElement as any).resizeHandler) { if (terminalElement && (terminalElement as any).resizeHandler) {
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any); window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
} }
if (xtermRef.current) { if (terminalElement && (terminalElement as any).focusHandler) {
xtermRef.current.dispose(); terminalElement.removeEventListener('click', (terminalElement as any).focusHandler as (this: HTMLDivElement, ev: PointerEvent) => any);
xtermRef.current = null; }
fitAddonRef.current = null; if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
setIsTerminalReady(false);
}
};
}, [isClient, isMobile]);
// Handle terminal input with current executionId
useEffect(() => {
if (!isTerminalReady || !xtermRef.current) {
return;
}
const terminal = xtermRef.current;
const handleData = (data: string) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const message = {
action: 'input',
executionId,
input: data
};
wsRef.current.send(JSON.stringify(message));
} }
}; };
}, [executionId, isClient, inWhiptailSession, isMobile]);
// Store the handler reference
inputHandlerRef.current = handleData;
terminal.onData(handleData);
return () => {
// Clear the handler reference
inputHandlerRef.current = null;
};
}, [executionId, isTerminalReady]); // Depend on terminal ready state
useEffect(() => { useEffect(() => {
// Prevent multiple connections in React Strict Mode // Prevent multiple connections in React Strict Mode
@@ -298,10 +321,14 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
// Only auto-start on initial connection, not on reconnections // Only auto-start on initial connection, not on reconnections
if (isInitialConnection && !isRunning) { if (isInitialConnection && !isRunning) {
// Generate a new execution ID for the initial run
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
setExecutionId(newExecutionId);
const message = { const message = {
action: 'start', action: 'start',
scriptPath, scriptPath,
executionId, executionId: newExecutionId,
mode, mode,
server, server,
isUpdate, isUpdate,
@@ -314,7 +341,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data as string) as TerminalMessage; const message = JSON.parse(event.data as string) as TerminalMessage;
console.log('WebSocket message received:', message);
handleMessage(message); handleMessage(message);
} catch (error) { } catch (error) {
console.error('Error parsing WebSocket message:', error); console.error('Error parsing WebSocket message:', error);
@@ -346,15 +372,19 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
wsRef.current.close(); wsRef.current.close();
} }
}; };
}, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile]); }, [scriptPath, mode, server, isUpdate, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
const startScript = () => { const startScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
// Generate a new execution ID for each script run
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
setExecutionId(newExecutionId);
setIsStopped(false); setIsStopped(false);
wsRef.current.send(JSON.stringify({ wsRef.current.send(JSON.stringify({
action: 'start', action: 'start',
scriptPath, scriptPath,
executionId, executionId: newExecutionId,
mode, mode,
server, server,
isUpdate, isUpdate,

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 body = await request.json();
const { name, ip, user, password }: CreateServerData = body; const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
// Validate required fields // Validate required fields
if (!name || !ip || !user || !password) { if (!name || !ip || !user) {
return NextResponse.json( return NextResponse.json(
{ error: 'Missing required fields' }, { error: 'Missing required fields: name, ip, and user are required' },
{ status: 400 } { status: 400 }
); );
} }
// Validate SSH port
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
return NextResponse.json(
{ error: 'SSH port must be between 1 and 65535' },
{ status: 400 }
);
}
// Validate authentication based on auth_type
const authType = auth_type ?? 'password';
if (authType === 'password' || authType === 'both') {
if (!password?.trim()) {
return NextResponse.json(
{ error: 'Password is required for password authentication' },
{ status: 400 }
);
}
}
if (authType === 'key' || authType === 'both') {
if (!ssh_key?.trim()) {
return NextResponse.json(
{ error: 'SSH key is required for key authentication' },
{ status: 400 }
);
}
}
// Check if at least one authentication method is provided
if (authType === 'both') {
if (!password?.trim() && !ssh_key?.trim()) {
return NextResponse.json(
{ error: 'At least one authentication method (password or SSH key) is required' },
{ status: 400 }
);
}
}
const db = getDatabase(); const db = getDatabase();
// Check if server exists // Check if server exists
@@ -73,7 +112,17 @@ export async function PUT(
); );
} }
const result = db.updateServer(id, { name, ip, user, password }); const result = db.updateServer(id, {
name,
ip,
user,
password,
auth_type: authType,
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
color
});
return NextResponse.json( return NextResponse.json(
{ {

View File

@@ -20,18 +20,67 @@ export async function GET() {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { name, ip, user, password }: CreateServerData = body; const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
// Validate required fields // Validate required fields
if (!name || !ip || !user || !password) { if (!name || !ip || !user) {
return NextResponse.json( return NextResponse.json(
{ error: 'Missing required fields' }, { error: 'Missing required fields: name, ip, and user are required' },
{ status: 400 } { status: 400 }
); );
} }
// Validate SSH port
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
return NextResponse.json(
{ error: 'SSH port must be between 1 and 65535' },
{ status: 400 }
);
}
// Validate authentication based on auth_type
const authType = auth_type ?? 'password';
if (authType === 'password' || authType === 'both') {
if (!password?.trim()) {
return NextResponse.json(
{ error: 'Password is required for password authentication' },
{ status: 400 }
);
}
}
if (authType === 'key' || authType === 'both') {
if (!ssh_key?.trim()) {
return NextResponse.json(
{ error: 'SSH key is required for key authentication' },
{ status: 400 }
);
}
}
// Check if at least one authentication method is provided
if (authType === 'both') {
if (!password?.trim() && !ssh_key?.trim()) {
return NextResponse.json(
{ error: 'At least one authentication method (password or SSH key) is required' },
{ status: 400 }
);
}
}
const db = getDatabase(); const db = getDatabase();
const result = db.createServer({ name, ip, user, password }); const result = db.createServer({
name,
ip,
user,
password,
auth_type: authType,
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
color
});
return NextResponse.json( return NextResponse.json(
{ {

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 { try {
const filters = JSON.parse(filtersMatch[1]!); const filtersJson = filtersMatch[1]?.trim();
// Check if filters JSON is empty or invalid
if (!filtersJson || filtersJson === '') {
return NextResponse.json({ filters: null });
}
const filters = JSON.parse(filtersJson);
// Validate the parsed filters // Validate the parsed filters
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder']; const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];

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 "~/styles/globals.css";
import { type Metadata } from "next"; import { type Metadata, type Viewport } from "next";
import { Geist } from "next/font/google"; import { Geist } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react"; import { TRPCReactProvider } from "~/trpc/react";
import { AuthProvider } from "./_components/AuthProvider";
import { AuthGuard } from "./_components/AuthGuard";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PVE Scripts local", title: "PVE Scripts local",
description: "Manage and execute Proxmox helper scripts locally with live output streaming", description: "Manage and execute Proxmox helper scripts locally with live output streaming",
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
icons: [ icons: [
{ rel: "icon", url: "/favicon.png", type: "image/png" }, { rel: "icon", url: "/favicon.png", type: "image/png" },
{ rel: "icon", url: "/favicon.ico", sizes: "any" }, { rel: "icon", url: "/favicon.ico", sizes: "any" },
@@ -16,6 +17,12 @@ export const metadata: Metadata = {
], ],
}; };
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
const geist = Geist({ const geist = Geist({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-jetbrains-mono", variable: "--font-jetbrains-mono",
@@ -40,7 +47,13 @@ export default function RootLayout({
className="bg-background text-foreground transition-colors" className="bg-background text-foreground transition-colors"
suppressHydrationWarning={true} suppressHydrationWarning={true}
> >
<TRPCReactProvider>{children}</TRPCReactProvider> <TRPCReactProvider>
<AuthProvider>
<AuthGuard>
{children}
</AuthGuard>
</AuthProvider>
</TRPCReactProvider>
</body> </body>
</html> </html>
); );

View File

@@ -12,12 +12,41 @@ import { SettingsButton } from './_components/SettingsButton';
import { VersionDisplay } from './_components/VersionDisplay'; import { VersionDisplay } from './_components/VersionDisplay';
import { Button } from './_components/ui/button'; import { Button } from './_components/ui/button';
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react'; import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
import { api } from '~/trpc/react';
export default function Home() { export default function Home() {
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null); const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts'); const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
// Fetch data for script counts
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData } = api.scripts.getCtScripts.useQuery();
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
// Calculate script counts
const scriptCounts = {
available: scriptCardsData?.success ? scriptCardsData.cards?.length ?? 0 : 0,
downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// Count scripts that are both in GitHub data and have local versions
const githubScripts = scriptCardsData.cards ?? [];
const localScripts = localScriptsData.scripts ?? [];
return githubScripts.filter(script => {
if (!script?.name) return false;
return localScripts.some(local => {
if (!local?.name) return false;
const localName = local.name.replace(/\.sh$/, '');
return localName.toLowerCase() === script.name.toLowerCase() ||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
});
}).length;
})(),
installed: installedScriptsData?.scripts?.length ?? 0
};
const scrollToTerminal = () => { const scrollToTerminal = () => {
if (terminalRef.current) { if (terminalRef.current) {
// Get the element's position and scroll with a small offset for better mobile experience // Get the element's position and scroll with a small offset for better mobile experience
@@ -83,6 +112,9 @@ export default function Home() {
<Package className="h-4 w-4" /> <Package className="h-4 w-4" />
<span className="hidden sm:inline">Available Scripts</span> <span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">Available</span> <span className="sm:hidden">Available</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.available}
</span>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -96,6 +128,9 @@ export default function Home() {
<HardDrive className="h-4 w-4" /> <HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Downloaded Scripts</span> <span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">Downloaded</span> <span className="sm:hidden">Downloaded</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.downloaded}
</span>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -109,6 +144,9 @@ export default function Home() {
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">Installed Scripts</span> <span className="hidden sm:inline">Installed Scripts</span>
<span className="sm:hidden">Installed</span> <span className="sm:hidden">Installed</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.installed}
</span>
</Button> </Button>
</nav> </nav>
</div> </div>

View File

@@ -25,6 +25,14 @@ export const env = createEnv({
WEBSOCKET_PORT: z.string().default("3001"), WEBSOCKET_PORT: z.string().default("3001"),
// GitHub Configuration // GitHub Configuration
GITHUB_TOKEN: z.string().optional(), GITHUB_TOKEN: z.string().optional(),
// Authentication Configuration
AUTH_USERNAME: z.string().optional(),
AUTH_PASSWORD_HASH: z.string().optional(),
AUTH_ENABLED: z.string().optional(),
AUTH_SETUP_COMPLETED: z.string().optional(),
JWT_SECRET: z.string().optional(),
// Server Color Coding Configuration
SERVER_COLOR_CODING_ENABLED: z.string().optional(),
}, },
/** /**
@@ -56,6 +64,14 @@ export const env = createEnv({
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT, WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
// GitHub Configuration // GitHub Configuration
GITHUB_TOKEN: process.env.GITHUB_TOKEN, GITHUB_TOKEN: process.env.GITHUB_TOKEN,
// Authentication Configuration
AUTH_USERNAME: process.env.AUTH_USERNAME,
AUTH_PASSWORD_HASH: process.env.AUTH_PASSWORD_HASH,
AUTH_ENABLED: process.env.AUTH_ENABLED,
AUTH_SETUP_COMPLETED: process.env.AUTH_SETUP_COMPLETED,
JWT_SECRET: process.env.JWT_SECRET,
// Server Color Coding Configuration
SERVER_COLOR_CODING_ENABLED: process.env.SERVER_COLOR_CODING_ENABLED,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
}, },
/** /**

240
src/lib/auth.ts Normal file
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, server as any,
command, command,
(data: string) => { (data: string) => {
console.log('Command output chunk:', data);
commandOutput += data; commandOutput += data;
}, },
(error: string) => { (error: string) => {
@@ -276,7 +275,6 @@ export const installedScriptsRouter = createTRPCRouter({
}, },
(exitCode: number) => { (exitCode: number) => {
console.log('Command exit code:', exitCode); console.log('Command exit code:', exitCode);
console.log('Full command output:', commandOutput);
// Parse the complete output to get config file paths that contain community-script tag // Parse the complete output to get config file paths that contain community-script tag
const configFiles = commandOutput.split('\n') const configFiles = commandOutput.split('\n')
@@ -306,8 +304,6 @@ export const installedScriptsRouter = createTRPCRouter({
server as any, server as any,
readCommand, readCommand,
(configData: string) => { (configData: string) => {
console.log('Config data for', containerId, ':', configData.substring(0, 300) + '...');
// Parse config file for hostname // Parse config file for hostname
const lines = configData.split('\n'); const lines = configData.split('\n');
let hostname = ''; let hostname = '';
@@ -316,7 +312,6 @@ export const installedScriptsRouter = createTRPCRouter({
const trimmedLine = line.trim(); const trimmedLine = line.trim();
if (trimmedLine.startsWith('hostname:')) { if (trimmedLine.startsWith('hostname:')) {
hostname = trimmedLine.substring(9).trim(); hostname = trimmedLine.substring(9).trim();
console.log('Found hostname for', containerId, ':', hostname);
break; break;
} }
} }
@@ -368,7 +363,6 @@ export const installedScriptsRouter = createTRPCRouter({
// Get existing scripts to check for duplicates // Get existing scripts to check for duplicates
const existingScripts = db.getAllInstalledScripts(); const existingScripts = db.getAllInstalledScripts();
console.log('Existing scripts in database:', existingScripts.length);
// Create installed script records for detected containers (skip duplicates) // Create installed script records for detected containers (skip duplicates)
const createdScripts = []; const createdScripts = [];
@@ -504,7 +498,6 @@ export const installedScriptsRouter = createTRPCRouter({
server as any, server as any,
checkCommand, checkCommand,
(data: string) => { (data: string) => {
console.log(`Container check result for ${scriptData.script_name}:`, data.trim());
resolve(data.trim() === 'exists'); resolve(data.trim() === 'exists');
}, },
(error: string) => { (error: string) => {

View File

@@ -163,12 +163,22 @@ export const scriptsRouter = createTRPCRouter({
const script = scripts.find(s => s.slug === card.slug); const script = scripts.find(s => s.slug === card.slug);
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? []; const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
// Extract OS and version from first install method
const firstInstallMethod = script?.install_methods?.[0];
const os = firstInstallMethod?.resources?.os;
const version = firstInstallMethod?.resources?.version;
return { return {
...card, ...card,
categories: script?.categories ?? [], categories: script?.categories ?? [],
categoryNames: categoryNames, categoryNames: categoryNames,
// Add date_created from script // Add date_created from script
date_created: script?.date_created, date_created: script?.date_created,
// Add OS and version from install methods
os: os,
version: version,
// Add interface port
interface_port: script?.interface_port,
} as ScriptCard; } as ScriptCard;
}); });

View File

@@ -16,12 +16,68 @@ class DatabaseService {
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
ip TEXT NOT NULL, ip TEXT NOT NULL,
user TEXT NOT NULL, user TEXT NOT NULL,
password TEXT NOT NULL, password TEXT,
auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both')),
ssh_key TEXT,
ssh_key_passphrase TEXT,
ssh_port INTEGER DEFAULT 22,
color TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) )
`); `);
// Migration: Add new columns to existing servers table
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both'))
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN ssh_key TEXT
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN ssh_key_passphrase TEXT
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN ssh_port INTEGER DEFAULT 22
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN color TEXT
`);
} catch (e) {
// Column already exists, ignore error
}
// Update existing servers to have auth_type='password' if not set
this.db.exec(`
UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL
`);
// Update existing servers to have ssh_port=22 if not set
this.db.exec(`
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
`);
// Create installed_scripts table if it doesn't exist // Create installed_scripts table if it doesn't exist
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS installed_scripts ( CREATE TABLE IF NOT EXISTS installed_scripts (
@@ -53,12 +109,12 @@ class DatabaseService {
* @param {import('../types/server').CreateServerData} serverData * @param {import('../types/server').CreateServerData} serverData
*/ */
createServer(serverData) { createServer(serverData) {
const { name, ip, user, password } = serverData; const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData;
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
INSERT INTO servers (name, ip, user, password) INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
return stmt.run(name, ip, user, password); return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color);
} }
getAllServers() { getAllServers() {
@@ -79,13 +135,13 @@ class DatabaseService {
* @param {import('../types/server').CreateServerData} serverData * @param {import('../types/server').CreateServerData} serverData
*/ */
updateServer(id, serverData) { updateServer(id, serverData) {
const { name, ip, user, password } = serverData; const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData;
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
UPDATE servers UPDATE servers
SET name = ?, ip = ?, user = ?, password = ? SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, color = ?
WHERE id = ? WHERE id = ?
`); `);
return stmt.run(name, ip, user, password, id); return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color, id);
} }
/** /**
@@ -123,7 +179,8 @@ class DatabaseService {
s.name as server_name, s.name as server_name,
s.ip as server_ip, s.ip as server_ip,
s.user as server_user, s.user as server_user,
s.password as server_password s.password as server_password,
s.color as server_color
FROM installed_scripts inst FROM installed_scripts inst
LEFT JOIN servers s ON inst.server_id = s.id LEFT JOIN servers s ON inst.server_id = s.id
ORDER BY inst.installation_date DESC ORDER BY inst.installation_date DESC

View File

@@ -1,16 +1,131 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { spawn as ptySpawn } from 'node-pty'; import { spawn as ptySpawn } from 'node-pty';
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
/** /**
* @typedef {Object} Server * @typedef {Object} Server
* @property {string} ip - Server IP address * @property {string} ip - Server IP address
* @property {string} user - Username * @property {string} user - Username
* @property {string} password - Password * @property {string} [password] - Password (optional)
* @property {string} name - Server name * @property {string} name - Server name
* @property {string} [auth_type] - Authentication type ('password', 'key', 'both')
* @property {string} [ssh_key] - SSH private key content
* @property {string} [ssh_key_passphrase] - SSH key passphrase
* @property {number} [ssh_port] - SSH port (default: 22)
*/ */
class SSHExecutionService { class SSHExecutionService {
/**
* Create a temporary SSH key file for authentication
* @param {Server} server - Server configuration
* @returns {string} Path to temporary key file
*/
createTempKeyFile(server) {
const { ssh_key } = server;
if (!ssh_key) {
throw new Error('SSH key not provided');
}
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
const tempKeyPath = join(tempDir, 'private_key');
writeFileSync(tempKeyPath, ssh_key);
chmodSync(tempKeyPath, 0o600); // Set proper permissions
return tempKeyPath;
}
/**
* Build SSH command arguments based on authentication type
* @param {Server} server - Server configuration
* @param {string|null} [tempKeyPath=null] - Path to temporary key file (if using key auth)
* @returns {{command: string, args: string[]}} Command and arguments for SSH
*/
buildSSHCommand(server, tempKeyPath = null) {
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_port = 22 } = server;
const baseArgs = [
'-t',
'-p', ssh_port.toString(),
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'RequestTTY=yes',
'-o', 'SetEnv=TERM=xterm-256color',
'-o', 'SetEnv=COLUMNS=120',
'-o', 'SetEnv=LINES=30',
'-o', 'SetEnv=COLORTERM=truecolor',
'-o', 'SetEnv=FORCE_COLOR=1',
'-o', 'SetEnv=NO_COLOR=0',
'-o', 'SetEnv=CLICOLOR=1',
'-o', 'SetEnv=CLICOLOR_FORCE=1'
];
if (auth_type === 'key') {
// SSH key authentication
if (tempKeyPath) {
baseArgs.push('-i', tempKeyPath);
baseArgs.push('-o', 'PasswordAuthentication=no');
baseArgs.push('-o', 'PubkeyAuthentication=yes');
}
if (ssh_key_passphrase) {
return {
command: 'sshpass',
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
};
} else {
return {
command: 'ssh',
args: [...baseArgs, `${user}@${ip}`]
};
}
} else if (auth_type === 'both') {
// Try SSH key first, then password
if (tempKeyPath) {
baseArgs.push('-i', tempKeyPath);
baseArgs.push('-o', 'PasswordAuthentication=yes');
baseArgs.push('-o', 'PubkeyAuthentication=yes');
if (ssh_key_passphrase) {
return {
command: 'sshpass',
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
};
} else {
return {
command: 'ssh',
args: [...baseArgs, `${user}@${ip}`]
};
}
} else {
// Fallback to password
if (password) {
return {
command: 'sshpass',
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
};
} else {
throw new Error('Password is required for password authentication');
}
}
} else {
// Password authentication (default)
if (password) {
return {
command: 'sshpass',
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
};
} else {
throw new Error('Password is required for password authentication');
}
}
}
/** /**
* Execute a script on a remote server via SSH * Execute a script on a remote server via SSH
* @param {Server} server - Server configuration * @param {Server} server - Server configuration
@@ -21,7 +136,8 @@ class SSHExecutionService {
* @returns {Promise<Object>} Process information * @returns {Promise<Object>} Process information
*/ */
async executeScript(server, scriptPath, onData, onError, onExit) { async executeScript(server, scriptPath, onData, onError, onExit) {
const { ip, user, password } = server; /** @type {string|null} */
let tempKeyPath = null;
try { try {
await this.transferScriptsFolder(server, onData, onError); await this.transferScriptsFolder(server, onData, onError);
@@ -29,46 +145,37 @@ class SSHExecutionService {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath; const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
// Use ptySpawn for proper terminal emulation and color support try {
const sshCommand = ptySpawn('sshpass', [ // Create temporary key file if using key authentication
'-p', password, if (server.auth_type === 'key' || server.auth_type === 'both') {
'ssh', tempKeyPath = this.createTempKeyFile(server);
'-t',
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'PasswordAuthentication=yes',
'-o', 'PubkeyAuthentication=no',
'-o', 'RequestTTY=yes',
'-o', 'SetEnv=TERM=xterm-256color',
'-o', 'SetEnv=COLUMNS=120',
'-o', 'SetEnv=LINES=30',
'-o', 'SetEnv=COLORTERM=truecolor',
'-o', 'SetEnv=FORCE_COLOR=1',
'-o', 'SetEnv=NO_COLOR=0',
'-o', 'SetEnv=CLICOLOR=1',
'-o', 'SetEnv=CLICOLOR_FORCE=1',
`${user}@${ip}`,
`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`
], {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: process.cwd(),
env: {
...process.env,
TERM: 'xterm-256color',
COLUMNS: '120',
LINES: '30',
SHELL: '/bin/bash',
COLORTERM: 'truecolor',
FORCE_COLOR: '1',
NO_COLOR: '0',
CLICOLOR: '1',
CLICOLOR_FORCE: '1'
} }
});
// Build SSH command based on authentication type
const { command, args } = this.buildSSHCommand(server, tempKeyPath);
// Add the script execution command to the args
args.push(`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`);
// Use ptySpawn for proper terminal emulation and color support
const sshCommand = ptySpawn(command, args, {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: process.cwd(),
env: {
...process.env,
TERM: 'xterm-256color',
COLUMNS: '120',
LINES: '30',
SHELL: '/bin/bash',
COLORTERM: 'truecolor',
FORCE_COLOR: '1',
NO_COLOR: '0',
CLICOLOR: '1',
CLICOLOR_FORCE: '1'
}
});
// Use pty's onData method which handles both stdout and stderr combined // Use pty's onData method which handles both stdout and stderr combined
sshCommand.onData((data) => { sshCommand.onData((data) => {
@@ -82,8 +189,34 @@ class SSHExecutionService {
resolve({ resolve({
process: sshCommand, process: sshCommand,
kill: () => sshCommand.kill('SIGTERM') kill: () => {
sshCommand.kill('SIGTERM');
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
}
}); });
} catch (error) {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error);
}
}); });
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
@@ -100,20 +233,49 @@ class SSHExecutionService {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async transferScriptsFolder(server, onData, onError) { async transferScriptsFolder(server, onData, onError) {
const { ip, user, password } = server; const { ip, user, password, auth_type = 'password', ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
/** @type {string|null} */
let tempKeyPath = null;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const rsyncCommand = spawn('rsync', [ try {
'-avz', // Create temporary key file if using key authentication
'--delete', if (auth_type === 'key' || auth_type === 'both') {
'--exclude=*.log', if (ssh_key) {
'--exclude=*.tmp', tempKeyPath = this.createTempKeyFile(server);
'--rsh=sshpass -p ' + password + ' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null', }
'scripts/', }
`${user}@${ip}:/tmp/scripts/`
], { // Build rsync command based on authentication type
stdio: ['pipe', 'pipe', 'pipe'] let rshCommand;
}); if (auth_type === 'key' && tempKeyPath) {
if (ssh_key_passphrase) {
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} else {
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
}
} else if (auth_type === 'both' && tempKeyPath) {
if (ssh_key_passphrase) {
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} else {
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
}
} else {
// Fallback to password authentication
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
}
const rsyncCommand = spawn('rsync', [
'-avz',
'--delete',
'--exclude=*.log',
'--exclude=*.tmp',
`--rsh=${rshCommand}`,
'scripts/',
`${user}@${ip}:/tmp/scripts/`
], {
stdio: ['pipe', 'pipe', 'pipe']
});
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => { rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
// Ensure proper UTF-8 encoding for ANSI colors // Ensure proper UTF-8 encoding for ANSI colors
@@ -128,6 +290,17 @@ class SSHExecutionService {
}); });
rsyncCommand.on('close', (code) => { rsyncCommand.on('close', (code) => {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
if (code === 0) { if (code === 0) {
resolve(); resolve();
} else { } else {
@@ -136,8 +309,32 @@ class SSHExecutionService {
}); });
rsyncCommand.on('error', (error) => { rsyncCommand.on('error', (error) => {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error); reject(error);
}); });
} catch (error) {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error);
}
}); });
} }
@@ -151,47 +348,79 @@ class SSHExecutionService {
* @returns {Promise<Object>} Process information * @returns {Promise<Object>} Process information
*/ */
async executeCommand(server, command, onData, onError, onExit) { async executeCommand(server, command, onData, onError, onExit) {
const { ip, user, password } = server; /** @type {string|null} */
let tempKeyPath = null;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Use ptySpawn for proper terminal emulation and color support try {
const sshCommand = ptySpawn('sshpass', [ // Create temporary key file if using key authentication
'-p', password, if (server.auth_type === 'key' || server.auth_type === 'both') {
'ssh', tempKeyPath = this.createTempKeyFile(server);
'-t', }
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no', // Build SSH command based on authentication type
'-o', 'UserKnownHostsFile=/dev/null', const { command: sshCommandName, args } = this.buildSSHCommand(server, tempKeyPath);
'-o', 'LogLevel=ERROR',
'-o', 'PasswordAuthentication=yes', // Add the command to execute to the args
'-o', 'PubkeyAuthentication=no', args.push(command);
'-o', 'RequestTTY=yes',
'-o', 'SetEnv=TERM=xterm-256color', // Use ptySpawn for proper terminal emulation and color support
'-o', 'SetEnv=COLUMNS=120', const sshCommand = ptySpawn(sshCommandName, args, {
'-o', 'SetEnv=LINES=30', name: 'xterm-color',
'-o', 'SetEnv=COLORTERM=truecolor', cols: 120,
'-o', 'SetEnv=FORCE_COLOR=1', rows: 30,
'-o', 'SetEnv=NO_COLOR=0', cwd: process.cwd(),
'-o', 'SetEnv=CLICOLOR=1', env: process.env
`${user}@${ip}`, });
command
], {
name: 'xterm-color',
cols: 120,
rows: 30,
cwd: process.cwd(),
env: process.env
});
sshCommand.onData((data) => { sshCommand.onData((data) => {
onData(data); onData(data);
}); });
sshCommand.onExit((e) => { sshCommand.onExit((e) => {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
onExit(e.exitCode); onExit(e.exitCode);
}); });
resolve({ process: sshCommand }); resolve({
process: sshCommand,
kill: () => {
sshCommand.kill('SIGTERM');
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
}
});
} catch (error) {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error);
}
}); });
} }

View File

@@ -1,6 +1,7 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { writeFileSync, unlinkSync, chmodSync } from 'fs'; import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { tmpdir } from 'os';
class SSHService { class SSHService {
/** /**
@@ -10,38 +11,42 @@ class SSHService {
* @returns {Promise<Object>} Connection test result * @returns {Promise<Object>} Connection test result
*/ */
async testConnection(server) { async testConnection(server) {
const { ip, user, password } = server; const { auth_type = 'password' } = server;
return new Promise((resolve) => { return new Promise((resolve) => {
const timeout = 15000; // 15 seconds timeout for login test const timeout = 15000; // 15 seconds timeout for login test
let resolved = false; let resolved = false;
// Try sshpass first if available // Choose authentication method based on auth_type
this.testWithSshpass(server).then(result => { let authPromise;
if (auth_type === 'key') {
authPromise = this.testWithSSHKey(server);
} else if (auth_type === 'both') {
// Try SSH key first, then password
authPromise = this.testWithSSHKey(server).catch(() => this.testWithSshpass(server));
} else {
// Default to password authentication
authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server));
}
authPromise.then(result => {
if (!resolved) { if (!resolved) {
resolved = true; resolved = true;
resolve(result); resolve(result);
} }
}).catch(() => { }).catch(() => {
// If sshpass fails, try expect // If primary method fails, return error
this.testWithExpect(server).then(result => { if (!resolved) {
if (!resolved) { resolved = true;
resolved = true; resolve({
resolve(result); success: false,
} message: `SSH login test failed for ${auth_type} authentication`,
}).catch(() => { details: {
// If both fail, return error method: 'auth_failed',
if (!resolved) { auth_type: auth_type
resolved = true; }
resolve({ });
success: false, }
message: 'SSH login test requires sshpass or expect - neither available or working',
details: {
method: 'no_auth_tools'
}
});
}
});
}); });
// Set up overall timeout // Set up overall timeout
@@ -64,7 +69,11 @@ class SSHService {
* @returns {Promise<Object>} Connection test result * @returns {Promise<Object>} Connection test result
*/ */
async testWithSshpass(server) { async testWithSshpass(server) {
const { ip, user, password } = server; const { ip, user, password, ssh_port = 22 } = server;
if (!password) {
throw new Error('Password is required for password authentication');
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = 10000; const timeout = 10000;
@@ -73,6 +82,7 @@ class SSHService {
const sshCommand = spawn('sshpass', [ const sshCommand = spawn('sshpass', [
'-p', password, '-p', password,
'ssh', 'ssh',
'-p', ssh_port.toString(),
'-o', 'ConnectTimeout=10', '-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no', '-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null', '-o', 'UserKnownHostsFile=/dev/null',
@@ -156,7 +166,7 @@ class SSHService {
* @returns {Promise<Object>} Connection test result * @returns {Promise<Object>} Connection test result
*/ */
async testWithExpect(server) { async testWithExpect(server) {
const { ip, user, password } = server; const { ip, user, password, ssh_port = 22 } = server;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = 10000; const timeout = 10000;
@@ -164,7 +174,7 @@ class SSHService {
const expectScript = `#!/usr/bin/expect -f const expectScript = `#!/usr/bin/expect -f
set timeout 10 set timeout 10
spawn ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS" spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
expect { expect {
"password:" { "password:" {
send "${password}\r" send "${password}\r"
@@ -428,13 +438,14 @@ expect {
* @returns {Promise<Object>} Connection test result * @returns {Promise<Object>} Connection test result
*/ */
async testSSHConnection(server) { async testSSHConnection(server) {
const { ip, user } = server; const { ip, user, ssh_port = 22 } = server;
return new Promise((resolve) => { return new Promise((resolve) => {
const timeout = 5000; const timeout = 5000;
let resolved = false; let resolved = false;
const sshCommand = spawn('ssh', [ const sshCommand = spawn('ssh', [
'-p', ssh_port.toString(),
'-o', 'ConnectTimeout=5', '-o', 'ConnectTimeout=5',
'-o', 'StrictHostKeyChecking=no', '-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null', '-o', 'UserKnownHostsFile=/dev/null',
@@ -523,6 +534,148 @@ expect {
}); });
} }
/**
* Test SSH connection using SSH key authentication
* @param {import('../types/server').Server} server - Server configuration
* @returns {Promise<Object>} Connection test result
*/
async testWithSSHKey(server) {
const { ip, user, ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
if (!ssh_key) {
throw new Error('SSH key not provided');
}
return new Promise((resolve, reject) => {
const timeout = 10000;
let resolved = false;
let tempKeyPath = null;
try {
// Create temporary key file
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
tempKeyPath = join(tempDir, 'private_key');
// Write the private key to temporary file
writeFileSync(tempKeyPath, ssh_key);
chmodSync(tempKeyPath, 0o600); // Set proper permissions
// Build SSH command
const sshArgs = [
'-i', tempKeyPath,
'-p', ssh_port.toString(),
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'PasswordAuthentication=no',
'-o', 'PubkeyAuthentication=yes',
`${user}@${ip}`,
'echo "SSH_LOGIN_SUCCESS"'
];
// Use sshpass if passphrase is provided
let command, args;
if (ssh_key_passphrase) {
command = 'sshpass';
args = ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...sshArgs];
} else {
command = 'ssh';
args = sshArgs;
}
const sshCommand = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe']
});
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
sshCommand.kill('SIGTERM');
reject(new Error('SSH key login timeout'));
}
}, timeout);
let output = '';
let errorOutput = '';
sshCommand.stdout.on('data', (data) => {
output += data.toString();
});
sshCommand.stderr.on('data', (data) => {
errorOutput += data.toString();
});
sshCommand.on('close', (code) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
if (code === 0 && output.includes('SSH_LOGIN_SUCCESS')) {
resolve({
success: true,
message: 'SSH key authentication successful - credentials verified',
details: {
server: server.name || 'Unknown',
ip: ip,
user: user,
method: 'ssh_key_verified'
}
});
} else {
let errorMessage = 'SSH key authentication failed';
if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) {
errorMessage = 'SSH key authentication failed - check key and permissions';
} else if (errorOutput.includes('Connection refused')) {
errorMessage = 'Connection refused - server may be down or SSH not running';
} else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) {
errorMessage = 'Host not found - check IP address';
} else if (errorOutput.includes('Connection timed out')) {
errorMessage = 'Connection timeout - server may be unreachable';
} else if (errorOutput.includes('Load key') || errorOutput.includes('invalid format')) {
errorMessage = 'Invalid SSH key format';
} else if (errorOutput.includes('Enter passphrase')) {
errorMessage = 'SSH key passphrase required but not provided';
} else {
errorMessage = `SSH key authentication failed: ${errorOutput.trim()}`;
}
reject(new Error(errorMessage));
}
}
});
sshCommand.on('error', (error) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
reject(error);
}
});
} catch (error) {
if (!resolved) {
resolved = true;
reject(error);
}
} finally {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
// Also remove the temp directory
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
}
});
}
} }
// Singleton instance // Singleton instance

View File

@@ -57,6 +57,9 @@ export interface ScriptCard {
categories?: number[]; categories?: number[];
categoryNames?: string[]; categoryNames?: string[];
date_created?: string; date_created?: string;
os?: string;
version?: string;
interface_port?: number | null;
} }
export interface GitHubFile { export interface GitHubFile {

View File

@@ -3,7 +3,12 @@ export interface Server {
name: string; name: string;
ip: string; ip: string;
user: string; user: string;
password: string; password?: string;
auth_type?: 'password' | 'key' | 'both';
ssh_key?: string;
ssh_key_passphrase?: string;
ssh_port?: number;
color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -12,7 +17,12 @@ export interface CreateServerData {
name: string; name: string;
ip: string; ip: string;
user: string; user: string;
password: string; password?: string;
auth_type?: 'password' | 'key' | 'both';
ssh_key?: string;
ssh_key_passphrase?: string;
ssh_port?: number;
color?: string;
} }
export interface UpdateServerData extends CreateServerData { export interface UpdateServerData extends CreateServerData {

View File

@@ -592,6 +592,7 @@ start_application() {
if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then
log "Service was running before update, re-enabling and starting systemd service..." log "Service was running before update, re-enabling and starting systemd service..."
if systemctl enable --now pvescriptslocal.service; then if systemctl enable --now pvescriptslocal.service; then
systemctl restart pvescriptslocal.service
log_success "Service enabled and started successfully" log_success "Service enabled and started successfully"
# Wait a moment and check if it's running # Wait a moment and check if it's running
sleep 2 sleep 2
@@ -703,7 +704,7 @@ main() {
if [ -f "package.json" ] && [ -f "server.js" ]; then if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)" app_dir="$(pwd)"
else else
# Try multiple common locations # Try multiple common locations:
for search_path in /opt /root /home /usr/local; do for search_path in /opt /root /home /usr/local; do
if [ -d "$search_path" ]; then if [ -d "$search_path" ]; then
app_dir=$(find "$search_path" -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1) app_dir=$(find "$search_path" -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)