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,802 @@
/**
* 完成初始化頁面
*
* 流程:
* 1. 填寫公司基本資訊
* 2. 設定郵件網域
* 3. 設定管理員帳號
* 4. 確認並執行初始化
*/
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
interface CompanyInfo {
company_name: string
company_name_en: string
tenant_code: string
tenant_prefix: string
tax_id: string
tel: string
add: string
}
interface MailDomainInfo {
domain_set: 1 | 2
domain: string
}
interface AdminInfo {
admin_legal_name: string
admin_english_name: string
admin_email: string
admin_phone: string
password_method: 'auto' | 'manual'
manual_password?: string
}
export default function CompleteInitialization() {
const router = useRouter()
const [step, setStep] = useState<'checking' | 'company' | 'maildomain' | 'admin' | 'confirm' | 'executing'>('checking')
const [sessionId, setSessionId] = useState<number | null>(null)
const [checkingDb, setCheckingDb] = useState(true)
const [dbReady, setDbReady] = useState(false)
const [companyInfo, setCompanyInfo] = useState<CompanyInfo>({
company_name: '',
company_name_en: '',
tenant_code: '',
tenant_prefix: '',
tax_id: '',
tel: '',
add: '',
})
const [mailDomainInfo, setMailDomainInfo] = useState<MailDomainInfo>({
domain_set: 2,
domain: '',
})
const [adminInfo, setAdminInfo] = useState<AdminInfo>({
admin_legal_name: '',
admin_english_name: '',
admin_email: '',
admin_phone: '',
password_method: 'auto',
})
const [generatedPassword, setGeneratedPassword] = useState<string>('')
const [executing, setExecuting] = useState(false)
// 載入 Keycloak Realm 名稱作為租戶代碼預設值
useEffect(() => {
const loadKeycloakRealm = async () => {
try {
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/get-config/keycloak')
if (response.ok) {
const data = await response.json()
if (data.configured && data.config && data.config.realm) {
setCompanyInfo(prev => ({
...prev,
tenant_code: data.config.realm.toLowerCase(), // ⚠️ 必須小寫,與 Keycloak Realm 一致
}))
}
}
setCheckingDb(false)
setDbReady(true)
setStep('company')
} catch (error) {
console.error('[Installation] Failed to load Keycloak config:', error)
setCheckingDb(false)
setDbReady(true)
setStep('company')
}
}
loadKeycloakRealm()
}, [])
// Step 1: 建立會話並儲存公司資訊
const handleSaveCompany = async () => {
try {
// 建立會話
if (!sessionId) {
const sessionResponse = await fetch('http://10.1.0.245:10181/api/v1/installation/sessions', {
method: 'POST',
})
if (!sessionResponse.ok) {
throw new Error('建立會話失敗')
}
const sessionData = await sessionResponse.json()
setSessionId(sessionData.session_id)
}
setStep('maildomain')
} catch (error) {
alert(`錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
}
}
// Step 2: 儲存郵件網域設定
const handleSaveMailDomain = async () => {
try {
if (!sessionId) {
throw new Error('會話 ID 不存在')
}
// 合併公司資訊 + 郵件網域資訊
const tenantData = {
...companyInfo,
...mailDomainInfo,
}
const response = await fetch(`http://10.1.0.245:10181/api/v1/installation/sessions/${sessionId}/tenant-info`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tenantData),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))
throw new Error(`儲存租戶資訊失敗: ${errorData.detail || response.statusText}`)
}
setStep('admin')
} catch (error) {
console.error('[MailDomain] Save failed:', error)
alert(`錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
}
}
// Step 3: 設定管理員帳號
const handleSaveAdmin = async () => {
try {
if (!sessionId) {
throw new Error('會話 ID 不存在')
}
const response = await fetch(`http://10.1.0.245:10181/api/v1/installation/sessions/${sessionId}/admin-setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(adminInfo),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.detail || '設定管理員失敗')
}
if (data.initial_password) {
setGeneratedPassword(data.initial_password)
}
setStep('confirm')
} catch (error) {
alert(`錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
}
}
// Step 4: 執行初始化
const handleExecute = async () => {
try {
if (!sessionId) {
throw new Error('會話 ID 不存在')
}
setStep('executing')
setExecuting(true)
const response = await fetch(`http://10.1.0.245:10181/api/v1/installation/sessions/${sessionId}/execute`, {
method: 'POST',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.detail || '初始化失敗')
}
// 從回應中取得臨時密碼
const tempPassword = data.result?.credentials?.plain_password || '(密碼已清除,請聯絡管理員)'
const username = adminInfo.admin_english_name
// 顯示完成訊息和臨時密碼
alert(`初始化完成!
請使用以下資訊登入 SSO 系統:
帳號: ${username}
臨時密碼: ${tempPassword}
⚠️ 重要提醒:
1. 請立即記下或截圖這個密碼
2. 首次登入後系統會要求您變更密碼
3. 此密碼僅顯示一次,關閉後將無法再次查看
點擊「確定」後將跳轉至登入頁面。`)
// 跳轉到登入頁面
window.location.href = '/auth/signin'
} catch (error) {
alert(`錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
setExecuting(false)
}
}
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">
{step === 'company' ? '1' : step === 'maildomain' ? '2' : step === 'admin' ? '3' : step === 'confirm' ? '4' : '5'} / 4
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-gradient-to-r from-green-500 to-emerald-600 h-2 rounded-full transition-all"
style={{
width:
step === 'company' ? '25%' :
step === 'maildomain' ? '50%' :
step === 'admin' ? '75%' : '100%'
}}
></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"></h1>
<p className="text-gray-400">
{step === 'company' && '填寫公司基本資訊'}
{step === 'maildomain' && '設定郵件網域'}
{step === 'admin' && '設定系統管理員'}
{step === 'confirm' && '確認並執行'}
{step === 'executing' && '正在初始化系統...'}
</p>
</div>
</div>
{/* Step 1: 公司資訊 */}
{step === 'company' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="text"
value={companyInfo.company_name}
onChange={(e) => setCompanyInfo({ ...companyInfo, company_name: 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>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="text"
value={companyInfo.company_name_en}
onChange={(e) => setCompanyInfo({ ...companyInfo, company_name_en: 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="例如: Porsche World Co., Ltd."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="text"
value={companyInfo.tax_id}
onChange={(e) => setCompanyInfo({ ...companyInfo, tax_id: 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="例如: 82871784"
maxLength={8}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="text"
value={companyInfo.tel}
onChange={(e) => setCompanyInfo({ ...companyInfo, tel: 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="例如: 02-26262026"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<textarea
value={companyInfo.add}
onChange={(e) => setCompanyInfo({ ...companyInfo, add: 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="例如: 新北市淡水區北新路197號7樓"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Keycloak Realm <span className="text-green-400">*</span>
</label>
<input
type="text"
value={companyInfo.tenant_code}
readOnly
className="w-full px-4 py-2 bg-gray-600 border border-gray-500 rounded-lg text-gray-300 font-mono cursor-not-allowed"
placeholder="自動載入 Keycloak Realm..."
/>
<p className="text-xs text-gray-400 mt-1">🔒 Keycloak Realm </p>
</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={companyInfo.tenant_prefix}
onChange={(e) => setCompanyInfo({ ...companyInfo, tenant_prefix: e.target.value.toUpperCase() })}
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 font-mono"
placeholder="例如: PW"
maxLength={10}
/>
<p className="text-xs text-gray-400 mt-1">{companyInfo.tenant_prefix || 'XX'}001</p>
</div>
</div>
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4">
<p className="text-sm text-blue-300">
<strong>💡 </strong>
</p>
<ul className="text-sm text-blue-300 mt-2 space-y-1 ml-4 list-disc">
<li>code= Keycloak Realm SSO </li>
<li> <strong></strong> Keycloak Realm </li>
<li>prefixPW PW001</li>
<li></li>
</ul>
</div>
<div className="flex gap-4 mt-8">
<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={handleSaveCompany}
disabled={!companyInfo.company_name || !companyInfo.tenant_code || !companyInfo.tenant_prefix}
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>
)}
{/* Step 2: 郵件網域設定 */}
{step === 'maildomain' && (
<div className="space-y-4">
<div className="bg-yellow-900/20 border border-yellow-700 rounded-lg p-4 mb-6">
<p className="text-sm text-yellow-300">
<strong> DNS </strong>
</p>
<p className="text-sm text-yellow-300 mt-2">
DNS MX
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<div className="space-y-2">
<label className="flex items-start p-4 bg-gray-700/50 border border-gray-600 rounded-lg cursor-pointer hover:bg-gray-700">
<input
type="radio"
name="domainSet"
checked={mailDomainInfo.domain_set === 1}
onChange={() => setMailDomainInfo({ ...mailDomainInfo, domain_set: 1 })}
className="mr-3 mt-1"
/>
<div>
<span className="text-gray-200 font-medium"></span>
<p className="text-sm text-gray-400 mt-1">
使user@porscheworld.tw
</p>
</div>
</label>
<label className="flex items-start p-4 bg-gray-700/50 border border-gray-600 rounded-lg cursor-pointer hover:bg-gray-700">
<input
type="radio"
name="domainSet"
checked={mailDomainInfo.domain_set === 2}
onChange={() => setMailDomainInfo({ ...mailDomainInfo, domain_set: 2 })}
className="mr-3 mt-1"
/>
<div>
<span className="text-gray-200 font-medium"></span>
<p className="text-sm text-gray-400 mt-1">
使hr@ease.taipei, mis@lab.taipei
</p>
</div>
</label>
</div>
</div>
{/* 組織網域模式:所有員工使用統一網域 */}
{mailDomainInfo.domain_set === 1 && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="text"
value={mailDomainInfo.domain}
onChange={(e) => setMailDomainInfo({ ...mailDomainInfo, domain: 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.tw"
/>
<p className="text-xs text-gray-400 mt-2">
DNS <br/>
MX : {mailDomainInfo.domain || 'yourdomain.tw'} mail.{mailDomainInfo.domain || 'yourdomain.tw'} ( 10)
</p>
</div>
)}
{/* 部門網域模式:需要輸入預設網域給系統管理員使用 */}
{mailDomainInfo.domain_set === 2 && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
使 <span className="text-green-400">*</span>
</label>
<input
type="text"
value={mailDomainInfo.domain}
onChange={(e) => setMailDomainInfo({ ...mailDomainInfo, domain: 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.tw"
/>
<p className="text-xs text-gray-400 mt-2">
</p>
</div>
)}
{mailDomainInfo.domain_set === 2 && (
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4">
<p className="text-sm text-blue-300">
<strong>💡 </strong>
</p>
<p className="text-sm text-blue-300 mt-2">
<br/>
ease.taipei ()lab.taipei ()porscheworld.tw ()
</p>
<p className="text-sm text-blue-300 mt-2">
</p>
</div>
)}
<div className="flex gap-4 mt-8">
<button
onClick={() => setStep('company')}
className="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleSaveMailDomain}
disabled={!mailDomainInfo.domain}
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>
)}
{/* Step 3: 管理員資訊 */}
{step === 'admin' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="text"
value={adminInfo.admin_legal_name}
onChange={(e) => setAdminInfo({ ...adminInfo, admin_legal_name: 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>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="text"
value={adminInfo.admin_english_name}
onChange={(e) => setAdminInfo({ ...adminInfo, admin_english_name: 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="例如: Porsche Chen"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Email <span className="text-green-400">*</span>
</label>
<input
type="email"
value={adminInfo.admin_email}
onChange={(e) => setAdminInfo({ ...adminInfo, admin_email: 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@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="tel"
value={adminInfo.admin_phone}
onChange={(e) => setAdminInfo({ ...adminInfo, admin_phone: 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="例如: 0912345678"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<div className="space-y-2">
<label className="flex items-center p-3 bg-gray-700/50 border border-gray-600 rounded-lg cursor-pointer hover:bg-gray-700">
<input
type="radio"
name="passwordMethod"
checked={adminInfo.password_method === 'auto'}
onChange={() => setAdminInfo({ ...adminInfo, password_method: 'auto', manual_password: undefined })}
className="mr-3"
/>
<span className="text-gray-200"></span>
</label>
<label className="flex items-center p-3 bg-gray-700/50 border border-gray-600 rounded-lg cursor-pointer hover:bg-gray-700">
<input
type="radio"
name="passwordMethod"
checked={adminInfo.password_method === 'manual'}
onChange={() => setAdminInfo({ ...adminInfo, password_method: 'manual' })}
className="mr-3"
/>
<span className="text-gray-200"></span>
</label>
</div>
</div>
{adminInfo.password_method === 'manual' && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="password"
value={adminInfo.manual_password || ''}
onChange={(e) => setAdminInfo({ ...adminInfo, manual_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 className="bg-blue-900/20 border border-blue-700 rounded-lg p-4 mt-6">
<p className="text-sm text-blue-300">
<strong>💡 </strong>
</p>
<ul className="text-sm text-blue-300 mt-2 space-y-1 ml-4 list-disc">
<li></li>
<li></li>
<li>調 MIS </li>
</ul>
</div>
<div className="flex gap-4 mt-8">
<button
onClick={() => setStep('maildomain')}
className="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleSaveAdmin}
disabled={!adminInfo.admin_legal_name || !adminInfo.admin_email || (adminInfo.password_method === 'manual' && !adminInfo.manual_password)}
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>
)}
{/* Step 4: 確認資訊 */}
{step === 'confirm' && (
<div className="space-y-6">
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4">
<p className="text-sm text-blue-300">
<strong> </strong>
</p>
<ul className="text-sm text-blue-300 mt-2 space-y-1 ml-4 list-disc">
<li></li>
<li>INIT</li>
<li>{companyInfo.tenant_prefix}001</li>
<li>Keycloak SSO </li>
<li> ({adminInfo.admin_email})</li>
</ul>
<p className="text-sm text-blue-300 mt-2">
</p>
</div>
<div className="space-y-4">
<div className="bg-gray-700/50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-green-300 mb-3"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">{companyInfo.company_name}</span>
</div>
{companyInfo.company_name_en && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">{companyInfo.company_name_en}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200 font-mono">{companyInfo.tenant_code}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200 font-mono">{companyInfo.tenant_prefix}</span>
</div>
{companyInfo.tax_id && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">{companyInfo.tax_id}</span>
</div>
)}
</div>
</div>
<div className="bg-gray-700/50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-green-300 mb-3"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">
{mailDomainInfo.domain_set === 1 ? '組織網域' : '部門網域'}
</span>
</div>
{mailDomainInfo.domain_set === 1 && mailDomainInfo.domain && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200 font-mono">{mailDomainInfo.domain}</span>
</div>
)}
</div>
</div>
<div className="bg-gray-700/50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-green-300 mb-3"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">{adminInfo.admin_legal_name}</span>
</div>
{adminInfo.admin_english_name && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">{adminInfo.admin_english_name}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-400">Email</span>
<span className="text-gray-200">{adminInfo.admin_email}</span>
</div>
{adminInfo.admin_phone && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">{adminInfo.admin_phone}</span>
</div>
)}
</div>
</div>
{generatedPassword && (
<div className="bg-yellow-900/20 border border-yellow-700 rounded-lg p-4">
<h3 className="text-lg font-semibold text-yellow-300 mb-3"> </h3>
<div className="bg-gray-900 rounded p-3 font-mono text-yellow-200 text-center text-lg">
{generatedPassword}
</div>
<p className="text-xs text-yellow-400 mt-2">
</p>
</div>
)}
</div>
<div className="bg-yellow-900/20 border border-yellow-700 rounded-lg p-4">
<p className="text-sm text-yellow-300">
<strong>📋 </strong>
</p>
<ol className="text-sm text-yellow-300 mt-2 space-y-1 ml-4 list-decimal">
<li>使</li>
<li></li>
<li></li>
<li>調</li>
</ol>
</div>
<div className="flex gap-4 mt-8">
<button
onClick={() => setStep('admin')}
className="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleExecute}
className="flex-1 bg-gradient-to-r from-green-600 to-emerald-600 text-white py-3 px-6 rounded-lg font-semibold text-lg hover:from-green-700 hover:to-emerald-700 transition-all transform hover:scale-105 shadow-lg"
>
🚀
</button>
</div>
</div>
)}
{/* Step 4: 執行中 */}
{step === 'executing' && (
<div className="text-center py-12">
<div className="w-20 h-20 border-4 border-green-500 border-t-transparent rounded-full animate-spin mx-auto mb-6"></div>
<h3 className="text-xl font-semibold text-green-300 mb-2"></h3>
<p className="text-gray-400">...</p>
</div>
)}
</div>
</div>
</div>
</div>
)
}

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>
)
}

View File

@@ -0,0 +1,295 @@
/**
* 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>
)
}

View File

@@ -0,0 +1,314 @@
/**
* Phase 2: 資料庫連接設定
*
* 功能:
* 1. 填寫資料庫連接資訊
* 2. 測試資料庫連接
* 3. 將設定寫入 .env 檔案
*/
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
interface DatabaseConfig {
host: string
port: number
database: string
user: string
password: string
}
interface TestResult {
success: boolean
message: string
db_version?: string
connection_info?: string
}
export default function Phase2Database() {
const router = useRouter()
const [config, setConfig] = useState<DatabaseConfig>({
host: '10.1.0.20',
port: 5433,
database: 'hr_portal',
user: '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/database')
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) || 5433,
database: data.config.database || 'hr_portal',
user: data.config.user || 'admin',
password: data.config.password === '****' ? '' : (data.config.password || ''),
})
setAlreadyConfigured(true)
}
} catch (error) {
console.error('[Database] 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-database', {
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-database', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = await response.json()
if (!response.ok) {
alert(`設定失敗: ${data.detail}`)
return
}
alert('資料庫設定成功!')
router.push('/installation/phase3-keycloak')
} 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-blue-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">2 / 7</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div className="bg-blue-600 h-2 rounded-full" style={{ width: '28.6%' }}></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-blue-900/30 rounded-full flex items-center justify-center mr-4 border border-blue-800">
<span className="text-2xl">🗄</span>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-100">Phase 2: 資料庫連接設定</h1>
<p className="text-gray-400"> PostgreSQL </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>PostgreSQL HR Portal
</p>
</div>
{/* Form */}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-blue-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-blue-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-blue-400">*</span>
</label>
<input
type="number"
value={config.port}
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) || 5432 })}
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-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-blue-400">*</span>
</label>
<input
type="text"
value={config.database}
onChange={(e) => setConfig({ ...config, database: 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-blue-500 focus:border-transparent"
placeholder="hr_portal"
/>
</div>
</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-blue-400">*</span>
</label>
<input
type="text"
value={config.user}
onChange={(e) => setConfig({ ...config, user: 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-blue-500 focus:border-transparent"
placeholder="admin"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-blue-400">*</span>
</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-blue-500 focus:border-transparent"
placeholder="資料庫密碼"
/>
</div>
</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.db_version && (
<div className="mt-2 text-sm text-green-400">
<p>: {testResult.db_version}</p>
{testResult.connection_info && <p>: {testResult.connection_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.host || !config.database || !config.user || !config.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-blue-600 text-white py-2 px-6 rounded-lg hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors font-semibold"
>
</button>
</div>
</div>
</div>
</div>
</div>
)
}

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>
)
}