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
+577
View File
@@ -0,0 +1,577 @@
'use client'
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { apiClient } from '@/lib/api-client'
// 第一層部門 (depth=0,擁有 email_domain)
interface TopLevelDepartment {
id: number
name: string
code: string
email_domain?: string
effective_email_domain?: string
depth: number
is_active: boolean
}
// 子部門 (depth>=1)
interface SubDepartment {
id: number
name: string
code: string
parent_id: number
depth: number
effective_email_domain?: string
}
interface EmployeeFormData {
username_base: string
legal_name: string
english_name: string
phone: string
mobile: string
hire_date: string
// 組織與職務資訊 (新多層部門架構)
top_department_id: string // 第一層部門 (決定郵件網域)
department_id: string // 指定部門 (選填,可為任何層)
job_title: string
email_quota_mb: string
// 到職自動化
auto_onboard: boolean
create_keycloak: boolean
create_email: boolean
create_drive: boolean
}
interface OnboardResult {
created?: boolean
disabled?: boolean
error?: string | null
message?: string
username?: string
email?: string
user_id?: string
quota_gb?: number
}
export default function NewEmployeePage() {
const { data: session, status } = useSession()
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [topDepartments, setTopDepartments] = useState<TopLevelDepartment[]>([])
const [subDepartments, setSubDepartments] = useState<SubDepartment[]>([])
const [loadingSubDepts, setLoadingSubDepts] = useState(false)
const [onboardResults, setOnboardResults] = useState<{
keycloak: OnboardResult
email: OnboardResult
drive: OnboardResult
} | null>(null)
const [formData, setFormData] = useState<EmployeeFormData>({
username_base: '',
legal_name: '',
english_name: '',
phone: '',
mobile: '',
hire_date: new Date().toISOString().split('T')[0],
top_department_id: '',
department_id: '',
job_title: '',
email_quota_mb: '5120',
auto_onboard: true,
create_keycloak: true,
create_email: true,
create_drive: true,
})
// 載入第一層部門列表
useEffect(() => {
if (status === 'authenticated') {
fetchTopDepartments()
}
}, [status])
// 當選擇第一層部門時,載入其子部門
useEffect(() => {
if (formData.top_department_id) {
fetchSubDepartments(parseInt(formData.top_department_id))
} else {
setSubDepartments([])
setFormData((prev) => ({ ...prev, department_id: '' }))
}
}, [formData.top_department_id])
const fetchTopDepartments = async () => {
try {
const data = await apiClient.get<TopLevelDepartment[]>('/departments/?depth=0')
setTopDepartments(data)
} catch (err) {
console.error('載入部門失敗:', err)
}
}
const fetchSubDepartments = async (parentId: number) => {
try {
setLoadingSubDepts(true)
const data = await apiClient.get<SubDepartment[]>(`/departments/?parent_id=${parentId}`)
setSubDepartments(data)
} catch (err) {
console.error('載入子部門失敗:', err)
setSubDepartments([])
} finally {
setLoadingSubDepts(false)
}
}
if (status === 'loading') {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">載入中...</div>
</div>
)
}
if (status === 'unauthenticated') {
router.push('/auth/signin')
return null
}
const selectedTopDept = topDepartments.find(
(d) => d.id === parseInt(formData.top_department_id)
)
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value, type } = e.target
const checked = (e.target as HTMLInputElement).checked
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
// 決定 department_id:若有選子部門用子部門,否則用第一層部門
const finalDepartmentId = formData.department_id
? parseInt(formData.department_id)
: formData.top_department_id
? parseInt(formData.top_department_id)
: undefined
const employeePayload = {
username_base: formData.username_base,
legal_name: formData.legal_name,
english_name: formData.english_name || undefined,
phone: formData.phone || undefined,
mobile: formData.mobile || undefined,
hire_date: formData.hire_date,
// 新架構:department_id 指向任何層部門
department_id: finalDepartmentId,
job_title: formData.job_title,
email_quota_mb: parseInt(formData.email_quota_mb),
}
const newEmployee = await apiClient.post('/employees/', employeePayload) as any
const newEmployeeId = newEmployee.id
// 自動執行到職流程
if (formData.auto_onboard) {
try {
const params = new URLSearchParams({
create_keycloak: String(formData.create_keycloak),
create_email: String(formData.create_email),
create_drive: String(formData.create_drive),
})
const onboardResponse = await apiClient.post(
`/employees/${newEmployeeId}/onboard?${params.toString()}`,
{}
) as any
setOnboardResults(onboardResponse.results)
setTimeout(() => {
router.push(`/employees/${newEmployeeId}`)
}, 3000)
} catch {
router.push(`/employees/${newEmployeeId}`)
}
} else {
router.push(`/employees/${newEmployeeId}`)
}
} catch (err: any) {
const errorMessage = err?.response?.data?.detail || err.message || '新增失敗'
setError(errorMessage)
setLoading(false)
}
}
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">新增員工</h1>
<p className="mt-2 text-gray-600">填寫基本資料以建立新員工</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{/* 到職流程結果顯示 */}
{onboardResults && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 className="text-sm font-medium text-green-900 mb-3">
✓ 員工已建立,到職流程執行完成
</h3>
<div className="space-y-2">
{formData.create_keycloak && (
<div className={`flex items-center gap-2 text-sm ${onboardResults.keycloak?.created ? 'text-green-700' : 'text-amber-700'}`}>
<span>{onboardResults.keycloak?.created ? '✓' : '⚠'}</span>
<span>Keycloak SSO: {onboardResults.keycloak?.message || (onboardResults.keycloak?.error ? `失敗 - ${onboardResults.keycloak.error}` : '未執行')}</span>
</div>
)}
{formData.create_email && (
<div className={`flex items-center gap-2 text-sm ${onboardResults.email?.created ? 'text-green-700' : 'text-amber-700'}`}>
<span>{onboardResults.email?.created ? '✓' : '⚠'}</span>
<span>郵件帳號: {onboardResults.email?.message || (onboardResults.email?.error ? `失敗 - ${onboardResults.email.error}` : '未執行')}</span>
</div>
)}
{formData.create_drive && (
<div className={`flex items-center gap-2 text-sm ${onboardResults.drive?.created ? 'text-green-700' : 'text-amber-700'}`}>
<span>{onboardResults.drive?.created ? '✓' : '⚠'}</span>
<span>雲端硬碟: {onboardResults.drive?.message || (onboardResults.drive?.error ? `失敗 - ${onboardResults.drive.error}` : '未執行')}</span>
</div>
)}
</div>
<p className="mt-3 text-xs text-green-600">3 秒後自動跳轉至員工詳情頁...</p>
</div>
)}
<form onSubmit={handleSubmit} className="bg-white shadow rounded-lg p-6">
<div className="space-y-6">
{/* 單一登入帳號 */}
<div>
<label htmlFor="username_base" className="block text-sm font-medium text-gray-700">
單一登入帳號 (SSO) <span className="text-red-500">*</span>
</label>
<p className="mt-1 text-sm text-gray-500">
員工的 SSO 登入帳號名稱,系統根據所屬第一層部門自動加上網域
<br />
例如: 輸入 <code className="bg-gray-100 px-1 rounded">porsche.chen</code> →
完整帳號 <code className="bg-gray-100 px-1 rounded">porsche.chen@ease.taipei</code>
</p>
<input
type="text"
id="username_base"
name="username_base"
required
value={formData.username_base}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
placeholder="firstname.lastname"
/>
</div>
{/* 中文姓名 */}
<div>
<label htmlFor="legal_name" className="block text-sm font-medium text-gray-700">
中文姓名 <span className="text-red-500">*</span>
</label>
<input
type="text"
id="legal_name"
name="legal_name"
required
value={formData.legal_name}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
placeholder="陳保時"
/>
</div>
{/* 英文姓名 */}
<div>
<label htmlFor="english_name" className="block text-sm font-medium text-gray-700">
英文姓名
</label>
<input
type="text"
id="english_name"
name="english_name"
value={formData.english_name}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
placeholder="Porsche Chen"
/>
</div>
{/* 電話 */}
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
辦公室電話
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
placeholder="02-12345678"
/>
</div>
{/* 手機 */}
<div>
<label htmlFor="mobile" className="block text-sm font-medium text-gray-700">
手機
</label>
<input
type="tel"
id="mobile"
name="mobile"
value={formData.mobile}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
placeholder="0912-345678"
/>
</div>
{/* 到職日 */}
<div>
<label htmlFor="hire_date" className="block text-sm font-medium text-gray-700">
到職日 <span className="text-red-500">*</span>
</label>
<input
type="date"
id="hire_date"
name="hire_date"
required
value={formData.hire_date}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
/>
</div>
{/* 分隔線 */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
組織與職務資訊
</h3>
</div>
{/* 第一層部門 (原事業部) */}
<div>
<label htmlFor="top_department_id" className="block text-sm font-medium text-gray-700">
所屬部門 (第一層) <span className="text-red-500">*</span>
</label>
<select
id="top_department_id"
name="top_department_id"
required
value={formData.top_department_id}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
>
<option value="">請選擇所屬部門</option>
{topDepartments.map((dept) => (
<option key={dept.id} value={dept.id}>
{dept.name}
{dept.email_domain ? ` (@${dept.email_domain})` : ''}
</option>
))}
</select>
<p className="mt-1 text-sm text-gray-500">
決定員工的主要郵件網域
{selectedTopDept?.email_domain && (
<span className="ml-1 font-mono text-blue-600">
@{selectedTopDept.email_domain}
</span>
)}
</p>
</div>
{/* 子部門 (選填) */}
<div>
<label htmlFor="department_id" className="block text-sm font-medium text-gray-700">
指定部門 (選填)
</label>
<select
id="department_id"
name="department_id"
value={formData.department_id}
onChange={handleChange}
disabled={!formData.top_department_id || loadingSubDepts}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-500"
>
<option value="">
{!formData.top_department_id
? '請先選擇第一層部門'
: loadingSubDepts
? '載入中...'
: subDepartments.length > 0
? '請選擇子部門 (選填)'
: '此部門尚無子部門'}
</option>
{subDepartments.map((dept) => (
<option key={dept.id} value={dept.id}>
{dept.name} ({dept.code})
</option>
))}
</select>
<p className="mt-1 text-sm text-gray-500">
選填,可稍後在部門成員管理中設定
</p>
</div>
{/* 職稱 */}
<div>
<label htmlFor="job_title" className="block text-sm font-medium text-gray-700">
職稱 <span className="text-red-500">*</span>
</label>
<input
type="text"
id="job_title"
name="job_title"
required
value={formData.job_title}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
placeholder="例如: 軟體工程師、技術總監"
/>
</div>
{/* 郵件配額 */}
<div>
<label htmlFor="email_quota_mb" className="block text-sm font-medium text-gray-700">
郵件配額 (MB) <span className="text-red-500">*</span>
</label>
<input
type="number"
id="email_quota_mb"
name="email_quota_mb"
required
min="1024"
max="51200"
step="1024"
value={formData.email_quota_mb}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
/>
<p className="mt-1 text-sm text-gray-500">
預設 5120 MB (5 GB),可設定 1024 MB 至 51200 MB 之間
</p>
</div>
</div>
{/* 到職自動化流程 */}
<div className="border-t border-gray-200 pt-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">
自動執行到職流程
</h3>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name="auto_onboard"
checked={formData.auto_onboard}
onChange={handleChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">建立員工後立即執行</span>
</label>
</div>
{formData.auto_onboard && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-3">
<p className="text-sm text-blue-700 font-medium">選擇要自動建立的帳號:</p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
name="create_keycloak"
checked={formData.create_keycloak}
onChange={handleChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-gray-700">Keycloak SSO 帳號</span>
<p className="text-xs text-gray-500">建立 SSO 登入帳號並設定初始密碼 (首次登入需修改)</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
name="create_email"
checked={formData.create_email}
onChange={handleChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-gray-700">郵件帳號</span>
<p className="text-xs text-gray-500">建立主要郵件帳號</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
name="create_drive"
checked={formData.create_drive}
onChange={handleChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-gray-700">雲端硬碟帳號</span>
<p className="text-xs text-gray-500">建立 Nextcloud 帳號 (Drive Service 未上線時自動跳過)</p>
</div>
</label>
</div>
)}
</div>
{/* 按鈕區 */}
<div className="mt-8 flex justify-end gap-4">
<button
type="button"
onClick={() => router.back()}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
disabled={loading}
>
取消
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '建立中...' : '建立員工'}
</button>
</div>
</form>
{/* 提示訊息 */}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h3 className="text-sm font-medium text-blue-900 mb-2">
💡 自動化流程說明
</h3>
<ul className="text-sm text-blue-700 space-y-1">
<li>• 員工編號將自動產生 (EMP001, EMP002...)</li>
<li>• 目前狀態會設定為「在職」</li>
<li>• SSO 帳號將根據所屬第一層部門的網域自動生成</li>
<li>• 建立員工後可在「組織架構」頁面管理部門成員</li>
<li>• 勾選「自動執行到職流程」可立即建立:</li>
<li className="ml-4">- Keycloak SSO 帳號 (含初始密碼)</li>
<li className="ml-4">- 主要郵件帳號</li>
<li className="ml-4">- 雲端硬碟帳號 (Drive Service)</li>
<li>• 也可在建立員工後,從員工詳情頁手動觸發到職流程</li>
</ul>
</div>
</div>
)
}