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:
802
frontend/app/installation/complete/page.tsx
Normal file
802
frontend/app/installation/complete/page.tsx
Normal 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>員工編號前綴(prefix)用於產生員工工號,例如:PW → 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>
|
||||
)
|
||||
}
|
||||
481
frontend/app/installation/page.tsx
Normal file
481
frontend/app/installation/page.tsx
Normal 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>健康檢查會驗證 Redis、Database、Keycloak 等服務狀態</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>
|
||||
)
|
||||
}
|
||||
295
frontend/app/installation/phase1-redis/page.tsx
Normal file
295
frontend/app/installation/phase1-redis/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
314
frontend/app/installation/phase2-database/page.tsx
Normal file
314
frontend/app/installation/phase2-database/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
297
frontend/app/installation/phase3-keycloak/page.tsx
Normal file
297
frontend/app/installation/phase3-keycloak/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user