Frontend — consistent HSL token usage across remaining pages: - Users: shared Card, Badge with success/error tokens, h2 typography, animate-fadeIn - Audit: shared Card, muted-foreground text, animate-fadeIn - Clients: shared Card, Badge active/inactive, hsl(--primary) icon color - Storage: shared Card, StatusBadge for status pills, hsl warning/primary bars replacing hardcoded amber/blue, all gray text → muted-foreground - Login: hsl(--surface) bg, hsl(--primary) submit button, brand mark icon, animate-scaleIn card entry, hsl(--warning) dev notice Backend tests — convert print-only stubs to real assertions: - test_pptx_creator: mkdir, deterministic save path, assert file exists + slide count - test_gemini_schema_support: direct google.genai client, skipif guard on GOOGLE_API_KEY, JSON parse + Pydantic model validation assertions - test_openai_schema_support: clean skip (OpenAI removed in Phase 6) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
150 lines
5.2 KiB
TypeScript
150 lines
5.2 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
import { useDispatch, useSelector } from 'react-redux';
|
|
import { AppDispatch, RootState } from '@/store/store';
|
|
import { fetchUsers, updateUserRole, deactivateUser } from '@/store/slices/adminSlice';
|
|
import RoleBadge from '../components/RoleBadge';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card } from '@/components/shared/Card';
|
|
import { toast } from 'sonner';
|
|
|
|
export default function UsersPage() {
|
|
const dispatch = useDispatch<AppDispatch>();
|
|
const { users, isLoading } = useSelector((state: RootState) => state.admin);
|
|
const currentUser = useSelector((state: RootState) => state.auth.user);
|
|
|
|
useEffect(() => {
|
|
dispatch(fetchUsers());
|
|
}, [dispatch]);
|
|
|
|
const handleRoleChange = async (userId: string, role: string) => {
|
|
try {
|
|
await dispatch(updateUserRole({ userId, role })).unwrap();
|
|
dispatch(fetchUsers());
|
|
toast.success('Role updated');
|
|
} catch (err) {
|
|
toast.error(typeof err === 'string' ? err : 'Failed to update role');
|
|
}
|
|
};
|
|
|
|
const handleDeactivate = async (userId: string) => {
|
|
try {
|
|
await dispatch(deactivateUser(userId)).unwrap();
|
|
toast.success('User deactivated');
|
|
} catch (err) {
|
|
toast.error(typeof err === 'string' ? err : 'Failed to deactivate user');
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-4 animate-fadeIn">
|
|
<h1 className="h2">Users</h1>
|
|
<div className="space-y-3">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div key={i} className="h-12 bg-[hsl(var(--surface-hover))] rounded animate-pulse" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4 animate-fadeIn">
|
|
<h1 className="h2">Users</h1>
|
|
<Card className="p-0 overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Role</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Last Login</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{users.map((user) => (
|
|
<TableRow key={user.id}>
|
|
<TableCell className="font-medium">{user.display_name}</TableCell>
|
|
<TableCell>{user.email}</TableCell>
|
|
<TableCell><RoleBadge role={user.role} /></TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
className={
|
|
user.is_active
|
|
? 'bg-[hsl(var(--success)/0.1)] text-[hsl(var(--success))] border-[hsl(var(--success)/0.2)]'
|
|
: 'bg-[hsl(var(--error)/0.1)] text-[hsl(var(--error))] border-[hsl(var(--error)/0.2)]'
|
|
}
|
|
variant="outline"
|
|
>
|
|
{user.is_active ? 'Active' : 'Inactive'}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{user.last_login_at
|
|
? new Date(user.last_login_at).toLocaleDateString()
|
|
: 'Never'}
|
|
</TableCell>
|
|
<TableCell>
|
|
{user.id !== currentUser?.id && (
|
|
<div className="flex gap-2">
|
|
<Select
|
|
value={user.role}
|
|
onValueChange={(v) => handleRoleChange(user.id, v)}
|
|
>
|
|
<SelectTrigger className="w-[140px] h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="super_admin">Super Admin</SelectItem>
|
|
<SelectItem value="client_admin">Client Admin</SelectItem>
|
|
<SelectItem value="user">User</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{user.is_active && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-[hsl(var(--error))] hover:text-[hsl(var(--error))] hover:border-[hsl(var(--error)/0.4)]"
|
|
onClick={() => handleDeactivate(user.id)}
|
|
>
|
|
Deactivate
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{users.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
|
No users found.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|