feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage

Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

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

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

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View File

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