792 lines
33 KiB
JavaScript
792 lines
33 KiB
JavaScript
import React, { useState, useEffect } from 'react'
|
||
import { XMarkIcon, EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
|
||
import toast from 'react-hot-toast'
|
||
|
||
const TenantModal = ({ isOpen, onClose, tenant = null, onSave }) => {
|
||
const [formData, setFormData] = useState({
|
||
name: '',
|
||
slug: '',
|
||
domain: '',
|
||
subscription_type: 'basic',
|
||
auth_provider: 'local',
|
||
is_active: true,
|
||
admin_email: '',
|
||
admin_phone: '',
|
||
billing_email: '',
|
||
auth_config: {
|
||
// Local auth config
|
||
allow_registration: true,
|
||
require_email_verification: false,
|
||
password_policy: {
|
||
min_length: 8,
|
||
require_uppercase: false,
|
||
require_lowercase: false,
|
||
require_numbers: false,
|
||
require_symbols: false
|
||
},
|
||
// SAML config
|
||
sso_url: '',
|
||
certificate: '',
|
||
issuer: '',
|
||
logout_url: '',
|
||
// OAuth config
|
||
client_id: '',
|
||
client_secret: '',
|
||
authorization_url: '',
|
||
token_url: '',
|
||
userinfo_url: '',
|
||
scopes: ['openid', 'profile', 'email'],
|
||
// LDAP config
|
||
url: '',
|
||
base_dn: '',
|
||
bind_dn: '',
|
||
bind_password: '',
|
||
user_search_filter: '(sAMAccountName={username})',
|
||
domain_name: ''
|
||
},
|
||
user_mapping: {
|
||
username: ['preferred_username', 'username', 'user'],
|
||
email: ['email', 'mail'],
|
||
firstName: ['given_name', 'givenName', 'first_name'],
|
||
lastName: ['family_name', 'surname', 'last_name'],
|
||
phoneNumber: ['phone_number', 'phone']
|
||
},
|
||
role_mapping: {
|
||
admin: ['admin', 'administrator', 'Domain Admins'],
|
||
operator: ['operator', 'user', 'Users'],
|
||
viewer: ['viewer', 'guest', 'read-only'],
|
||
default: 'viewer'
|
||
},
|
||
features: {
|
||
max_devices: 50,
|
||
max_users: 10,
|
||
api_rate_limit: 5000,
|
||
data_retention_days: 365,
|
||
features: ['basic_detection', 'alerts', 'dashboard']
|
||
},
|
||
branding: {
|
||
logo_url: '',
|
||
primary_color: '#3B82F6',
|
||
secondary_color: '#1F2937',
|
||
company_name: ''
|
||
},
|
||
// IP Restriction settings
|
||
ip_restriction_enabled: false,
|
||
ip_whitelist: [],
|
||
ip_restriction_message: 'Access denied. Your IP address is not authorized to access this tenant.'
|
||
})
|
||
|
||
const [showSecrets, setShowSecrets] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
const [newIP, setNewIP] = useState('')
|
||
|
||
// Load tenant data when editing
|
||
useEffect(() => {
|
||
if (tenant) {
|
||
setFormData({
|
||
...formData,
|
||
...tenant,
|
||
auth_config: { ...formData.auth_config, ...tenant.auth_config },
|
||
user_mapping: { ...formData.user_mapping, ...tenant.user_mapping },
|
||
role_mapping: { ...formData.role_mapping, ...tenant.role_mapping },
|
||
features: { ...formData.features, ...tenant.features },
|
||
branding: { ...formData.branding, ...tenant.branding },
|
||
// IP restriction fields with fallbacks
|
||
ip_restriction_enabled: tenant.ip_restriction_enabled || false,
|
||
ip_whitelist: tenant.ip_whitelist || [],
|
||
ip_restriction_message: tenant.ip_restriction_message || 'Access denied. Your IP address is not authorized to access this tenant.'
|
||
})
|
||
}
|
||
}, [tenant])
|
||
|
||
// Auto-generate slug from name
|
||
const handleNameChange = (e) => {
|
||
const name = e.target.value
|
||
const slug = name.toLowerCase()
|
||
.replace(/[^a-z0-9\s-]/g, '')
|
||
.replace(/\s+/g, '-')
|
||
.replace(/-+/g, '-')
|
||
.trim()
|
||
|
||
setFormData(prev => ({
|
||
...prev,
|
||
name,
|
||
slug: tenant ? prev.slug : slug // Don't auto-update slug when editing
|
||
}))
|
||
}
|
||
|
||
// IP Management functions
|
||
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 (formData.ip_whitelist.includes(ip)) {
|
||
toast.error('This IP is already in the whitelist')
|
||
return
|
||
}
|
||
|
||
setFormData(prev => ({
|
||
...prev,
|
||
ip_whitelist: [...prev.ip_whitelist, ip]
|
||
}))
|
||
setNewIP('')
|
||
toast.success('IP added to whitelist')
|
||
}
|
||
|
||
const removeIPFromWhitelist = (ipToRemove) => {
|
||
setFormData(prev => ({
|
||
...prev,
|
||
ip_whitelist: prev.ip_whitelist.filter(ip => ip !== ipToRemove)
|
||
}))
|
||
toast.success('IP removed from whitelist')
|
||
}
|
||
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault()
|
||
setLoading(true)
|
||
|
||
try {
|
||
// Validate required fields
|
||
if (!formData.name || !formData.slug) {
|
||
toast.error('Name and slug are required')
|
||
return
|
||
}
|
||
|
||
// Clean up empty auth config fields
|
||
const cleanAuthConfig = Object.fromEntries(
|
||
Object.entries(formData.auth_config).filter(([_, value]) => {
|
||
if (typeof value === 'string') return value.trim() !== ''
|
||
if (Array.isArray(value)) return value.length > 0
|
||
if (typeof value === 'object') return Object.keys(value).length > 0
|
||
return value !== null && value !== undefined
|
||
})
|
||
)
|
||
|
||
const tenantData = {
|
||
...formData,
|
||
auth_config: cleanAuthConfig,
|
||
// Convert empty strings to null to avoid validation errors
|
||
domain: formData.domain?.trim() || null,
|
||
admin_email: formData.admin_email?.trim() || null,
|
||
billing_email: formData.billing_email?.trim() || null
|
||
}
|
||
|
||
await onSave(tenantData)
|
||
onClose()
|
||
toast.success(tenant ? 'Tenant updated successfully' : 'Tenant created successfully')
|
||
} catch (error) {
|
||
toast.error(error.message || 'Failed to save tenant')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
if (!isOpen) return null
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||
<div className="relative top-8 mx-auto p-5 border w-full max-w-4xl shadow-lg rounded-md bg-white mb-8">
|
||
<div className="flex justify-between items-center mb-6">
|
||
<h3 className="text-lg font-medium text-gray-900">
|
||
{tenant ? 'Edit Tenant' : 'Create New Tenant'}
|
||
</h3>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-gray-600"
|
||
>
|
||
<XMarkIcon className="h-6 w-6" />
|
||
</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-6">
|
||
{/* Basic Information */}
|
||
<div className="bg-gray-50 p-4 rounded-lg">
|
||
<h4 className="text-md font-medium text-gray-900 mb-4">Basic Information</h4>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Organization Name *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.name}
|
||
onChange={handleNameChange}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="ACME Corporation"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Slug (Subdomain) *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.slug}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, slug: e.target.value.toLowerCase() }))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="acme"
|
||
pattern="^[a-z0-9-]+$"
|
||
required
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Will be: https://{formData.slug || 'slug'}.dev.uggla.uamils.com
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Custom Domain
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.domain}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, domain: e.target.value }))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="drones.acme.com"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Subscription Type
|
||
</label>
|
||
<select
|
||
value={formData.subscription_type}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, subscription_type: e.target.value }))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="free">Free</option>
|
||
<option value="basic">Basic</option>
|
||
<option value="premium">Premium</option>
|
||
<option value="enterprise">Enterprise</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Admin Email
|
||
</label>
|
||
<input
|
||
type="email"
|
||
value={formData.admin_email}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, admin_email: e.target.value }))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="admin@acme.com"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Billing Email
|
||
</label>
|
||
<input
|
||
type="email"
|
||
value={formData.billing_email}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, billing_email: e.target.value }))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="billing@acme.com"
|
||
/>
|
||
</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-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||
/>
|
||
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-900">
|
||
Active Tenant
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Authentication Configuration */}
|
||
<div className="bg-gray-50 p-4 rounded-lg">
|
||
<h4 className="text-md font-medium text-gray-900 mb-4">Authentication</h4>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Authentication Provider
|
||
</label>
|
||
<select
|
||
value={formData.auth_provider}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, auth_provider: e.target.value }))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="local">Local (Username/Password)</option>
|
||
<option value="saml">SAML (Single Sign-On)</option>
|
||
<option value="oauth">OAuth (Azure AD, Google, etc.)</option>
|
||
<option value="ldap">LDAP (Active Directory)</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Local Auth Config */}
|
||
{formData.auth_provider === 'local' && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
id="allow_registration"
|
||
checked={formData.auth_config.allow_registration}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, allow_registration: e.target.checked }
|
||
}))}
|
||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||
/>
|
||
<label htmlFor="allow_registration" className="ml-2 block text-sm text-gray-900">
|
||
Allow User Registration
|
||
</label>
|
||
</div>
|
||
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
id="require_email_verification"
|
||
checked={formData.auth_config.require_email_verification}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, require_email_verification: e.target.checked }
|
||
}))}
|
||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||
/>
|
||
<label htmlFor="require_email_verification" className="ml-2 block text-sm text-gray-900">
|
||
Require Email Verification
|
||
</label>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* SAML Config */}
|
||
{formData.auth_provider === 'saml' && (
|
||
<div className="grid grid-cols-1 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
SSO URL *
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={formData.auth_config.sso_url}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, sso_url: e.target.value }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="https://adfs.company.com/adfs/ls/"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Issuer *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.auth_config.issuer}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, issuer: e.target.value }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="urn:company:uav-detection"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
X.509 Certificate *
|
||
</label>
|
||
<textarea
|
||
value={formData.auth_config.certificate}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, certificate: e.target.value }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
rows="4"
|
||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* OAuth Config */}
|
||
{formData.auth_provider === 'oauth' && (
|
||
<div className="grid grid-cols-1 gap-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Client ID *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.auth_config.client_id}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, client_id: e.target.value }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Client Secret *
|
||
</label>
|
||
<div className="relative">
|
||
<input
|
||
type={showSecrets ? "text" : "password"}
|
||
value={formData.auth_config.client_secret}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, client_secret: e.target.value }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 pr-10 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowSecrets(!showSecrets)}
|
||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||
>
|
||
{showSecrets ? (
|
||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||
) : (
|
||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Authorization URL *
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={formData.auth_config.authorization_url}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, authorization_url: e.target.value }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Token URL *
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={formData.auth_config.token_url}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, token_url: e.target.value }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* LDAP Config */}
|
||
{formData.auth_provider === 'ldap' && (
|
||
<div className="grid grid-cols-1 gap-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
LDAP URL *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.auth_config.url}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, url: e.target.value }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="ldaps://dc.company.com:636"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Base DN *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.auth_config.base_dn}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, base_dn: e.target.value }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="dc=company,dc=com"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Bind DN
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.auth_config.bind_dn}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, bind_dn: e.target.value }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="cn=service,ou=accounts,dc=company,dc=com"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Bind Password
|
||
</label>
|
||
<input
|
||
type={showSecrets ? "text" : "password"}
|
||
value={formData.auth_config.bind_password}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
auth_config: { ...prev.auth_config, bind_password: e.target.value }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Features & Limits */}
|
||
<div className="bg-gray-50 p-4 rounded-lg">
|
||
<h4 className="text-md font-medium text-gray-900 mb-4">Features & Limits</h4>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Max Devices
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={formData.features.max_devices}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
features: { ...prev.features, max_devices: parseInt(e.target.value) || 0 }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
min="0"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Max Users
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={formData.features.max_users}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
features: { ...prev.features, max_users: parseInt(e.target.value) || 0 }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
min="0"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
API Rate Limit
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={formData.features.api_rate_limit}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
features: { ...prev.features, api_rate_limit: parseInt(e.target.value) || 0 }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
min="0"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Data Retention (Days)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={formData.features.data_retention_days}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
features: { ...prev.features, data_retention_days: parseInt(e.target.value) || 0 }
|
||
}))}
|
||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
min="0"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* IP Restriction Configuration */}
|
||
<div className="space-y-4">
|
||
<h4 className="text-md font-medium text-gray-900 border-b pb-2">IP Access Control</h4>
|
||
|
||
<div className="flex items-center space-x-2">
|
||
<input
|
||
type="checkbox"
|
||
id="ip_restriction_enabled"
|
||
checked={formData.ip_restriction_enabled}
|
||
onChange={(e) => setFormData(prev => ({
|
||
...prev,
|
||
ip_restriction_enabled: e.target.checked
|
||
}))}
|
||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||
/>
|
||
<label htmlFor="ip_restriction_enabled" className="text-sm font-medium text-gray-700">
|
||
Enable IP Restrictions
|
||
</label>
|
||
</div>
|
||
|
||
{formData.ip_restriction_enabled && (
|
||
<div className="space-y-4 p-4 bg-gray-50 rounded-lg">
|
||
<p className="text-sm text-gray-600">
|
||
Only IPs in the whitelist below will be able to access this tenant. You can add individual IP addresses, CIDR blocks, or wildcards.
|
||
</p>
|
||
|
||
{/* Add IP Input */}
|
||
<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-blue-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>
|
||
|
||
{/* IP Whitelist */}
|
||
{formData.ip_whitelist.length > 0 && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Allowed IP Addresses ({formData.ip_whitelist.length})
|
||
</label>
|
||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||
{formData.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={formData.ip_restriction_message}
|
||
onChange={(e) => setFormData(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-blue-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 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>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Form Actions */}
|
||
<div className="flex justify-end space-x-3 pt-6 border-t">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||
disabled={loading}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||
disabled={loading}
|
||
>
|
||
{loading ? 'Saving...' : (tenant ? 'Update Tenant' : 'Create Tenant')}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default TenantModal
|