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,297 @@
/**
* Phase 3: Keycloak SSO 設定
*
* 功能:
* 1. 填寫 Keycloak 連接資訊
* 2. 測試 Keycloak 連接
* 3. 將設定寫入 .env 檔案
*/
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
interface KeycloakConfig {
url: string
realm: string
admin_username: string
admin_password: string
}
interface TestResult {
success: boolean
message: string
keycloak_version?: string
realm_info?: string
}
export default function Phase3Keycloak() {
const router = useRouter()
const [config, setConfig] = useState<KeycloakConfig>({
url: 'https://auth.lab.taipei',
realm: 'porscheworld',
admin_username: 'admin',
admin_password: '',
})
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [setupPending, setSetupPending] = useState(false)
const [loading, setLoading] = useState(true)
const [alreadyConfigured, setAlreadyConfigured] = useState(false)
// 載入已儲存的配置
useEffect(() => {
loadSavedConfig()
}, [])
const loadSavedConfig = async () => {
try {
setLoading(true)
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/get-config/keycloak')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
if (data.configured && data.config) {
setConfig({
url: data.config.url || 'https://auth.lab.taipei',
realm: data.config.realm || 'porscheworld',
admin_username: data.config.admin_username || 'admin',
admin_password: data.config.admin_password === '****' ? '' : (data.config.admin_password || ''),
})
setAlreadyConfigured(true)
}
} catch (error) {
console.error('[Keycloak] Failed to load saved config:', error)
} finally {
setLoading(false)
}
}
const handleTest = async () => {
try {
setTesting(true)
setTestResult(null)
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/test-keycloak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = await response.json()
if (!response.ok) {
setTestResult({
success: false,
message: data.detail || `HTTP ${response.status}`,
})
return
}
setTestResult(data)
if (data.success) {
setSetupPending(true)
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : '連接失敗',
})
} finally {
setTesting(false)
}
}
const handleSetup = async () => {
try {
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/setup-keycloak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = await response.json()
if (!response.ok) {
alert(`設定失敗: ${data.detail}`)
return
}
alert('Keycloak 設定成功!')
router.push('/installation')
} catch (error) {
alert(`設定失敗: ${error instanceof Error ? error.message : '未知錯誤'}`)
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-green-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-400">...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-950">
<div className="container mx-auto px-4 py-8">
<div className="max-w-3xl mx-auto">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300"></span>
<span className="text-sm font-medium text-gray-300">3 / 7</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div className="bg-green-600 h-2 rounded-full" style={{ width: '42.9%' }}></div>
</div>
</div>
{/* Main Card */}
<div className="bg-gray-800 rounded-xl shadow-xl p-8 border border-gray-700">
<div className="flex items-center mb-6">
<div className="w-12 h-12 bg-green-900/30 rounded-full flex items-center justify-center mr-4 border border-green-800">
<span className="text-2xl">🔐</span>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-100">Phase 3: Keycloak SSO </h1>
<p className="text-gray-400"></p>
</div>
</div>
{alreadyConfigured && (
<div className="bg-green-900/20 border border-green-700 rounded-lg p-4 mb-6">
<p className="text-sm text-green-300 flex items-center">
<svg className="w-5 h-5 mr-2" 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>
<strong></strong> -
</p>
</div>
)}
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4 mb-6">
<p className="text-sm text-blue-300">
<strong></strong>Keycloak SSO 使HR Portal Keycloak
</p>
</div>
{/* Form */}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Keycloak <span className="text-green-400">*</span>
</label>
<input
type="text"
value={config.url}
onChange={(e) => setConfig({ ...config, url: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: https://auth.lab.taipei"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Realm <span className="text-green-400">*</span>
</label>
<input
type="text"
value={config.realm}
onChange={(e) => setConfig({ ...config, realm: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="porscheworld"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="text"
value={config.admin_username}
onChange={(e) => setConfig({ ...config, admin_username: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="admin"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="password"
value={config.admin_password}
onChange={(e) => setConfig({ ...config, admin_password: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="管理員密碼"
/>
</div>
</div>
{/* Test Result */}
{testResult && (
<div className={`mb-6 p-4 rounded-lg ${testResult.success ? 'bg-green-900/20 border border-green-700' : 'bg-red-900/20 border border-red-700'}`}>
<div className="flex items-start">
<span className="text-2xl mr-3">{testResult.success ? '✅' : '❌'}</span>
<div className="flex-1">
<h3 className={`font-semibold mb-1 ${testResult.success ? 'text-green-300' : 'text-red-300'}`}>
{testResult.success ? '連接成功' : '連接失敗'}
</h3>
<p className={`text-sm ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
{testResult.message}
</p>
{testResult.success && testResult.keycloak_version && (
<div className="mt-2 text-sm text-green-400">
<p>Keycloak : {testResult.keycloak_version}</p>
{testResult.realm_info && <p>Realm : {testResult.realm_info}</p>}
</div>
)}
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-4">
<button
onClick={() => router.back()}
className="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleTest}
disabled={testing || !config.url || !config.realm || !config.admin_username || !config.admin_password}
className="flex-1 bg-blue-600 text-white py-2 px-6 rounded-lg hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors"
>
{testing ? '測試中...' : '測試連接'}
</button>
<button
onClick={handleSetup}
disabled={!setupPending}
className="flex-1 bg-green-600 text-white py-2 px-6 rounded-lg hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors font-semibold"
>
</button>
</div>
</div>
</div>
</div>
</div>
)
}