Compare commits
17 Commits
fix/270
...
update-ver
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de304bb9b6 | ||
|
|
9e450ecbd1 | ||
|
|
cf3f9a5479 | ||
|
|
6ad18e185e | ||
|
|
42fbdc87da | ||
|
|
8e2286d847 | ||
|
|
73776ec6ac | ||
|
|
596887d19c | ||
|
|
0676772992 | ||
|
|
72a9ea52b0 | ||
|
|
826ee26cdf | ||
|
|
82d14f1fb1 | ||
|
|
220b610466 | ||
|
|
e460a05f91 | ||
|
|
21f723bff6 | ||
|
|
61904f2936 | ||
|
|
237aee9c46 |
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -158,8 +158,7 @@ export function FilterBar({
|
||||
<Button
|
||||
onClick={() => updateFilters({ searchQuery: "" })}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
|
||||
className="absolute inset-y-0 right-0 flex items-center justify-center pr-3 h-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { Script } from "~/types/script";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import { TextViewer } from "./TextViewer";
|
||||
import { ExecutionModeModal } from "./ExecutionModeModal";
|
||||
import { ConfirmationModal } from "./ConfirmationModal";
|
||||
import { ScriptVersionModal } from "./ScriptVersionModal";
|
||||
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
@@ -37,6 +39,10 @@ export function ScriptDetailModal({
|
||||
const [selectedDiffFile, setSelectedDiffFile] = useState<string | null>(null);
|
||||
const [textViewerOpen, setTextViewerOpen] = useState(false);
|
||||
const [executionModeOpen, setExecutionModeOpen] = useState(false);
|
||||
const [versionModalOpen, setVersionModalOpen] = useState(false);
|
||||
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
// Check if script files exist locally
|
||||
const {
|
||||
@@ -83,6 +89,31 @@ export function ScriptDetailModal({
|
||||
},
|
||||
});
|
||||
|
||||
// Delete script mutation
|
||||
const deleteScriptMutation = api.scripts.deleteScript.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setIsDeleting(false);
|
||||
if (data.success) {
|
||||
const message =
|
||||
"message" in data ? data.message : "Script deleted successfully";
|
||||
setLoadMessage(`[SUCCESS] ${message}`);
|
||||
// Refetch script files status and comparison data to update the UI
|
||||
void refetchScriptFiles();
|
||||
void refetchComparison();
|
||||
} else {
|
||||
const error = "error" in data ? data.error : "Failed to delete script";
|
||||
setLoadMessage(`[ERROR] ${error}`);
|
||||
}
|
||||
// Clear message after 5 seconds
|
||||
setTimeout(() => setLoadMessage(null), 5000);
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsDeleting(false);
|
||||
setLoadMessage(`[ERROR] ${error.message}`);
|
||||
setTimeout(() => setLoadMessage(null), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
const handleImageError = () => {
|
||||
@@ -105,16 +136,43 @@ export function ScriptDetailModal({
|
||||
|
||||
const handleInstallScript = () => {
|
||||
if (!script) return;
|
||||
|
||||
// Check if script has multiple variants (default and alpine)
|
||||
const installMethods = script.install_methods || [];
|
||||
const hasMultipleVariants = installMethods.filter(method =>
|
||||
method.type === 'default' || method.type === 'alpine'
|
||||
).length > 1;
|
||||
|
||||
if (hasMultipleVariants) {
|
||||
// Show version selection modal first
|
||||
setVersionModalOpen(true);
|
||||
} else {
|
||||
// Only one variant, proceed directly to execution mode
|
||||
// Use the first available method or default to 'default' type
|
||||
const defaultMethod = installMethods.find(method => method.type === 'default');
|
||||
const firstMethod = installMethods[0];
|
||||
setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default');
|
||||
setExecutionModeOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVersionSelect = (versionType: string) => {
|
||||
setSelectedVersionType(versionType);
|
||||
setVersionModalOpen(false);
|
||||
setExecutionModeOpen(true);
|
||||
};
|
||||
|
||||
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
|
||||
if (!script || !onInstallScript) return;
|
||||
|
||||
// Find the script path (CT or tools)
|
||||
// Find the script path based on selected version type
|
||||
const versionType = selectedVersionType || 'default';
|
||||
const scriptMethod = script.install_methods?.find(
|
||||
(method) => method.type === versionType && method.script,
|
||||
) || script.install_methods?.find(
|
||||
(method) => method.script,
|
||||
);
|
||||
|
||||
if (scriptMethod?.script) {
|
||||
const scriptPath = `scripts/${scriptMethod.script}`;
|
||||
const scriptName = script.name;
|
||||
@@ -130,6 +188,19 @@ export function ScriptDetailModal({
|
||||
setTextViewerOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteScript = () => {
|
||||
if (!script) return;
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (!script) return;
|
||||
setDeleteConfirmOpen(false);
|
||||
setIsDeleting(true);
|
||||
setLoadMessage(null);
|
||||
deleteScriptMutation.mutate({ slug: script.slug });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
|
||||
@@ -165,6 +236,20 @@ export function ScriptDetailModal({
|
||||
{script.privileged && <PrivilegedBadge />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interface Port*/}
|
||||
{script.interface_port && (
|
||||
<div className="ml-3 sm:ml-4 flex-shrink-0">
|
||||
<div className="bg-primary/10 border border-primary/30 rounded-lg px-3 py-1.5 sm:px-4 sm:py-2">
|
||||
<span className="text-xs sm:text-sm font-medium text-muted-foreground mr-2">
|
||||
Port:
|
||||
</span>
|
||||
<span className="text-sm sm:text-base font-semibold text-foreground font-mono">
|
||||
{script.interface_port}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
@@ -359,6 +444,42 @@ export function ScriptDetailModal({
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Delete Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
||||
<Button
|
||||
onClick={handleDeleteScript}
|
||||
disabled={isDeleting}
|
||||
variant="destructive"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>Deleting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
<span>Delete Script</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -708,11 +829,22 @@ export function ScriptDetailModal({
|
||||
?.script?.split("/")
|
||||
.pop() ?? `${script.slug}.sh`
|
||||
}
|
||||
script={script}
|
||||
isOpen={textViewerOpen}
|
||||
onClose={() => setTextViewerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Version Selection Modal */}
|
||||
{script && (
|
||||
<ScriptVersionModal
|
||||
script={script}
|
||||
isOpen={versionModalOpen}
|
||||
onClose={() => setVersionModalOpen(false)}
|
||||
onSelectVersion={handleVersionSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Execution Mode Modal */}
|
||||
{script && (
|
||||
<ExecutionModeModal
|
||||
@@ -722,6 +854,20 @@ export function ScriptDetailModal({
|
||||
onExecute={handleExecuteScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{script && (
|
||||
<ConfirmationModal
|
||||
isOpen={deleteConfirmOpen}
|
||||
onClose={() => setDeleteConfirmOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Script"
|
||||
message={`Are you sure you want to delete all downloaded files for "${script.name}"? This action cannot be undone.`}
|
||||
variant="simple"
|
||||
confirmButtonText="Delete"
|
||||
cancelButtonText="Cancel"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
210
src/app/_components/ScriptVersionModal.tsx
Normal file
210
src/app/_components/ScriptVersionModal.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Script, ScriptInstallMethod } from '../../types/script';
|
||||
import { Button } from './ui/button';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
|
||||
interface ScriptVersionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectVersion: (versionType: string) => void;
|
||||
script: Script | null;
|
||||
}
|
||||
|
||||
export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose });
|
||||
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
// Get available install methods
|
||||
const installMethods = script.install_methods || [];
|
||||
const defaultMethod = installMethods.find(method => method.type === 'default');
|
||||
const alpineMethod = installMethods.find(method => method.type === 'alpine');
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedVersion) {
|
||||
onSelectVersion(selectedVersion);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVersionSelect = (versionType: string) => {
|
||||
setSelectedVersion(versionType);
|
||||
};
|
||||
|
||||
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-2xl w-full border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h2 className="text-xl font-bold text-foreground">Select Version</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
Choose a version for "{script.name}"
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the version you want to install. Each version has different resource requirements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Default Version */}
|
||||
{defaultMethod && (
|
||||
<div
|
||||
onClick={() => handleVersionSelect('default')}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
||||
selectedVersion === 'default'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border bg-card hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedVersion === 'default'
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
{selectedVersion === 'default' && (
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-foreground capitalize">
|
||||
{defaultMethod.type}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
|
||||
<div>
|
||||
<span className="text-muted-foreground">CPU: </span>
|
||||
<span className="text-foreground font-medium">{defaultMethod.resources.cpu} cores</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">RAM: </span>
|
||||
<span className="text-foreground font-medium">{defaultMethod.resources.ram} MB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">HDD: </span>
|
||||
<span className="text-foreground font-medium">{defaultMethod.resources.hdd} GB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">OS: </span>
|
||||
<span className="text-foreground font-medium">
|
||||
{defaultMethod.resources.os} {defaultMethod.resources.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alpine Version */}
|
||||
{alpineMethod && (
|
||||
<div
|
||||
onClick={() => handleVersionSelect('alpine')}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
||||
selectedVersion === 'alpine'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border bg-card hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedVersion === 'alpine'
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
{selectedVersion === 'alpine' && (
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-foreground capitalize">
|
||||
{alpineMethod.type}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
|
||||
<div>
|
||||
<span className="text-muted-foreground">CPU: </span>
|
||||
<span className="text-foreground font-medium">{alpineMethod.resources.cpu} cores</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">RAM: </span>
|
||||
<span className="text-foreground font-medium">{alpineMethod.resources.ram} MB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">HDD: </span>
|
||||
<span className="text-foreground font-medium">{alpineMethod.resources.hdd} GB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">OS: </span>
|
||||
<span className="text-foreground font-medium">
|
||||
{alpineMethod.resources.os} {alpineMethod.resources.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedVersion}
|
||||
variant="default"
|
||||
size="default"
|
||||
className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,50 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
void loadColorCodingSetting();
|
||||
}, []);
|
||||
|
||||
const validateServerAddress = (address: string): boolean => {
|
||||
const trimmed = address.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
// IPv4 validation
|
||||
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
if (ipv4Regex.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// IPv6 validation (supports compressed format like ::1 and full format)
|
||||
// Matches: 2001:0db8:85a3:0000:0000:8a2e:0370:7334, ::1, 2001:db8::1, etc.
|
||||
// Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1
|
||||
// Simplified validation: check for valid hex segments separated by colons
|
||||
const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
if (ipv6Pattern.test(trimmed)) {
|
||||
// Additional validation: ensure only one :: compression exists
|
||||
const compressionCount = (trimmed.match(/::/g) || []).length;
|
||||
if (compressionCount <= 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// FQDN/hostname validation (RFC 1123 compliant)
|
||||
// Allows letters, numbers, hyphens, dots; must start and end with alphanumeric
|
||||
// Max length 253 characters, each label max 63 characters
|
||||
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
|
||||
if (hostnameRegex.test(trimmed) && trimmed.length <= 253) {
|
||||
// Additional check: each label (between dots) must be max 63 chars
|
||||
const labels = trimmed.split('.');
|
||||
if (labels.every(label => label.length > 0 && label.length <= 63)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also allow simple hostnames without dots (like 'localhost')
|
||||
const simpleHostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
|
||||
if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
|
||||
|
||||
@@ -61,12 +105,10 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
}
|
||||
|
||||
if (!formData.ip.trim()) {
|
||||
newErrors.ip = 'IP address is required';
|
||||
newErrors.ip = 'Server address is required';
|
||||
} else {
|
||||
// Basic IP validation
|
||||
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
if (!ipRegex.test(formData.ip)) {
|
||||
newErrors.ip = 'Please enter a valid IP address';
|
||||
if (!validateServerAddress(formData.ip)) {
|
||||
newErrors.ip = 'Please enter a valid IP address (IPv4/IPv6) or hostname';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +263,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
|
||||
<div>
|
||||
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
IP Address *
|
||||
Host/IP Address *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -231,7 +273,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||
errors.ip ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
placeholder="e.g., 192.168.1.100"
|
||||
placeholder="e.g., 192.168.1.100, server.example.com, or 2001:db8::1"
|
||||
/>
|
||||
{errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
|
||||
</div>
|
||||
|
||||
@@ -4,77 +4,156 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { Button } from './ui/button';
|
||||
import type { Script } from '../../types/script';
|
||||
|
||||
interface TextViewerProps {
|
||||
scriptName: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
script?: Script | null;
|
||||
}
|
||||
|
||||
interface ScriptContent {
|
||||
ctScript?: string;
|
||||
installScript?: string;
|
||||
alpineCtScript?: string;
|
||||
alpineInstallScript?: string;
|
||||
}
|
||||
|
||||
export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) {
|
||||
const [scriptContent, setScriptContent] = useState<ScriptContent>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct');
|
||||
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
|
||||
|
||||
// Extract slug from script name (remove .sh extension)
|
||||
const slug = scriptName.replace(/\.sh$/, '');
|
||||
const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, '');
|
||||
|
||||
// Check if alpine variant exists
|
||||
const hasAlpineVariant = script?.install_methods?.some(
|
||||
method => method.type === 'alpine' && method.script?.startsWith('ct/')
|
||||
);
|
||||
|
||||
// Get script names for default and alpine versions
|
||||
const defaultScriptName = scriptName.replace(/^alpine-/, '');
|
||||
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
|
||||
|
||||
const loadScriptContent = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Try to load from different possible locations
|
||||
const [ctResponse, toolsResponse, vmResponse, vwResponse, installResponse] = await Promise.allSettled([
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${scriptName}` } }))}`),
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${scriptName}` } }))}`),
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${scriptName}` } }))}`),
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${scriptName}` } }))}`),
|
||||
// Build fetch requests for default version
|
||||
const requests: Promise<Response>[] = [];
|
||||
|
||||
// Default CT script
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
|
||||
// Tools, VM, VW scripts
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
|
||||
// Default install script
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
|
||||
]);
|
||||
);
|
||||
|
||||
// Alpine versions if variant exists
|
||||
if (hasAlpineVariant) {
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`)
|
||||
);
|
||||
}
|
||||
|
||||
const responses = await Promise.allSettled(requests);
|
||||
|
||||
const content: ScriptContent = {};
|
||||
let responseIndex = 0;
|
||||
|
||||
if (ctResponse.status === 'fulfilled' && ctResponse.value.ok) {
|
||||
// Default CT script
|
||||
const ctResponse = responses[responseIndex];
|
||||
if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) {
|
||||
const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (ctData.result?.data?.json?.success) {
|
||||
content.ctScript = ctData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolsResponse.status === 'fulfilled' && toolsResponse.value.ok) {
|
||||
responseIndex++;
|
||||
// Tools script
|
||||
const toolsResponse = responses[responseIndex];
|
||||
if (toolsResponse?.status === 'fulfilled' && toolsResponse.value.ok) {
|
||||
const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (toolsData.result?.data?.json?.success) {
|
||||
content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too
|
||||
}
|
||||
}
|
||||
|
||||
if (vmResponse.status === 'fulfilled' && vmResponse.value.ok) {
|
||||
responseIndex++;
|
||||
// VM script
|
||||
const vmResponse = responses[responseIndex];
|
||||
if (vmResponse?.status === 'fulfilled' && vmResponse.value.ok) {
|
||||
const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (vmData.result?.data?.json?.success) {
|
||||
content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too
|
||||
}
|
||||
}
|
||||
|
||||
if (vwResponse.status === 'fulfilled' && vwResponse.value.ok) {
|
||||
responseIndex++;
|
||||
// VW script
|
||||
const vwResponse = responses[responseIndex];
|
||||
if (vwResponse?.status === 'fulfilled' && vwResponse.value.ok) {
|
||||
const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (vwData.result?.data?.json?.success) {
|
||||
content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too
|
||||
}
|
||||
}
|
||||
|
||||
if (installResponse.status === 'fulfilled' && installResponse.value.ok) {
|
||||
responseIndex++;
|
||||
// Default install script
|
||||
const installResponse = responses[responseIndex];
|
||||
if (installResponse?.status === 'fulfilled' && installResponse.value.ok) {
|
||||
const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (installData.result?.data?.json?.success) {
|
||||
content.installScript = installData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
responseIndex++;
|
||||
// Alpine CT script
|
||||
if (hasAlpineVariant) {
|
||||
const alpineCtResponse = responses[responseIndex];
|
||||
if (alpineCtResponse?.status === 'fulfilled' && alpineCtResponse.value.ok) {
|
||||
const alpineCtData = await alpineCtResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (alpineCtData.result?.data?.json?.success) {
|
||||
content.alpineCtScript = alpineCtData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
responseIndex++;
|
||||
}
|
||||
|
||||
// Alpine install script
|
||||
if (hasAlpineVariant) {
|
||||
const alpineInstallResponse = responses[responseIndex];
|
||||
if (alpineInstallResponse?.status === 'fulfilled' && alpineInstallResponse.value.ok) {
|
||||
const alpineInstallData = await alpineInstallResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (alpineInstallData.result?.data?.json?.success) {
|
||||
content.alpineInstallScript = alpineInstallData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setScriptContent(content);
|
||||
} catch (err) {
|
||||
@@ -82,7 +161,7 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [scriptName, slug]);
|
||||
}, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && scriptName) {
|
||||
@@ -106,11 +185,30 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border mx-4 sm:mx-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<h2 className="text-2xl font-bold text-foreground">
|
||||
Script Viewer: {scriptName}
|
||||
Script Viewer: {defaultScriptName}
|
||||
</h2>
|
||||
{scriptContent.ctScript && scriptContent.installScript && (
|
||||
{hasAlpineVariant && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={selectedVersion === 'default' ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedVersion('default')}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Default
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedVersion === 'alpine' ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedVersion('alpine')}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Alpine
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) ||
|
||||
(selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={activeTab === 'ct' ? 'outline' : 'ghost'}
|
||||
@@ -151,44 +249,87 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === 'ct' && scriptContent.ctScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.ctScript}
|
||||
</SyntaxHighlighter>
|
||||
) : activeTab === 'install' && scriptContent.installScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.installScript}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">
|
||||
{activeTab === 'ct' ? 'CT script not found' : 'Install script not found'}
|
||||
{activeTab === 'ct' && (
|
||||
selectedVersion === 'default' && scriptContent.ctScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.ctScript}
|
||||
</SyntaxHighlighter>
|
||||
) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.alpineCtScript}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">
|
||||
{selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'install' && (
|
||||
selectedVersion === 'default' && scriptContent.installScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.installScript}
|
||||
</SyntaxHighlighter>
|
||||
) : selectedVersion === 'alpine' && scriptContent.alpineInstallScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.alpineInstallScript}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">
|
||||
{selectedVersion === 'default' ? 'Default install script not found' : 'Alpine install script not found'}
|
||||
</div>
|
||||
</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);
|
||||
}
|
||||
|
||||
|
||||
@@ -363,6 +363,34 @@ export const scriptsRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
// Delete script files
|
||||
deleteScript: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// Get the script details
|
||||
const script = await localScriptsService.getScriptBySlug(input.slug);
|
||||
if (!script) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script not found',
|
||||
deletedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
// Delete the script files
|
||||
const result = await scriptDownloaderService.deleteScript(script);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error in deleteScript:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete script',
|
||||
deletedFiles: []
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Compare local and remote script content
|
||||
compareScriptContent: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Real JavaScript implementation for script downloading
|
||||
import { join } from 'path';
|
||||
import { writeFile, mkdir, access, readFile } from 'fs/promises';
|
||||
import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises';
|
||||
|
||||
export class ScriptDownloaderService {
|
||||
constructor() {
|
||||
@@ -145,6 +145,35 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
// Download alpine install script if alpine variant exists (only for CT scripts)
|
||||
const hasAlpineCtVariant = script.install_methods?.some(
|
||||
method => method.type === 'alpine' && method.script?.startsWith('ct/')
|
||||
);
|
||||
console.log(`[${script.slug}] Checking for alpine variant:`, {
|
||||
hasAlpineCtVariant,
|
||||
installMethods: script.install_methods?.map(m => ({ type: m.type, script: m.script }))
|
||||
});
|
||||
|
||||
if (hasAlpineCtVariant) {
|
||||
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
|
||||
try {
|
||||
console.log(`[${script.slug}] Downloading alpine install script: install/${alpineInstallScriptName}`);
|
||||
const alpineInstallContent = await this.downloadFileFromGitHub(`install/${alpineInstallScriptName}`);
|
||||
const localAlpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
|
||||
await writeFile(localAlpineInstallPath, alpineInstallContent, 'utf-8');
|
||||
files.push(`install/${alpineInstallScriptName}`);
|
||||
console.log(`[${script.slug}] Successfully downloaded: install/${alpineInstallScriptName}`);
|
||||
} catch (error) {
|
||||
// Alpine install script might not exist, that's okay
|
||||
console.error(`[${script.slug}] Alpine install script not found or error: install/${alpineInstallScriptName}`, error);
|
||||
if (error instanceof Error) {
|
||||
console.error(`[${script.slug}] Error details:`, error.message, error.stack);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[${script.slug}] No alpine CT variant found, skipping alpine install script download`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully loaded ${files.length} script(s) for ${script.name}`,
|
||||
@@ -286,6 +315,23 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check alpine install script if alpine variant exists (only for CT scripts)
|
||||
const hasAlpineCtVariant = script.install_methods?.some(
|
||||
method => method.type === 'alpine' && method.script?.startsWith('ct/')
|
||||
);
|
||||
if (hasAlpineCtVariant) {
|
||||
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
|
||||
const alpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
|
||||
|
||||
try {
|
||||
await access(alpineInstallPath);
|
||||
files.push(`install/${alpineInstallScriptName}`);
|
||||
installExists = true; // Mark as exists if alpine install script exists
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return { ctExists, installExists, files };
|
||||
} catch (error) {
|
||||
console.error('Error checking script existence:', error);
|
||||
@@ -293,6 +339,57 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteScript(script) {
|
||||
this.initializeConfig();
|
||||
const deletedFiles = [];
|
||||
|
||||
try {
|
||||
// Get the list of files that exist for this script
|
||||
const fileCheck = await this.checkScriptExists(script);
|
||||
|
||||
if (fileCheck.files.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No script files found to delete',
|
||||
deletedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
// Delete all files
|
||||
for (const filePath of fileCheck.files) {
|
||||
try {
|
||||
const fullPath = join(this.scriptsDirectory, filePath);
|
||||
await unlink(fullPath);
|
||||
deletedFiles.push(filePath);
|
||||
} catch (error) {
|
||||
// Log error but continue deleting other files
|
||||
console.error(`Error deleting file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedFiles.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to delete any script files',
|
||||
deletedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully deleted ${deletedFiles.length} file(s) for ${script.name}`,
|
||||
deletedFiles
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error deleting script:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to delete script',
|
||||
deletedFiles
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async compareScriptContent(script) {
|
||||
this.initializeConfig();
|
||||
const differences = [];
|
||||
@@ -376,6 +473,35 @@ export class ScriptDownloaderService {
|
||||
);
|
||||
}
|
||||
|
||||
// Compare alpine install script if alpine variant exists (only for CT scripts)
|
||||
const hasAlpineCtVariant = script.install_methods?.some(
|
||||
method => method.type === 'alpine' && method.script?.startsWith('ct/')
|
||||
);
|
||||
if (hasAlpineCtVariant) {
|
||||
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
|
||||
const alpineInstallScriptPath = `install/${alpineInstallScriptName}`;
|
||||
const localAlpineInstallPath = join(this.scriptsDirectory, alpineInstallScriptPath);
|
||||
|
||||
// Check if alpine install script exists locally
|
||||
try {
|
||||
await access(localAlpineInstallPath);
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(alpineInstallScriptPath, alpineInstallScriptPath)
|
||||
.then(result => {
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
differences.push(result.filePath);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't add to differences if there's an error reading files
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// Alpine install script doesn't exist locally, skip comparison
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all comparisons to complete
|
||||
await Promise.all(comparisonPromises);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||||
import { readFile, writeFile, mkdir, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { env } from '~/env.js';
|
||||
import type { Script } from '~/types/script';
|
||||
@@ -461,6 +461,57 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteScript(script: Script): Promise<{ success: boolean; message: string; deletedFiles: string[] }> {
|
||||
this.initializeConfig();
|
||||
const deletedFiles: string[] = [];
|
||||
|
||||
try {
|
||||
// Get the list of files that exist for this script
|
||||
const fileCheck = await this.checkScriptExists(script);
|
||||
|
||||
if (fileCheck.files.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No script files found to delete',
|
||||
deletedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
// Delete all files
|
||||
for (const filePath of fileCheck.files) {
|
||||
try {
|
||||
const fullPath = join(this.scriptsDirectory!, filePath);
|
||||
await unlink(fullPath);
|
||||
deletedFiles.push(filePath);
|
||||
} catch (error) {
|
||||
// Log error but continue deleting other files
|
||||
console.error(`Error deleting file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedFiles.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to delete any script files',
|
||||
deletedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully deleted ${deletedFiles.length} file(s) for ${script.name}`,
|
||||
deletedFiles
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error deleting script:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to delete script',
|
||||
deletedFiles
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async compareScriptContent(script: Script): Promise<{ hasDifferences: boolean; differences: string[] }> {
|
||||
this.initializeConfig();
|
||||
const differences: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user