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:
137
frontend/components/TenantList.tsx
Normal file
137
frontend/components/TenantList.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { tenantService } from '../services/tenant.service'
|
||||
import type { Tenant } from '../types/tenant'
|
||||
|
||||
export default function TenantList() {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadTenants = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await tenantService.getTenants()
|
||||
setTenants(response.items)
|
||||
} catch (err) {
|
||||
setError('Failed to load tenants')
|
||||
console.error('Error loading tenants:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTenants()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="text-gray-600">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tenants.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<p>No tenants found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusBadgeClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'trial':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'suspended':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'deleted':
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Code
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Initialization
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{tenants.map((tenant) => (
|
||||
<tr key={tenant.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{tenant.code}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{tenant.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusBadgeClass(
|
||||
tenant.status
|
||||
)}`}
|
||||
>
|
||||
{getStatusLabel(tenant.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{tenant.is_initialized ? (
|
||||
<span className="text-green-600">Initialized</span>
|
||||
) : (
|
||||
<span className="text-yellow-600">Not Initialized</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
onClick={() => {
|
||||
// Navigate to tenant details
|
||||
console.log('View details for tenant:', tenant.id)
|
||||
}}
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user