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=
|
||||
SAVE_FILTER=false
|
||||
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://192.168.*',
|
||||
],
|
||||
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
if (dev && !isServer) {
|
||||
config.watchOptions = {
|
||||
poll: 1000,
|
||||
aggregateTimeout: 300,
|
||||
};
|
||||
}
|
||||
return 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-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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
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,7 +11,7 @@ interface GeneralSettingsModalProps {
|
||||
}
|
||||
|
||||
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'github'>('general');
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth'>('general');
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [saveFilter, setSaveFilter] = useState(false);
|
||||
const [savedFilters, setSavedFilters] = useState<any>(null);
|
||||
@@ -19,12 +19,22 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
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(() => {
|
||||
if (isOpen) {
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab('auth')}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||
activeTab === 'auth'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
Authentication
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -301,6 +409,134 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'auth' && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Authentication Settings</h3>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||
Configure authentication to secure access to your application.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Authentication Status</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{authSetupCompleted
|
||||
? (authHasCredentials
|
||||
? `Authentication is ${authEnabled ? 'enabled' : 'disabled'}. Current username: ${authUsername}`
|
||||
: `Authentication is ${authEnabled ? 'enabled' : 'disabled'}. No credentials configured.`)
|
||||
: 'Authentication setup has not been completed yet.'
|
||||
}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Enable Authentication</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{authEnabled
|
||||
? 'Authentication is required on every page load'
|
||||
: 'Authentication is optional'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={authEnabled}
|
||||
onCheckedChange={toggleAuthEnabled}
|
||||
disabled={authLoading || !authSetupCompleted}
|
||||
label="Enable authentication"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Change your username and password for authentication.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="auth-username" className="block text-sm font-medium text-foreground mb-1">
|
||||
Username
|
||||
</label>
|
||||
<Input
|
||||
id="auth-username"
|
||||
type="text"
|
||||
placeholder="Enter username"
|
||||
value={authUsername}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthUsername(e.target.value)}
|
||||
disabled={authLoading}
|
||||
className="w-full"
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="auth-password" className="block text-sm font-medium text-foreground mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<Input
|
||||
id="auth-password"
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
value={authPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthPassword(e.target.value)}
|
||||
disabled={authLoading}
|
||||
className="w-full"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="auth-confirm-password" className="block text-sm font-medium text-foreground mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
id="auth-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
value={authConfirmPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthConfirmPassword(e.target.value)}
|
||||
disabled={authLoading}
|
||||
className="w-full"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`p-3 rounded-md text-sm ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 text-green-800 border border-green-200'
|
||||
: 'bg-red-50 text-red-800 border border-red-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={saveAuthCredentials}
|
||||
disabled={authLoading || !authUsername.trim() || !authPassword.trim() || !authConfirmPassword.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
{authLoading ? 'Saving...' : 'Update Credentials'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={loadAuthCredentials}
|
||||
disabled={authLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{authLoading ? 'Loading...' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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);
|
||||
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) {
|
||||
|
||||
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 {
|
||||
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'];
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||
<TRPCReactProvider>
|
||||
<AuthProvider>
|
||||
<AuthGuard>
|
||||
{children}
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
</TRPCReactProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
12
src/env.js
12
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,
|
||||
},
|
||||
/**
|
||||
|
||||
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