File size: 8,890 Bytes
6282d86 b92ea37 6282d86 b8629ec 6282d86 b92ea37 6282d86 b92ea37 6282d86 b92ea37 6282d86 b92ea37 6282d86 b8629ec b92ea37 b8629ec b92ea37 b8629ec 6282d86 b92ea37 6282d86 b92ea37 6282d86 b92ea37 6282d86 b92ea37 6282d86 b92ea37 6282d86 b92ea37 6282d86 b92ea37 6282d86 b8629ec b92ea37 b8629ec 6282d86 b92ea37 6282d86 b92ea37 6282d86 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/lib/auth';
import { api } from '@/lib/api';
import { useToast } from '@/hooks/useToast';
import { Search, KeyRound } from 'lucide-react';
const ROLE_COLORS: Record<string, string> = {
STUDENT: 'bg-slate-700 text-slate-300',
ORG_MEMBER: 'bg-blue-900/60 text-blue-300',
ORG_ADMIN: 'bg-violet-900/60 text-violet-300',
ADMIN: 'bg-amber-900/60 text-amber-300',
SUPER_ADMIN: 'bg-red-900/60 text-red-300',
};
export default function UsersManager() {
const { t } = useTranslation();
const { token } = useAuth();
const toast = useToast();
const [users, setUsers] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const LIMIT = 20;
async function load() {
if (!token) return;
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), limit: String(LIMIT) });
if (search) params.set('search', search);
const data = await api.get(`/v1/super-admin/users?${params}`, token);
setUsers(data.data ?? []);
setTotal(data.total ?? 0);
} catch { toast.error(t('super_admin.err_load_users')); }
finally { setLoading(false); }
}
useEffect(() => { load(); }, [page, token]);
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
setPage(1);
load();
}
async function handleRoleChange(userId: string, role: string) {
try {
await api.patch(`/v1/super-admin/users/${userId}/role`, { role }, token);
toast.success(t('super_admin.role_updated'));
load();
} catch { toast.error(t('super_admin.err_role_change')); }
}
async function handleResetPassword(user: any) {
if (!user.email) { toast.error(t('super_admin.reset_no_email')); return; }
if (!confirm(t('super_admin.reset_confirm', { email: user.email }))) return;
try {
await api.post(`/v1/super-admin/users/${user.id}/reset-password`, {}, token);
toast.success(t('super_admin.reset_sent', { email: user.email }));
} catch { toast.error(t('super_admin.err_reset_password')); }
}
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold text-white">{t('super_admin.users_title')}</h1>
<p className="text-sm text-slate-400 mt-0.5">{t('super_admin.users_total', { count: total })}</p>
</div>
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('super_admin.user_search_placeholder')}
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500"
/>
</div>
<button type="submit" className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition-colors">{t('super_admin.org_search_btn')}</button>
</form>
<div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
{loading ? (
<div className="p-8 text-center text-slate-500 animate-pulse">{t('super_admin.org_loading')}</div>
) : users.length === 0 ? (
<div className="p-8 text-center text-slate-500">{t('super_admin.user_empty')}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-800">
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{t('super_admin.col_user')}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{t('super_admin.col_organization')}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{t('super_admin.col_role')}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{t('super_admin.col_created_at')}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{users.map(user => (
<tr key={user.id} className="hover:bg-slate-800/50 transition-colors">
<td className="px-4 py-3">
<div className="font-medium text-white">{user.name || '—'}</div>
<div className="text-xs text-slate-500">{user.email || user.phone || '—'}</div>
</td>
<td className="px-4 py-3 text-slate-300 text-xs">{user.organization?.name || '—'}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${ROLE_COLORS[user.role] ?? 'bg-slate-700 text-slate-300'}`}>{user.role}</span>
</td>
<td className="px-4 py-3 text-xs text-slate-400">{new Date(user.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<select
defaultValue={user.role}
onChange={e => handleRoleChange(user.id, e.target.value)}
className="text-xs bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-300 focus:outline-none focus:border-violet-500"
>
{['STUDENT', 'ORG_MEMBER', 'ORG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].map(r => <option key={r} value={r}>{r}</option>)}
</select>
<button
onClick={() => handleResetPassword(user)}
title={t('super_admin.btn_reset_password')}
className="p-1.5 text-slate-400 hover:text-violet-300 hover:bg-slate-700 rounded transition-colors"
>
<KeyRound className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{total > LIMIT && (
<div className="flex items-center justify-between text-sm text-slate-400">
<span>{t('super_admin.pagination_info', { from: ((page - 1) * LIMIT) + 1, to: Math.min(page * LIMIT, total), total })}</span>
<div className="flex gap-2">
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">{t('super_admin.prev')}</button>
<button onClick={() => setPage(p => p + 1)} disabled={page * LIMIT >= total} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">{t('super_admin.next')}</button>
</div>
</div>
)}
</div>
);
}
|