Files
drone-detector/client/src/pages/Settings.jsx
2025-09-20 06:40:44 +02:00

1892 lines
75 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
import toast from 'react-hot-toast';
import { useTranslation } from '../utils/tempTranslations';
import {
CogIcon,
ShieldCheckIcon,
PaintBrushIcon,
UserGroupIcon,
GlobeAltIcon,
KeyIcon,
EyeIcon,
EyeSlashIcon,
TrashIcon,
PencilIcon
} from '@heroicons/react/24/outline';
import { hasPermission, canAccessSettings } from '../utils/rbac';
const Settings = () => {
const { user } = useAuth();
const { t } = useTranslation();
const [tenantConfig, setTenantConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Define tabs with translations inside component after useTranslation hook
const allTabs = [
{
id: 'general',
name: t('settings.general'),
icon: CogIcon,
permission: 'tenant.view'
},
{
id: 'branding',
name: t('settings.branding'),
icon: PaintBrushIcon,
permission: 'branding.view'
},
{
id: 'security',
name: t('settings.security'),
icon: ShieldCheckIcon,
permission: 'security.view'
},
{
id: 'authentication',
name: t('settings.authentication'),
icon: KeyIcon,
permission: 'auth.view'
},
{
id: 'users',
name: t('settings.users'),
icon: UserGroupIcon,
permission: 'users.view'
}
];
// Calculate available tabs
const availableTabs = user?.role
? allTabs.filter(tab => hasPermission(user.role, tab.permission))
: [];
// Set active tab - default to first available or general
const [activeTab, setActiveTab] = useState(() => {
return availableTabs.length > 0 ? availableTabs[0].id : 'general';
});
// Check if user can access settings based on RBAC permissions
const canAccess = canAccessSettings(user?.role);
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 (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
<span className="ml-4 text-gray-600">{t('settings.loading')}</span>
</div>
);
}
if (!canAccess) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<ShieldCheckIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('settings.accessDenied')}</h3>
<p className="mt-1 text-sm text-gray-500">
{t('settings.accessDeniedMessage')}
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="border-b border-gray-200">
<div className="sm:flex sm:items-baseline">
<h3 className="text-lg leading-6 font-medium text-gray-900">
{t('settings.title')}
</h3>
<div className="mt-4 sm:mt-0 sm:ml-10">
<nav className="-mb-px flex space-x-8">
{availableTabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm flex items-center`}
>
<Icon className="h-5 w-5 mr-2" />
{tab.name}
</button>
);
})}
</nav>
</div>
</div>
</div>
<div className="mt-6">
{activeTab === 'general' && hasPermission(user?.role, 'tenant.view') && (
<GeneralSettings tenantConfig={tenantConfig} />
)}
{activeTab === 'branding' && hasPermission(user?.role, 'branding.view') && (
<BrandingSettings tenantConfig={tenantConfig} onRefresh={fetchTenantConfig} />
)}
{activeTab === 'security' && hasPermission(user?.role, 'security.view') && (
<SecuritySettings tenantConfig={tenantConfig} onRefresh={fetchTenantConfig} />
)}
{activeTab === 'authentication' && hasPermission(user?.role, 'auth.view') && (
<AuthenticationSettings tenantConfig={tenantConfig} />
)}
{activeTab === 'users' && hasPermission(user?.role, 'users.view') && (
<UsersSettings tenantConfig={tenantConfig} onRefresh={fetchTenantConfig} />
)}
</div>
</div>
</div>
</div>
);
};
// General Settings Component
const GeneralSettings = ({ tenantConfig }) => {
const { t } = useTranslation();
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('settings.generalInformation')}</h3>
<div className="mt-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">{t('settings.tenantName')}</label>
<p className="mt-1 text-sm text-gray-900">{tenantConfig?.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">{t('settings.tenantId')}</label>
<p className="mt-1 text-sm text-gray-500 font-mono">{tenantConfig?.slug}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">{t('settings.authenticationProvider')}</label>
<p className="mt-1 text-sm text-gray-900 uppercase">{tenantConfig?.auth_provider}</p>
</div>
</div>
</div>
</div>
);
};
// Branding Settings Component
const BrandingSettings = ({ tenantConfig, onRefresh }) => {
const { user } = useAuth();
const { t } = useTranslation();
const [branding, setBranding] = useState({
logo_url: '',
primary_color: '#3B82F6',
secondary_color: '#1F2937',
company_name: ''
});
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [logoPreview, setLogoPreview] = useState(null);
const canEdit = hasPermission(user?.role, 'branding.edit');
useEffect(() => {
if (tenantConfig?.branding) {
setBranding(tenantConfig.branding);
}
}, [tenantConfig]);
const handleSave = async () => {
setSaving(true);
try {
await api.put('/tenant/branding', branding);
toast.success(t('settings.brandingUpdated'));
if (onRefresh) onRefresh();
} catch (error) {
toast.error(t('settings.brandingUpdateFailed'));
} finally {
setSaving(false);
}
};
const handleLogoUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error('File size must be less than 5MB');
return;
}
setUploading(true);
try {
const formData = new FormData();
formData.append('logo', file);
const response = await api.post('/tenant/logo-upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (response.data.success) {
setBranding(prev => ({ ...prev, logo_url: response.data.data.logo_url }));
setLogoPreview(null);
// Clear the file input to allow selecting the same file again
event.target.value = '';
toast.success('Logo uploaded successfully');
if (onRefresh) onRefresh();
}
} catch (error) {
toast.error('Failed to upload logo');
// Clear the file input on error too
event.target.value = '';
} finally {
setUploading(false);
}
};
const handleFilePreview = (event) => {
const file = event.target.files[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => setLogoPreview(e.target.result);
reader.readAsDataURL(file);
}
};
const handleLogoRemove = async () => {
if (!branding.logo_url) return;
// Confirm removal
if (!window.confirm(t('settings.confirmRemoveLogo'))) {
return;
}
setUploading(true);
try {
const response = await api.delete('/tenant/logo');
if (response.data.success) {
setBranding(prev => ({ ...prev, logo_url: null }));
setLogoPreview(null);
toast.success(t('settings.logoRemoved'));
if (onRefresh) onRefresh();
}
} catch (error) {
console.error('Error removing logo:', error);
toast.error(t('settings.logoRemoveFailed'));
} finally {
setUploading(false);
}
};
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Branding & Appearance</h3>
<div className="mt-5 space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700">{t('settings.companyName')}</label>
<input
type="text"
value={branding.company_name}
onChange={(e) => setBranding(prev => ({ ...prev, company_name: e.target.value }))}
disabled={!canEdit}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Company Logo</label>
{/* Current logo display */}
{branding.logo_url && (
<div className="mb-4 p-4 border border-gray-200 rounded-lg bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex-1">
<img
src={branding.logo_url.startsWith('http') ? branding.logo_url : `${api.defaults.baseURL.replace('/api', '')}${branding.logo_url}`}
alt="Current logo"
className="h-16 w-auto object-contain border border-gray-200 rounded p-2 bg-white"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
<p className="text-xs text-gray-500 mt-1">Current logo</p>
</div>
<div className="flex space-x-2 ml-4">
<button
type="button"
onClick={() => document.getElementById('logo-file-input').click()}
disabled={!canEdit || uploading}
className="px-3 py-1.5 text-xs font-medium text-primary-700 bg-primary-100 rounded hover:bg-primary-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('settings.changeLogo')}
</button>
<button
type="button"
onClick={handleLogoRemove}
disabled={!canEdit || uploading}
className="px-3 py-1.5 text-xs font-medium text-red-700 bg-red-100 rounded hover:bg-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('settings.removeLogo')}
</button>
</div>
</div>
</div>
)}
{/* Upload interface */}
<div className="flex items-center space-x-4">
<div className="flex-1">
<input
id="logo-file-input"
type="file"
accept="image/*"
disabled={!canEdit || uploading}
onChange={(e) => {
handleFilePreview(e);
handleLogoUpload(e);
}}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 disabled:opacity-50"
/>
<p className="text-xs text-gray-500 mt-1">PNG, JPG up to 5MB {branding.logo_url ? '• Click "' + t('settings.changeLogo') + '" to replace current logo' : ''}</p>
</div>
{uploading && (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
<span className="ml-2 text-sm text-gray-600">Uploading...</span>
</div>
)}
</div>
{/* Preview */}
{logoPreview && (
<div className="mt-4">
<img
src={logoPreview}
alt="Logo preview"
className="h-16 w-auto object-contain border border-gray-200 rounded p-2"
/>
<p className="text-xs text-gray-500 mt-1">Preview</p>
</div>
)}
{/* Manual URL input as fallback */}
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700">{t('settings.logoUrl')}</label>
<input
type="url"
value={branding.logo_url}
onChange={(e) => setBranding(prev => ({ ...prev, logo_url: e.target.value }))}
disabled={!canEdit}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="https://example.com/logo.png"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">{t('settings.primaryColor')}</label>
<div className="mt-1 flex">
<input
type="color"
value={branding.primary_color}
onChange={(e) => setBranding(prev => ({ ...prev, primary_color: e.target.value }))}
disabled={!canEdit}
className="h-10 w-20 border border-gray-300 rounded-md disabled:opacity-50"
/>
<input
type="text"
value={branding.primary_color}
onChange={(e) => setBranding(prev => ({ ...prev, primary_color: e.target.value }))}
disabled={!canEdit}
className="ml-2 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">{t('settings.secondaryColor')}</label>
<div className="mt-1 flex">
<input
type="color"
value={branding.secondary_color}
onChange={(e) => setBranding(prev => ({ ...prev, secondary_color: e.target.value }))}
disabled={!canEdit}
className="h-10 w-20 border border-gray-300 rounded-md disabled:opacity-50"
/>
<input
type="text"
value={branding.secondary_color}
onChange={(e) => setBranding(prev => ({ ...prev, secondary_color: e.target.value }))}
disabled={!canEdit}
className="ml-2 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
</div>
</div>
</div>
<div className="flex justify-end">
{canEdit ? (
<button
onClick={handleSave}
disabled={saving}
className="bg-primary-600 text-white px-4 py-2 rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{saving ? t('settings.saving') : t('settings.saveBranding')}
</button>
) : (
<div className="text-sm text-gray-500 py-2">
You don't have permission to edit branding settings
</div>
)}
</div>
</div>
</div>
</div>
);
};
// Placeholder components for other tabs
const SecuritySettings = ({ tenantConfig, onRefresh }) => {
const { user } = useAuth();
const { t } = useTranslation();
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);
const canEdit = hasPermission(user?.role, 'security.edit');
console.log('🔐 SecuritySettings Debug:', {
userRole: user?.role,
canEdit: canEdit,
tenantConfig: tenantConfig,
securitySettings: securitySettings
});
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 {
console.log('🔒 Sending security settings:', securitySettings);
await api.put('/tenant/security', securitySettings);
toast.success(t('settings.securityUpdated'));
if (onRefresh) onRefresh();
} catch (error) {
console.error('Failed to update security settings:', error);
toast.error(t('settings.securityUpdateFailed'));
} finally {
setSaving(false);
}
};
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Security Settings</h3>
<button
onClick={() => {
console.log('🔘 Save button clicked!');
console.log('🔘 canEdit:', canEdit);
console.log('🔘 saving:', saving);
handleSave();
}}
disabled={saving || !canEdit}
className="bg-primary-600 text-white px-4 py-2 rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{saving ? t('settings.saving') : t('settings.saveChanges')}
</button>
</div>
<div className="space-y-6">
{/* IP Restriction Toggle */}
<div className="flex items-center justify-between">
<div>
<h4 className="text-md font-medium text-gray-900">IP Access Control</h4>
<p className="text-sm text-gray-500">
Restrict access to this tenant to specific IP addresses
</p>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="ip_restriction_enabled"
checked={securitySettings.ip_restriction_enabled}
disabled={!canEdit}
onChange={(e) => 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 disabled:opacity-50"
/>
<label htmlFor="ip_restriction_enabled" className="ml-2 text-sm font-medium text-gray-700">
Enable IP Restrictions
</label>
</div>
</div>
{/* IP Restriction Configuration */}
{securitySettings.ip_restriction_enabled && (
<div className="space-y-4 p-4 bg-gray-50 rounded-lg border">
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">
⚠️ Important Security Notes
</h3>
<div className="mt-1 text-sm text-yellow-700">
<ul className="list-disc list-inside space-y-1">
<li>Make sure to include your current IP to avoid being locked out</li>
<li>IP restrictions apply to all tenant access including login and API calls</li>
<li>Use CIDR notation for IP ranges (e.g., 192.168.1.0/24)</li>
<li>Use wildcards for partial matching (e.g., 192.168.1.*)</li>
</ul>
</div>
</div>
</div>
</div>
{/* Add IP Input */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Add IP Address or Range
</label>
<div className="flex space-x-2">
<input
type="text"
placeholder="Enter IP address (e.g., 192.168.1.100, 10.0.0.0/24, or 192.168.1.*)"
value={newIP}
onChange={(e) => 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"
/>
<button
type="button"
onClick={addIPToWhitelist}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
>
Add IP
</button>
</div>
</div>
{/* IP Whitelist */}
{securitySettings.ip_whitelist.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Allowed IP Addresses ({securitySettings.ip_whitelist.length})
</label>
<div className="space-y-2 max-h-40 overflow-y-auto">
{securitySettings.ip_whitelist.map((ip, index) => (
<div key={index} className="flex items-center justify-between bg-white px-3 py-2 rounded border">
<span className="text-sm font-mono">{ip}</span>
<button
type="button"
onClick={() => removeIPFromWhitelist(ip)}
className="text-red-600 hover:text-red-800 text-sm"
>
Remove
</button>
</div>
))}
</div>
</div>
)}
{/* Custom restriction message */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Access Denied Message
</label>
<textarea
value={securitySettings.ip_restriction_message}
onChange={(e) => setSecuritySettings(prev => ({
...prev,
ip_restriction_message: e.target.value
}))}
rows={3}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Message shown when access is denied due to IP restrictions"
/>
<p className="text-xs text-gray-500 mt-1">
This message will be shown to users whose IP is not in the whitelist.
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
};
const AuthenticationSettings = ({ tenantConfig }) => {
const { user } = useAuth();
const canEdit = hasPermission(user?.role, 'auth.edit');
const canView = hasPermission(user?.role, 'auth.view');
const [authConfig, setAuthConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(false);
useEffect(() => {
fetchAuthConfig();
}, []);
const fetchAuthConfig = async () => {
try {
const response = await api.get('/tenant/auth');
setAuthConfig(response.data.data);
} catch (error) {
console.error('Failed to fetch auth config:', error);
toast.error('Failed to load authentication settings');
} finally {
setLoading(false);
}
};
const saveAuthConfig = async (newConfig) => {
setSaving(true);
try {
const response = await api.put('/tenant/auth', newConfig);
setAuthConfig(response.data.data);
setEditing(false);
toast.success('Authentication settings updated successfully');
} catch (error) {
console.error('Failed to save auth config:', error);
toast.error(error.response?.data?.message || 'Failed to save authentication settings');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
</div>
);
}
const authProvider = tenantConfig?.auth_provider || 'local';
return (
<div className="space-y-6">
{/* Current Authentication Provider */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">Authentication Provider</h3>
<p className="mt-1 text-sm text-gray-500">
Current authentication method for this tenant
</p>
</div>
<div className="flex items-center space-x-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
authProvider === 'local'
? 'bg-blue-100 text-blue-800'
: authProvider === 'saml'
? 'bg-purple-100 text-purple-800'
: authProvider === 'oauth'
? 'bg-green-100 text-green-800'
: authProvider === 'ldap'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}>
{authProvider.toUpperCase()}
</span>
<button
onClick={() => setEditing(!editing)}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
{editing ? 'Cancel' : 'Configure'}
</button>
</div>
</div>
{/* Provider Description */}
<div className="mt-4 p-4 bg-gray-50 rounded-md">
<p className="text-sm text-gray-600">
{authProvider === 'local' && 'Users are managed directly in this system with username/password authentication.'}
{authProvider === 'saml' && 'Users authenticate through SAML Single Sign-On (SSO) provider.'}
{authProvider === 'oauth' && 'Users authenticate through OAuth provider (Google, Microsoft, etc.).'}
{authProvider === 'ldap' && 'Users authenticate through LDAP/Active Directory.'}
{authProvider === 'ad' && 'Users authenticate through Active Directory.'}
</p>
</div>
</div>
</div>
{/* Authentication Configuration */}
{editing && (
<AuthProviderConfig
provider={authProvider}
config={authConfig}
onSave={saveAuthConfig}
onCancel={() => setEditing(false)}
saving={saving}
/>
)}
{/* User Role Mappings */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Role Mappings</h3>
<p className="mt-1 text-sm text-gray-500">
Configure how external users are assigned roles in your system
</p>
<div className="mt-6 space-y-4">
{authProvider === 'local' ? (
<div className="text-sm text-gray-500">
Role assignments for local users are managed in the Users tab.
</div>
) : (
<RoleMappingConfig provider={authProvider} config={authConfig} />
)}
</div>
</div>
</div>
{/* Session Settings */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Session Settings</h3>
<p className="mt-1 text-sm text-gray-500">
Configure session timeout and security settings
</p>
<div className="mt-6">
<SessionConfig config={authConfig} onSave={saveAuthConfig} />
</div>
</div>
</div>
</div>
);
};
// Auth Provider Configuration Component
const AuthProviderConfig = ({ provider, config, onSave, onCancel, saving }) => {
const [formData, setFormData] = useState(config || {});
const handleSubmit = (e) => {
e.preventDefault();
onSave(formData);
};
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
{provider.toUpperCase()} Configuration
</h3>
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
{provider === 'saml' && (
<SAMLConfig formData={formData} setFormData={setFormData} />
)}
{provider === 'oauth' && (
<OAuthConfig formData={formData} setFormData={setFormData} />
)}
{provider === 'ldap' && (
<LDAPConfig formData={formData} setFormData={setFormData} />
)}
{provider === 'ad' && (
<ADConfig formData={formData} setFormData={setFormData} />
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Configuration'}
</button>
</div>
</form>
</div>
</div>
);
};
// SAML Configuration
const SAMLConfig = ({ formData, setFormData }) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">SSO URL</label>
<input
type="url"
value={formData.sso_url || ''}
onChange={(e) => setFormData({...formData, sso_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 sm:text-sm"
placeholder="https://your-idp.com/sso"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Entity ID</label>
<input
type="text"
value={formData.entity_id || ''}
onChange={(e) => setFormData({...formData, entity_id: e.target.value})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="your-app-entity-id"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">X.509 Certificate</label>
<textarea
value={formData.certificate || ''}
onChange={(e) => setFormData({...formData, certificate: e.target.value})}
rows={4}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="-----BEGIN CERTIFICATE-----"
/>
</div>
</div>
);
// OAuth Configuration
const OAuthConfig = ({ formData, setFormData }) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Provider</label>
<select
value={formData.oauth_provider || 'google'}
onChange={(e) => setFormData({...formData, oauth_provider: e.target.value})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
>
<option value="google">Google</option>
<option value="microsoft">Microsoft</option>
<option value="github">GitHub</option>
<option value="custom">Custom OAuth Provider</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Client ID</label>
<input
type="text"
value={formData.client_id || ''}
onChange={(e) => setFormData({...formData, client_id: e.target.value})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Client Secret</label>
<input
type="password"
value={formData.client_secret || ''}
onChange={(e) => setFormData({...formData, client_secret: e.target.value})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
</div>
);
// LDAP Configuration
const LDAPConfig = ({ formData, setFormData }) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">LDAP Server</label>
<input
type="text"
value={formData.ldap_server || ''}
onChange={(e) => setFormData({...formData, ldap_server: e.target.value})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="ldap://your-server.com:389"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Base DN</label>
<input
type="text"
value={formData.base_dn || ''}
onChange={(e) => setFormData({...formData, base_dn: e.target.value})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="dc=company,dc=com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Bind DN</label>
<input
type="text"
value={formData.bind_dn || ''}
onChange={(e) => setFormData({...formData, bind_dn: e.target.value})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="cn=admin,dc=company,dc=com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Bind Password</label>
<input
type="password"
value={formData.bind_password || ''}
onChange={(e) => setFormData({...formData, bind_password: e.target.value})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
</div>
);
// Active Directory Configuration
const ADConfig = ({ formData, setFormData }) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Domain Controller</label>
<input
type="text"
value={formData.domain_controller || ''}
onChange={(e) => setFormData({...formData, domain_controller: e.target.value})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="dc.company.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Domain</label>
<input
type="text"
value={formData.domain || ''}
onChange={(e) => setFormData({...formData, domain: e.target.value})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="COMPANY"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Service Account</label>
<input
type="text"
value={formData.service_account || ''}
onChange={(e) => setFormData({...formData, service_account: e.target.value})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="svc-account@company.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Service Password</label>
<input
type="password"
value={formData.service_password || ''}
onChange={(e) => setFormData({...formData, service_password: e.target.value})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
</div>
);
// Role Mapping Configuration
const RoleMappingConfig = ({ provider, config }) => (
<div className="space-y-4">
<div className="text-sm text-gray-600">
Configure how {provider.toUpperCase()} groups/attributes map to system roles:
</div>
<div className="space-y-3">
{['admin', 'user_admin', 'security_admin', 'branding_admin', 'operator', 'viewer'].map(role => (
<div key={role} className="flex items-center justify-between p-3 border border-gray-200 rounded-md">
<div>
<span className="font-medium text-gray-900">{role}</span>
<span className="ml-2 text-sm text-gray-500">
{role === 'admin' && '(Full system access)'}
{role === 'user_admin' && '(User management only)'}
{role === 'security_admin' && '(Security settings only)'}
{role === 'branding_admin' && '(Branding settings only)'}
{role === 'operator' && '(Basic operations)'}
{role === 'viewer' && '(Read-only access)'}
</span>
</div>
<input
type="text"
placeholder={provider === 'saml' ? 'SAML attribute value' : 'Group name'}
className="ml-4 block w-48 border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
))}
</div>
</div>
);
// Session Configuration
const SessionConfig = ({ config, onSave }) => {
const [sessionSettings, setSessionSettings] = useState({
session_timeout: config?.session_timeout || 480, // 8 hours default
require_mfa: config?.require_mfa || false,
allow_concurrent_sessions: config?.allow_concurrent_sessions || true
});
const handleSave = () => {
onSave({ ...config, ...sessionSettings });
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Session Timeout (minutes)</label>
<input
type="number"
value={sessionSettings.session_timeout}
onChange={(e) => setSessionSettings({...sessionSettings, session_timeout: parseInt(e.target.value)})}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
min="15"
max="1440"
/>
<p className="mt-1 text-xs text-gray-500">Users will be logged out after this period of inactivity</p>
</div>
<div className="flex items-center">
<input
id="require-mfa"
type="checkbox"
checked={sessionSettings.require_mfa}
onChange={(e) => setSessionSettings({...sessionSettings, require_mfa: e.target.checked})}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label htmlFor="require-mfa" className="ml-2 block text-sm text-gray-900">
Require Multi-Factor Authentication
</label>
</div>
<div className="flex items-center">
<input
id="concurrent-sessions"
type="checkbox"
checked={sessionSettings.allow_concurrent_sessions}
onChange={(e) => setSessionSettings({...sessionSettings, allow_concurrent_sessions: e.target.checked})}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label htmlFor="concurrent-sessions" className="ml-2 block text-sm text-gray-900">
Allow Concurrent Sessions
</label>
</div>
<button
onClick={handleSave}
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Save Session Settings
</button>
</div>
);
};
const UsersSettings = ({ tenantConfig, onRefresh }) => {
const { user } = useAuth();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateUser, setShowCreateUser] = useState(false);
const [showEditUser, setShowEditUser] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deletingUser, setDeletingUser] = useState(null);
const authProvider = tenantConfig?.auth_provider;
const isLocalAuth = authProvider === 'local';
// Check RBAC permissions for user management
const canViewUsers = hasPermission(user?.role, 'users.view');
const canCreateUsers = hasPermission(user?.role, 'users.create') && isLocalAuth;
const canEditUsers = hasPermission(user?.role, 'users.edit') && isLocalAuth;
const canDeleteUsers = hasPermission(user?.role, 'users.delete') && isLocalAuth;
const canManageUsers = canCreateUsers || canEditUsers; // Show management UI if can create or edit
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const response = await api.get('/tenant/users');
setUsers(response.data.data || []);
} catch (error) {
console.error('Failed to fetch users:', error);
toast.error('Failed to load users');
} finally {
setLoading(false);
}
};
// Helper functions for local user management
const handleEditUser = (user) => {
setEditingUser(user);
setShowEditUser(true);
};
const handleToggleUserStatus = async (user) => {
try {
await api.put(`/tenant/users/${user.id}/status`, {
is_active: !user.is_active
});
toast.success(`User ${user.is_active ? 'deactivated' : 'activated'} successfully`);
fetchUsers();
} catch (error) {
toast.error('Failed to update user status');
}
};
const handleDeleteUser = (user) => {
setDeletingUser(user);
setShowDeleteConfirm(true);
};
const confirmDeleteUser = async () => {
if (!deletingUser) return;
try {
await api.delete(`/tenant/users/${deletingUser.id}`);
toast.success('User deleted successfully');
fetchUsers();
setShowDeleteConfirm(false);
setDeletingUser(null);
} catch (error) {
toast.error('Failed to delete user');
}
};
if (loading) {
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">User Management</h3>
<p className="text-sm text-gray-500">
{isLocalAuth
? canManageUsers
? 'Manage local users for this tenant'
: 'View users for this tenant'
: `Users are managed through ${authProvider.toUpperCase()}. Showing read-only information.`
}
</p>
</div>
{canCreateUsers && (
<button
onClick={() => setShowCreateUser(true)}
className="bg-primary-600 text-white px-4 py-2 rounded-md hover:bg-primary-700"
>
Add User
</button>
)}
</div>
{/* Authentication Provider Info */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-md">
<div className="flex items-center">
<KeyIcon className="h-5 w-5 text-blue-400 mr-2" />
<div>
<h4 className="text-sm font-medium text-blue-800">
Authentication Provider: {authProvider?.toUpperCase()}
</h4>
<p className="text-sm text-blue-700">
{authProvider === 'local' && 'Local authentication - Users are managed directly in this system.'}
{authProvider === 'saml' && 'SAML SSO - Users authenticate through your SAML identity provider.'}
{authProvider === 'oauth' && 'OAuth - Users authenticate through your OAuth provider.'}
{authProvider === 'ldap' && 'LDAP/Active Directory - Users authenticate through your directory service.'}
</p>
</div>
</div>
</div>
{/* Users List */}
{users.length === 0 ? (
<div className="text-center py-8">
<UserGroupIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No users found</h3>
<p className="mt-1 text-sm text-gray-500">
{isLocalAuth
? canCreateUsers
? 'Get started by creating a new user.'
: 'No users have been created yet.'
: 'Users will appear here when they log in through your authentication provider.'
}
</p>
</div>
) : (
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
</th>
{(canEditUsers || canDeleteUsers) && (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
)}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">{user.username}</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.role === 'admin'
? 'bg-purple-100 text-purple-800'
: user.role === 'operator'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.last_login
? new Date(user.last_login).toLocaleDateString()
: 'Never'
}
</td>
{(canEditUsers || canDeleteUsers) && (
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center space-x-2">
{canEditUsers && (
<button
onClick={() => handleEditUser(user)}
className="inline-flex items-center px-2 py-1 border border-transparent text-xs font-medium rounded text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
title="Edit user"
>
<PencilIcon className="h-3 w-3 mr-1" />
Edit
</button>
)}
{canDeleteUsers && (
<button
onClick={() => handleToggleUserStatus(user)}
className={`inline-flex items-center px-2 py-1 border border-transparent text-xs font-medium rounded focus:outline-none focus:ring-2 focus:ring-offset-2 ${
user.is_active
? 'text-orange-700 bg-orange-100 hover:bg-orange-200 focus:ring-orange-500'
: 'text-green-700 bg-green-100 hover:bg-green-200 focus:ring-green-500'
}`}
title={user.is_active ? 'Deactivate user' : 'Activate user'}
>
{user.is_active ? 'Deactivate' : 'Activate'}
</button>
)}
{canDeleteUsers && (
<button
onClick={() => handleDeleteUser(user)}
className="inline-flex items-center px-2 py-1 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
title="Delete user permanently"
>
<TrashIcon className="h-3 w-3 mr-1" />
Delete
</button>
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Non-Local Auth Guidance */}
{!isLocalAuth && (
<div className="mt-6 p-4 bg-gray-50 border border-gray-200 rounded-md">
<h4 className="text-sm font-medium text-gray-900 mb-2">
User Management for {authProvider?.toUpperCase()}
</h4>
<div className="text-sm text-gray-700 space-y-2">
{authProvider === 'saml' && (
<div>
<p>• Users are managed in your SAML identity provider</p>
<p>• Role assignments can be configured in the Authentication tab</p>
<p>• User information is synced automatically during login</p>
</div>
)}
{authProvider === 'oauth' && (
<div>
<p>• Users authenticate through your OAuth provider</p>
<p>• Role mappings can be configured based on OAuth claims</p>
<p>• User information is retrieved from the OAuth provider</p>
</div>
)}
{authProvider === 'ldap' && (
<div>
<p>• Users are managed in your LDAP/Active Directory</p>
<p>• Group-to-role mappings can be configured in Authentication settings</p>
<p>• User information is synced from the directory during login</p>
</div>
)}
</div>
</div>
)}
</div>
{/* Create User Modal for Local Auth */}
{showCreateUser && canCreateUsers && (
<CreateUserModal
isOpen={showCreateUser}
onClose={() => setShowCreateUser(false)}
onUserCreated={() => {
fetchUsers();
setShowCreateUser(false);
}}
/>
)}
{/* Edit User Modal for Local Auth */}
{showEditUser && canEditUsers && editingUser && (
<EditUserModal
isOpen={showEditUser}
onClose={() => {
setShowEditUser(false);
setEditingUser(null);
}}
user={editingUser}
onUserUpdated={() => {
fetchUsers();
setShowEditUser(false);
setEditingUser(null);
}}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && deletingUser && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3 text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<TrashIcon className="h-6 w-6 text-red-600" />
</div>
<h3 className="text-lg font-medium text-gray-900 mt-4">Delete User</h3>
<div className="mt-2 px-7 py-3">
<p className="text-sm text-gray-500">
Are you sure you want to delete <strong>{deletingUser.username}</strong>?
This action cannot be undone and will permanently remove the user and all associated data.
</p>
</div>
<div className="flex justify-center space-x-4 mt-4">
<button
onClick={() => {
setShowDeleteConfirm(false);
setDeletingUser(null);
}}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Cancel
</button>
<button
onClick={confirmDeleteUser}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Delete User
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
// Create User Modal Component (for local auth only)
const CreateUserModal = ({ isOpen, onClose, onUserCreated }) => {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
role: 'viewer',
is_active: true
});
const [saving, setSaving] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
await api.post('/tenant/users', formData);
toast.success('User created successfully');
onUserCreated();
setFormData({
username: '',
email: '',
password: '',
role: 'viewer',
is_active: true
});
} catch (error) {
toast.error('Failed to create user');
} finally {
setSaving(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose}></div>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<form onSubmit={handleSubmit}>
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="w-full">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Create New User
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Username</label>
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Password</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
value={formData.password}
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 pr-10"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
) : (
<EyeIcon className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role</label>
<select
value={formData.role}
onChange={(e) => setFormData(prev => ({ ...prev, role: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
>
<option value="viewer">Viewer</option>
<option value="operator">Operator</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => setFormData(prev => ({ ...prev, is_active: e.target.checked }))}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label htmlFor="is_active" className="ml-2 text-sm font-medium text-gray-700">
Active User
</label>
</div>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="submit"
disabled={saving}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
>
{saving ? 'Creating...' : 'Create User'}
</button>
<button
type="button"
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
);
};
// Edit User Modal Component (for local auth only)
const EditUserModal = ({ isOpen, onClose, user, onUserUpdated }) => {
const [formData, setFormData] = useState({
email: '',
first_name: '',
last_name: '',
phone: '',
role: 'viewer',
password: '',
confirmPassword: ''
});
const [saving, setSaving] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
useEffect(() => {
if (user) {
setFormData({
email: user.email || '',
first_name: user.first_name || '',
last_name: user.last_name || '',
phone: user.phone || '',
role: user.role || 'viewer',
password: '',
confirmPassword: ''
});
}
}, [user]);
const handleSubmit = async (e) => {
e.preventDefault();
// Validate passwords if provided
if (formData.password && formData.password !== formData.confirmPassword) {
toast.error('Passwords do not match');
return;
}
setSaving(true);
try {
// Prepare update data (exclude password if empty)
const updateData = {
email: formData.email,
first_name: formData.first_name,
last_name: formData.last_name,
phone: formData.phone,
role: formData.role
};
// Only include password if it's provided
if (formData.password.trim()) {
updateData.password = formData.password;
}
await api.put(`/tenant/users/${user.id}`, updateData);
toast.success('User updated successfully');
onUserUpdated();
} catch (error) {
const message = error.response?.data?.message || 'Failed to update user';
toast.error(message);
} finally {
setSaving(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose}></div>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<form onSubmit={handleSubmit}>
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="w-full">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Edit User: {user?.username}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">First Name</label>
<input
type="text"
value={formData.first_name}
onChange={(e) => setFormData(prev => ({ ...prev, first_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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name</label>
<input
type="text"
value={formData.last_name}
onChange={(e) => setFormData(prev => ({ ...prev, last_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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Phone</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role</label>
<select
value={formData.role}
onChange={(e) => setFormData(prev => ({ ...prev, role: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
>
<option value="viewer">Viewer</option>
<option value="operator">Operator</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="border-t pt-4">
<h4 className="text-sm font-medium text-gray-700 mb-3">Change Password (Optional)</h4>
<div>
<label className="block text-sm font-medium text-gray-700">New Password</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 pr-10"
placeholder="Leave empty to keep current password"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
) : (
<EyeIcon className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700">Confirm New Password</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 pr-10"
placeholder="Confirm new password"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
) : (
<EyeIcon className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="submit"
disabled={saving}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
>
{saving ? 'Updating...' : 'Update User'}
</button>
<button
type="button"
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default Settings;