Files
hr-portal/frontend/components/TenantList.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

138 lines
4.2 KiB
TypeScript

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