Files
hr-portal/frontend/components/employees/email-accounts-tab.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

398 lines
14 KiB
TypeScript
Raw Permalink 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.
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
interface EmailAccount {
id: number
email_address: string
quota_mb: number
used_mb?: number
forward_to?: string
auto_reply?: string
is_active: boolean
created_at: string
updated_at: string
}
interface EmailAccountsTabProps {
employeeId: number
// Phase 2.4: 員工主要身份資訊
primaryIdentity?: {
email_domain: string
business_unit_name: string
email_quota_mb: number
}
}
export default function EmailAccountsTab({ employeeId, primaryIdentity }: EmailAccountsTabProps) {
const { data: session } = useSession()
const [emailAccounts, setEmailAccounts] = useState<EmailAccount[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showAddForm, setShowAddForm] = useState(false)
// Phase 2.4: 根據主要身份設定預設網域和配額
const defaultDomain = primaryIdentity?.email_domain || 'porscheworld.tw'
const defaultQuota = primaryIdentity?.email_quota_mb || 2048
const [newEmail, setNewEmail] = useState({
emailPrefix: '',
domain: defaultDomain,
quota_mb: defaultQuota,
})
useEffect(() => {
fetchEmailAccounts()
}, [employeeId])
const fetchEmailAccounts = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/email-accounts/?employee_id=${employeeId}`
)
if (!response.ok) {
throw new Error('無法載入郵件帳號')
}
const data = await response.json()
setEmailAccounts(data.items || [])
} catch (err) {
setError(err instanceof Error ? err.message : '載入失敗')
} finally {
setLoading(false)
}
}
const handleCreateEmailAccount = async (e: React.FormEvent) => {
e.preventDefault()
if (!newEmail.emailPrefix.trim()) {
alert('請輸入郵件帳號')
return
}
try {
const emailAddress = `${newEmail.emailPrefix}@${newEmail.domain}`
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/email-accounts/`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
employee_id: employeeId,
email_address: emailAddress,
quota_mb: newEmail.quota_mb,
}),
}
)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.detail || '創建郵件帳號失敗')
}
alert('郵件帳號創建成功!')
setShowAddForm(false)
setNewEmail({ emailPrefix: '', domain: 'porscheworld.tw', quota_mb: 2048 })
fetchEmailAccounts()
} catch (err) {
alert(err instanceof Error ? err.message : '操作失敗')
}
}
const handleToggleActive = async (accountId: number, currentStatus: boolean) => {
if (!confirm(`確定要${currentStatus ? '停用' : '啟用'}此郵件帳號嗎?`)) {
return
}
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/email-accounts/${accountId}`,
{
method: currentStatus ? 'DELETE' : 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: !currentStatus ? JSON.stringify({ is_active: true }) : undefined,
}
)
if (!response.ok) {
throw new Error('操作失敗')
}
alert(`郵件帳號已${currentStatus ? '停用' : '啟用'}`)
fetchEmailAccounts()
} catch (err) {
alert(err instanceof Error ? err.message : '操作失敗')
}
}
const formatQuotaUsage = (used: number, total: number) => {
const percentage = (used / total) * 100
return {
percentage: Math.round(percentage),
usedGB: (used / 1024).toFixed(2),
totalGB: (total / 1024).toFixed(2),
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-gray-600">...</div>
</div>
)
}
if (error) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)
}
return (
<div className="space-y-6">
{/* 標題與新增按鈕 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{showAddForm ? '取消' : '+ 新增郵件帳號'}
</button>
</div>
{/* 新增表單 */}
{showAddForm && (
<form
onSubmit={handleCreateEmailAccount}
className="bg-blue-50 border border-blue-200 rounded-lg p-6"
>
<h4 className="text-md font-semibold text-gray-900 mb-4">
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
*
{primaryIdentity && (
<span className="ml-2 text-xs text-blue-600">
(使 {primaryIdentity.business_unit_name} )
</span>
)}
</label>
<div className="flex gap-2">
<input
type="text"
value={newEmail.emailPrefix}
onChange={(e) =>
setNewEmail({ ...newEmail, emailPrefix: e.target.value })
}
placeholder="例如: john.doe"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
<span className="flex items-center px-3 py-2 bg-gray-100 border border-gray-300 rounded-lg">
@
</span>
<select
value={newEmail.domain}
onChange={(e) =>
setNewEmail({ ...newEmail, domain: e.target.value })
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="porscheworld.tw">porscheworld.tw</option>
<option value="lab.taipei">lab.taipei</option>
<option value="ease.taipei">ease.taipei</option>
</select>
</div>
{primaryIdentity && newEmail.domain !== primaryIdentity.email_domain && (
<p className="mt-1 text-xs text-amber-600">
注意:您選擇的網域與員工所屬事業部不同
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
(MB) *
</label>
<select
value={newEmail.quota_mb}
onChange={(e) =>
setNewEmail({ ...newEmail, quota_mb: parseInt(e.target.value) })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value={1024}>1 GB ()</option>
<option value={2048}>2 GB ()</option>
<option value={5120}>5 GB ()</option>
<option value={10240}>10 GB ()</option>
</select>
</div>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setShowAddForm(false)}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
</button>
</div>
</form>
)}
{/* 郵件帳號列表 */}
{emailAccounts.length === 0 ? (
<div className="bg-gray-50 rounded-lg p-8 text-center text-gray-600">
<p></p>
<p className="text-sm mt-2"></p>
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{emailAccounts.map((account) => {
const quotaInfo = account.used_mb
? formatQuotaUsage(account.used_mb, account.quota_mb)
: null
return (
<div
key={account.id}
className={`border rounded-lg p-4 ${
account.is_active
? 'bg-white border-gray-200'
: 'bg-gray-50 border-gray-300 opacity-60'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
{/* 郵件地址 */}
<div className="flex items-center gap-2 mb-2">
<h4 className="text-lg font-semibold text-gray-900 font-mono">
{account.email_address}
</h4>
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
account.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{account.is_active ? '啟用' : '停用'}
</span>
</div>
{/* 配額資訊 */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-600"></span>
<span className="text-sm font-medium text-gray-900">
{quotaInfo
? `${quotaInfo.usedGB} / ${quotaInfo.totalGB} GB (${quotaInfo.percentage}%)`
: `${(account.quota_mb / 1024).toFixed(2)} GB`}
</span>
</div>
{quotaInfo && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${
quotaInfo.percentage >= 90
? 'bg-red-600'
: quotaInfo.percentage >= 80
? 'bg-yellow-600'
: 'bg-green-600'
}`}
style={{ width: `${quotaInfo.percentage}%` }}
/>
</div>
)}
</div>
{/* 轉寄與自動回覆 */}
{account.forward_to && (
<div className="text-sm text-gray-600 mb-1">
<span className="font-medium">:</span> {account.forward_to}
</div>
)}
{account.auto_reply && (
<div className="text-sm text-gray-600 mb-1">
<span className="font-medium">:</span>
</div>
)}
{/* 時間戳 */}
<div className="text-xs text-gray-500 mt-2">
:{' '}
{new Date(account.created_at).toLocaleString('zh-TW')}
</div>
</div>
{/* 操作按鈕 */}
<div className="flex flex-col gap-2 ml-4">
<button
onClick={() =>
handleToggleActive(account.id, account.is_active)
}
className={`px-3 py-1 text-sm rounded ${
account.is_active
? 'bg-red-100 text-red-700 hover:bg-red-200'
: 'bg-green-100 text-green-700 hover:bg-green-200'
}`}
>
{account.is_active ? '停用' : '啟用'}
</button>
</div>
</div>
</div>
)
})}
</div>
)}
{/* WebMail 連結提示 */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-blue-900 mb-2">
📧 WebMail
</h4>
<p className="text-sm text-blue-800">
使 Keycloak SSO {' '}
<a
href="https://mail.ease.taipei"
target="_blank"
rel="noopener noreferrer"
className="underline font-semibold"
>
WebMail
</a>
,
</p>
<p className="text-xs text-blue-700 mt-1">
注意:員工無法自行新增郵件帳號, HR
</p>
</div>
</div>
)
}