Files
hr-portal/frontend/app/installation/page.tsx
Porsche Chen 360533393f 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>
2026-02-23 20:12:43 +08:00

482 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
)
}