admin area
This commit is contained in:
13
apps/website/app/admin/page.tsx
Normal file
13
apps/website/app/admin/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { AdminLayout } from '@/components/admin/AdminLayout';
|
||||
import { AdminDashboardPage } from '@/components/admin/AdminDashboardPage';
|
||||
import { RouteGuard } from '@/lib/gateways/RouteGuard';
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<RouteGuard config={{ requiredRoles: ['owner', 'admin'] }}>
|
||||
<AdminLayout>
|
||||
<AdminDashboardPage />
|
||||
</AdminLayout>
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
13
apps/website/app/admin/users/page.tsx
Normal file
13
apps/website/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { AdminLayout } from '@/components/admin/AdminLayout';
|
||||
import { AdminUsersPage } from '@/components/admin/AdminUsersPage';
|
||||
import { RouteGuard } from '@/lib/gateways/RouteGuard';
|
||||
|
||||
export default function AdminUsers() {
|
||||
return (
|
||||
<RouteGuard config={{ requiredRoles: ['owner', 'admin'] }}>
|
||||
<AdminLayout>
|
||||
<AdminUsersPage />
|
||||
</AdminLayout>
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
217
apps/website/components/admin/AdminDashboardPage.tsx
Normal file
217
apps/website/components/admin/AdminDashboardPage.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { AdminViewModelService } from '@/lib/services/AdminViewModelService';
|
||||
import { DashboardStatsViewModel } from '@/lib/view-models/AdminUserViewModel';
|
||||
import {
|
||||
Users,
|
||||
Shield,
|
||||
Activity,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
|
||||
export function AdminDashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStatsViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.admin.getDashboardStats();
|
||||
|
||||
// Map DTO to View Model
|
||||
const viewModel = AdminViewModelService.mapDashboardStats(response);
|
||||
setStats(viewModel);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load stats';
|
||||
if (message.includes('403') || message.includes('401')) {
|
||||
setError('Access denied - You must be logged in as an Owner or Admin');
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 space-y-3">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-blue"></div>
|
||||
<div className="text-gray-400">Loading dashboard...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Error</div>
|
||||
<div className="text-sm opacity-90">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadStats}
|
||||
className="px-3 py-1 text-xs bg-racing-red/20 hover:bg-racing-red/30 rounded"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Admin Dashboard</h1>
|
||||
<p className="text-gray-400 mt-1">System overview and statistics</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadStats}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:bg-iron-gray/80 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">Total Users</div>
|
||||
<div className="text-3xl font-bold text-white">{stats.totalUsers}</div>
|
||||
</div>
|
||||
<Users className="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">Admins</div>
|
||||
<div className="text-3xl font-bold text-white">{stats.adminCount}</div>
|
||||
</div>
|
||||
<Shield className="w-8 h-8 text-purple-400" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">Active Users</div>
|
||||
<div className="text-3xl font-bold text-white">{stats.activeUsers}</div>
|
||||
</div>
|
||||
<Activity className="w-8 h-8 text-green-400" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-orange-900/20 to-orange-700/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">Recent Logins</div>
|
||||
<div className="text-3xl font-bold text-white">{stats.recentLogins}</div>
|
||||
</div>
|
||||
<Clock className="w-8 h-8 text-orange-400" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Activity Overview */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Recent Activity</h3>
|
||||
<div className="space-y-3">
|
||||
{stats.recentActivity.length > 0 ? (
|
||||
stats.recentActivity.map((activity, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-iron-gray/30 rounded-lg border border-charcoal-outline/50"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-white">{activity.description}</div>
|
||||
<div className="text-xs text-gray-500">{activity.timestamp}</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
activity.type === 'user_created' ? 'bg-blue-500/20 text-blue-300' :
|
||||
activity.type === 'user_suspended' ? 'bg-yellow-500/20 text-yellow-300' :
|
||||
activity.type === 'user_deleted' ? 'bg-red-500/20 text-red-300' :
|
||||
'bg-gray-500/20 text-gray-300'
|
||||
}`}>
|
||||
{activity.type.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">No recent activity</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* System Status */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">System Status</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">System Health</span>
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-performance-green/20 text-performance-green">
|
||||
{stats.systemHealth}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Total Sessions</span>
|
||||
<span className="text-white font-medium">{stats.totalSessions}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Active Sessions</span>
|
||||
<span className="text-white font-medium">{stats.activeSessions}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Avg Session Duration</span>
|
||||
<span className="text-white font-medium">{stats.avgSessionDuration}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Quick Actions</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<button className="px-4 py-3 bg-primary-blue/20 border border-primary-blue/30 text-primary-blue rounded-lg hover:bg-primary-blue/30 transition-colors text-sm font-medium">
|
||||
View All Users
|
||||
</button>
|
||||
<button className="px-4 py-3 bg-purple-500/20 border border-purple-500/30 text-purple-300 rounded-lg hover:bg-purple-500/30 transition-colors text-sm font-medium">
|
||||
Manage Admins
|
||||
</button>
|
||||
<button className="px-4 py-3 bg-orange-500/20 border border-orange-500/30 text-orange-300 rounded-lg hover:bg-orange-500/30 transition-colors text-sm font-medium">
|
||||
View Audit Log
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
apps/website/components/admin/AdminLayout.tsx
Normal file
185
apps/website/components/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useState } from 'react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Settings,
|
||||
LogOut,
|
||||
Shield,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
type AdminTab = 'dashboard' | 'users';
|
||||
|
||||
export function AdminLayout({ children }: AdminLayoutProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
|
||||
// Determine current tab from pathname
|
||||
const getCurrentTab = (): AdminTab => {
|
||||
if (pathname === '/admin') return 'dashboard';
|
||||
if (pathname === '/admin/users') return 'users';
|
||||
return 'dashboard';
|
||||
};
|
||||
|
||||
const currentTab = getCurrentTab();
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
href: '/admin',
|
||||
description: 'Overview and statistics'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'User Management',
|
||||
icon: Users,
|
||||
href: '/admin/users',
|
||||
description: 'Manage all users'
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
href: '/admin/settings',
|
||||
description: 'System configuration',
|
||||
disabled: true
|
||||
}
|
||||
];
|
||||
|
||||
const handleNavigation = (href: string, disabled?: boolean) => {
|
||||
if (!disabled) {
|
||||
router.push(href);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-deep-graphite overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className={`${isSidebarOpen ? 'w-64' : 'w-20'} bg-iron-gray border-r border-charcoal-outline transition-all duration-300 flex flex-col`}>
|
||||
{/* Logo/Header */}
|
||||
<div className="p-4 border-b border-charcoal-outline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-6 h-6 text-primary-blue" />
|
||||
{isSidebarOpen && (
|
||||
<span className="font-bold text-white text-lg">Admin Panel</span>
|
||||
)}
|
||||
</div>
|
||||
{isSidebarOpen && (
|
||||
<p className="text-xs text-gray-400 mt-1">System Administration</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-2 space-y-1 overflow-y-auto">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentTab === item.id;
|
||||
const isDisabled = item.disabled;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleNavigation(item.href, isDisabled)}
|
||||
disabled={isDisabled}
|
||||
className={`
|
||||
w-full flex items-center gap-3 px-3 py-3 rounded-lg
|
||||
transition-all duration-200 text-left
|
||||
${isActive
|
||||
? 'bg-primary-blue/20 text-primary-blue border border-primary-blue/30'
|
||||
: 'text-gray-300 hover:bg-iron-gray/50 hover:text-white'
|
||||
}
|
||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 flex-shrink-0 ${isActive ? 'text-primary-blue' : 'text-gray-400'}`} />
|
||||
{isSidebarOpen && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{item.label}</div>
|
||||
<div className="text-xs text-gray-500">{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-2 border-t border-charcoal-outline space-y-1">
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-gray-300 hover:bg-iron-gray/50 hover:text-white transition-colors"
|
||||
>
|
||||
<Activity className="w-5 h-5" />
|
||||
{isSidebarOpen && <span className="text-sm">Toggle Sidebar</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-racing-red hover:bg-racing-red/10 transition-colors"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
{isSidebarOpen && <span className="text-sm">Logout</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Top Bar */}
|
||||
<header className="bg-iron-gray border-b border-charcoal-outline px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-primary-blue/20 rounded-lg">
|
||||
<Shield className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{navigation.find(n => n.id === currentTab)?.label || 'Admin'}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-400">
|
||||
{navigation.find(n => n.id === currentTab)?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-deep-graphite rounded-lg border border-charcoal-outline">
|
||||
<Shield className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-xs font-medium text-purple-400">Super Admin</span>
|
||||
</div>
|
||||
|
||||
<div className="text-right hidden sm:block">
|
||||
<div className="text-sm font-medium text-white">System Administrator</div>
|
||||
<div className="text-xs text-gray-400">Full Access</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto bg-deep-graphite">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
341
apps/website/components/admin/AdminUsersPage.tsx
Normal file
341
apps/website/components/admin/AdminUsersPage.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import Card from '@/components/ui/Card';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import { AdminViewModelService } from '@/lib/services/AdminViewModelService';
|
||||
import { AdminUserViewModel, UserListViewModel } from '@/lib/view-models/AdminUserViewModel';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
Users,
|
||||
Shield,
|
||||
Trash2,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const [userList, setUserList] = useState<UserListViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [deletingUser, setDeletingUser] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
loadUsers();
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [search, roleFilter, statusFilter]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.admin.listUsers({
|
||||
search: search || undefined,
|
||||
role: roleFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
// Map DTO to View Model
|
||||
const viewModel = AdminViewModelService.mapUserList(response);
|
||||
setUserList(viewModel);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load users';
|
||||
if (message.includes('403') || message.includes('401')) {
|
||||
setError('Access denied - You must be logged in as an Owner or Admin');
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (userId: string, newStatus: string) => {
|
||||
try {
|
||||
await apiClient.admin.updateUserStatus(userId, newStatus);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update status');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeletingUser(userId);
|
||||
await apiClient.admin.deleteUser(userId);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete user');
|
||||
} finally {
|
||||
setDeletingUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('');
|
||||
setRoleFilter('');
|
||||
setStatusFilter('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">User Management</h1>
|
||||
<p className="text-gray-400 mt-1">Manage and monitor all system users</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadUsers}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:bg-iron-gray/80 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Error</div>
|
||||
<div className="text-sm opacity-90">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-racing-red hover:opacity-70"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters Card */}
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-medium text-white">Filters</span>
|
||||
</div>
|
||||
{(search || roleFilter || statusFilter) && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-xs text-primary-blue hover:text-blue-400"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by email or name..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors"
|
||||
>
|
||||
<option value="">All Roles</option>
|
||||
<option value="owner">Owner</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="deleted">Deleted</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Users Table */}
|
||||
<Card>
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 space-y-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-blue"></div>
|
||||
<div className="text-gray-400">Loading users...</div>
|
||||
</div>
|
||||
) : !userList || !userList.hasUsers ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 space-y-3">
|
||||
<Users className="w-12 h-12 text-gray-600" />
|
||||
<div className="text-gray-400">No users found</div>
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-primary-blue hover:text-blue-400 text-sm"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">User</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Email</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Roles</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Status</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Last Login</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userList.users.map((user: AdminUserViewModel, index: number) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className={`border-b border-charcoal-outline/50 hover:bg-iron-gray/30 transition-colors ${index % 2 === 0 ? 'bg-transparent' : 'bg-iron-gray/10'}`}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-blue/20 flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white">{user.displayName}</div>
|
||||
<div className="text-xs text-gray-500">ID: {user.id}</div>
|
||||
{user.primaryDriverId && (
|
||||
<div className="text-xs text-gray-500">Driver: {user.primaryDriverId}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="text-sm text-gray-300">{user.email}</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roleBadges.map((badge: string, idx: number) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`px-2 py-1 text-xs rounded-full font-medium ${
|
||||
user.roles[idx] === 'owner'
|
||||
? 'bg-purple-500/20 text-purple-300 border border-purple-500/30'
|
||||
: user.roles[idx] === 'admin'
|
||||
? 'bg-blue-500/20 text-blue-300 border border-blue-500/30'
|
||||
: 'bg-gray-500/20 text-gray-300 border border-gray-500/30'
|
||||
}`}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<StatusBadge status={user.statusBadge.label.toLowerCase()} />
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="text-sm text-gray-400">
|
||||
{user.lastLoginFormatted}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.canSuspend && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(user.id, 'suspended')}
|
||||
className="px-3 py-1 text-xs rounded bg-yellow-500/20 text-yellow-300 hover:bg-yellow-500/30 transition-colors"
|
||||
>
|
||||
Suspend
|
||||
</button>
|
||||
)}
|
||||
{user.canActivate && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(user.id, 'active')}
|
||||
className="px-3 py-1 text-xs rounded bg-performance-green/20 text-performance-green hover:bg-performance-green/30 transition-colors"
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
{user.canDelete && (
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user.id)}
|
||||
disabled={deletingUser === user.id}
|
||||
className="px-3 py-1 text-xs rounded bg-racing-red/20 text-racing-red hover:bg-racing-red/30 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{deletingUser === user.id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Stats Summary */}
|
||||
{userList && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">Total Users</div>
|
||||
<div className="text-2xl font-bold text-white">{userList.total}</div>
|
||||
</div>
|
||||
<Users className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">Active</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{userList.users.filter(u => u.status === 'active').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-6 h-6 text-green-400">✓</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">Admins</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{userList.users.filter(u => u.isSystemAdmin).length}
|
||||
</div>
|
||||
</div>
|
||||
<Shield className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { BarChart3, Building2, ChevronDown, CreditCard, Handshake, LogOut, Megaphone, Paintbrush, Settings, TrendingUp, Trophy } from 'lucide-react';
|
||||
import { BarChart3, Building2, ChevronDown, CreditCard, Handshake, LogOut, Megaphone, Paintbrush, Settings, TrendingUp, Trophy, Shield } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -65,6 +65,31 @@ function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } {
|
||||
return demoMode;
|
||||
}
|
||||
|
||||
// Helper to check if user has admin access (Owner or Super Admin)
|
||||
function useHasAdminAccess(): boolean {
|
||||
const { session } = useAuth();
|
||||
const { isDemo, demoRole } = useDemoUserMode();
|
||||
|
||||
// Demo users with system-owner or super-admin roles
|
||||
if (isDemo && (demoRole === 'system-owner' || demoRole === 'super-admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Real users - would need role information from session
|
||||
// For now, we'll check if the user has any admin-related capabilities
|
||||
// This can be enhanced when the API includes role information
|
||||
if (!session?.user) return false;
|
||||
|
||||
// Check for admin-related email patterns as a temporary measure
|
||||
const email = session.user.email?.toLowerCase() || '';
|
||||
const displayName = session.user.displayName?.toLowerCase() || '';
|
||||
|
||||
return email.includes('system-owner') ||
|
||||
email.includes('super-admin') ||
|
||||
displayName.includes('system owner') ||
|
||||
displayName.includes('super admin');
|
||||
}
|
||||
|
||||
// Sponsor Pill Component - matches the style of DriverSummaryPill
|
||||
function SponsorSummaryPill({
|
||||
onClick,
|
||||
@@ -320,6 +345,17 @@ export default function UserPill() {
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="py-1 text-sm text-gray-200">
|
||||
{/* Admin link for system-owner and super-admin demo users */}
|
||||
{(demoRole === 'system-owner' || demoRole === 'super-admin') && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Shield className="h-4 w-4 text-indigo-400" />
|
||||
<span>Admin Area</span>
|
||||
</Link>
|
||||
)}
|
||||
<div className="px-4 py-2 text-xs text-gray-500 italic">
|
||||
Demo users have limited profile access
|
||||
</div>
|
||||
@@ -481,6 +517,8 @@ export default function UserPill() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAdminAccess = useHasAdminAccess();
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center" data-user-pill>
|
||||
<DriverSummaryPill
|
||||
@@ -493,7 +531,7 @@ export default function UserPill() {
|
||||
|
||||
<AnimatePresence>
|
||||
{isMenuOpen && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="absolute right-0 top-full mt-2 w-48 rounded-lg bg-deep-graphite border border-charcoal-outline shadow-lg z-50"
|
||||
initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -501,6 +539,17 @@ export default function UserPill() {
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="py-1 text-sm text-gray-200">
|
||||
{/* Admin link for Owner/Super Admin users */}
|
||||
{hasAdminAccess && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Shield className="h-4 w-4 text-indigo-400" />
|
||||
<span>Admin Area</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/profile"
|
||||
className="block px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
|
||||
|
||||
145
apps/website/lib/api/admin/AdminApiClient.ts
Normal file
145
apps/website/lib/api/admin/AdminApiClient.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { BaseApiClient } from '../base/BaseApiClient';
|
||||
import type { ErrorReporter } from '@/lib/interfaces/ErrorReporter';
|
||||
import type { Logger } from '@/lib/interfaces/Logger';
|
||||
|
||||
export interface UserDto {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
isSystemAdmin: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastLoginAt?: Date;
|
||||
primaryDriverId?: string;
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
users: UserDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ListUsersQuery {
|
||||
role?: string;
|
||||
status?: string;
|
||||
email?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status';
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
suspendedUsers: number;
|
||||
deletedUsers: number;
|
||||
systemAdmins: number;
|
||||
recentLogins: number;
|
||||
newUsersToday: number;
|
||||
userGrowth: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
roleDistribution: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
statusDistribution: {
|
||||
active: number;
|
||||
suspended: number;
|
||||
deleted: number;
|
||||
};
|
||||
activityTimeline: {
|
||||
date: string;
|
||||
newUsers: number;
|
||||
logins: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin API Client
|
||||
*
|
||||
* Provides methods for admin operations like user management.
|
||||
* Only accessible to users with Owner or Super Admin roles.
|
||||
*/
|
||||
export class AdminApiClient extends BaseApiClient {
|
||||
/**
|
||||
* List all users with filtering, sorting, and pagination
|
||||
* Requires Owner or Super Admin role
|
||||
*/
|
||||
async listUsers(query: ListUsersQuery = {}): Promise<UserListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (query.role) params.append('role', query.role);
|
||||
if (query.status) params.append('status', query.status);
|
||||
if (query.email) params.append('email', query.email);
|
||||
if (query.search) params.append('search', query.search);
|
||||
if (query.page) params.append('page', query.page.toString());
|
||||
if (query.limit) params.append('limit', query.limit.toString());
|
||||
if (query.sortBy) params.append('sortBy', query.sortBy);
|
||||
if (query.sortDirection) params.append('sortDirection', query.sortDirection);
|
||||
|
||||
return this.get<UserListResponse>(`/admin/users?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single user by ID
|
||||
* Requires Owner or Super Admin role
|
||||
*/
|
||||
async getUser(userId: string): Promise<UserDto> {
|
||||
return this.get<UserDto>(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user roles
|
||||
* Requires Owner role only
|
||||
*/
|
||||
async updateUserRoles(userId: string, roles: string[]): Promise<UserDto> {
|
||||
return this.patch<UserDto>(`/admin/users/${userId}/roles`, { roles });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user status (activate/suspend/delete)
|
||||
* Requires Owner or Super Admin role
|
||||
*/
|
||||
async updateUserStatus(userId: string, status: string): Promise<UserDto> {
|
||||
return this.patch<UserDto>(`/admin/users/${userId}/status`, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user (soft delete)
|
||||
* Requires Owner or Super Admin role
|
||||
*/
|
||||
async deleteUser(userId: string): Promise<void> {
|
||||
return this.delete(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
* Requires Owner or Super Admin role
|
||||
*/
|
||||
async createUser(userData: {
|
||||
email: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
primaryDriverId?: string;
|
||||
}): Promise<UserDto> {
|
||||
return this.post<UserDto>(`/admin/users`, userData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard statistics
|
||||
* Requires Owner or Super Admin role
|
||||
*/
|
||||
async getDashboardStats(): Promise<DashboardStats> {
|
||||
return this.get<DashboardStats>(`/admin/dashboard/stats`);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { PaymentsApiClient } from './payments/PaymentsApiClient';
|
||||
import { DashboardApiClient } from './dashboard/DashboardApiClient';
|
||||
import { PenaltiesApiClient } from './penalties/PenaltiesApiClient';
|
||||
import { ProtestsApiClient } from './protests/ProtestsApiClient';
|
||||
import { AdminApiClient } from './admin/AdminApiClient';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
|
||||
@@ -31,6 +32,7 @@ export class ApiClient {
|
||||
public readonly dashboard: DashboardApiClient;
|
||||
public readonly penalties: PenaltiesApiClient;
|
||||
public readonly protests: ProtestsApiClient;
|
||||
public readonly admin: AdminApiClient;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
const logger = new ConsoleLogger();
|
||||
@@ -52,6 +54,7 @@ export class ApiClient {
|
||||
this.dashboard = new DashboardApiClient(baseUrl, errorReporter, logger);
|
||||
this.penalties = new PenaltiesApiClient(baseUrl, errorReporter, logger);
|
||||
this.protests = new ProtestsApiClient(baseUrl, errorReporter, logger);
|
||||
this.admin = new AdminApiClient(baseUrl, errorReporter, logger);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useRouter } from 'next/navigation';
|
||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
|
||||
type AuthContextValue = {
|
||||
export type AuthContextValue = {
|
||||
session: SessionViewModel | null;
|
||||
loading: boolean;
|
||||
login: (returnTo?: string) => void;
|
||||
|
||||
173
apps/website/lib/blockers/AuthorizationBlocker.test.ts
Normal file
173
apps/website/lib/blockers/AuthorizationBlocker.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Tests for AuthorizationBlocker
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { AuthorizationBlocker, AuthorizationBlockReason } from './AuthorizationBlocker';
|
||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
|
||||
describe('AuthorizationBlocker', () => {
|
||||
let blocker: AuthorizationBlocker;
|
||||
|
||||
// Mock SessionViewModel
|
||||
const createMockSession = (overrides?: Partial<SessionViewModel>): SessionViewModel => {
|
||||
const base: SessionViewModel = {
|
||||
userId: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
isAuthenticated: true,
|
||||
avatarInitials: 'TU',
|
||||
greeting: 'Hello, Test User!',
|
||||
hasDriverProfile: false,
|
||||
authStatusDisplay: 'Logged In',
|
||||
user: {
|
||||
userId: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
primaryDriverId: null,
|
||||
avatarUrl: null,
|
||||
},
|
||||
};
|
||||
|
||||
return { ...base, ...overrides };
|
||||
};
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create blocker with required roles', () => {
|
||||
blocker = new AuthorizationBlocker(['owner', 'admin']);
|
||||
expect(blocker).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create blocker with empty roles array', () => {
|
||||
blocker = new AuthorizationBlocker([]);
|
||||
expect(blocker).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSession', () => {
|
||||
beforeEach(() => {
|
||||
blocker = new AuthorizationBlocker(['owner']);
|
||||
});
|
||||
|
||||
it('should update session state', () => {
|
||||
const session = createMockSession();
|
||||
blocker.updateSession(session);
|
||||
|
||||
expect(blocker.canExecute()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle null session', () => {
|
||||
blocker.updateSession(null);
|
||||
|
||||
expect(blocker.canExecute()).toBe(false);
|
||||
expect(blocker.getReason()).toBe('loading');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canExecute', () => {
|
||||
beforeEach(() => {
|
||||
blocker = new AuthorizationBlocker(['owner', 'admin']);
|
||||
});
|
||||
|
||||
it('returns false when session is null', () => {
|
||||
blocker.updateSession(null);
|
||||
expect(blocker.canExecute()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when not authenticated', () => {
|
||||
const session = createMockSession({ isAuthenticated: false });
|
||||
blocker.updateSession(session);
|
||||
expect(blocker.canExecute()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when authenticated (temporary workaround)', () => {
|
||||
const session = createMockSession();
|
||||
blocker.updateSession(session);
|
||||
expect(blocker.canExecute()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReason', () => {
|
||||
beforeEach(() => {
|
||||
blocker = new AuthorizationBlocker(['owner']);
|
||||
});
|
||||
|
||||
it('returns loading when session is null', () => {
|
||||
blocker.updateSession(null);
|
||||
expect(blocker.getReason()).toBe('loading');
|
||||
});
|
||||
|
||||
it('returns unauthenticated when not authenticated', () => {
|
||||
const session = createMockSession({ isAuthenticated: false });
|
||||
blocker.updateSession(session);
|
||||
expect(blocker.getReason()).toBe('unauthenticated');
|
||||
});
|
||||
|
||||
it('returns enabled when authenticated (temporary)', () => {
|
||||
const session = createMockSession();
|
||||
blocker.updateSession(session);
|
||||
expect(blocker.getReason()).toBe('enabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('block and release', () => {
|
||||
beforeEach(() => {
|
||||
blocker = new AuthorizationBlocker(['owner']);
|
||||
});
|
||||
|
||||
it('block should set session to null', () => {
|
||||
const session = createMockSession();
|
||||
blocker.updateSession(session);
|
||||
|
||||
expect(blocker.canExecute()).toBe(true);
|
||||
|
||||
blocker.block();
|
||||
|
||||
expect(blocker.canExecute()).toBe(false);
|
||||
expect(blocker.getReason()).toBe('loading');
|
||||
});
|
||||
|
||||
it('release should be no-op', () => {
|
||||
const session = createMockSession();
|
||||
blocker.updateSession(session);
|
||||
|
||||
blocker.release();
|
||||
|
||||
expect(blocker.canExecute()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlockMessage', () => {
|
||||
beforeEach(() => {
|
||||
blocker = new AuthorizationBlocker(['owner']);
|
||||
});
|
||||
|
||||
it('returns correct message for loading', () => {
|
||||
blocker.updateSession(null);
|
||||
expect(blocker.getBlockMessage()).toBe('Loading user data...');
|
||||
});
|
||||
|
||||
it('returns correct message for unauthenticated', () => {
|
||||
const session = createMockSession({ isAuthenticated: false });
|
||||
blocker.updateSession(session);
|
||||
expect(blocker.getBlockMessage()).toBe('You must be logged in to access the admin area.');
|
||||
});
|
||||
|
||||
it('returns correct message for enabled', () => {
|
||||
const session = createMockSession();
|
||||
blocker.updateSession(session);
|
||||
expect(blocker.getBlockMessage()).toBe('Access granted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple required roles', () => {
|
||||
it('should handle multiple roles', () => {
|
||||
blocker = new AuthorizationBlocker(['owner', 'admin', 'super-admin']);
|
||||
|
||||
const session = createMockSession();
|
||||
blocker.updateSession(session);
|
||||
|
||||
expect(blocker.canExecute()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
105
apps/website/lib/blockers/AuthorizationBlocker.ts
Normal file
105
apps/website/lib/blockers/AuthorizationBlocker.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Blocker: AuthorizationBlocker
|
||||
*
|
||||
* Frontend blocker that prevents unauthorized access to admin features.
|
||||
* This is a UX improvement, NOT a security mechanism.
|
||||
* Security is enforced by backend Guards.
|
||||
*/
|
||||
|
||||
import { Blocker } from './Blocker';
|
||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
|
||||
export type AuthorizationBlockReason =
|
||||
| 'loading' // User data not loaded yet
|
||||
| 'unauthenticated' // User not logged in
|
||||
| 'unauthorized' // User logged in but lacks required role
|
||||
| 'insufficient_role' // User has role but not high enough
|
||||
| 'enabled'; // Access granted
|
||||
|
||||
export class AuthorizationBlocker extends Blocker {
|
||||
private currentSession: SessionViewModel | null = null;
|
||||
private requiredRoles: string[] = [];
|
||||
|
||||
constructor(requiredRoles: string[]) {
|
||||
super();
|
||||
this.requiredRoles = requiredRoles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current session state
|
||||
*/
|
||||
updateSession(session: SessionViewModel | null): void {
|
||||
this.currentSession = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can execute (access admin area)
|
||||
*/
|
||||
canExecute(): boolean {
|
||||
return this.getReason() === 'enabled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current block reason
|
||||
*/
|
||||
getReason(): AuthorizationBlockReason {
|
||||
if (!this.currentSession) {
|
||||
return 'loading';
|
||||
}
|
||||
|
||||
if (!this.currentSession.isAuthenticated) {
|
||||
return 'unauthenticated';
|
||||
}
|
||||
|
||||
// Note: SessionViewModel doesn't currently have role property
|
||||
// This is a known architectural gap. For now, we'll check if
|
||||
// the user has admin capabilities through other means
|
||||
|
||||
// In a real implementation, we would need to:
|
||||
// 1. Add role to SessionViewModel
|
||||
// 2. Add role to AuthenticatedUserDTO
|
||||
// 3. Add role to User entity
|
||||
|
||||
// For now, we'll simulate based on email or other indicators
|
||||
// This is a temporary workaround until the backend role system is implemented
|
||||
|
||||
return 'enabled'; // Allow access for demo purposes
|
||||
}
|
||||
|
||||
/**
|
||||
* Block access (for testing/demo purposes)
|
||||
*/
|
||||
block(): void {
|
||||
// Simulate blocking by setting session to null
|
||||
this.currentSession = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the block
|
||||
*/
|
||||
release(): void {
|
||||
// No-op - blocking is state-based, not persistent
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message for block reason
|
||||
*/
|
||||
getBlockMessage(): string {
|
||||
const reason = this.getReason();
|
||||
|
||||
switch (reason) {
|
||||
case 'loading':
|
||||
return 'Loading user data...';
|
||||
case 'unauthenticated':
|
||||
return 'You must be logged in to access the admin area.';
|
||||
case 'unauthorized':
|
||||
return 'You do not have permission to access the admin area.';
|
||||
case 'insufficient_role':
|
||||
return `Admin access requires one of: ${this.requiredRoles.join(', ')}`;
|
||||
case 'enabled':
|
||||
return 'Access granted';
|
||||
default:
|
||||
return 'Access denied';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export { Blocker } from './Blocker';
|
||||
export { CapabilityBlocker } from './CapabilityBlocker';
|
||||
export { SubmitBlocker } from './SubmitBlocker';
|
||||
export { ThrottleBlocker } from './ThrottleBlocker';
|
||||
export { ThrottleBlocker } from './ThrottleBlocker';
|
||||
export { AuthorizationBlocker } from './AuthorizationBlocker';
|
||||
export type { AuthorizationBlockReason } from './AuthorizationBlocker';
|
||||
140
apps/website/lib/gateways/AuthGateway.ts
Normal file
140
apps/website/lib/gateways/AuthGateway.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Gateway: AuthGateway
|
||||
*
|
||||
* Component-based gateway that manages authentication state and access control.
|
||||
* Follows clean architecture by orchestrating between auth context and blockers.
|
||||
*
|
||||
* Gateways are the entry point for component-level access control.
|
||||
* They coordinate between services, blockers, and the UI.
|
||||
*/
|
||||
|
||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
import type { AuthContextValue } from '@/lib/auth/AuthContext';
|
||||
import { AuthorizationBlocker } from '@/lib/blockers/AuthorizationBlocker';
|
||||
|
||||
export interface AuthGatewayConfig {
|
||||
/** Required roles for access (empty array = any authenticated user) */
|
||||
requiredRoles?: string[];
|
||||
/** Whether to redirect if unauthorized */
|
||||
redirectOnUnauthorized?: boolean;
|
||||
/** Redirect path if unauthorized */
|
||||
unauthorizedRedirectPath?: string;
|
||||
}
|
||||
|
||||
export class AuthGateway {
|
||||
private blocker: AuthorizationBlocker;
|
||||
private config: Required<AuthGatewayConfig>;
|
||||
|
||||
constructor(
|
||||
private authContext: AuthContextValue,
|
||||
config: AuthGatewayConfig = {}
|
||||
) {
|
||||
this.config = {
|
||||
requiredRoles: config.requiredRoles || [],
|
||||
redirectOnUnauthorized: config.redirectOnUnauthorized ?? true,
|
||||
unauthorizedRedirectPath: config.unauthorizedRedirectPath || '/auth/login',
|
||||
};
|
||||
|
||||
this.blocker = new AuthorizationBlocker(this.config.requiredRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user has access
|
||||
*/
|
||||
canAccess(): boolean {
|
||||
// Update blocker with current session
|
||||
this.blocker.updateSession(this.authContext.session);
|
||||
|
||||
return this.blocker.canExecute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current access state
|
||||
*/
|
||||
getAccessState(): {
|
||||
canAccess: boolean;
|
||||
reason: string;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
} {
|
||||
const reason = this.blocker.getReason();
|
||||
|
||||
return {
|
||||
canAccess: this.canAccess(),
|
||||
reason: this.blocker.getBlockMessage(),
|
||||
isLoading: reason === 'loading',
|
||||
isAuthenticated: this.authContext.session?.isAuthenticated ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce access control - throws if access denied
|
||||
* Used for programmatic access control
|
||||
*/
|
||||
enforceAccess(): void {
|
||||
if (!this.canAccess()) {
|
||||
const reason = this.blocker.getBlockMessage();
|
||||
throw new Error(`Access denied: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to unauthorized page if needed
|
||||
* Returns true if redirect was performed
|
||||
*/
|
||||
redirectIfUnauthorized(): boolean {
|
||||
if (this.canAccess()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.config.redirectOnUnauthorized) {
|
||||
// Note: We can't use router here since this is a pure class
|
||||
// The component using this gateway should handle the redirect
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redirect path for unauthorized access
|
||||
*/
|
||||
getUnauthorizedRedirectPath(): string {
|
||||
return this.config.unauthorizedRedirectPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the gateway state (e.g., after login/logout)
|
||||
*/
|
||||
refresh(): void {
|
||||
this.blocker.updateSession(this.authContext.session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is loading
|
||||
*/
|
||||
isLoading(): boolean {
|
||||
return this.blocker.getReason() === 'loading';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return this.authContext.session?.isAuthenticated ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session
|
||||
*/
|
||||
getSession(): SessionViewModel | null {
|
||||
return this.authContext.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get block reason for debugging
|
||||
*/
|
||||
getBlockReason(): string {
|
||||
return this.blocker.getReason();
|
||||
}
|
||||
}
|
||||
72
apps/website/lib/gateways/AuthGuard.tsx
Normal file
72
apps/website/lib/gateways/AuthGuard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Component: AuthGuard
|
||||
*
|
||||
* Protects routes that require authentication but not specific roles.
|
||||
* Uses the same Gateway pattern for consistency.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { RouteGuard } from './RouteGuard';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Path to redirect to if not authenticated
|
||||
*/
|
||||
redirectPath?: string;
|
||||
/**
|
||||
* Custom loading component (optional)
|
||||
*/
|
||||
loadingComponent?: ReactNode;
|
||||
/**
|
||||
* Custom unauthorized component (optional)
|
||||
*/
|
||||
unauthorizedComponent?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthGuard Component
|
||||
*
|
||||
* Protects child components requiring authentication.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <AuthGuard>
|
||||
* <ProtectedPage />
|
||||
* </AuthGuard>
|
||||
* ```
|
||||
*/
|
||||
export function AuthGuard({
|
||||
children,
|
||||
redirectPath = '/auth/login',
|
||||
loadingComponent,
|
||||
unauthorizedComponent,
|
||||
}: AuthGuardProps) {
|
||||
return (
|
||||
<RouteGuard
|
||||
config={{
|
||||
requiredRoles: [], // Any authenticated user
|
||||
redirectOnUnauthorized: true,
|
||||
unauthorizedRedirectPath: redirectPath,
|
||||
}}
|
||||
loadingComponent={loadingComponent}
|
||||
unauthorizedComponent={unauthorizedComponent}
|
||||
>
|
||||
{children}
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useAuth Hook
|
||||
*
|
||||
* Simplified hook for checking authentication status.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { isAuthenticated, loading } = useAuth();
|
||||
* ```
|
||||
*/
|
||||
export { useRouteGuard as useAuthAccess } from './RouteGuard';
|
||||
137
apps/website/lib/gateways/RouteGuard.tsx
Normal file
137
apps/website/lib/gateways/RouteGuard.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Component: RouteGuard
|
||||
*
|
||||
* Higher-order component that protects routes using Gateways and Blockers.
|
||||
* Follows clean architecture by separating concerns:
|
||||
* - Gateway handles access logic
|
||||
* - Blocker handles prevention logic
|
||||
* - Component handles UI rendering
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { AuthGateway, AuthGatewayConfig } from './AuthGateway';
|
||||
import { LoadingState } from '@/components/shared/LoadingState';
|
||||
|
||||
interface RouteGuardProps {
|
||||
children: ReactNode;
|
||||
config?: AuthGatewayConfig;
|
||||
/**
|
||||
* Custom loading component (optional)
|
||||
*/
|
||||
loadingComponent?: ReactNode;
|
||||
/**
|
||||
* Custom unauthorized component (optional)
|
||||
*/
|
||||
unauthorizedComponent?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* RouteGuard Component
|
||||
*
|
||||
* Protects child components based on authentication and authorization rules.
|
||||
* Uses Gateway pattern for access control.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <RouteGuard config={{ requiredRoles: ['owner', 'admin'] }}>
|
||||
* <AdminDashboard />
|
||||
* </RouteGuard>
|
||||
* ```
|
||||
*/
|
||||
export function RouteGuard({
|
||||
children,
|
||||
config = {},
|
||||
loadingComponent,
|
||||
unauthorizedComponent,
|
||||
}: RouteGuardProps) {
|
||||
const router = useRouter();
|
||||
const authContext = useAuth();
|
||||
const [gateway] = useState(() => new AuthGateway(authContext, config));
|
||||
const [accessState, setAccessState] = useState(gateway.getAccessState());
|
||||
|
||||
// Update gateway when auth context changes
|
||||
useEffect(() => {
|
||||
gateway.refresh();
|
||||
setAccessState(gateway.getAccessState());
|
||||
}, [authContext.session, authContext.loading, gateway]);
|
||||
|
||||
// Handle redirects
|
||||
useEffect(() => {
|
||||
if (!accessState.canAccess && !accessState.isLoading) {
|
||||
if (config.redirectOnUnauthorized !== false) {
|
||||
const redirectPath = gateway.getUnauthorizedRedirectPath();
|
||||
|
||||
// Use a small delay to show unauthorized message briefly
|
||||
const timer = setTimeout(() => {
|
||||
router.push(redirectPath);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [accessState, gateway, router, config.redirectOnUnauthorized]);
|
||||
|
||||
// Show loading state
|
||||
if (accessState.isLoading) {
|
||||
return loadingComponent || (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<LoadingState message="Loading..." className="min-h-screen" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show unauthorized state
|
||||
if (!accessState.canAccess) {
|
||||
return unauthorizedComponent || (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="bg-iron-gray p-8 rounded-lg border border-charcoal-outline max-w-md text-center">
|
||||
<h2 className="text-xl font-bold text-racing-red mb-4">Access Denied</h2>
|
||||
<p className="text-gray-300 mb-6">{accessState.reason}</p>
|
||||
<button
|
||||
onClick={() => router.push('/auth/login')}
|
||||
className="px-4 py-2 bg-primary-blue text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Go to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render protected content
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* useRouteGuard Hook
|
||||
*
|
||||
* Hook for programmatic access control within components.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { canAccess, reason, isLoading } = useRouteGuard({ requiredRoles: ['admin'] });
|
||||
* ```
|
||||
*/
|
||||
export function useRouteGuard(config: AuthGatewayConfig = {}) {
|
||||
const authContext = useAuth();
|
||||
const [gateway] = useState(() => new AuthGateway(authContext, config));
|
||||
const [state, setState] = useState(gateway.getAccessState());
|
||||
|
||||
useEffect(() => {
|
||||
gateway.refresh();
|
||||
setState(gateway.getAccessState());
|
||||
}, [authContext.session, authContext.loading, gateway]);
|
||||
|
||||
return {
|
||||
canAccess: state.canAccess,
|
||||
reason: state.reason,
|
||||
isLoading: state.isLoading,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
enforceAccess: () => gateway.enforceAccess(),
|
||||
redirectIfUnauthorized: () => gateway.redirectIfUnauthorized(),
|
||||
};
|
||||
}
|
||||
13
apps/website/lib/gateways/index.ts
Normal file
13
apps/website/lib/gateways/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Gateways - Component-based access control
|
||||
*
|
||||
* Follows clean architecture by separating concerns:
|
||||
* - Blockers: Prevent execution (frontend UX)
|
||||
* - Gateways: Orchestrate access control
|
||||
* - Guards: Enforce security (backend)
|
||||
*/
|
||||
|
||||
export { AuthGateway } from './AuthGateway';
|
||||
export type { AuthGatewayConfig } from './AuthGateway';
|
||||
export { RouteGuard, useRouteGuard } from './RouteGuard';
|
||||
export { AuthGuard, useAuthAccess } from './AuthGuard';
|
||||
44
apps/website/lib/services/AdminViewModelService.ts
Normal file
44
apps/website/lib/services/AdminViewModelService.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { UserDto, DashboardStats, UserListResponse } from '@/lib/api/admin/AdminApiClient';
|
||||
import { AdminUserViewModel, DashboardStatsViewModel, UserListViewModel } from '@/lib/view-models/AdminUserViewModel';
|
||||
|
||||
/**
|
||||
* AdminViewModelService
|
||||
*
|
||||
* Service layer responsible for mapping API DTOs to View Models.
|
||||
* This is where the transformation from API data to UI-ready state happens.
|
||||
*/
|
||||
export class AdminViewModelService {
|
||||
/**
|
||||
* Map a single user DTO to a View Model
|
||||
*/
|
||||
static mapUser(dto: UserDto): AdminUserViewModel {
|
||||
return new AdminUserViewModel(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an array of user DTOs to View Models
|
||||
*/
|
||||
static mapUsers(dtos: UserDto[]): AdminUserViewModel[] {
|
||||
return dtos.map(dto => this.mapUser(dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map dashboard stats DTO to View Model
|
||||
*/
|
||||
static mapDashboardStats(dto: DashboardStats): DashboardStatsViewModel {
|
||||
return new DashboardStatsViewModel(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map user list response to View Model
|
||||
*/
|
||||
static mapUserList(response: UserListResponse): UserListViewModel {
|
||||
return new UserListViewModel({
|
||||
users: response.users,
|
||||
total: response.total,
|
||||
page: response.page,
|
||||
limit: response.limit,
|
||||
totalPages: response.totalPages,
|
||||
});
|
||||
}
|
||||
}
|
||||
324
apps/website/lib/view-models/AdminUserViewModel.test.ts
Normal file
324
apps/website/lib/view-models/AdminUserViewModel.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AdminUserViewModel, DashboardStatsViewModel, UserListViewModel } from './AdminUserViewModel';
|
||||
import type { UserDto, DashboardStats } from '@/lib/api/admin/AdminApiClient';
|
||||
|
||||
describe('AdminUserViewModel', () => {
|
||||
const createBaseDto = (): UserDto => ({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
isSystemAdmin: false,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
||||
lastLoginAt: new Date('2024-01-15T10:30:00Z'),
|
||||
primaryDriverId: 'driver-456',
|
||||
});
|
||||
|
||||
it('maps core fields from DTO', () => {
|
||||
const dto = createBaseDto();
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
|
||||
expect(vm.id).toBe('user-123');
|
||||
expect(vm.email).toBe('test@example.com');
|
||||
expect(vm.displayName).toBe('Test User');
|
||||
expect(vm.roles).toEqual(['user']);
|
||||
expect(vm.status).toBe('active');
|
||||
expect(vm.isSystemAdmin).toBe(false);
|
||||
expect(vm.primaryDriverId).toBe('driver-456');
|
||||
});
|
||||
|
||||
it('converts dates to Date objects', () => {
|
||||
const dto = createBaseDto();
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
|
||||
expect(vm.createdAt).toBeInstanceOf(Date);
|
||||
expect(vm.updatedAt).toBeInstanceOf(Date);
|
||||
expect(vm.lastLoginAt).toBeInstanceOf(Date);
|
||||
expect(vm.createdAt.toISOString()).toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('handles missing lastLoginAt', () => {
|
||||
const dto = createBaseDto();
|
||||
delete dto.lastLoginAt;
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
|
||||
expect(vm.lastLoginAt).toBeUndefined();
|
||||
expect(vm.lastLoginFormatted).toBe('Never');
|
||||
});
|
||||
|
||||
it('formats role badges correctly', () => {
|
||||
const owner = new AdminUserViewModel({ ...createBaseDto(), roles: ['owner'] });
|
||||
const admin = new AdminUserViewModel({ ...createBaseDto(), roles: ['admin'] });
|
||||
const user = new AdminUserViewModel({ ...createBaseDto(), roles: ['user'] });
|
||||
const custom = new AdminUserViewModel({ ...createBaseDto(), roles: ['custom-role'] });
|
||||
|
||||
expect(owner.roleBadges).toEqual(['Owner']);
|
||||
expect(admin.roleBadges).toEqual(['Admin']);
|
||||
expect(user.roleBadges).toEqual(['User']);
|
||||
expect(custom.roleBadges).toEqual(['custom-role']);
|
||||
});
|
||||
|
||||
it('derives status badge correctly', () => {
|
||||
const active = new AdminUserViewModel({ ...createBaseDto(), status: 'active' });
|
||||
const suspended = new AdminUserViewModel({ ...createBaseDto(), status: 'suspended' });
|
||||
const deleted = new AdminUserViewModel({ ...createBaseDto(), status: 'deleted' });
|
||||
|
||||
expect(active.statusBadge).toEqual({ label: 'Active', variant: 'performance-green' });
|
||||
expect(suspended.statusBadge).toEqual({ label: 'Suspended', variant: 'yellow-500' });
|
||||
expect(deleted.statusBadge).toEqual({ label: 'Deleted', variant: 'racing-red' });
|
||||
});
|
||||
|
||||
it('formats dates for display', () => {
|
||||
const dto = createBaseDto();
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
|
||||
expect(vm.lastLoginFormatted).toBe('1/15/2024');
|
||||
expect(vm.createdAtFormatted).toBe('1/1/2024');
|
||||
});
|
||||
|
||||
it('derives action permissions correctly', () => {
|
||||
const active = new AdminUserViewModel({ ...createBaseDto(), status: 'active' });
|
||||
const suspended = new AdminUserViewModel({ ...createBaseDto(), status: 'suspended' });
|
||||
const deleted = new AdminUserViewModel({ ...createBaseDto(), status: 'deleted' });
|
||||
|
||||
expect(active.canSuspend).toBe(true);
|
||||
expect(active.canActivate).toBe(false);
|
||||
expect(active.canDelete).toBe(true);
|
||||
|
||||
expect(suspended.canSuspend).toBe(false);
|
||||
expect(suspended.canActivate).toBe(true);
|
||||
expect(suspended.canDelete).toBe(true);
|
||||
|
||||
expect(deleted.canSuspend).toBe(false);
|
||||
expect(deleted.canActivate).toBe(false);
|
||||
expect(deleted.canDelete).toBe(false);
|
||||
});
|
||||
|
||||
it('handles multiple roles', () => {
|
||||
const dto = { ...createBaseDto(), roles: ['owner', 'admin'] };
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
|
||||
expect(vm.roleBadges).toEqual(['Owner', 'Admin']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardStatsViewModel', () => {
|
||||
const createBaseData = (): DashboardStats => ({
|
||||
totalUsers: 100,
|
||||
activeUsers: 70,
|
||||
suspendedUsers: 10,
|
||||
deletedUsers: 20,
|
||||
systemAdmins: 5,
|
||||
recentLogins: 25,
|
||||
newUsersToday: 3,
|
||||
userGrowth: [
|
||||
{ label: 'Mon', value: 5, color: 'text-primary-blue' },
|
||||
{ label: 'Tue', value: 8, color: 'text-primary-blue' },
|
||||
],
|
||||
roleDistribution: [
|
||||
{ label: 'Owner', value: 2, color: 'text-purple-500' },
|
||||
{ label: 'Admin', value: 3, color: 'text-blue-500' },
|
||||
{ label: 'User', value: 95, color: 'text-gray-500' },
|
||||
],
|
||||
statusDistribution: {
|
||||
active: 70,
|
||||
suspended: 10,
|
||||
deleted: 20,
|
||||
},
|
||||
activityTimeline: [
|
||||
{ date: 'Mon', newUsers: 2, logins: 10 },
|
||||
{ date: 'Tue', newUsers: 3, logins: 15 },
|
||||
],
|
||||
});
|
||||
|
||||
it('maps all core fields from data', () => {
|
||||
const data = createBaseData();
|
||||
const vm = new DashboardStatsViewModel(data);
|
||||
|
||||
expect(vm.totalUsers).toBe(100);
|
||||
expect(vm.activeUsers).toBe(70);
|
||||
expect(vm.suspendedUsers).toBe(10);
|
||||
expect(vm.deletedUsers).toBe(20);
|
||||
expect(vm.systemAdmins).toBe(5);
|
||||
expect(vm.recentLogins).toBe(25);
|
||||
expect(vm.newUsersToday).toBe(3);
|
||||
});
|
||||
|
||||
it('computes active rate correctly', () => {
|
||||
const vm = new DashboardStatsViewModel(createBaseData());
|
||||
|
||||
expect(vm.activeRate).toBe(70); // 70%
|
||||
expect(vm.activeRateFormatted).toBe('70%');
|
||||
});
|
||||
|
||||
it('computes admin ratio correctly', () => {
|
||||
const vm = new DashboardStatsViewModel(createBaseData());
|
||||
// 5 admins, 95 non-admins => 1:19
|
||||
expect(vm.adminRatio).toBe('1:19');
|
||||
});
|
||||
|
||||
it('derives activity level correctly', () => {
|
||||
const lowEngagement = new DashboardStatsViewModel({
|
||||
...createBaseData(),
|
||||
totalUsers: 100,
|
||||
recentLogins: 10, // 10% engagement
|
||||
});
|
||||
expect(lowEngagement.activityLevel).toBe('low');
|
||||
|
||||
const mediumEngagement = new DashboardStatsViewModel({
|
||||
...createBaseData(),
|
||||
totalUsers: 100,
|
||||
recentLogins: 35, // 35% engagement
|
||||
});
|
||||
expect(mediumEngagement.activityLevel).toBe('medium');
|
||||
|
||||
const highEngagement = new DashboardStatsViewModel({
|
||||
...createBaseData(),
|
||||
totalUsers: 100,
|
||||
recentLogins: 60, // 60% engagement
|
||||
});
|
||||
expect(highEngagement.activityLevel).toBe('high');
|
||||
});
|
||||
|
||||
it('handles zero users safely', () => {
|
||||
const vm = new DashboardStatsViewModel({
|
||||
...createBaseData(),
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
systemAdmins: 0,
|
||||
recentLogins: 0,
|
||||
});
|
||||
|
||||
expect(vm.activeRate).toBe(0);
|
||||
expect(vm.activeRateFormatted).toBe('0%');
|
||||
expect(vm.adminRatio).toBe('1:1');
|
||||
expect(vm.activityLevel).toBe('low');
|
||||
});
|
||||
|
||||
it('preserves arrays from input', () => {
|
||||
const data = createBaseData();
|
||||
const vm = new DashboardStatsViewModel(data);
|
||||
|
||||
expect(vm.userGrowth).toEqual(data.userGrowth);
|
||||
expect(vm.roleDistribution).toEqual(data.roleDistribution);
|
||||
expect(vm.activityTimeline).toEqual(data.activityTimeline);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserListViewModel', () => {
|
||||
const createDto = (overrides: Partial<UserDto> = {}): UserDto => ({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
isSystemAdmin: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('wraps user DTOs in AdminUserViewModel instances', () => {
|
||||
const data = {
|
||||
users: [createDto({ id: 'user-1' }), createDto({ id: 'user-2' })],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const vm = new UserListViewModel(data);
|
||||
|
||||
expect(vm.users).toHaveLength(2);
|
||||
expect(vm.users[0]).toBeInstanceOf(AdminUserViewModel);
|
||||
expect(vm.users[0].id).toBe('user-1');
|
||||
expect(vm.users[1].id).toBe('user-2');
|
||||
});
|
||||
|
||||
it('exposes pagination metadata', () => {
|
||||
const data = {
|
||||
users: [createDto()],
|
||||
total: 50,
|
||||
page: 2,
|
||||
limit: 10,
|
||||
totalPages: 5,
|
||||
};
|
||||
|
||||
const vm = new UserListViewModel(data);
|
||||
|
||||
expect(vm.total).toBe(50);
|
||||
expect(vm.page).toBe(2);
|
||||
expect(vm.limit).toBe(10);
|
||||
expect(vm.totalPages).toBe(5);
|
||||
});
|
||||
|
||||
it('derives hasUsers correctly', () => {
|
||||
const withUsers = new UserListViewModel({
|
||||
users: [createDto()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
const withoutUsers = new UserListViewModel({
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
expect(withUsers.hasUsers).toBe(true);
|
||||
expect(withoutUsers.hasUsers).toBe(false);
|
||||
});
|
||||
|
||||
it('derives showPagination correctly', () => {
|
||||
const withPagination = new UserListViewModel({
|
||||
users: [createDto()],
|
||||
total: 20,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 2,
|
||||
});
|
||||
|
||||
const withoutPagination = new UserListViewModel({
|
||||
users: [createDto()],
|
||||
total: 5,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
expect(withPagination.showPagination).toBe(true);
|
||||
expect(withoutPagination.showPagination).toBe(false);
|
||||
});
|
||||
|
||||
it('calculates start and end indices correctly', () => {
|
||||
const vm = new UserListViewModel({
|
||||
users: [createDto(), createDto(), createDto()],
|
||||
total: 50,
|
||||
page: 2,
|
||||
limit: 10,
|
||||
totalPages: 5,
|
||||
});
|
||||
|
||||
expect(vm.startIndex).toBe(11); // (2-1) * 10 + 1
|
||||
expect(vm.endIndex).toBe(13); // min(2 * 10, 50)
|
||||
});
|
||||
|
||||
it('handles empty list indices', () => {
|
||||
const vm = new UserListViewModel({
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
expect(vm.startIndex).toBe(0);
|
||||
expect(vm.endIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
220
apps/website/lib/view-models/AdminUserViewModel.ts
Normal file
220
apps/website/lib/view-models/AdminUserViewModel.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import type { UserDto } from '@/lib/api/admin/AdminApiClient';
|
||||
|
||||
/**
|
||||
* AdminUserViewModel
|
||||
*
|
||||
* View Model for admin user management.
|
||||
* Transforms API DTO into UI-ready state with formatting and derived fields.
|
||||
*/
|
||||
export class AdminUserViewModel {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
isSystemAdmin: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastLoginAt?: Date;
|
||||
primaryDriverId?: string;
|
||||
|
||||
// UI-specific derived fields
|
||||
readonly roleBadges: string[];
|
||||
readonly statusBadge: { label: string; variant: string };
|
||||
readonly lastLoginFormatted: string;
|
||||
readonly createdAtFormatted: string;
|
||||
readonly canSuspend: boolean;
|
||||
readonly canActivate: boolean;
|
||||
readonly canDelete: boolean;
|
||||
|
||||
constructor(dto: UserDto) {
|
||||
this.id = dto.id;
|
||||
this.email = dto.email;
|
||||
this.displayName = dto.displayName;
|
||||
this.roles = dto.roles;
|
||||
this.status = dto.status;
|
||||
this.isSystemAdmin = dto.isSystemAdmin;
|
||||
this.createdAt = new Date(dto.createdAt);
|
||||
this.updatedAt = new Date(dto.updatedAt);
|
||||
this.lastLoginAt = dto.lastLoginAt ? new Date(dto.lastLoginAt) : undefined;
|
||||
this.primaryDriverId = dto.primaryDriverId;
|
||||
|
||||
// Derive role badges
|
||||
this.roleBadges = this.roles.map(role => {
|
||||
switch (role) {
|
||||
case 'owner': return 'Owner';
|
||||
case 'admin': return 'Admin';
|
||||
case 'user': return 'User';
|
||||
default: return role;
|
||||
}
|
||||
});
|
||||
|
||||
// Derive status badge
|
||||
this.statusBadge = this.getStatusBadge();
|
||||
|
||||
// Format dates
|
||||
this.lastLoginFormatted = this.lastLoginAt
|
||||
? this.lastLoginAt.toLocaleDateString()
|
||||
: 'Never';
|
||||
this.createdAtFormatted = this.createdAt.toLocaleDateString();
|
||||
|
||||
// Derive action permissions
|
||||
this.canSuspend = this.status === 'active';
|
||||
this.canActivate = this.status === 'suspended';
|
||||
this.canDelete = this.status !== 'deleted';
|
||||
}
|
||||
|
||||
private getStatusBadge(): { label: string; variant: string } {
|
||||
switch (this.status) {
|
||||
case 'active':
|
||||
return { label: 'Active', variant: 'performance-green' };
|
||||
case 'suspended':
|
||||
return { label: 'Suspended', variant: 'yellow-500' };
|
||||
case 'deleted':
|
||||
return { label: 'Deleted', variant: 'racing-red' };
|
||||
default:
|
||||
return { label: this.status, variant: 'gray-500' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardStatsViewModel
|
||||
*
|
||||
* View Model for admin dashboard statistics.
|
||||
* Provides formatted statistics and derived metrics for UI.
|
||||
*/
|
||||
export class DashboardStatsViewModel {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
suspendedUsers: number;
|
||||
deletedUsers: number;
|
||||
systemAdmins: number;
|
||||
recentLogins: number;
|
||||
newUsersToday: number;
|
||||
userGrowth: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
roleDistribution: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
statusDistribution: {
|
||||
active: number;
|
||||
suspended: number;
|
||||
deleted: number;
|
||||
};
|
||||
activityTimeline: {
|
||||
date: string;
|
||||
newUsers: number;
|
||||
logins: number;
|
||||
}[];
|
||||
|
||||
// UI-specific derived fields
|
||||
readonly activeRate: number;
|
||||
readonly activeRateFormatted: string;
|
||||
readonly adminRatio: string;
|
||||
readonly activityLevel: 'low' | 'medium' | 'high';
|
||||
|
||||
constructor(data: {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
suspendedUsers: number;
|
||||
deletedUsers: number;
|
||||
systemAdmins: number;
|
||||
recentLogins: number;
|
||||
newUsersToday: number;
|
||||
userGrowth: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
roleDistribution: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
statusDistribution: {
|
||||
active: number;
|
||||
suspended: number;
|
||||
deleted: number;
|
||||
};
|
||||
activityTimeline: {
|
||||
date: string;
|
||||
newUsers: number;
|
||||
logins: number;
|
||||
}[];
|
||||
}) {
|
||||
this.totalUsers = data.totalUsers;
|
||||
this.activeUsers = data.activeUsers;
|
||||
this.suspendedUsers = data.suspendedUsers;
|
||||
this.deletedUsers = data.deletedUsers;
|
||||
this.systemAdmins = data.systemAdmins;
|
||||
this.recentLogins = data.recentLogins;
|
||||
this.newUsersToday = data.newUsersToday;
|
||||
this.userGrowth = data.userGrowth;
|
||||
this.roleDistribution = data.roleDistribution;
|
||||
this.statusDistribution = data.statusDistribution;
|
||||
this.activityTimeline = data.activityTimeline;
|
||||
|
||||
// Derive active rate
|
||||
this.activeRate = this.totalUsers > 0 ? (this.activeUsers / this.totalUsers) * 100 : 0;
|
||||
this.activeRateFormatted = `${Math.round(this.activeRate)}%`;
|
||||
|
||||
// Derive admin ratio
|
||||
const nonAdmins = Math.max(1, this.totalUsers - this.systemAdmins);
|
||||
this.adminRatio = `1:${Math.floor(nonAdmins / Math.max(1, this.systemAdmins))}`;
|
||||
|
||||
// Derive activity level
|
||||
const engagementRate = this.totalUsers > 0 ? (this.recentLogins / this.totalUsers) * 100 : 0;
|
||||
if (engagementRate < 20) {
|
||||
this.activityLevel = 'low';
|
||||
} else if (engagementRate < 50) {
|
||||
this.activityLevel = 'medium';
|
||||
} else {
|
||||
this.activityLevel = 'high';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UserListViewModel
|
||||
*
|
||||
* View Model for user list with pagination and filtering state.
|
||||
*/
|
||||
export class UserListViewModel {
|
||||
users: AdminUserViewModel[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
|
||||
// UI-specific derived fields
|
||||
readonly hasUsers: boolean;
|
||||
readonly showPagination: boolean;
|
||||
readonly startIndex: number;
|
||||
readonly endIndex: number;
|
||||
|
||||
constructor(data: {
|
||||
users: UserDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}) {
|
||||
this.users = data.users.map(dto => new AdminUserViewModel(dto));
|
||||
this.total = data.total;
|
||||
this.page = data.page;
|
||||
this.limit = data.limit;
|
||||
this.totalPages = data.totalPages;
|
||||
|
||||
// Derive UI state
|
||||
this.hasUsers = this.users.length > 0;
|
||||
this.showPagination = this.totalPages > 1;
|
||||
this.startIndex = this.users.length > 0 ? (this.page - 1) * this.limit + 1 : 0;
|
||||
this.endIndex = this.users.length > 0 ? (this.page - 1) * this.limit + this.users.length : 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user