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 (
{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 (
{name}
);
}
// === 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')}
{/* 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')}
)}
);
}