1892 lines
75 KiB
JavaScript
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(t('settings.failedToLoad'));
|
|
} 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.noPermission')}
|
|
</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;
|