Files
hr-portal/frontend/app/installation/complete/page.tsx
Porsche Chen 360533393f feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

Technical Stack:
Backend:
- FastAPI + SQLAlchemy
- PostgreSQL 16 (10.1.0.20:5433)
- Keycloak Admin API integration
- Docker Mailserver integration (SSH)
- Alembic migrations

Frontend:
- Next.js 14 (App Router)
- NextAuth 4 with Keycloak Provider
- Redis session storage (ioredis)
- Tailwind CSS

Infrastructure:
- Redis 7 (10.1.0.254:6379) - Session + Cache
- Keycloak 26.1.0 (auth.lab.taipei)
- Docker Mailserver (10.1.0.254)

Architecture Highlights:
- Session管理由 Keycloak + Redis 統一控制
- 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session
- Token 自動刷新,異質服務整合
- 未來可無縫遷移到雲端

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 20:12:43 +08:00

803 lines
36 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 完成初始化頁面
*
* 流程:
* 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>
)
}