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
This commit is contained in:
committed by
GitHub
parent
608a7ac78c
commit
6265ffeab5
@@ -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=
|
||||||
@@ -42,6 +42,16 @@ const config = {
|
|||||||
'http://172.31.*',
|
'http://172.31.*',
|
||||||
'http://192.168.*',
|
'http://192.168.*',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
webpack: (config, { dev, isServer }) => {
|
||||||
|
if (dev && !isServer) {
|
||||||
|
config.watchOptions = {
|
||||||
|
poll: 1000,
|
||||||
|
aggregateTimeout: 300,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
151
package-lock.json
generated
151
package-lock.json
generated
@@ -19,9 +19,11 @@
|
|||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.3",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
@@ -42,7 +44,9 @@
|
|||||||
"@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/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.7.1",
|
"@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",
|
||||||
@@ -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,6 +3054,24 @@
|
|||||||
"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.1",
|
"version": "24.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
|
||||||
@@ -4259,6 +4288,15 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "12.4.1",
|
"version": "12.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
|
||||||
@@ -4385,6 +4423,12 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/cac": {
|
"node_modules/cac": {
|
||||||
"version": "6.7.14",
|
"version": "6.7.14",
|
||||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
@@ -4954,6 +4998,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.232",
|
"version": "1.5.232",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.232.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.232.tgz",
|
||||||
@@ -7166,6 +7219,40 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||||
|
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^3.2.2",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonwebtoken/node_modules/semver": {
|
||||||
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsx-ast-utils": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@@ -7182,6 +7269,27 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^1.4.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -7481,6 +7589,42 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -7488,6 +7632,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -7731,7 +7881,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nan": {
|
"node_modules/nan": {
|
||||||
|
|||||||
@@ -33,9 +33,11 @@
|
|||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.3",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
@@ -56,7 +58,9 @@
|
|||||||
"@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/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.7.1",
|
"@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",
|
||||||
|
|||||||
73
src/app/_components/AuthGuard.tsx
Normal file
73
src/app/_components/AuthGuard.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, type ReactNode } from 'react';
|
||||||
|
import { useAuth } from './AuthProvider';
|
||||||
|
import { AuthModal } from './AuthModal';
|
||||||
|
import { SetupModal } from './SetupModal';
|
||||||
|
|
||||||
|
interface AuthGuardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthConfig {
|
||||||
|
username: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
hasCredentials: boolean;
|
||||||
|
setupCompleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: AuthGuardProps) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null);
|
||||||
|
const [configLoading, setConfigLoading] = useState(true);
|
||||||
|
const [setupCompleted, setSetupCompleted] = useState(false);
|
||||||
|
|
||||||
|
const handleSetupComplete = async () => {
|
||||||
|
setSetupCompleted(true);
|
||||||
|
// Refresh auth config without reloading the page
|
||||||
|
await fetchAuthConfig();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAuthConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/auth-credentials');
|
||||||
|
if (response.ok) {
|
||||||
|
const config = await response.json() as AuthConfig;
|
||||||
|
setAuthConfig(config);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching auth config:', error);
|
||||||
|
} finally {
|
||||||
|
setConfigLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchAuthConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show loading while checking auth status
|
||||||
|
if (isLoading || configLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-4"></div>
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show setup modal if setup has not been completed yet
|
||||||
|
if (authConfig && !authConfig.setupCompleted && !setupCompleted) {
|
||||||
|
return <SetupModal isOpen={true} onComplete={handleSetupComplete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show auth modal if auth is enabled but user is not authenticated
|
||||||
|
if (authConfig && authConfig.enabled && !isAuthenticated) {
|
||||||
|
return <AuthModal isOpen={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render children if authenticated or auth is disabled
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
111
src/app/_components/AuthModal.tsx
Normal file
111
src/app/_components/AuthModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { useAuth } from './AuthProvider';
|
||||||
|
import { Lock, User, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AuthModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthModal({ isOpen }: AuthModalProps) {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const success = await login(username, password);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setError('Invalid username or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Lock className="h-8 w-8 text-blue-600" />
|
||||||
|
<h2 className="text-2xl font-bold text-card-foreground">Authentication Required</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-muted-foreground text-center mb-6">
|
||||||
|
Please enter your credentials to access the application.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-800 border border-red-200 rounded-md">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !username.trim() || !password.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing In...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/app/_components/AuthProvider.tsx
Normal file
119
src/app/_components/AuthProvider.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
username: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
// First check if setup is completed
|
||||||
|
const setupResponse = await fetch('/api/settings/auth-credentials');
|
||||||
|
if (setupResponse.ok) {
|
||||||
|
const setupData = await setupResponse.json() as { setupCompleted: boolean; enabled: boolean };
|
||||||
|
|
||||||
|
// If setup is not completed or auth is disabled, don't verify
|
||||||
|
if (!setupData.setupCompleted || !setupData.enabled) {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsername(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only verify authentication if setup is completed and auth is enabled
|
||||||
|
const response = await fetch('/api/auth/verify');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { username: string };
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setUsername(data.username);
|
||||||
|
} else {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsername(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking auth:', error);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsername(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (username: string, password: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { username: string };
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setUsername(data.username);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.error('Login failed:', errorData.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
// Clear the auth cookie by setting it to expire
|
||||||
|
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsername(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
isAuthenticated,
|
||||||
|
username,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
checkAuth,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -11,13 +11,22 @@ 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 [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 +34,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
|||||||
void loadGithubToken();
|
void loadGithubToken();
|
||||||
void loadSaveFilter();
|
void loadSaveFilter();
|
||||||
void loadSavedFilters();
|
void loadSavedFilters();
|
||||||
|
void loadAuthCredentials();
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@@ -138,6 +148,92 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 +281,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>
|
||||||
|
|
||||||
@@ -301,6 +409,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>
|
||||||
|
|||||||
204
src/app/_components/SetupModal.tsx
Normal file
204
src/app/_components/SetupModal.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Toggle } from './ui/toggle';
|
||||||
|
import { Lock, User, Shield, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SetupModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [enableAuth, setEnableAuth] = useState(true);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Only validate passwords if authentication is enabled
|
||||||
|
if (enableAuth && password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: enableAuth ? username : undefined,
|
||||||
|
password: enableAuth ? password : undefined,
|
||||||
|
enabled: enableAuth
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// If authentication is enabled, automatically log in the user
|
||||||
|
if (enableAuth) {
|
||||||
|
const loginResponse = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loginResponse.ok) {
|
||||||
|
// Login successful, complete setup
|
||||||
|
onComplete();
|
||||||
|
} else {
|
||||||
|
// Setup succeeded but login failed, still complete setup
|
||||||
|
console.warn('Setup completed but auto-login failed');
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Authentication disabled, just complete setup
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json() as { error: string };
|
||||||
|
setError(errorData.error ?? 'Failed to setup authentication');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Setup error:', error);
|
||||||
|
setError('Failed to setup authentication');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield className="h-8 w-8 text-green-600" />
|
||||||
|
<h2 className="text-2xl font-bold text-card-foreground">Setup Authentication</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-muted-foreground text-center mb-6">
|
||||||
|
Set up authentication to secure your application. This will be required for future access.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-username" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="setup-username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Choose a username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required={enableAuth}
|
||||||
|
minLength={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-password" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="setup-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Choose a password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required={enableAuth}
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm-password" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required={enableAuth}
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-foreground mb-1">Enable Authentication</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{enableAuth
|
||||||
|
? 'Authentication will be required on every page load'
|
||||||
|
: 'Authentication will be optional (can be enabled later in settings)'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={enableAuth}
|
||||||
|
onCheckedChange={setEnableAuth}
|
||||||
|
disabled={isLoading}
|
||||||
|
label="Enable authentication"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-800 border border-red-200 rounded-md">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
(enableAuth && (!username.trim() || !password.trim() || !confirmPassword.trim()))
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Setting Up...' : 'Complete Setup'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -110,7 +110,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
setIsRunning(false);
|
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(() => {
|
||||||
@@ -346,7 +346,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
wsRef.current.close();
|
wsRef.current.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile]);
|
}, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile, isRunning]);
|
||||||
|
|
||||||
const startScript = () => {
|
const startScript = () => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
||||||
|
|||||||
66
src/app/api/auth/login/route.ts
Normal file
66
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { comparePassword, generateToken, getAuthConfig } from '~/lib/auth';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password } = await request.json() as { username: string; password: string };
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username and password are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
|
||||||
|
if (!authConfig.hasCredentials) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Authentication not configured' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username !== authConfig.username) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid credentials' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPassword = await comparePassword(password, authConfig.passwordHash!);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid credentials' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateToken(username);
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Login successful',
|
||||||
|
username
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set httpOnly cookie
|
||||||
|
response.cookies.set('auth-token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during login:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/app/api/auth/setup/route.ts
Normal file
94
src/app/api/auth/setup/route.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { updateAuthCredentials, getAuthConfig, setSetupCompleted } from '~/lib/auth';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password, enabled } = await request.json() as { username?: string; password?: string; enabled?: boolean };
|
||||||
|
|
||||||
|
// If authentication is disabled, we don't need any credentials
|
||||||
|
if (enabled === false) {
|
||||||
|
// Just set AUTH_ENABLED to false without storing credentials
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_ENABLED
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||||
|
if (enabledRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set setup completed flag
|
||||||
|
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=.*$/m;
|
||||||
|
if (setupCompletedRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(setupCompletedRegex, 'AUTH_SETUP_COMPLETED=true');
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_SETUP_COMPLETED=true\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any empty AUTH_USERNAME or AUTH_PASSWORD_HASH lines
|
||||||
|
envContent = envContent.replace(/^AUTH_USERNAME=\s*$/m, '');
|
||||||
|
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=\s*$/m, '');
|
||||||
|
envContent = envContent.replace(/\n\n+/g, '\n');
|
||||||
|
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Authentication disabled successfully'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If authentication is enabled, require username and password
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username is required when authentication is enabled' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < 3) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username must be at least 3 characters long' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password || password.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password must be at least 6 characters long' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if credentials already exist
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
if (authConfig.hasCredentials) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Authentication is already configured' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAuthCredentials(username, password, enabled ?? true);
|
||||||
|
setSetupCompleted();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Authentication setup completed successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during setup:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/auth/verify/route.ts
Normal file
37
src/app/api/auth/verify/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { verifyToken } from '~/lib/auth';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const token = request.cookies.get('auth-token')?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No token provided' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = verifyToken(token);
|
||||||
|
|
||||||
|
if (!decoded) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid token' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
username: decoded.username,
|
||||||
|
authenticated: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying token:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/app/api/settings/auth-credentials/route.ts
Normal file
117
src/app/api/settings/auth-credentials/route.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
username: authConfig.username,
|
||||||
|
enabled: authConfig.enabled,
|
||||||
|
hasCredentials: authConfig.hasCredentials,
|
||||||
|
setupCompleted: authConfig.setupCompleted,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading auth credentials:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to read auth configuration' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password, enabled } = await request.json() as { username: string; password: string; enabled?: boolean };
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username and password are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < 3) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username must be at least 3 characters long' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password must be at least 6 characters long' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAuthCredentials(username, password, enabled ?? false);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Authentication credentials updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating auth credentials:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update auth credentials' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { enabled } = await request.json() as { enabled: boolean };
|
||||||
|
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Enabled flag must be a boolean' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// When enabling, just update the flag
|
||||||
|
updateAuthEnabled(enabled);
|
||||||
|
} else {
|
||||||
|
// When disabling, clear all credentials and set flag to false
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove AUTH_USERNAME and AUTH_PASSWORD_HASH
|
||||||
|
envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, '');
|
||||||
|
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, '');
|
||||||
|
|
||||||
|
// Update or add AUTH_ENABLED
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||||
|
if (enabledRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty lines
|
||||||
|
envContent = envContent.replace(/\n\n+/g, '\n');
|
||||||
|
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating auth enabled status:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update auth status' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'];
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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",
|
||||||
@@ -45,7 +47,13 @@ export default function RootLayout({
|
|||||||
className="bg-background text-foreground transition-colors"
|
className="bg-background text-foreground transition-colors"
|
||||||
suppressHydrationWarning={true}
|
suppressHydrationWarning={true}
|
||||||
>
|
>
|
||||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
<TRPCReactProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthGuard>
|
||||||
|
{children}
|
||||||
|
</AuthGuard>
|
||||||
|
</AuthProvider>
|
||||||
|
</TRPCReactProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
12
src/env.js
12
src/env.js
@@ -25,6 +25,12 @@ 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(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +62,12 @@ 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,
|
||||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|||||||
240
src/lib/auth.ts
Normal file
240
src/lib/auth.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 10;
|
||||||
|
const JWT_EXPIRY = '7d'; // 7 days
|
||||||
|
|
||||||
|
// Cache for JWT secret to avoid multiple file reads
|
||||||
|
let jwtSecretCache: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or generate JWT secret
|
||||||
|
*/
|
||||||
|
export function getJwtSecret(): string {
|
||||||
|
// Return cached secret if available
|
||||||
|
if (jwtSecretCache) {
|
||||||
|
return jwtSecretCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if JWT_SECRET already exists
|
||||||
|
const jwtSecretRegex = /^JWT_SECRET=(.*)$/m;
|
||||||
|
const jwtSecretMatch = jwtSecretRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (jwtSecretMatch?.[1]?.trim()) {
|
||||||
|
jwtSecretCache = jwtSecretMatch[1].trim();
|
||||||
|
return jwtSecretCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new secret
|
||||||
|
const newSecret = randomBytes(64).toString('hex');
|
||||||
|
|
||||||
|
// Add to .env file
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `JWT_SECRET=${newSecret}\n`;
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
// Cache the new secret
|
||||||
|
jwtSecretCache = newSecret;
|
||||||
|
|
||||||
|
return newSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a password using bcrypt
|
||||||
|
*/
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare a password with a hash
|
||||||
|
*/
|
||||||
|
export async function comparePassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a JWT token
|
||||||
|
*/
|
||||||
|
export function generateToken(username: string): string {
|
||||||
|
const secret = getJwtSecret();
|
||||||
|
return jwt.sign({ username }, secret, { expiresIn: JWT_EXPIRY });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a JWT token
|
||||||
|
*/
|
||||||
|
export function verifyToken(token: string): { username: string } | null {
|
||||||
|
try {
|
||||||
|
const secret = getJwtSecret();
|
||||||
|
const decoded = jwt.verify(token, secret) as { username: string };
|
||||||
|
return decoded;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read auth configuration from .env
|
||||||
|
*/
|
||||||
|
export function getAuthConfig(): {
|
||||||
|
username: string | null;
|
||||||
|
passwordHash: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
hasCredentials: boolean;
|
||||||
|
setupCompleted: boolean;
|
||||||
|
} {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return {
|
||||||
|
username: null,
|
||||||
|
passwordHash: null,
|
||||||
|
enabled: false,
|
||||||
|
hasCredentials: false,
|
||||||
|
setupCompleted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
|
||||||
|
// Extract AUTH_USERNAME
|
||||||
|
const usernameRegex = /^AUTH_USERNAME=(.*)$/m;
|
||||||
|
const usernameMatch = usernameRegex.exec(envContent);
|
||||||
|
const username = usernameMatch ? usernameMatch[1]?.trim() : null;
|
||||||
|
|
||||||
|
// Extract AUTH_PASSWORD_HASH
|
||||||
|
const passwordHashRegex = /^AUTH_PASSWORD_HASH=(.*)$/m;
|
||||||
|
const passwordHashMatch = passwordHashRegex.exec(envContent);
|
||||||
|
const passwordHash = passwordHashMatch ? passwordHashMatch[1]?.trim() : null;
|
||||||
|
|
||||||
|
// Extract AUTH_ENABLED
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=(.*)$/m;
|
||||||
|
const enabledMatch = enabledRegex.exec(envContent);
|
||||||
|
const enabled = enabledMatch ? enabledMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||||
|
|
||||||
|
// Extract AUTH_SETUP_COMPLETED
|
||||||
|
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=(.*)$/m;
|
||||||
|
const setupCompletedMatch = setupCompletedRegex.exec(envContent);
|
||||||
|
const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||||
|
|
||||||
|
const hasCredentials = !!(username && passwordHash);
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: username ?? null,
|
||||||
|
passwordHash: passwordHash ?? null,
|
||||||
|
enabled,
|
||||||
|
hasCredentials,
|
||||||
|
setupCompleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update auth credentials in .env
|
||||||
|
*/
|
||||||
|
export async function updateAuthCredentials(
|
||||||
|
username: string,
|
||||||
|
password?: string,
|
||||||
|
enabled?: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the password if provided
|
||||||
|
const passwordHash = password ? await hashPassword(password) : null;
|
||||||
|
|
||||||
|
// Update or add AUTH_USERNAME
|
||||||
|
const usernameRegex = /^AUTH_USERNAME=.*$/m;
|
||||||
|
if (usernameRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(usernameRegex, `AUTH_USERNAME=${username}`);
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_USERNAME=${username}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_PASSWORD_HASH only if password is provided
|
||||||
|
if (passwordHash) {
|
||||||
|
const passwordHashRegex = /^AUTH_PASSWORD_HASH=.*$/m;
|
||||||
|
if (passwordHashRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(passwordHashRegex, `AUTH_PASSWORD_HASH=${passwordHash}`);
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_PASSWORD_HASH=${passwordHash}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_ENABLED if provided
|
||||||
|
if (enabled !== undefined) {
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||||
|
if (enabledRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`);
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set AUTH_SETUP_COMPLETED flag in .env
|
||||||
|
*/
|
||||||
|
export function setSetupCompleted(): void {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_SETUP_COMPLETED
|
||||||
|
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=.*$/m;
|
||||||
|
if (setupCompletedRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(setupCompletedRegex, 'AUTH_SETUP_COMPLETED=true');
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_SETUP_COMPLETED=true\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update AUTH_ENABLED flag in .env
|
||||||
|
*/
|
||||||
|
export function updateAuthEnabled(enabled: boolean): void {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_ENABLED
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||||
|
if (enabledRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`);
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user