Major Features: - ✅ Multi-tenant architecture (tenant isolation) - ✅ Employee CRUD with lifecycle management (onboarding/offboarding) - ✅ Department tree structure with email domain management - ✅ Company info management (single-record editing) - ✅ System functions CRUD (permission management) - ✅ Email account management (multi-account per employee) - ✅ Keycloak SSO integration (auth.lab.taipei) - ✅ Redis session storage (10.1.0.254:6379) - Solves Cookie 4KB limitation - Cross-system session sharing - Sliding expiration (8 hours) - Automatic token refresh Technical Stack: Backend: - FastAPI + SQLAlchemy - PostgreSQL 16 (10.1.0.20:5433) - Keycloak Admin API integration - Docker Mailserver integration (SSH) - Alembic migrations Frontend: - Next.js 14 (App Router) - NextAuth 4 with Keycloak Provider - Redis session storage (ioredis) - Tailwind CSS Infrastructure: - Redis 7 (10.1.0.254:6379) - Session + Cache - Keycloak 26.1.0 (auth.lab.taipei) - Docker Mailserver (10.1.0.254) Architecture Highlights: - Session管理由 Keycloak + Redis 統一控制 - 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session - Token 自動刷新,異質服務整合 - 未來可無縫遷移到雲端 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
436 lines
14 KiB
TypeScript
436 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useState, FormEvent } from 'react'
|
|
import { tenantService } from '../services/tenant.service'
|
|
import type { TenantCreateRequest, TenantCreateResponse } from '../types/tenant'
|
|
|
|
interface TenantCreateFormProps {
|
|
onSuccess: (response: TenantCreateResponse) => void
|
|
onCancel?: () => void
|
|
}
|
|
|
|
interface FormData {
|
|
code: string
|
|
name: string
|
|
name_eng: string
|
|
tax_id: string
|
|
prefix: string
|
|
tel: string
|
|
add: string
|
|
url: string
|
|
plan_id: string
|
|
max_users: number
|
|
storage_quota_gb: number
|
|
admin_username: string
|
|
admin_email: string
|
|
admin_name: string
|
|
admin_temp_password: string
|
|
}
|
|
|
|
interface FormErrors {
|
|
[key: string]: string
|
|
}
|
|
|
|
export default function TenantCreateForm({ onSuccess, onCancel }: TenantCreateFormProps) {
|
|
const [formData, setFormData] = useState<FormData>({
|
|
code: '',
|
|
name: '',
|
|
name_eng: '',
|
|
tax_id: '',
|
|
prefix: '',
|
|
tel: '',
|
|
add: '',
|
|
url: '',
|
|
plan_id: 'starter',
|
|
max_users: 5,
|
|
storage_quota_gb: 100,
|
|
admin_username: '',
|
|
admin_email: '',
|
|
admin_name: '',
|
|
admin_temp_password: '',
|
|
})
|
|
|
|
const [errors, setErrors] = useState<FormErrors>({})
|
|
const [loading, setLoading] = useState(false)
|
|
const [apiError, setApiError] = useState<string | null>(null)
|
|
|
|
const validateForm = (): boolean => {
|
|
const newErrors: FormErrors = {}
|
|
|
|
// Required fields
|
|
if (!formData.code.trim()) {
|
|
newErrors.code = 'Tenant code is required'
|
|
}
|
|
if (!formData.name.trim()) {
|
|
newErrors.name = 'Company name is required'
|
|
}
|
|
if (!formData.prefix.trim()) {
|
|
newErrors.prefix = 'Employee prefix is required'
|
|
}
|
|
if (!formData.admin_username.trim()) {
|
|
newErrors.admin_username = 'Admin username is required'
|
|
}
|
|
if (!formData.admin_email.trim()) {
|
|
newErrors.admin_email = 'Admin email is required'
|
|
} else if (!formData.admin_email.includes('@')) {
|
|
// Email format validation (only if not empty)
|
|
newErrors.admin_email = 'Invalid email format'
|
|
}
|
|
if (!formData.admin_name.trim()) {
|
|
newErrors.admin_name = 'Admin name is required'
|
|
}
|
|
if (!formData.admin_temp_password.trim()) {
|
|
newErrors.admin_temp_password = 'Admin password is required'
|
|
} else if (formData.admin_temp_password.length < 8) {
|
|
// Password length validation (only if not empty)
|
|
newErrors.admin_temp_password = 'Password must be at least 8 characters'
|
|
}
|
|
|
|
// Tax ID validation (8 digits if provided)
|
|
if (formData.tax_id && !/^\d{8}$/.test(formData.tax_id)) {
|
|
newErrors.tax_id = 'Tax ID must be 8 digits'
|
|
}
|
|
|
|
setErrors(newErrors)
|
|
return Object.keys(newErrors).length === 0
|
|
}
|
|
|
|
const handleSubmit = async (e: FormEvent) => {
|
|
e.preventDefault()
|
|
setApiError(null)
|
|
|
|
if (!validateForm()) {
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
|
|
try {
|
|
const requestData: TenantCreateRequest = {
|
|
code: formData.code,
|
|
name: formData.name,
|
|
name_eng: formData.name_eng || '',
|
|
tax_id: formData.tax_id || undefined,
|
|
prefix: formData.prefix,
|
|
tel: formData.tel || '',
|
|
add: formData.add || '',
|
|
url: formData.url || '',
|
|
plan_id: formData.plan_id,
|
|
max_users: formData.max_users,
|
|
storage_quota_gb: formData.storage_quota_gb,
|
|
admin_username: formData.admin_username,
|
|
admin_email: formData.admin_email,
|
|
admin_name: formData.admin_name,
|
|
admin_temp_password: formData.admin_temp_password,
|
|
}
|
|
|
|
const response = await tenantService.createTenant(requestData)
|
|
|
|
// Reset form
|
|
setFormData({
|
|
code: '',
|
|
name: '',
|
|
name_eng: '',
|
|
tax_id: '',
|
|
prefix: '',
|
|
tel: '',
|
|
add: '',
|
|
url: '',
|
|
plan_id: 'starter',
|
|
max_users: 5,
|
|
storage_quota_gb: 100,
|
|
admin_username: '',
|
|
admin_email: '',
|
|
admin_name: '',
|
|
admin_temp_password: '',
|
|
})
|
|
|
|
onSuccess(response)
|
|
} catch (error: any) {
|
|
const errorMessage =
|
|
error?.response?.data?.detail || 'Failed to create tenant. Please try again.'
|
|
setApiError(errorMessage)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleChange = (field: keyof FormData, value: string | number) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
[field]: value,
|
|
}))
|
|
// Clear error when user starts typing
|
|
if (errors[field]) {
|
|
setErrors((prev) => {
|
|
const newErrors = { ...prev }
|
|
delete newErrors[field]
|
|
return newErrors
|
|
})
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* API Error Display */}
|
|
{apiError && (
|
|
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
|
|
<p className="text-red-700">{apiError}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Basic Information Section */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold text-gray-900">Basic Information</h3>
|
|
|
|
<div>
|
|
<label htmlFor="code" className="block text-sm font-medium text-gray-700">
|
|
Tenant Code *
|
|
</label>
|
|
<input
|
|
id="code"
|
|
type="text"
|
|
value={formData.code}
|
|
onChange={(e) => handleChange('code', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
{errors.code && <p className="mt-1 text-sm text-red-600">{errors.code}</p>}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
|
Company Name *
|
|
</label>
|
|
<input
|
|
id="name"
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => handleChange('name', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name}</p>}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="name_eng" className="block text-sm font-medium text-gray-700">
|
|
Company Name (English)
|
|
</label>
|
|
<input
|
|
id="name_eng"
|
|
type="text"
|
|
value={formData.name_eng}
|
|
onChange={(e) => handleChange('name_eng', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="tax_id" className="block text-sm font-medium text-gray-700">
|
|
Tax ID
|
|
</label>
|
|
<input
|
|
id="tax_id"
|
|
type="text"
|
|
value={formData.tax_id}
|
|
onChange={(e) => handleChange('tax_id', e.target.value)}
|
|
placeholder="8 digits"
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
{errors.tax_id && <p className="mt-1 text-sm text-red-600">{errors.tax_id}</p>}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="prefix" className="block text-sm font-medium text-gray-700">
|
|
Employee Prefix *
|
|
</label>
|
|
<input
|
|
id="prefix"
|
|
type="text"
|
|
value={formData.prefix}
|
|
onChange={(e) => handleChange('prefix', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
{errors.prefix && <p className="mt-1 text-sm text-red-600">{errors.prefix}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contact Information Section */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold text-gray-900">Contact Information</h3>
|
|
|
|
<div>
|
|
<label htmlFor="tel" className="block text-sm font-medium text-gray-700">
|
|
Phone
|
|
</label>
|
|
<input
|
|
id="tel"
|
|
type="text"
|
|
value={formData.tel}
|
|
onChange={(e) => handleChange('tel', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="add" className="block text-sm font-medium text-gray-700">
|
|
Address
|
|
</label>
|
|
<textarea
|
|
id="add"
|
|
value={formData.add}
|
|
onChange={(e) => handleChange('add', e.target.value)}
|
|
rows={3}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="url" className="block text-sm font-medium text-gray-700">
|
|
Website
|
|
</label>
|
|
<input
|
|
id="url"
|
|
type="text"
|
|
value={formData.url}
|
|
onChange={(e) => handleChange('url', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Plan Settings Section */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold text-gray-900">Plan Settings</h3>
|
|
|
|
<div>
|
|
<label htmlFor="plan_id" className="block text-sm font-medium text-gray-700">
|
|
Plan ID
|
|
</label>
|
|
<input
|
|
id="plan_id"
|
|
type="text"
|
|
value={formData.plan_id}
|
|
onChange={(e) => handleChange('plan_id', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="max_users" className="block text-sm font-medium text-gray-700">
|
|
Max Users
|
|
</label>
|
|
<input
|
|
id="max_users"
|
|
type="number"
|
|
value={formData.max_users}
|
|
onChange={(e) => handleChange('max_users', parseInt(e.target.value))}
|
|
min="1"
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="storage_quota_gb" className="block text-sm font-medium text-gray-700">
|
|
Storage Quota (GB)
|
|
</label>
|
|
<input
|
|
id="storage_quota_gb"
|
|
type="number"
|
|
value={formData.storage_quota_gb}
|
|
onChange={(e) => handleChange('storage_quota_gb', parseInt(e.target.value))}
|
|
min="1"
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Admin Account Section */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold text-gray-900">Admin Account</h3>
|
|
|
|
<div>
|
|
<label htmlFor="admin_username" className="block text-sm font-medium text-gray-700">
|
|
Admin Username *
|
|
</label>
|
|
<input
|
|
id="admin_username"
|
|
type="text"
|
|
value={formData.admin_username}
|
|
onChange={(e) => handleChange('admin_username', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
{errors.admin_username && (
|
|
<p className="mt-1 text-sm text-red-600">{errors.admin_username}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="admin_email" className="block text-sm font-medium text-gray-700">
|
|
Admin Email *
|
|
</label>
|
|
<input
|
|
id="admin_email"
|
|
type="email"
|
|
value={formData.admin_email}
|
|
onChange={(e) => handleChange('admin_email', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
{errors.admin_email && (
|
|
<p className="mt-1 text-sm text-red-600">{errors.admin_email}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="admin_name" className="block text-sm font-medium text-gray-700">
|
|
Admin Name *
|
|
</label>
|
|
<input
|
|
id="admin_name"
|
|
type="text"
|
|
value={formData.admin_name}
|
|
onChange={(e) => handleChange('admin_name', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
{errors.admin_name && <p className="mt-1 text-sm text-red-600">{errors.admin_name}</p>}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="admin_temp_password" className="block text-sm font-medium text-gray-700">
|
|
Admin Password *
|
|
</label>
|
|
<input
|
|
id="admin_temp_password"
|
|
type="password"
|
|
value={formData.admin_temp_password}
|
|
onChange={(e) => handleChange('admin_temp_password', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
{errors.admin_temp_password && (
|
|
<p className="mt-1 text-sm text-red-600">{errors.admin_temp_password}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Form Actions */}
|
|
<div className="flex justify-end space-x-3">
|
|
{onCancel && (
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
|
>
|
|
{loading ? 'Creating...' : 'Create Tenant'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)
|
|
}
|