Files
hr-portal/frontend/app/installation/phase1-redis/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

296 lines
11 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.
/**
* Phase 1: Redis 快取設定
*
* 功能:
* 1. 填寫 Redis 連接資訊
* 2. 測試 Redis 連接
* 3. 將設定寫入 .env 檔案
*/
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
interface RedisConfig {
host: string
port: number
password: string
db: number
}
interface TestResult {
success: boolean
message: string
redis_version?: string
memory_usage?: string
}
export default function Phase1Redis() {
const router = useRouter()
const [config, setConfig] = useState<RedisConfig>({
host: '10.1.0.20',
port: 6379,
password: '',
db: 0,
})
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/redis')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
if (data.configured && data.config) {
setConfig({
host: data.config.host || '10.1.0.20',
port: parseInt(data.config.port) || 6379,
password: data.config.password === '****' ? '' : (data.config.password || ''),
db: parseInt(data.config.db) || 0,
})
setAlreadyConfigured(true)
}
} catch (error) {
console.error('[Redis] 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-redis', {
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-redis', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = await response.json()
if (!response.ok) {
alert(`設定失敗: ${data.detail}`)
return
}
alert('Redis 設定成功!')
router.push('/installation/phase2-database')
} 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-red-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">1 / 7</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div className="bg-red-600 h-2 rounded-full" style={{ width: '14.3%' }}></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-red-900/30 rounded-full flex items-center justify-center mr-4 border border-red-800">
<span className="text-2xl">🔴</span>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-100">Phase 1: Redis </h1>
<p className="text-gray-400"> Session </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>Redis 使 Session NextAuth Redis
</p>
</div>
{/* Form */}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Redis <span className="text-red-400">*</span>
</label>
<input
type="text"
value={config.host}
onChange={(e) => setConfig({ ...config, host: 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-red-500 focus:border-transparent"
placeholder="例如: 10.1.0.20"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-red-400">*</span>
</label>
<input
type="number"
value={config.port}
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) || 6379 })}
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-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="number"
value={config.db}
onChange={(e) => setConfig({ ...config, db: parseInt(e.target.value) || 0 })}
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-red-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
()
</label>
<input
type="password"
value={config.password}
onChange={(e) => setConfig({ ...config, 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-red-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.redis_version && (
<div className="mt-2 text-sm text-green-400">
<p>Redis : {testResult.redis_version}</p>
{testResult.memory_usage && <p>使: {testResult.memory_usage}</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.host}
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-red-600 text-white py-2 px-6 rounded-lg hover:bg-red-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors font-semibold"
>
</button>
</div>
</div>
</div>
</div>
</div>
)
}