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,481 @@
/**
* HR Portal 系統初始化引導頁面
*
* 功能:
* 1. 檢查系統初始化狀態三階段Initialization/Operational/Transition
* 2. 引導用戶完成環境配置
* 3. 顯示當前階段與配置進度
*/
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
interface SystemStatus {
current_phase: 'initialization' | 'operational' | 'transition'
is_initialized: boolean
initialization_completed: boolean
configured_count: number
configured_categories: string[]
missing_categories: string[]
is_locked?: boolean
next_action: string
message: string
// Operational 階段欄位
last_health_check_at?: string
health_check_status?: string
// Transition 階段欄位
env_db_consistent?: boolean
inconsistencies?: string
}
interface ConfigDetail {
redis?: { host: string; port: string; db: string }
database?: { host: string; port: string; name: string; user: string }
keycloak?: { url: string; realm: string; admin_username: string }
}
export default function InstallationPage() {
const router = useRouter()
const [status, setStatus] = useState<SystemStatus | null>(null)
const [configDetails, setConfigDetails] = useState<ConfigDetail>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
checkSystemStatus()
}, [])
const checkSystemStatus = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/check-status')
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data: SystemStatus = await response.json()
setStatus(data)
// 載入已完成配置的詳細資訊
if (data.configured_categories.length > 0) {
await loadConfigDetails(data.configured_categories)
}
// 如果已初始化,導向健康檢查頁面
if (data.is_initialized) {
router.push('/installation/health-check')
}
} catch (err) {
console.error('[Installation] Failed to check status:', err)
setError(err instanceof Error ? err.message : '無法連接到後端 API')
} finally {
setLoading(false)
}
}
const loadConfigDetails = async (categories: string[]) => {
const details: ConfigDetail = {}
for (const category of categories) {
try {
const response = await fetch(`http://10.1.0.245:10181/api/v1/installation/get-config/${category}`)
if (response.ok) {
const data = await response.json()
console.log(`[Installation] Loaded ${category} config:`, data)
if (data.configured && data.config) {
details[category as keyof ConfigDetail] = data.config
console.log(`[Installation] ${category} details:`, data.config)
}
}
} catch (error) {
console.error(`[Installation] Failed to load ${category} config:`, error)
}
}
console.log('[Installation] All config details:', details)
setConfigDetails(details)
}
const startInstallation = () => {
// 導向下一個未完成的設定階段
if (!status) return
if (!status.configured_categories.includes('redis')) {
router.push('/installation/phase1-redis')
} else if (!status.configured_categories.includes('database')) {
router.push('/installation/phase2-database')
} else if (!status.configured_categories.includes('keycloak')) {
router.push('/installation/phase3-keycloak')
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<div className="text-center">
<div className="w-16 h-16 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<h1 className="text-2xl font-bold text-gray-100 mb-2">HR Portal </h1>
<p className="text-gray-400">...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<div className="bg-gray-800 rounded-lg shadow-xl p-8 max-w-md border border-gray-700">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4 border border-red-800">
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-100 mb-2"></h2>
<p className="text-gray-300 mb-4">{error}</p>
<div className="bg-yellow-900/20 border border-yellow-700 rounded-lg p-4 text-left text-sm">
<p className="font-semibold text-yellow-400 mb-2"></p>
<ul className="list-disc list-inside text-yellow-300 space-y-1">
<li> (Port 10181)</li>
<li></li>
<li></li>
</ul>
</div>
</div>
<button
onClick={checkSystemStatus}
className="w-full bg-indigo-600 text-white py-2 px-4 rounded-lg hover:bg-indigo-700 transition-colors"
>
</button>
</div>
</div>
)
}
// 階段顏色與圖示(深色主題)
const getPhaseConfig = (phase: string) => {
switch (phase) {
case 'initialization':
return {
color: 'from-blue-500 to-indigo-600',
bgColor: 'bg-blue-900/20',
borderColor: 'border-blue-700',
textColor: 'text-blue-300',
icon: '⚙️',
title: 'Initialization 階段',
description: '系統初始化中,正在設定環境配置'
}
case 'operational':
return {
color: 'from-green-500 to-emerald-600',
bgColor: 'bg-green-900/20',
borderColor: 'border-green-700',
textColor: 'text-green-300',
icon: '✅',
title: 'Operational 階段',
description: '系統正常運作中,可進行健康檢查'
}
case 'transition':
return {
color: 'from-orange-500 to-amber-600',
bgColor: 'bg-orange-900/20',
borderColor: 'border-orange-700',
textColor: 'text-orange-300',
icon: '🔄',
title: 'Transition 階段',
description: '系統移轉中,正在檢查環境一致性'
}
default:
return {
color: 'from-gray-500 to-gray-600',
bgColor: 'bg-gray-800',
borderColor: 'border-gray-700',
textColor: 'text-gray-300',
icon: '❓',
title: '未知階段',
description: '系統狀態未知'
}
}
}
const phaseConfig = status ? getPhaseConfig(status.current_phase) : null
return (
<div className="min-h-screen bg-gray-950">
<div className="container mx-auto px-4 py-16">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-100 mb-4">
HR Portal
</h1>
<p className="text-xl text-gray-400">
</p>
</div>
{/* Phase Status Card */}
{status && phaseConfig && (
<div className="bg-gray-800 rounded-xl shadow-2xl p-8 mb-8 border border-gray-700">
<div className="flex items-center justify-center mb-6">
<div className={`w-20 h-20 bg-gradient-to-br ${phaseConfig.color} rounded-full flex items-center justify-center text-4xl`}>
{phaseConfig.icon}
</div>
</div>
<h2 className="text-2xl font-bold text-center text-gray-100 mb-2">
{phaseConfig.title}
</h2>
<p className="text-center text-gray-400 mb-8">
{phaseConfig.description}
</p>
{/* Message from Backend */}
<div className={`${phaseConfig.bgColor} ${phaseConfig.borderColor} border rounded-lg p-4 mb-8`}>
<p className={`${phaseConfig.textColor} text-center font-medium`}>
{status.message}
</p>
</div>
{/* Configuration Progress */}
{status.current_phase === 'initialization' && (
<>
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-300"></span>
<span className="text-sm text-gray-400">
{status.configured_count} / 3
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-gradient-to-r from-indigo-500 to-purple-600 h-2 rounded-full transition-all"
style={{ width: `${(status.configured_count / 3) * 100}%` }}
></div>
</div>
</div>
<div className="space-y-3 mb-8">
{[
{ key: 'redis', name: 'Redis 快取設定', icon: '🔴', route: '/installation/phase1-redis' },
{ key: 'database', name: '資料庫連接設定', icon: '🗄️', route: '/installation/phase2-database' },
{ key: 'keycloak', name: 'Keycloak SSO 設定', icon: '🔐', route: '/installation/phase3-keycloak' },
].map((step) => {
const isConfigured = status.configured_categories.includes(step.key)
const config = configDetails[step.key as keyof ConfigDetail]
// 格式化配置資訊
let configInfo = ''
if (config) {
if (step.key === 'redis') {
configInfo = `${config.host}:${config.port} (DB ${config.db})`
} else if (step.key === 'database') {
configInfo = `${config.host}:${config.port}/${config.name}`
} else if (step.key === 'keycloak') {
configInfo = `${config.url} (${config.realm})`
}
}
return (
<div
key={step.key}
className={`p-4 rounded-lg border ${
isConfigured
? 'bg-green-900/20 border-green-700'
: 'bg-gray-700/50 border-gray-600'
}`}
>
<div className="flex items-center">
<span className="text-2xl mr-4">{step.icon}</span>
<div className="flex-1">
<div className={`font-medium ${isConfigured ? 'text-green-300' : 'text-gray-200'}`}>
{step.name}
</div>
{isConfigured && configInfo && (
<div className="text-xs text-green-400/70 mt-1 font-mono">
{configInfo}
</div>
)}
</div>
{isConfigured ? (
<span className="text-sm text-green-400 font-medium flex items-center">
<svg className="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</span>
) : (
<button
onClick={() => router.push(step.route)}
className="text-sm text-indigo-400 hover:text-indigo-300 font-medium"
>
</button>
)}
</div>
</div>
)
})}
</div>
{/* Action Buttons */}
<div className="space-y-3">
{status.missing_categories.length > 0 ? (
<button
onClick={startInstallation}
className="w-full bg-gradient-to-r from-indigo-600 to-purple-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg"
>
</button>
) : (
<button
onClick={() => router.push('/installation/complete')}
className="w-full bg-gradient-to-r from-green-600 to-emerald-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-green-700 hover:to-emerald-700 transition-all transform hover:scale-105 shadow-lg hover:shadow-green-500/50"
>
</button>
)}
{/* 開發測試:重置按鈕 */}
{status.configured_count > 0 && (
<button
onClick={async () => {
if (!confirm('確定要重置所有環境配置?此操作無法復原。')) return
try {
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/reset-config/all', {
method: 'DELETE'
})
const data = await response.json()
if (data.success) {
alert('重置成功!頁面即將重新載入。')
window.location.reload()
} else {
alert('重置失敗:' + data.message)
}
} catch (error) {
alert('重置失敗:' + (error instanceof Error ? error.message : '未知錯誤'))
}
}}
className="w-full bg-red-600/20 border border-red-700 text-red-300 py-2 px-4 rounded-lg text-sm hover:bg-red-600/30 transition-all"
>
🔄
</button>
)}
</div>
</>
)}
{/* Operational Phase Actions */}
{status.current_phase === 'operational' && (
<div className="space-y-4">
<button
onClick={() => router.push('/installation/health-check')}
className="w-full bg-gradient-to-r from-green-600 to-emerald-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-green-700 hover:to-emerald-700 transition-all shadow-lg hover:shadow-green-500/50"
>
</button>
<button
onClick={() => router.push('/dashboard')}
className="w-full bg-gray-700 text-gray-100 border border-gray-600 py-4 px-6 rounded-lg font-semibold text-lg hover:bg-gray-600 transition-all shadow-sm"
>
</button>
</div>
)}
{/* Transition Phase Actions */}
{status.current_phase === 'transition' && (
<div className="space-y-4">
<button
onClick={() => router.push('/installation/consistency-check')}
className="w-full bg-gradient-to-r from-orange-600 to-amber-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-orange-700 hover:to-amber-700 transition-all shadow-lg hover:shadow-orange-500/50"
>
</button>
{status.env_db_consistent && (
<button
onClick={() => {/* TODO: Switch to operational */}}
className="w-full bg-gradient-to-r from-green-600 to-emerald-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-green-700 hover:to-emerald-700 transition-all shadow-lg hover:shadow-green-500/50"
>
</button>
)}
</div>
)}
</div>
)}
{/* Info Card */}
{status && (
<div className={`${phaseConfig?.bgColor} border ${phaseConfig?.borderColor} rounded-lg p-6`}>
<h3 className={`font-semibold ${phaseConfig?.textColor} mb-3 flex items-center`}>
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
{status.current_phase === 'initialization' && '初始化階段說明'}
{status.current_phase === 'operational' && '營運階段說明'}
{status.current_phase === 'transition' && '移轉階段說明'}
</h3>
<ul className={`space-y-2 text-sm ${phaseConfig?.textColor}`}>
{status.current_phase === 'initialization' && (
<>
<li className="flex items-start">
<span className="mr-2"></span>
<span> .env </span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span></span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span></span>
</li>
</>
)}
{status.current_phase === 'operational' && (
<>
<li className="flex items-start">
<span className="mr-2"></span>
<span></span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span> RedisDatabaseKeycloak </span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span> Transition </span>
</li>
</>
)}
{status.current_phase === 'transition' && (
<>
<li className="flex items-start">
<span className="mr-2"></span>
<span>Transition </span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span> .env </span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span></span>
</li>
</>
)}
</ul>
</div>
)}
</div>
</div>
</div>
)
}