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>
|
||||
)
|
||||
}
|
||||
401
frontend/components/employees/permissions-tab.tsx
Normal file
401
frontend/components/employees/permissions-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user