Features: - Image generation (OpenAI, Gemini, Leonardo, Bria, Stability, Flux) - Nano Banana iterative editing - Video generation and upscaling - Audio TTS, STT, sound effects (ElevenLabs) - Text prompt studio and alt text - User authentication with JWT/cookies - Admin panel with voice management - Job queue with Celery - PostgreSQL + Redis backend - Next.js 15 + FastAPI architecture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { toast } from 'react-hot-toast';
|
|
import { Users, Search, Edit2, Shield, ShieldOff, Trash2 } from 'lucide-react';
|
|
import AdminGuard from '@/components/AdminGuard';
|
|
import api from '@/lib/api';
|
|
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
role: string;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
last_login?: string;
|
|
}
|
|
|
|
export default function UserManagementPage() {
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [roleFilter, setRoleFilter] = useState('');
|
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
|
const [newRole, setNewRole] = useState('');
|
|
|
|
useEffect(() => {
|
|
fetchUsers();
|
|
}, [roleFilter]);
|
|
|
|
const fetchUsers = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params: any = {};
|
|
if (roleFilter) params.role = roleFilter;
|
|
|
|
const response = await api.get('/admin/users', { params });
|
|
setUsers(response.data.items || []);
|
|
} catch (err) {
|
|
// Mock data for demo
|
|
setUsers([
|
|
{
|
|
id: '1',
|
|
email: 'admin@forgeai.dev',
|
|
name: 'Admin User',
|
|
role: 'admin',
|
|
is_active: true,
|
|
created_at: '2024-01-15T10:00:00Z',
|
|
last_login: '2024-12-09T14:30:00Z',
|
|
},
|
|
{
|
|
id: '2',
|
|
email: 'test@forgeai.dev',
|
|
name: 'Test User',
|
|
role: 'user',
|
|
is_active: true,
|
|
created_at: '2024-02-01T10:00:00Z',
|
|
last_login: '2024-12-09T12:00:00Z',
|
|
},
|
|
{
|
|
id: '3',
|
|
email: 'john@example.com',
|
|
name: 'John Doe',
|
|
role: 'user',
|
|
is_active: true,
|
|
created_at: '2024-03-01T10:00:00Z',
|
|
},
|
|
]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUpdateRole = async () => {
|
|
if (!editingUser || !newRole) return;
|
|
|
|
try {
|
|
await api.patch(`/admin/users/${editingUser.id}`, { role: newRole });
|
|
toast.success('User role updated');
|
|
setEditingUser(null);
|
|
fetchUsers();
|
|
} catch (err) {
|
|
toast.error('Failed to update role');
|
|
}
|
|
};
|
|
|
|
const handleToggleActive = async (user: User) => {
|
|
try {
|
|
await api.patch(`/admin/users/${user.id}`, { is_active: !user.is_active });
|
|
toast.success(user.is_active ? 'User deactivated' : 'User activated');
|
|
fetchUsers();
|
|
} catch (err) {
|
|
toast.error('Failed to update user status');
|
|
}
|
|
};
|
|
|
|
const filteredUsers = users.filter(
|
|
(user) =>
|
|
user.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
user.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
|
|
const getRoleBadgeColor = (role: string) => {
|
|
switch (role) {
|
|
case 'super_admin':
|
|
return 'bg-red-900/50 text-red-400';
|
|
case 'admin':
|
|
return 'bg-orange-900/50 text-orange-400';
|
|
default:
|
|
return 'bg-blue-900/50 text-blue-400';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AdminGuard>
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 bg-blue-900/30 rounded-lg flex items-center justify-center">
|
|
<Users className="w-6 h-6 text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">User Management</h1>
|
|
<p className="text-gray-500">Manage users and their roles</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex gap-4">
|
|
<div className="flex-1">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search users..."
|
|
className="input-field pl-10"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<select
|
|
value={roleFilter}
|
|
onChange={(e) => setRoleFilter(e.target.value)}
|
|
className="select-field w-40"
|
|
>
|
|
<option value="">All Roles</option>
|
|
<option value="user">User</option>
|
|
<option value="admin">Admin</option>
|
|
<option value="super_admin">Super Admin</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Users Table */}
|
|
<div className="bg-forge-dark rounded-xl border border-gray-800 overflow-hidden">
|
|
{loading ? (
|
|
<div className="p-8 text-center text-gray-500">Loading...</div>
|
|
) : filteredUsers.length === 0 ? (
|
|
<div className="p-8 text-center text-gray-500">No users found</div>
|
|
) : (
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-gray-800">
|
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500">
|
|
User
|
|
</th>
|
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500">
|
|
Role
|
|
</th>
|
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500">
|
|
Status
|
|
</th>
|
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500">
|
|
Last Login
|
|
</th>
|
|
<th className="text-right px-6 py-4 text-sm font-medium text-gray-500">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredUsers.map((user) => (
|
|
<tr
|
|
key={user.id}
|
|
className="border-b border-gray-800 last:border-0 hover:bg-forge-gray/50"
|
|
>
|
|
<td className="px-6 py-4">
|
|
<div>
|
|
<p className="text-white font-medium">{user.name}</p>
|
|
<p className="text-sm text-gray-500">{user.email}</p>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<span className={`badge ${getRoleBadgeColor(user.role)}`}>
|
|
{user.role.replace('_', ' ')}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<span
|
|
className={`badge ${
|
|
user.is_active
|
|
? 'bg-green-900/50 text-green-400'
|
|
: 'bg-gray-700 text-gray-400'
|
|
}`}
|
|
>
|
|
{user.is_active ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-gray-400 text-sm">
|
|
{user.last_login
|
|
? new Date(user.last_login).toLocaleDateString()
|
|
: 'Never'}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={() => {
|
|
setEditingUser(user);
|
|
setNewRole(user.role);
|
|
}}
|
|
className="p-2 text-gray-400 hover:text-forge-yellow transition-colors"
|
|
title="Edit role"
|
|
>
|
|
<Edit2 className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleToggleActive(user)}
|
|
className={`p-2 transition-colors ${
|
|
user.is_active
|
|
? 'text-gray-400 hover:text-red-400'
|
|
: 'text-gray-400 hover:text-green-400'
|
|
}`}
|
|
title={user.is_active ? 'Deactivate' : 'Activate'}
|
|
>
|
|
{user.is_active ? (
|
|
<ShieldOff className="w-4 h-4" />
|
|
) : (
|
|
<Shield className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{/* Edit Role Modal */}
|
|
{editingUser && (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-forge-dark rounded-xl border border-gray-800 w-full max-w-md">
|
|
<div className="p-6 border-b border-gray-800 flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold text-white">Change User Role</h3>
|
|
<button
|
|
onClick={() => setEditingUser(null)}
|
|
className="text-gray-400 hover:text-white"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
<div className="p-6 space-y-4">
|
|
<div>
|
|
<p className="text-gray-400 text-sm mb-1">User</p>
|
|
<p className="text-white">{editingUser.name}</p>
|
|
<p className="text-sm text-gray-500">{editingUser.email}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
New Role
|
|
</label>
|
|
<select
|
|
value={newRole}
|
|
onChange={(e) => setNewRole(e.target.value)}
|
|
className="select-field"
|
|
>
|
|
<option value="user">User</option>
|
|
<option value="admin">Admin</option>
|
|
<option value="super_admin">Super Admin</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => setEditingUser(null)}
|
|
className="btn-secondary flex-1"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleUpdateRole}
|
|
className="btn-primary flex-1"
|
|
>
|
|
Update Role
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</AdminGuard>
|
|
);
|
|
}
|