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>
402 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|