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:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View 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>
)
}