From 6265ffeab5287d94019fbadb2260041b97dd0bd5 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner <73236783+michelroegl-brunner@users.noreply.github.com> Date: Fri, 10 Oct 2025 12:45:45 +0200 Subject: [PATCH] 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 --- .env.example | 5 + next.config.js | 10 + package-lock.json | 151 ++++++++++- package.json | 4 + src/app/_components/AuthGuard.tsx | 73 ++++++ src/app/_components/AuthModal.tsx | 111 ++++++++ src/app/_components/AuthProvider.tsx | 119 +++++++++ src/app/_components/GeneralSettingsModal.tsx | 238 ++++++++++++++++- src/app/_components/SetupModal.tsx | 204 +++++++++++++++ src/app/_components/Terminal.tsx | 4 +- src/app/api/auth/login/route.ts | 66 +++++ src/app/api/auth/setup/route.ts | 94 +++++++ src/app/api/auth/verify/route.ts | 37 +++ .../api/settings/auth-credentials/route.ts | 117 +++++++++ src/app/api/settings/filters/route.ts | 9 +- src/app/layout.tsx | 10 +- src/env.js | 12 + src/lib/auth.ts | 240 ++++++++++++++++++ 18 files changed, 1498 insertions(+), 6 deletions(-) create mode 100644 src/app/_components/AuthGuard.tsx create mode 100644 src/app/_components/AuthModal.tsx create mode 100644 src/app/_components/AuthProvider.tsx create mode 100644 src/app/_components/SetupModal.tsx create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/setup/route.ts create mode 100644 src/app/api/auth/verify/route.ts create mode 100644 src/app/api/settings/auth-credentials/route.ts create mode 100644 src/lib/auth.ts diff --git a/.env.example b/.env.example index 0057588..5871f87 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,8 @@ WEBSOCKET_PORT="3001" GITHUB_TOKEN= SAVE_FILTER=false FILTERS= +AUTH_USERNAME= +AUTH_PASSWORD_HASH= +AUTH_ENABLED=false +AUTH_SETUP_COMPLETED=false +JWT_SECRET= \ No newline at end of file diff --git a/next.config.js b/next.config.js index acbc589..589cbc4 100644 --- a/next.config.js +++ b/next.config.js @@ -42,6 +42,16 @@ const config = { 'http://172.31.*', 'http://192.168.*', ], + + webpack: (config, { dev, isServer }) => { + if (dev && !isServer) { + config.watchOptions = { + poll: 1000, + aggregateTimeout: 300, + }; + } + return config; + }, }; export default config; diff --git a/package-lock.json b/package-lock.json index 3b708a5..401fd43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,9 +19,11 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", + "bcryptjs": "^3.0.2", "better-sqlite3": "^12.4.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.545.0", "next": "^15.5.3", "node-pty": "^1.0.0", @@ -42,7 +44,9 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.8", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.7.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -2986,6 +2990,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -3043,6 +3054,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.7.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz", @@ -4259,6 +4288,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/better-sqlite3": { "version": "12.4.1", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz", @@ -4385,6 +4423,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4954,6 +4998,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.232", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.232.tgz", @@ -7166,6 +7219,40 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -7182,6 +7269,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7481,6 +7589,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7488,6 +7632,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7731,7 +7881,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nan": { diff --git a/package.json b/package.json index 51c5ab0..bd3ffdd 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,11 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", + "bcryptjs": "^3.0.2", "better-sqlite3": "^12.4.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.545.0", "next": "^15.5.3", "node-pty": "^1.0.0", @@ -56,7 +58,9 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.8", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.7.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/src/app/_components/AuthGuard.tsx b/src/app/_components/AuthGuard.tsx new file mode 100644 index 0000000..2b5fe08 --- /dev/null +++ b/src/app/_components/AuthGuard.tsx @@ -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(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 ( +
+
+
+

Loading...

+
+
+ ); + } + + // Show setup modal if setup has not been completed yet + if (authConfig && !authConfig.setupCompleted && !setupCompleted) { + return ; + } + + // Show auth modal if auth is enabled but user is not authenticated + if (authConfig && authConfig.enabled && !isAuthenticated) { + return ; + } + + // Render children if authenticated or auth is disabled + return <>{children}; +} diff --git a/src/app/_components/AuthModal.tsx b/src/app/_components/AuthModal.tsx new file mode 100644 index 0000000..e26cb59 --- /dev/null +++ b/src/app/_components/AuthModal.tsx @@ -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(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 ( +
+
+ {/* Header */} +
+
+ +

Authentication Required

+
+
+ + {/* Content */} +
+

+ Please enter your credentials to access the application. +

+ +
+
+ +
+ + setUsername(e.target.value)} + disabled={isLoading} + className="pl-10" + required + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + disabled={isLoading} + className="pl-10" + required + /> +
+
+ + {error && ( +
+ + {error} +
+ )} + + +
+
+
+
+ ); +} diff --git a/src/app/_components/AuthProvider.tsx b/src/app/_components/AuthProvider.tsx new file mode 100644 index 0000000..a9efd3f --- /dev/null +++ b/src/app/_components/AuthProvider.tsx @@ -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; + logout: () => void; + checkAuth: () => Promise; +} + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [username, setUsername] = useState(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 => { + 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 ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/src/app/_components/GeneralSettingsModal.tsx b/src/app/_components/GeneralSettingsModal.tsx index 05e5dd7..66ac083 100644 --- a/src/app/_components/GeneralSettingsModal.tsx +++ b/src/app/_components/GeneralSettingsModal.tsx @@ -11,13 +11,22 @@ interface GeneralSettingsModalProps { } export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) { - const [activeTab, setActiveTab] = useState<'general' | 'github'>('general'); + const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth'>('general'); const [githubToken, setGithubToken] = useState(''); const [saveFilter, setSaveFilter] = useState(false); const [savedFilters, setSavedFilters] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // Auth state + const [authUsername, setAuthUsername] = useState(''); + const [authPassword, setAuthPassword] = useState(''); + const [authConfirmPassword, setAuthConfirmPassword] = useState(''); + const [authEnabled, setAuthEnabled] = useState(false); + const [authHasCredentials, setAuthHasCredentials] = useState(false); + const [authSetupCompleted, setAuthSetupCompleted] = useState(false); + const [authLoading, setAuthLoading] = useState(false); // Load existing settings when modal opens useEffect(() => { @@ -25,6 +34,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr void loadGithubToken(); void loadSaveFilter(); void loadSavedFilters(); + void loadAuthCredentials(); } }, [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; return ( @@ -185,6 +281,18 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr > GitHub + @@ -301,6 +409,134 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr )} + + {activeTab === 'auth' && ( +
+
+

Authentication Settings

+

+ Configure authentication to secure access to your application. +

+
+
+

Authentication Status

+

+ {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.' + } +

+ +
+
+
+

Enable Authentication

+

+ {authEnabled + ? 'Authentication is required on every page load' + : 'Authentication is optional' + } +

+
+ +
+
+
+ +
+

Update Credentials

+

+ Change your username and password for authentication. +

+ +
+
+ + ) => setAuthUsername(e.target.value)} + disabled={authLoading} + className="w-full" + minLength={3} + /> +
+ +
+ + ) => setAuthPassword(e.target.value)} + disabled={authLoading} + className="w-full" + minLength={6} + /> +
+ +
+ + ) => setAuthConfirmPassword(e.target.value)} + disabled={authLoading} + className="w-full" + minLength={6} + /> +
+ + {message && ( +
+ {message.text} +
+ )} + +
+ + +
+
+
+
+
+
+ )} diff --git a/src/app/_components/SetupModal.tsx b/src/app/_components/SetupModal.tsx new file mode 100644 index 0000000..5e4d25d --- /dev/null +++ b/src/app/_components/SetupModal.tsx @@ -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(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 ( +
+
+ {/* Header */} +
+
+ +

Setup Authentication

+
+
+ + {/* Content */} +
+

+ Set up authentication to secure your application. This will be required for future access. +

+ +
+
+ +
+ + setUsername(e.target.value)} + disabled={isLoading} + className="pl-10" + required={enableAuth} + minLength={3} + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + disabled={isLoading} + className="pl-10" + required={enableAuth} + minLength={6} + /> +
+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={isLoading} + className="pl-10" + required={enableAuth} + minLength={6} + /> +
+
+ +
+
+
+

Enable Authentication

+

+ {enableAuth + ? 'Authentication will be required on every page load' + : 'Authentication will be optional (can be enabled later in settings)' + } +

+
+ +
+
+ + {error && ( +
+ + {error} +
+ )} + + +
+
+
+
+ ); +} diff --git a/src/app/_components/Terminal.tsx b/src/app/_components/Terminal.tsx index ee1f93f..f14e36d 100644 --- a/src/app/_components/Terminal.tsx +++ b/src/app/_components/Terminal.tsx @@ -110,7 +110,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate setIsRunning(false); break; } - }, [scriptPath, containerId, scriptName, inWhiptailSession]); + }, [scriptPath, containerId, scriptName]); // Ensure we're on the client side useEffect(() => { @@ -346,7 +346,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate wsRef.current.close(); } }; - }, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile]); + }, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile, isRunning]); const startScript = () => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) { diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..b562b1f --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/auth/setup/route.ts b/src/app/api/auth/setup/route.ts new file mode 100644 index 0000000..b996c03 --- /dev/null +++ b/src/app/api/auth/setup/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts new file mode 100644 index 0000000..b111154 --- /dev/null +++ b/src/app/api/auth/verify/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/settings/auth-credentials/route.ts b/src/app/api/settings/auth-credentials/route.ts new file mode 100644 index 0000000..8c88566 --- /dev/null +++ b/src/app/api/settings/auth-credentials/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/settings/filters/route.ts b/src/app/api/settings/filters/route.ts index 87d4498..97e246e 100644 --- a/src/app/api/settings/filters/route.ts +++ b/src/app/api/settings/filters/route.ts @@ -81,7 +81,14 @@ export async function GET() { } try { - const filters = JSON.parse(filtersMatch[1]!); + const filtersJson = filtersMatch[1]?.trim(); + + // Check if filters JSON is empty or invalid + if (!filtersJson || filtersJson === '') { + return NextResponse.json({ filters: null }); + } + + const filters = JSON.parse(filtersJson); // Validate the parsed filters const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder']; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8b73235..b52fa38 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,8 @@ import { type Metadata, type Viewport } from "next"; import { Geist } from "next/font/google"; import { TRPCReactProvider } from "~/trpc/react"; +import { AuthProvider } from "./_components/AuthProvider"; +import { AuthGuard } from "./_components/AuthGuard"; export const metadata: Metadata = { title: "PVE Scripts local", @@ -45,7 +47,13 @@ export default function RootLayout({ className="bg-background text-foreground transition-colors" suppressHydrationWarning={true} > - {children} + + + + {children} + + + ); diff --git a/src/env.js b/src/env.js index 66f49d3..0414abb 100644 --- a/src/env.js +++ b/src/env.js @@ -25,6 +25,12 @@ export const env = createEnv({ WEBSOCKET_PORT: z.string().default("3001"), // GitHub Configuration GITHUB_TOKEN: z.string().optional(), + // Authentication Configuration + AUTH_USERNAME: z.string().optional(), + AUTH_PASSWORD_HASH: z.string().optional(), + AUTH_ENABLED: z.string().optional(), + AUTH_SETUP_COMPLETED: z.string().optional(), + JWT_SECRET: z.string().optional(), }, /** @@ -56,6 +62,12 @@ export const env = createEnv({ WEBSOCKET_PORT: process.env.WEBSOCKET_PORT, // GitHub Configuration GITHUB_TOKEN: process.env.GITHUB_TOKEN, + // Authentication Configuration + AUTH_USERNAME: process.env.AUTH_USERNAME, + AUTH_PASSWORD_HASH: process.env.AUTH_PASSWORD_HASH, + AUTH_ENABLED: process.env.AUTH_ENABLED, + AUTH_SETUP_COMPLETED: process.env.AUTH_SETUP_COMPLETED, + JWT_SECRET: process.env.JWT_SECRET, // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, }, /** diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..f4c7d3c --- /dev/null +++ b/src/lib/auth.ts @@ -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 { + return bcrypt.hash(password, SALT_ROUNDS); +} + +/** + * Compare a password with a hash + */ +export async function comparePassword(password: string, hash: string): Promise { + 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 { + 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); +} +