import { useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { rbacApi, type AdminRole, type AssignRolePayload } from '@/api/rbac'; import { adminUsersApi, type UserListItem } from '@/api/adminUsers'; import { PermissionGate } from '@/components/auth/PermissionGate'; import { usePermissionStore } from '@/store/permissions'; import { usePlatform } from '@/platform/hooks/usePlatform'; // === Icons === const BackIcon = () => ( ); const SearchIcon = () => ( ); const ChevronDownIcon = ({ className }: { className?: string }) => ( ); const ChevronLeftIcon = () => ( ); const ChevronRightIcon = () => ( ); const XCircleIcon = () => ( ); const UserPlusIcon = () => ( ); // === Constants === const PAGE_SIZE = 10; // === Sub-components === interface UserSearchDropdownProps { searchQuery: string; onSearchChange: (value: string) => void; onSelectUser: (user: UserListItem) => void; selectedUser: UserListItem | null; onClearUser: () => void; } function UserSearchDropdown({ searchQuery, onSearchChange, onSelectUser, selectedUser, onClearUser, }: UserSearchDropdownProps) { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const { data: searchResults, isLoading: searching } = useQuery({ queryKey: ['admin-user-search', searchQuery], queryFn: () => adminUsersApi.getUsers({ search: searchQuery, limit: 10 }), enabled: searchQuery.length >= 2 && !selectedUser, staleTime: 30_000, }); const handleSelect = useCallback( (user: UserListItem) => { onSelectUser(user); setIsOpen(false); }, [onSelectUser], ); if (selectedUser) { return (
{selectedUser.first_name?.[0] || selectedUser.username?.[0] || '?'}
{selectedUser.full_name} {selectedUser.username && ( @{selectedUser.username} )}
); } return (
{ onSearchChange(e.target.value); setIsOpen(true); }} onFocus={() => { if (searchQuery.length >= 2) setIsOpen(true); }} placeholder={t('admin.roleAssign.searchPlaceholder')} className="w-full rounded-lg border border-dark-600 bg-dark-900 py-2 pl-10 pr-3 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500" />
{isOpen && searchQuery.length >= 2 && ( <> {/* Backdrop to close dropdown */}
setIsOpen(false)} aria-hidden="true" />
{searching ? (
) : !searchResults || searchResults.users.length === 0 ? (
{t('admin.roleAssign.noUsersFound')}
) : ( searchResults.users.map((user) => ( )) )}
)}
); } interface RoleBadgeProps { name: string; color: string | null; } function RoleBadge({ name, color }: RoleBadgeProps) { return ( ); } // === Main Page === export default function AdminRoleAssign() { const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { capabilities } = usePlatform(); const canManageRole = usePermissionStore((s) => s.canManageRole); // Assign form state const [searchQuery, setSearchQuery] = useState(''); const [selectedUser, setSelectedUser] = useState(null); const [selectedRoleId, setSelectedRoleId] = useState(null); const [expiresAt, setExpiresAt] = useState(''); const [formError, setFormError] = useState(null); const [formSuccess, setFormSuccess] = useState(null); // Pagination state const [page, setPage] = useState(0); // Revoke confirm const [revokeConfirm, setRevokeConfirm] = useState(null); // Queries const { data: roles, isLoading: rolesLoading } = useQuery({ queryKey: ['admin-roles'], queryFn: rbacApi.getRoles, }); // Fetch assignments for each role in parallel, then flatten const { data: allAssignments, isLoading: assignmentsLoading } = useQuery({ queryKey: ['admin-role-assignments', roles?.map((r) => r.id)], queryFn: async () => { if (!roles || roles.length === 0) return []; const results = await Promise.all( roles.map((role) => rbacApi.getRoleUsers(role.id).then((assignments) => assignments.map((a) => ({ ...a, role_color: role.color, role_level: role.level, })), ), ), ); return results.flat(); }, enabled: !!roles && roles.length > 0, }); // Available roles filtered by canManageRole const assignableRoles = useMemo(() => { if (!roles) return []; return roles.filter((r) => r.is_active && canManageRole(r.level)); }, [roles, canManageRole]); // Roles map for quick lookup const rolesMap = useMemo(() => { if (!roles) return new Map(); return new Map(roles.map((r) => [r.id, r])); }, [roles]); // Paginated assignments sorted by assigned_at descending const sortedAssignments = useMemo(() => { if (!allAssignments) return []; return [...allAssignments].sort( (a, b) => new Date(b.assigned_at).getTime() - new Date(a.assigned_at).getTime(), ); }, [allAssignments]); const totalPages = Math.ceil(sortedAssignments.length / PAGE_SIZE); const paginatedAssignments = useMemo(() => { const start = page * PAGE_SIZE; return sortedAssignments.slice(start, start + PAGE_SIZE); }, [sortedAssignments, page]); // Mutations const assignMutation = useMutation({ mutationFn: (payload: AssignRolePayload) => rbacApi.assignRole(payload), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-role-assignments'] }); queryClient.invalidateQueries({ queryKey: ['admin-roles'] }); setSelectedUser(null); setSearchQuery(''); setSelectedRoleId(null); setExpiresAt(''); setFormError(null); setFormSuccess(t('admin.roleAssign.assignSuccess')); setTimeout(() => setFormSuccess(null), 3000); }, onError: () => { setFormError(t('admin.roleAssign.errors.assignFailed')); }, }); const revokeMutation = useMutation({ mutationFn: rbacApi.revokeRole, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-role-assignments'] }); queryClient.invalidateQueries({ queryKey: ['admin-roles'] }); setRevokeConfirm(null); }, onError: () => { setRevokeConfirm(null); setFormError(t('admin.roleAssign.errors.revokeFailed')); }, }); // Handlers const handleSelectUser = useCallback((user: UserListItem) => { setSelectedUser(user); setSearchQuery(''); setFormError(null); setFormSuccess(null); }, []); const handleClearUser = useCallback(() => { setSelectedUser(null); setSearchQuery(''); }, []); const handleAssign = useCallback( (e: React.FormEvent) => { e.preventDefault(); setFormError(null); setFormSuccess(null); if (!selectedUser) { setFormError(t('admin.roleAssign.errors.userRequired')); return; } if (!selectedRoleId) { setFormError(t('admin.roleAssign.errors.roleRequired')); return; } const payload: AssignRolePayload = { user_id: selectedUser.id, role_id: selectedRoleId, expires_at: expiresAt || null, }; assignMutation.mutate(payload); }, [selectedUser, selectedRoleId, expiresAt, assignMutation, t], ); const formatDate = useCallback((dateStr: string) => { const date = new Date(dateStr); return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); }, []); const isExpired = useCallback((expiresAtStr: string | null) => { if (!expiresAtStr) return false; return new Date(expiresAtStr).getTime() < Date.now(); }, []); return (
{/* Header */}
{!capabilities.hasBackButton && ( )}

{t('admin.roleAssign.title')}

{t('admin.roleAssign.subtitle')}

{/* Assign Section */}

{t('admin.roleAssign.assignSection')}

{/* User search */}
{/* Role dropdown + Expiry in row */}
{/* Role dropdown */}
{/* Expiry date */}
setExpiresAt(e.target.value)} className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 outline-none transition-colors [color-scheme:dark] focus:border-accent-500" />

{t('admin.roleAssign.expiresHint')}

{/* Error / Success */} {formError &&

{formError}

} {formSuccess &&

{formSuccess}

} {/* Submit */}
{/* Current Assignments Table */}

{t('admin.roleAssign.currentAssignments')}

{sortedAssignments.length > 0 && (

{t('admin.roleAssign.totalAssignments', { count: sortedAssignments.length })}

)}
{rolesLoading || assignmentsLoading ? (
) : sortedAssignments.length === 0 ? (

{t('admin.roleAssign.noAssignments')}

) : ( <> {/* Desktop table header */}
{t('admin.roleAssign.table.user')}
{t('admin.roleAssign.table.role')}
{t('admin.roleAssign.table.assignedBy')}
{t('admin.roleAssign.table.assignedAt')}
{t('admin.roleAssign.table.expires')}
{t('admin.roleAssign.table.actions')}
{/* Rows */}
{paginatedAssignments.map((assignment) => { const role = rolesMap.get(assignment.role_id); const canRevoke = role ? canManageRole(role.level) : false; const expired = isExpired(assignment.expires_at); return (
{/* User */}
{assignment.user_first_name?.[0] || '?'}
{assignment.user_first_name || t('admin.roleAssign.unknownUser')}
{assignment.user_email || (assignment.user_telegram_id ? `TG: ${assignment.user_telegram_id}` : `ID: ${assignment.user_id}`)}
{/* Role */}
{t('admin.roleAssign.table.role')}:
{/* Assigned by */}
{t('admin.roleAssign.table.assignedBy')}: {assignment.assigned_by ? `#${assignment.assigned_by}` : t('admin.roleAssign.system')}
{/* Assigned at */}
{t('admin.roleAssign.table.assignedAt')}: {formatDate(assignment.assigned_at)}
{/* Expires */}
{t('admin.roleAssign.table.expires')}: {assignment.expires_at ? ( {formatDate(assignment.expires_at)} {expired && ( ({t('admin.roleAssign.expired')}) )} ) : ( {t('admin.roleAssign.permanent')} )}
{/* Actions */}
); })}
{/* Pagination */} {totalPages > 1 && (
{t('admin.roleAssign.pagination.showing', { from: page * PAGE_SIZE + 1, to: Math.min((page + 1) * PAGE_SIZE, sortedAssignments.length), total: sortedAssignments.length, })}
{page + 1} / {totalPages}
)} )}
{/* Revoke Confirmation */} {revokeConfirm !== null && (

{t('admin.roleAssign.confirm.title')}

{t('admin.roleAssign.confirm.text')}

)}
); }