Files
hr-portal/frontend/components/employees/permissions-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

402 lines
13 KiB
TypeScript

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