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,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>
)
}

View File

@@ -0,0 +1,401 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
type SystemName = 'gitea' | 'portainer' | 'traefik' | 'keycloak'
type AccessLevel = 'admin' | 'user' | 'readonly'
interface Permission {
id: number
employee_id: number
system_name: SystemName
access_level: AccessLevel
granted_at: string
granted_by?: number
granted_by_name?: string
}
interface PermissionsTabProps {
employeeId: number
}
interface SystemInfo {
name: SystemName
display_name: string
description: string
url: string
icon: string
}
const SYSTEM_INFO: Record<SystemName, SystemInfo> = {
gitea: {
name: 'gitea',
display_name: 'Gitea',
description: 'Git 代碼託管平台',
url: 'https://git.lab.taipei',
icon: '📦',
},
portainer: {
name: 'portainer',
display_name: 'Portainer',
description: 'Docker 容器管理平台',
url: 'https://portainer.lab.taipei',
icon: '🐳',
},
traefik: {
name: 'traefik',
display_name: 'Traefik',
description: '反向代理與負載均衡',
url: 'https://traefik.lab.taipei',
icon: '🔀',
},
keycloak: {
name: 'keycloak',
display_name: 'Keycloak',
description: 'SSO 認證服務',
url: 'https://auth.ease.taipei',
icon: '🔐',
},
}
const ACCESS_LEVEL_LABELS: Record<AccessLevel, string> = {
admin: '管理員',
user: '使用者',
readonly: '唯讀',
}
const ACCESS_LEVEL_COLORS: Record<AccessLevel, string> = {
admin: 'bg-red-100 text-red-800',
user: 'bg-blue-100 text-blue-800',
readonly: 'bg-gray-100 text-gray-800',
}
export default function PermissionsTab({ employeeId }: PermissionsTabProps) {
const { data: session } = useSession()
const [permissions, setPermissions] = useState<Permission[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showAddForm, setShowAddForm] = useState(false)
const [newPermission, setNewPermission] = useState({
system_name: 'gitea' as SystemName,
access_level: 'user' as AccessLevel,
})
useEffect(() => {
fetchPermissions()
}, [employeeId])
const fetchPermissions = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/permissions/?employee_id=${employeeId}`
)
if (!response.ok) {
throw new Error('無法載入權限資料')
}
const data = await response.json()
setPermissions(data.items || [])
} catch (err) {
setError(err instanceof Error ? err.message : '載入失敗')
} finally {
setLoading(false)
}
}
const handleGrantPermission = async (e: React.FormEvent) => {
e.preventDefault()
// 檢查是否已有相同系統的權限
const existingPermission = permissions.find(
(p) => p.system_name === newPermission.system_name
)
if (existingPermission) {
if (
!confirm(
`此員工已有 ${SYSTEM_INFO[newPermission.system_name].display_name} 的權限 (${ACCESS_LEVEL_LABELS[existingPermission.access_level]}),是否要更新為 ${ACCESS_LEVEL_LABELS[newPermission.access_level]}?`
)
) {
return
}
}
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/permissions/`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
employee_id: employeeId,
system_name: newPermission.system_name,
access_level: newPermission.access_level,
}),
}
)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.detail || '授予權限失敗')
}
alert('權限授予成功!')
setShowAddForm(false)
setNewPermission({ system_name: 'gitea', access_level: 'user' })
fetchPermissions()
} catch (err) {
alert(err instanceof Error ? err.message : '操作失敗')
}
}
const handleRevokePermission = async (permissionId: number, systemName: SystemName) => {
if (
!confirm(
`確定要撤銷此員工的 ${SYSTEM_INFO[systemName].display_name} 權限嗎?`
)
) {
return
}
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/permissions/${permissionId}`,
{
method: 'DELETE',
}
)
if (!response.ok) {
throw new Error('撤銷權限失敗')
}
alert('權限已撤銷')
fetchPermissions()
} catch (err) {
alert(err instanceof Error ? err.message : '操作失敗')
}
}
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>
)
}
// 計算已授予和未授予的系統
const grantedSystems = new Set(permissions.map((p) => p.system_name))
const availableSystems = Object.keys(SYSTEM_INFO).filter(
(sys) => !grantedSystems.has(sys as SystemName)
) as SystemName[]
return (
<div className="space-y-6">
{/* 標題與新增按鈕 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900"></h3>
{availableSystems.length > 0 && (
<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={handleGrantPermission}
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">
*
</label>
<select
value={newPermission.system_name}
onChange={(e) =>
setNewPermission({
...newPermission,
system_name: e.target.value as SystemName,
})
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
{availableSystems.map((sys) => (
<option key={sys} value={sys}>
{SYSTEM_INFO[sys].icon} {SYSTEM_INFO[sys].display_name} -{' '}
{SYSTEM_INFO[sys].description}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
*
</label>
<select
value={newPermission.access_level}
onChange={(e) =>
setNewPermission({
...newPermission,
access_level: e.target.value as AccessLevel,
})
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="readonly"> ()</option>
<option value="user">使 ()</option>
<option value="admin"> ()</option>
</select>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<p className="text-sm text-yellow-800">
<strong>:</strong>
</p>
<ul className="text-xs text-yellow-700 mt-1 list-disc list-inside">
<li>唯讀: 只能查看資料,</li>
<li>使用者: 可建立</li>
<li>管理員: 完整控制權限,</li>
</ul>
</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>
)}
{/* 權限列表 */}
{permissions.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 md:grid-cols-2 gap-4">
{permissions.map((permission) => {
const systemInfo = SYSTEM_INFO[permission.system_name]
return (
<div
key={permission.id}
className="border border-gray-200 rounded-lg p-4 bg-white hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-2xl">{systemInfo.icon}</span>
<div>
<h4 className="text-lg font-semibold text-gray-900">
{systemInfo.display_name}
</h4>
<p className="text-sm text-gray-600">
{systemInfo.description}
</p>
</div>
</div>
</div>
<div className="mb-3">
<span
className={`px-3 py-1 text-sm font-semibold rounded-full ${
ACCESS_LEVEL_COLORS[permission.access_level]
}`}
>
{ACCESS_LEVEL_LABELS[permission.access_level]}
</span>
</div>
<div className="border-t pt-3 space-y-2">
<div className="text-xs text-gray-600">
<span className="font-medium">:</span>{' '}
{new Date(permission.granted_at).toLocaleString('zh-TW')}
</div>
{permission.granted_by_name && (
<div className="text-xs text-gray-600">
<span className="font-medium">:</span>{' '}
{permission.granted_by_name}
</div>
)}
<div className="flex gap-2 mt-3">
<a
href={systemInfo.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
</a>
<button
onClick={() =>
handleRevokePermission(
permission.id,
permission.system_name
)
}
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
>
</button>
</div>
</div>
</div>
)
})}
</div>
)}
{/* 系統說明 */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-900 mb-2">
🔒
</h4>
<ul className="text-sm text-gray-700 space-y-1">
<li>
Keycloak Groups ,
</li>
<li> ,</li>
<li> ,</li>
<li> </li>
</ul>
</div>
</div>
)
}