feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
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>
This commit is contained in:
435
frontend/components/TenantCreateForm.tsx
Normal file
435
frontend/components/TenantCreateForm.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user