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:
397
frontend/components/employees/email-accounts-tab.tsx
Normal file
397
frontend/components/employees/email-accounts-tab.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user