feat: Add persistent session authentication with configurable duration
- Implement persistent session authentication with httpOnly cookies - Add configurable session duration (1-365 days) in settings - Add session expiration display in settings modal - Add logout button next to theme toggle - Enhance token verification to return expiration time - Add retry logic for failed auth checks - Add comprehensive authentication documentation to help modal - Improve session restoration on page load
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
username: string | null;
|
||||
isLoading: boolean;
|
||||
expirationTime: number | null;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
@@ -21,8 +22,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [expirationTime, setExpirationTime] = useState<number | null>(null);
|
||||
|
||||
const checkAuth = async () => {
|
||||
const checkAuthInternal = async (retryCount = 0) => {
|
||||
try {
|
||||
// First check if setup is completed
|
||||
const setupResponse = await fetch('/api/settings/auth-credentials');
|
||||
@@ -33,30 +35,60 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
if (!setupData.setupCompleted || !setupData.enabled) {
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
setExpirationTime(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only verify authentication if setup is completed and auth is enabled
|
||||
const response = await fetch('/api/auth/verify');
|
||||
const response = await fetch('/api/auth/verify', {
|
||||
credentials: 'include', // Ensure cookies are sent
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { username: string };
|
||||
const data = await response.json() as {
|
||||
username: string;
|
||||
expirationTime?: number | null;
|
||||
timeUntilExpiration?: number | null;
|
||||
};
|
||||
setIsAuthenticated(true);
|
||||
setUsername(data.username);
|
||||
setExpirationTime(data.expirationTime ?? null);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
setExpirationTime(null);
|
||||
|
||||
// Retry logic for failed auth checks (max 2 retries)
|
||||
if (retryCount < 2) {
|
||||
setTimeout(() => {
|
||||
void checkAuthInternal(retryCount + 1);
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking auth:', error);
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
setExpirationTime(null);
|
||||
|
||||
// Retry logic for network errors (max 2 retries)
|
||||
if (retryCount < 2) {
|
||||
setTimeout(() => {
|
||||
void checkAuthInternal(retryCount + 1);
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkAuth = useCallback(() => {
|
||||
return checkAuthInternal(0);
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
@@ -65,12 +97,16 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: 'include', // Ensure cookies are received
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { username: string };
|
||||
setIsAuthenticated(true);
|
||||
setUsername(data.username);
|
||||
|
||||
// Check auth again to get expiration time
|
||||
await checkAuth();
|
||||
return true;
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
@@ -88,11 +124,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
setExpirationTime(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void checkAuth();
|
||||
}, []);
|
||||
}, [checkAuth]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
@@ -100,6 +137,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
isAuthenticated,
|
||||
username,
|
||||
isLoading,
|
||||
expirationTime,
|
||||
login,
|
||||
logout,
|
||||
checkAuth,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
import { useTheme } from './ThemeProvider';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useAuth } from './AuthProvider';
|
||||
|
||||
interface GeneralSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -17,7 +18,9 @@ interface GeneralSettingsModalProps {
|
||||
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose });
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { isAuthenticated, expirationTime, checkAuth } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth' | 'auto-sync'>('general');
|
||||
const [sessionExpirationDisplay, setSessionExpirationDisplay] = useState<string>('');
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [saveFilter, setSaveFilter] = useState(false);
|
||||
const [savedFilters, setSavedFilters] = useState<any>(null);
|
||||
@@ -34,6 +37,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
const [authHasCredentials, setAuthHasCredentials] = useState(false);
|
||||
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
const [sessionDurationDays, setSessionDurationDays] = useState(7);
|
||||
|
||||
// Auto-sync state
|
||||
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false);
|
||||
@@ -214,11 +218,12 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
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 };
|
||||
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean; sessionDurationDays?: number };
|
||||
setAuthUsername(data.username ?? '');
|
||||
setAuthEnabled(data.enabled ?? false);
|
||||
setAuthHasCredentials(data.hasCredentials ?? false);
|
||||
setAuthSetupCompleted(data.setupCompleted ?? false);
|
||||
setSessionDurationDays(data.sessionDurationDays ?? 7);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading auth credentials:', error);
|
||||
@@ -227,6 +232,64 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
}
|
||||
};
|
||||
|
||||
// Format expiration time display
|
||||
const formatExpirationTime = (expTime: number | null): string => {
|
||||
if (!expTime) return 'No active session';
|
||||
|
||||
const now = Date.now();
|
||||
const timeUntilExpiration = expTime - now;
|
||||
|
||||
if (timeUntilExpiration <= 0) {
|
||||
return 'Session expired';
|
||||
}
|
||||
|
||||
const days = Math.floor(timeUntilExpiration / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((timeUntilExpiration % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((timeUntilExpiration % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days > 0) {
|
||||
parts.push(`${days} ${days === 1 ? 'day' : 'days'}`);
|
||||
}
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`);
|
||||
}
|
||||
if (minutes > 0 && days === 0) {
|
||||
parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return 'Less than a minute';
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
// Update expiration display periodically
|
||||
useEffect(() => {
|
||||
const updateExpirationDisplay = () => {
|
||||
if (expirationTime) {
|
||||
setSessionExpirationDisplay(formatExpirationTime(expirationTime));
|
||||
} else {
|
||||
setSessionExpirationDisplay('');
|
||||
}
|
||||
};
|
||||
|
||||
updateExpirationDisplay();
|
||||
|
||||
// Update every minute
|
||||
const interval = setInterval(updateExpirationDisplay, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [expirationTime]);
|
||||
|
||||
// Refresh auth when tab changes to auth tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'auth' && isOpen) {
|
||||
void checkAuth();
|
||||
}
|
||||
}, [activeTab, isOpen, checkAuth]);
|
||||
|
||||
const saveAuthCredentials = async () => {
|
||||
if (authPassword !== authConfirmPassword) {
|
||||
setMessage({ type: 'error', text: 'Passwords do not match' });
|
||||
@@ -265,6 +328,41 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
}
|
||||
};
|
||||
|
||||
const saveSessionDuration = async (days: number) => {
|
||||
if (days < 1 || days > 365) {
|
||||
setMessage({ type: 'error', text: 'Session duration must be between 1 and 365 days' });
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/auth-credentials', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ sessionDurationDays: days }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: `Session duration updated to ${days} days` });
|
||||
setSessionDurationDays(days);
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({ type: 'error', text: errorData.error ?? 'Failed to update session duration' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to update session duration' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAuthEnabled = async (enabled: boolean) => {
|
||||
setAuthLoading(true);
|
||||
setMessage(null);
|
||||
@@ -662,7 +760,10 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
{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>
|
||||
<div className="flex items-center gap-2 mb-3 sm:mb-4">
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground">Authentication Settings</h3>
|
||||
<ContextualHelpIcon section="auth-settings" tooltip="Help with Authentication Settings" />
|
||||
</div>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||
Configure authentication to secure access to your application.
|
||||
</p>
|
||||
@@ -699,6 +800,68 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAuthenticated && expirationTime && (
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Session Information</h4>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Session expires in:</p>
|
||||
<p className="text-sm font-medium text-foreground">{sessionExpirationDisplay}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Expiration date:</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{new Date(expirationTime).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Session Duration</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure how long user sessions should last before requiring re-authentication.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="session-duration" className="block text-sm font-medium text-foreground mb-1">
|
||||
Session Duration (days)
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="session-duration"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
placeholder="Enter days"
|
||||
value={sessionDurationDays}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value)) {
|
||||
setSessionDurationDays(value);
|
||||
}
|
||||
}}
|
||||
disabled={authLoading || !authSetupCompleted}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">days (1-365)</span>
|
||||
<Button
|
||||
onClick={() => saveSessionDuration(sessionDurationDays)}
|
||||
disabled={authLoading || !authSetupCompleted}
|
||||
size="sm"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Note: This setting applies to new logins. Current sessions will not be affected.
|
||||
</p>
|
||||
</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">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react';
|
||||
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download, Lock } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
|
||||
interface HelpModalProps {
|
||||
@@ -11,7 +11,7 @@ interface HelpModalProps {
|
||||
initialSection?: string;
|
||||
}
|
||||
|
||||
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
|
||||
type HelpSection = 'server-settings' | 'general-settings' | 'auth-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
|
||||
|
||||
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose });
|
||||
@@ -22,6 +22,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
const sections = [
|
||||
{ id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server },
|
||||
{ id: 'general-settings' as HelpSection, label: 'General Settings', icon: Settings },
|
||||
{ id: 'auth-settings' as HelpSection, label: 'Authentication Settings', icon: Lock },
|
||||
{ id: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw },
|
||||
{ id: 'auto-sync' as HelpSection, label: 'Auto-Sync', icon: Clock },
|
||||
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
|
||||
@@ -126,16 +127,113 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
<li>• Token is stored securely and only used for API calls</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'auth-settings':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">Authentication Settings</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Secure your application with username and password authentication and configure session management.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Authentication</h4>
|
||||
<h4 className="font-medium text-foreground mb-2">Overview</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Secure your application with username and password authentication.
|
||||
Authentication settings allow you to secure access to your application with username and password protection.
|
||||
Sessions persist across page refreshes, so users don't need to log in repeatedly.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Set up username and password for app access</li>
|
||||
<li>• Enable/disable authentication as needed</li>
|
||||
<li>• Credentials are stored securely</li>
|
||||
<li>• Credentials are stored securely using bcrypt hashing</li>
|
||||
<li>• Sessions use secure httpOnly cookies</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Setting Up Authentication</h4>
|
||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Navigate to General Settings → Authentication tab</li>
|
||||
<li>Enter a username (minimum 3 characters)</li>
|
||||
<li>Enter a password (minimum 6 characters)</li>
|
||||
<li>Confirm your password</li>
|
||||
<li>Click "Save Credentials" to save your authentication settings</li>
|
||||
<li>Toggle "Enable Authentication" to activate authentication</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Session Duration</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Configure how long user sessions should last before requiring re-authentication.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Configurable Duration:</strong> Set session duration from 1 to 365 days</li>
|
||||
<li>• <strong>Default Duration:</strong> Sessions default to 7 days if not configured</li>
|
||||
<li>• <strong>Session Persistence:</strong> Sessions persist across page refreshes and browser restarts</li>
|
||||
<li>• <strong>New Logins Only:</strong> Duration changes apply to new logins, not existing sessions</li>
|
||||
</ul>
|
||||
<div className="mt-3 p-3 bg-info/10 rounded-md">
|
||||
<h5 className="font-medium text-info-foreground mb-2">How to Configure:</h5>
|
||||
<ol className="text-xs text-info/80 space-y-1 list-decimal list-inside">
|
||||
<li>Go to General Settings → Authentication tab</li>
|
||||
<li>Find the "Session Duration" section</li>
|
||||
<li>Enter the number of days (1-365)</li>
|
||||
<li>Click "Save" to apply the setting</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Session Information</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
When authenticated, you can view your current session information in the Authentication tab.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• <strong>Time Until Expiration:</strong> See how much time remains before your session expires</li>
|
||||
<li>• <strong>Expiration Date:</strong> View the exact date and time your session will expire</li>
|
||||
<li>• <strong>Auto-Update:</strong> The expiration display updates every minute</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Updating Credentials</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
You can change your username and password at any time from the Authentication tab.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Update username without changing password (leave password fields empty)</li>
|
||||
<li>• Change password by entering a new password and confirmation</li>
|
||||
<li>• Both username and password can be updated together</li>
|
||||
<li>• Changes take effect immediately after saving</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-muted/50">
|
||||
<h4 className="font-medium text-foreground mb-2">Security Features</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Password Hashing:</strong> Passwords are hashed using bcrypt before storage</li>
|
||||
<li>• <strong>Secure Cookies:</strong> Authentication tokens stored in httpOnly cookies</li>
|
||||
<li>• <strong>HTTPS in Production:</strong> Cookies are secure (HTTPS-only) in production mode</li>
|
||||
<li>• <strong>SameSite Protection:</strong> Cookies use strict SameSite policy to prevent CSRF attacks</li>
|
||||
<li>• <strong>JWT Tokens:</strong> Sessions use JSON Web Tokens with expiration</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-warning/10 border-warning/20">
|
||||
<h4 className="font-medium text-warning-foreground mb-2">⚠️ Important Notes</h4>
|
||||
<ul className="text-sm text-warning/80 space-y-2">
|
||||
<li>• <strong>First-Time Setup:</strong> You must complete the initial setup before enabling authentication</li>
|
||||
<li>• <strong>Session Duration:</strong> Changes to session duration only affect new logins</li>
|
||||
<li>• <strong>Logout:</strong> You can log out manually, which immediately invalidates your session</li>
|
||||
<li>• <strong>Lost Credentials:</strong> If you forget your password, you'll need to reset it manually in the .env file</li>
|
||||
<li>• <strong>Disabling Auth:</strong> Disabling authentication clears all credentials and allows unrestricted access</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const token = generateToken(username);
|
||||
const sessionDurationDays = authConfig.sessionDurationDays;
|
||||
const token = generateToken(username, sessionDurationDays);
|
||||
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
@@ -46,12 +47,12 @@ export async function POST(request: NextRequest) {
|
||||
username
|
||||
});
|
||||
|
||||
// Set httpOnly cookie
|
||||
// Set httpOnly cookie with configured duration
|
||||
response.cookies.set('auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
|
||||
path: '/',
|
||||
});
|
||||
|
||||
|
||||
@@ -22,10 +22,17 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate expiration time in milliseconds
|
||||
const expirationTime = decoded.exp ? decoded.exp * 1000 : null;
|
||||
const currentTime = Date.now();
|
||||
const timeUntilExpiration = expirationTime ? expirationTime - currentTime : null;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
username: decoded.username,
|
||||
authenticated: true
|
||||
authenticated: true,
|
||||
expirationTime,
|
||||
timeUntilExpiration
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error verifying token:', error);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth';
|
||||
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled, updateSessionDuration } from '~/lib/auth';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { withApiLogging } from '../../../../server/logging/withApiLogging';
|
||||
@@ -14,6 +14,7 @@ export const GET = withApiLogging(async function GET() {
|
||||
enabled: authConfig.enabled,
|
||||
hasCredentials: authConfig.hasCredentials,
|
||||
setupCompleted: authConfig.setupCompleted,
|
||||
sessionDurationDays: authConfig.sessionDurationDays,
|
||||
});
|
||||
} catch {
|
||||
// Error handled by withApiLogging
|
||||
@@ -66,48 +67,75 @@ export const POST = withApiLogging(async function POST(request: NextRequest) {
|
||||
|
||||
export const PATCH = withApiLogging(async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const { enabled } = await request.json() as { enabled: boolean };
|
||||
const body = await request.json() as { enabled?: boolean; sessionDurationDays?: number };
|
||||
|
||||
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');
|
||||
if (body.enabled !== undefined) {
|
||||
const { enabled } = body;
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Enabled flag must be a boolean' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 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');
|
||||
if (enabled) {
|
||||
// When enabling, just update the flag
|
||||
updateAuthEnabled(enabled);
|
||||
} else {
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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`
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
|
||||
});
|
||||
if (body.sessionDurationDays !== undefined) {
|
||||
const { sessionDurationDays } = body;
|
||||
|
||||
if (typeof sessionDurationDays !== 'number' || sessionDurationDays < 1 || sessionDurationDays > 365) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Session duration must be a number between 1 and 365 days' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
updateSessionDuration(sessionDurationDays);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Session duration updated to ${sessionDurationDays} days`
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'No valid field to update' },
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch {
|
||||
// Error handled by withApiLogging
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -16,10 +16,12 @@ import { Button } from './_components/ui/button';
|
||||
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
||||
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
||||
import { Footer } from './_components/Footer';
|
||||
import { Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { Package, HardDrive, FolderOpen, LogOut } from 'lucide-react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useAuth } from './_components/AuthProvider';
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -152,7 +154,19 @@ export default function Home() {
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1">
|
||||
<span className="break-words">PVE Scripts Management</span>
|
||||
</h1>
|
||||
<div className="flex-1 flex justify-end">
|
||||
<div className="flex-1 flex justify-end items-center gap-2">
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={logout}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Logout"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
const JWT_EXPIRY = '7d'; // 7 days
|
||||
const DEFAULT_JWT_EXPIRY_DAYS = 7; // Default 7 days
|
||||
|
||||
// Cache for JWT secret to avoid multiple file reads
|
||||
let jwtSecretCache: string | null = null;
|
||||
@@ -66,18 +66,31 @@ export async function comparePassword(password: string, hash: string): Promise<b
|
||||
/**
|
||||
* Generate a JWT token
|
||||
*/
|
||||
export function generateToken(username: string): string {
|
||||
export function generateToken(username: string, durationDays?: number): string {
|
||||
const secret = getJwtSecret();
|
||||
return jwt.sign({ username }, secret, { expiresIn: JWT_EXPIRY });
|
||||
const days = durationDays ?? DEFAULT_JWT_EXPIRY_DAYS;
|
||||
return jwt.sign({ username }, secret, { expiresIn: `${days}d` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a JWT token without verification (for extracting expiration time)
|
||||
*/
|
||||
export function decodeToken(token: string): { username: string; exp?: number; iat?: number } | null {
|
||||
try {
|
||||
const decoded = jwt.decode(token) as { username: string; exp?: number; iat?: number } | null;
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
*/
|
||||
export function verifyToken(token: string): { username: string } | null {
|
||||
export function verifyToken(token: string): { username: string; exp?: number; iat?: number } | null {
|
||||
try {
|
||||
const secret = getJwtSecret();
|
||||
const decoded = jwt.verify(token, secret) as { username: string };
|
||||
const decoded = jwt.verify(token, secret) as { username: string; exp?: number; iat?: number };
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -93,6 +106,7 @@ export function getAuthConfig(): {
|
||||
enabled: boolean;
|
||||
hasCredentials: boolean;
|
||||
setupCompleted: boolean;
|
||||
sessionDurationDays: number;
|
||||
} {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
@@ -103,6 +117,7 @@ export function getAuthConfig(): {
|
||||
enabled: false,
|
||||
hasCredentials: false,
|
||||
setupCompleted: false,
|
||||
sessionDurationDays: DEFAULT_JWT_EXPIRY_DAYS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +143,13 @@ export function getAuthConfig(): {
|
||||
const setupCompletedMatch = setupCompletedRegex.exec(envContent);
|
||||
const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||
|
||||
// Extract AUTH_SESSION_DURATION_DAYS
|
||||
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m;
|
||||
const sessionDurationMatch = sessionDurationRegex.exec(envContent);
|
||||
const sessionDurationDays = sessionDurationMatch
|
||||
? parseInt(sessionDurationMatch[1]?.trim() || String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS
|
||||
: DEFAULT_JWT_EXPIRY_DAYS;
|
||||
|
||||
const hasCredentials = !!(username && passwordHash);
|
||||
|
||||
return {
|
||||
@@ -136,6 +158,7 @@ export function getAuthConfig(): {
|
||||
enabled,
|
||||
hasCredentials,
|
||||
setupCompleted,
|
||||
sessionDurationDays,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,3 +261,30 @@ export function updateAuthEnabled(enabled: boolean): void {
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update AUTH_SESSION_DURATION_DAYS in .env
|
||||
*/
|
||||
export function updateSessionDuration(days: number): void {
|
||||
// Validate: between 1 and 365 days
|
||||
const validDays = Math.max(1, Math.min(365, Math.floor(days)));
|
||||
|
||||
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_SESSION_DURATION_DAYS
|
||||
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=.*$/m;
|
||||
if (sessionDurationRegex.test(envContent)) {
|
||||
envContent = envContent.replace(sessionDurationRegex, `AUTH_SESSION_DURATION_DAYS=${validDays}`);
|
||||
} else {
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_SESSION_DURATION_DAYS=${validDays}\n`;
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user