Download apps/admin/src/pages/super-admin/UsersManager.tsx from safetrack/edtech: direct link, hf CLI and curl.
- Browser
- Download file 8.89 kB
-
https://huggingface.co/spaces/safetrack/edtech/resolve/main/apps/admin/src/pages/super-admin/UsersManager.tsx
- Command line
-
hf download hf://spaces/safetrack/edtech/apps/admin/src/pages/super-admin/UsersManager.tsx
-
curl -L -o UsersManager.tsx https://huggingface.co/spaces/safetrack/edtech/resolve/main/apps/admin/src/pages/super-admin/UsersManager.tsx
8.89 kB
| 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> | |
| ); | |
| } | |