diff --git a/client/src/App.jsx b/client/src/App.jsx index ce7b55e..eaaa792 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -11,6 +11,7 @@ import Devices from './pages/Devices'; import Detections from './pages/Detections'; import Alerts from './pages/Alerts'; import Debug from './pages/Debug'; +import Settings from './pages/Settings'; import Login from './pages/Login'; import ProtectedRoute from './components/ProtectedRoute'; @@ -70,6 +71,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index 210de40..fcf73c8 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -14,7 +14,8 @@ import { XMarkIcon, SignalIcon, WifiIcon, - BugAntIcon + BugAntIcon, + CogIcon } from '@heroicons/react/24/outline'; import classNames from 'classnames'; @@ -27,6 +28,7 @@ const baseNavigation = [ ]; const adminNavigation = [ + { name: 'Settings', href: '/settings', icon: CogIcon }, { name: 'Debug', href: '/debug', icon: BugAntIcon }, ]; diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx new file mode 100644 index 0000000..a9526fd --- /dev/null +++ b/client/src/pages/Settings.jsx @@ -0,0 +1,865 @@ +import React, { useState, useEffect } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import api from '../services/api'; +import toast from 'react-hot-toast'; +import { + CogIcon, + ShieldCheckIcon, + PaintBrushIcon, + UserGroupIcon, + GlobeAltIcon, + KeyIcon, + EyeIcon, + EyeSlashIcon +} from '@heroicons/react/24/outline'; + +const Settings = () => { + const { user } = useAuth(); + const [activeTab, setActiveTab] = useState('general'); + const [tenantConfig, setTenantConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + // Check if user has admin role + const isAdmin = user?.role === 'admin'; + + useEffect(() => { + fetchTenantConfig(); + }, []); + + const fetchTenantConfig = async () => { + try { + // Get current tenant configuration + const response = await api.get('/tenant/info'); + setTenantConfig(response.data.data); + } catch (error) { + console.error('Failed to fetch tenant config:', error); + toast.error('Failed to load tenant settings'); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (!isAdmin) { + return ( +
+
+ +

Access Denied

+

+ You need admin privileges to access tenant settings. +

+
+
+ ); + } + + const tabs = [ + { id: 'general', name: 'General', icon: CogIcon }, + { id: 'branding', name: 'Branding', icon: PaintBrushIcon }, + { id: 'security', name: 'Security', icon: ShieldCheckIcon }, + { id: 'authentication', name: 'Authentication', icon: KeyIcon }, + { id: 'users', name: 'Users', icon: UserGroupIcon }, + ]; + + return ( +
+
+
+
+
+

+ Tenant Settings +

+
+ +
+
+
+ +
+ {activeTab === 'general' && } + {activeTab === 'branding' && } + {activeTab === 'security' && } + {activeTab === 'authentication' && } + {activeTab === 'users' && } +
+
+
+
+ ); +}; + +// General Settings Component +const GeneralSettings = ({ tenantConfig }) => ( +
+
+

General Information

+
+
+ +

{tenantConfig?.name}

+
+
+ +

{tenantConfig?.slug}

+
+
+ +

{tenantConfig?.auth_provider}

+
+
+
+
+); + +// Branding Settings Component +const BrandingSettings = ({ tenantConfig, onRefresh }) => { + const [branding, setBranding] = useState({ + logo_url: '', + primary_color: '#3B82F6', + secondary_color: '#1F2937', + company_name: '' + }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (tenantConfig?.branding) { + setBranding(tenantConfig.branding); + } + }, [tenantConfig]); + + const handleSave = async () => { + setSaving(true); + try { + await api.put('/tenant/branding', branding); + toast.success('Branding updated successfully'); + if (onRefresh) onRefresh(); + } catch (error) { + toast.error('Failed to update branding'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

Branding & Appearance

+
+
+ + setBranding(prev => ({ ...prev, company_name: e.target.value }))} + className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + /> +
+ +
+ + setBranding(prev => ({ ...prev, logo_url: e.target.value }))} + className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + placeholder="https://example.com/logo.png" + /> +
+ +
+
+ +
+ setBranding(prev => ({ ...prev, primary_color: e.target.value }))} + className="h-10 w-20 border border-gray-300 rounded-md" + /> + setBranding(prev => ({ ...prev, primary_color: e.target.value }))} + className="ml-2 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + /> +
+
+ +
+ +
+ setBranding(prev => ({ ...prev, secondary_color: e.target.value }))} + className="h-10 w-20 border border-gray-300 rounded-md" + /> + setBranding(prev => ({ ...prev, secondary_color: e.target.value }))} + className="ml-2 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + /> +
+
+
+ +
+ +
+
+
+
+ ); +}; + +// Placeholder components for other tabs +const SecuritySettings = ({ tenantConfig, onRefresh }) => { + const [securitySettings, setSecuritySettings] = useState({ + ip_restriction_enabled: false, + ip_whitelist: [], + ip_restriction_message: 'Access denied. Your IP address is not authorized to access this tenant.' + }); + const [newIP, setNewIP] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (tenantConfig) { + setSecuritySettings({ + ip_restriction_enabled: tenantConfig.ip_restriction_enabled || false, + ip_whitelist: tenantConfig.ip_whitelist || [], + ip_restriction_message: tenantConfig.ip_restriction_message || 'Access denied. Your IP address is not authorized to access this tenant.' + }); + } + }, [tenantConfig]); + + const addIPToWhitelist = () => { + if (!newIP.trim()) { + toast.error('Please enter an IP address or CIDR block'); + return; + } + + // Basic validation for IP format + const ipPattern = /^(?:(?: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-9]|[1-2][0-9]|3[0-2]))?$|^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^\d{1,3}\.\d{1,3}\.\d{1,3}\.\*$/; + + if (!ipPattern.test(newIP.trim())) { + toast.error('Please enter a valid IP address, CIDR block (e.g., 192.168.1.0/24), or wildcard (e.g., 192.168.1.*)'); + return; + } + + const ip = newIP.trim(); + if (securitySettings.ip_whitelist.includes(ip)) { + toast.error('This IP is already in the whitelist'); + return; + } + + setSecuritySettings(prev => ({ + ...prev, + ip_whitelist: [...prev.ip_whitelist, ip] + })); + setNewIP(''); + toast.success('IP added to whitelist'); + }; + + const removeIPFromWhitelist = (ipToRemove) => { + setSecuritySettings(prev => ({ + ...prev, + ip_whitelist: prev.ip_whitelist.filter(ip => ip !== ipToRemove) + })); + toast.success('IP removed from whitelist'); + }; + + const handleSave = async () => { + setSaving(true); + try { + await api.put('/tenant/security', securitySettings); + toast.success('Security settings updated successfully'); + if (onRefresh) onRefresh(); + } catch (error) { + console.error('Failed to update security settings:', error); + toast.error('Failed to update security settings'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+

Security Settings

+ +
+ +
+ {/* IP Restriction Toggle */} +
+
+

IP Access Control

+

+ Restrict access to this tenant to specific IP addresses +

+
+
+ setSecuritySettings(prev => ({ + ...prev, + ip_restriction_enabled: e.target.checked + }))} + className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded" + /> + +
+
+ + {/* IP Restriction Configuration */} + {securitySettings.ip_restriction_enabled && ( +
+
+
+
+

+ ⚠️ Important Security Notes +

+
+
    +
  • Make sure to include your current IP to avoid being locked out
  • +
  • IP restrictions apply to all tenant access including login and API calls
  • +
  • Use CIDR notation for IP ranges (e.g., 192.168.1.0/24)
  • +
  • Use wildcards for partial matching (e.g., 192.168.1.*)
  • +
+
+
+
+
+ + {/* Add IP Input */} +
+ +
+ setNewIP(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addIPToWhitelist(); + } + }} + className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500" + /> + +
+
+ + {/* IP Whitelist */} + {securitySettings.ip_whitelist.length > 0 && ( +
+ +
+ {securitySettings.ip_whitelist.map((ip, index) => ( +
+ {ip} + +
+ ))} +
+
+ )} + + {/* Custom restriction message */} +
+ +