mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
Replace the cabinet's hand-written heroicons-style SVG icon components with the Remnawave panel's own icon family — Phosphor via react-icons/pi (Duotone). All icons now live in a single central barrel (src/components/icons: index + extended-icons + editor-icons, 144 icons); every feature file imports from it instead of redefining inline SVGs (~440 icon defs removed). - Add react-icons dependency - Desktop nav (AppShell) now uses the central Phosphor icons, removing a stroke-vs-duotone inconsistency the partial migration introduced - Icons with custom props (SortIcon's direction, expandable chevrons) kept as thin Phosphor wrappers; the RemnawaveIcon brand logo and animated SVGs (checkmarks, spinners) are intentionally left as-is - Restore the original per-call-site icon sizes wherever the centralized default differed (211 call sites)
669 lines
26 KiB
TypeScript
669 lines
26 KiB
TypeScript
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';
|
|
import {
|
|
BackIcon,
|
|
SearchIcon,
|
|
ChevronDownIcon,
|
|
ChevronLeftIcon,
|
|
ChevronRightIcon,
|
|
XCircleIcon,
|
|
UserPlusIcon,
|
|
} from '@/components/icons';
|
|
|
|
// === Constants ===
|
|
|
|
const PAGE_SIZE = 10;
|
|
const SUPERADMIN_LEVEL = 999;
|
|
|
|
// === 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 (
|
|
<div className="flex items-center gap-2 rounded-lg border border-dark-600 bg-dark-900 px-3 py-2">
|
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-xs font-medium text-white">
|
|
{selectedUser.first_name?.[0] || selectedUser.username?.[0] || '?'}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<span className="text-sm text-dark-100">{selectedUser.full_name}</span>
|
|
{selectedUser.username && (
|
|
<span className="ml-1.5 text-xs text-dark-500">@{selectedUser.username}</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClearUser}
|
|
className="shrink-0 text-dark-400 transition-colors hover:text-dark-200"
|
|
aria-label={t('admin.roleAssign.clearUser')}
|
|
>
|
|
<XCircleIcon className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
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"
|
|
/>
|
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
|
<SearchIcon />
|
|
</div>
|
|
</div>
|
|
|
|
{isOpen && searchQuery.length >= 2 && (
|
|
<>
|
|
{/* Backdrop to close dropdown */}
|
|
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} aria-hidden="true" />
|
|
<div className="absolute left-0 right-0 top-full z-20 mt-1 max-h-60 overflow-y-auto rounded-lg border border-dark-600 bg-dark-800 shadow-xl">
|
|
{searching ? (
|
|
<div className="flex items-center justify-center py-4">
|
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
|
</div>
|
|
) : !searchResults || searchResults.users.length === 0 ? (
|
|
<div className="px-3 py-4 text-center text-sm text-dark-400">
|
|
{t('admin.roleAssign.noUsersFound')}
|
|
</div>
|
|
) : (
|
|
searchResults.users.map((user) => (
|
|
<button
|
|
key={user.id}
|
|
type="button"
|
|
onClick={() => handleSelect(user)}
|
|
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-dark-700"
|
|
>
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-xs font-medium text-white">
|
|
{user.first_name?.[0] || user.username?.[0] || '?'}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium text-dark-100">
|
|
{user.full_name}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-dark-400">
|
|
{user.username && <span>@{user.username}</span>}
|
|
<span>ID: {user.telegram_id}</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface RoleBadgeProps {
|
|
name: string;
|
|
color: string | null;
|
|
}
|
|
|
|
function RoleBadge({ name, color }: RoleBadgeProps) {
|
|
return (
|
|
<span
|
|
className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium"
|
|
style={{
|
|
borderColor: color ? `${color}40` : undefined,
|
|
backgroundColor: color ? `${color}20` : undefined,
|
|
color: color || undefined,
|
|
}}
|
|
>
|
|
<span
|
|
className="inline-block h-2 w-2 rounded-full"
|
|
style={{ backgroundColor: color || '#6b7280' }}
|
|
aria-hidden="true"
|
|
/>
|
|
{name}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// === 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<UserListItem | null>(null);
|
|
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
|
|
const [expiresAt, setExpiresAt] = useState('');
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [formSuccess, setFormSuccess] = useState<string | null>(null);
|
|
|
|
// Pagination state
|
|
const [page, setPage] = useState(0);
|
|
|
|
// Revoke confirm
|
|
const [revokeConfirm, setRevokeConfirm] = useState<number | null>(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 (superadmin excluded — env-only)
|
|
const assignableRoles = useMemo(() => {
|
|
if (!roles) return [];
|
|
return roles.filter((r) => r.is_active && r.level < SUPERADMIN_LEVEL && canManageRole(r.level));
|
|
}, [roles, canManageRole]);
|
|
|
|
// Roles map for quick lookup
|
|
const rolesMap = useMemo(() => {
|
|
if (!roles) return new Map<number, AdminRole>();
|
|
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.SyntheticEvent) => {
|
|
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 (
|
|
<div className="animate-fade-in">
|
|
{/* Header */}
|
|
<div className="mb-6 flex items-center gap-3">
|
|
{!capabilities.hasBackButton && (
|
|
<button
|
|
onClick={() => navigate('/admin')}
|
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
|
>
|
|
<BackIcon />
|
|
</button>
|
|
)}
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-dark-100">{t('admin.roleAssign.title')}</h1>
|
|
<p className="text-sm text-dark-400">{t('admin.roleAssign.subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Assign Section */}
|
|
<PermissionGate permission="roles:assign">
|
|
<div className="mb-6 rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-5">
|
|
<h2 className="mb-4 flex items-center gap-2 text-base font-semibold text-dark-100">
|
|
<UserPlusIcon />
|
|
{t('admin.roleAssign.assignSection')}
|
|
</h2>
|
|
|
|
<form onSubmit={handleAssign} className="space-y-4">
|
|
{/* User search */}
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-dark-200">
|
|
{t('admin.roleAssign.userLabel')}
|
|
</label>
|
|
<UserSearchDropdown
|
|
searchQuery={searchQuery}
|
|
onSearchChange={setSearchQuery}
|
|
onSelectUser={handleSelectUser}
|
|
selectedUser={selectedUser}
|
|
onClearUser={handleClearUser}
|
|
/>
|
|
</div>
|
|
|
|
{/* Role dropdown + Expiry in row */}
|
|
<div className="flex flex-col gap-4 sm:flex-row">
|
|
{/* Role dropdown */}
|
|
<div className="flex-1">
|
|
<label
|
|
htmlFor="assign-role"
|
|
className="mb-1 block text-sm font-medium text-dark-200"
|
|
>
|
|
{t('admin.roleAssign.roleLabel')}
|
|
</label>
|
|
<div className="relative">
|
|
<select
|
|
id="assign-role"
|
|
value={selectedRoleId ?? ''}
|
|
onChange={(e) =>
|
|
setSelectedRoleId(e.target.value ? Number(e.target.value) : null)
|
|
}
|
|
className="w-full appearance-none rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 pr-8 text-dark-100 outline-none transition-colors focus:border-accent-500"
|
|
>
|
|
<option value="">{t('admin.roleAssign.selectRole')}</option>
|
|
{assignableRoles.map((role) => (
|
|
<option key={role.id} value={role.id}>
|
|
{role.name} (L{role.level})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<ChevronDownIcon className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-400" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expiry date */}
|
|
<div className="flex-1">
|
|
<label
|
|
htmlFor="assign-expires"
|
|
className="mb-1 block text-sm font-medium text-dark-200"
|
|
>
|
|
{t('admin.roleAssign.expiresLabel')}
|
|
</label>
|
|
<input
|
|
id="assign-expires"
|
|
type="datetime-local"
|
|
value={expiresAt}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<p className="mt-1 text-xs text-dark-500">{t('admin.roleAssign.expiresHint')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error / Success */}
|
|
{formError && <p className="text-sm text-error-400">{formError}</p>}
|
|
{formSuccess && <p className="text-sm text-success-400">{formSuccess}</p>}
|
|
|
|
{/* Submit */}
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
disabled={assignMutation.isPending || !selectedUser || !selectedRoleId}
|
|
className="flex items-center gap-2 rounded-lg bg-accent-500 px-5 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{assignMutation.isPending
|
|
? t('admin.roleAssign.assigning')
|
|
: t('admin.roleAssign.assignButton')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</PermissionGate>
|
|
|
|
{/* Current Assignments Table */}
|
|
<div className="rounded-xl border border-dark-700 bg-dark-800">
|
|
<div className="border-b border-dark-700 px-4 py-3 sm:px-5">
|
|
<h2 className="text-base font-semibold text-dark-100">
|
|
{t('admin.roleAssign.currentAssignments')}
|
|
</h2>
|
|
{sortedAssignments.length > 0 && (
|
|
<p className="text-xs text-dark-400">
|
|
{t('admin.roleAssign.totalAssignments', { count: sortedAssignments.length })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{rolesLoading || assignmentsLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
|
</div>
|
|
) : sortedAssignments.length === 0 ? (
|
|
<div className="py-12 text-center">
|
|
<p className="text-dark-400">{t('admin.roleAssign.noAssignments')}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Desktop table header */}
|
|
<div className="hidden border-b border-dark-700 px-4 py-2.5 sm:grid sm:grid-cols-12 sm:gap-4 sm:px-5">
|
|
<div className="col-span-3 text-xs font-medium uppercase tracking-wider text-dark-500">
|
|
{t('admin.roleAssign.table.user')}
|
|
</div>
|
|
<div className="col-span-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
|
{t('admin.roleAssign.table.role')}
|
|
</div>
|
|
<div className="col-span-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
|
{t('admin.roleAssign.table.assignedBy')}
|
|
</div>
|
|
<div className="col-span-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
|
{t('admin.roleAssign.table.assignedAt')}
|
|
</div>
|
|
<div className="col-span-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
|
{t('admin.roleAssign.table.expires')}
|
|
</div>
|
|
<div className="col-span-1 text-xs font-medium uppercase tracking-wider text-dark-500">
|
|
{t('admin.roleAssign.table.actions')}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rows */}
|
|
<div className="divide-y divide-dark-700/50">
|
|
{paginatedAssignments.map((assignment) => {
|
|
const role = rolesMap.get(assignment.role_id);
|
|
const canRevoke = role ? canManageRole(role.level) : false;
|
|
const expired = isExpired(assignment.expires_at);
|
|
|
|
return (
|
|
<div
|
|
key={assignment.id}
|
|
className={`px-4 py-3 sm:grid sm:grid-cols-12 sm:items-center sm:gap-4 sm:px-5 ${
|
|
expired ? 'opacity-50' : ''
|
|
}`}
|
|
>
|
|
{/* User */}
|
|
<div className="col-span-3 mb-2 flex items-center gap-2.5 sm:mb-0">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-xs font-medium text-white">
|
|
{assignment.user_first_name?.[0] || '?'}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-medium text-dark-100">
|
|
{assignment.user_first_name || t('admin.roleAssign.unknownUser')}
|
|
</div>
|
|
<div className="truncate text-xs text-dark-500">
|
|
{assignment.user_email ||
|
|
(assignment.user_telegram_id
|
|
? `TG: ${assignment.user_telegram_id}`
|
|
: `ID: ${assignment.user_id}`)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Role */}
|
|
<div className="col-span-2 mb-2 sm:mb-0">
|
|
<span className="mr-1.5 text-xs text-dark-500 sm:hidden">
|
|
{t('admin.roleAssign.table.role')}:
|
|
</span>
|
|
<RoleBadge name={assignment.role_name} color={assignment.role_color} />
|
|
</div>
|
|
|
|
{/* Assigned by */}
|
|
<div className="col-span-2 mb-2 text-sm text-dark-400 sm:mb-0">
|
|
<span className="mr-1.5 text-xs text-dark-500 sm:hidden">
|
|
{t('admin.roleAssign.table.assignedBy')}:
|
|
</span>
|
|
{assignment.assigned_by
|
|
? `#${assignment.assigned_by}`
|
|
: t('admin.roleAssign.system')}
|
|
</div>
|
|
|
|
{/* Assigned at */}
|
|
<div className="col-span-2 mb-2 text-sm text-dark-400 sm:mb-0">
|
|
<span className="mr-1.5 text-xs text-dark-500 sm:hidden">
|
|
{t('admin.roleAssign.table.assignedAt')}:
|
|
</span>
|
|
{formatDate(assignment.assigned_at)}
|
|
</div>
|
|
|
|
{/* Expires */}
|
|
<div className="col-span-2 mb-2 sm:mb-0">
|
|
<span className="mr-1.5 text-xs text-dark-500 sm:hidden">
|
|
{t('admin.roleAssign.table.expires')}:
|
|
</span>
|
|
{assignment.expires_at ? (
|
|
<span
|
|
className={`text-sm ${expired ? 'text-error-400' : 'text-warning-400'}`}
|
|
>
|
|
{formatDate(assignment.expires_at)}
|
|
{expired && (
|
|
<span className="ml-1 text-xs">({t('admin.roleAssign.expired')})</span>
|
|
)}
|
|
</span>
|
|
) : (
|
|
<span className="text-sm text-dark-500">
|
|
{t('admin.roleAssign.permanent')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="col-span-1 flex justify-end sm:justify-start">
|
|
{assignment.role_level >= SUPERADMIN_LEVEL ? (
|
|
<span
|
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium text-dark-500"
|
|
title={t('admin.roleAssign.envManaged')}
|
|
>
|
|
ENV
|
|
</span>
|
|
) : (
|
|
<PermissionGate permission="roles:assign">
|
|
<button
|
|
onClick={() => setRevokeConfirm(assignment.id)}
|
|
disabled={!canRevoke}
|
|
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:cursor-not-allowed disabled:opacity-40"
|
|
title={t('admin.roleAssign.revoke')}
|
|
aria-label={t('admin.roleAssign.revokeAriaLabel', {
|
|
user: assignment.user_first_name || assignment.user_id,
|
|
role: assignment.role_name,
|
|
})}
|
|
>
|
|
<XCircleIcon className="h-4 w-4" />
|
|
</button>
|
|
</PermissionGate>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between border-t border-dark-700 px-4 py-3 sm:px-5">
|
|
<div className="text-sm text-dark-400">
|
|
{t('admin.roleAssign.pagination.showing', {
|
|
from: page * PAGE_SIZE + 1,
|
|
to: Math.min((page + 1) * PAGE_SIZE, sortedAssignments.length),
|
|
total: sortedAssignments.length,
|
|
})}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
disabled={page === 0}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
|
aria-label={t('admin.roleAssign.pagination.prev')}
|
|
>
|
|
<ChevronLeftIcon />
|
|
</button>
|
|
<span className="px-3 py-2 text-sm text-dark-300">
|
|
{page + 1} / {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
|
disabled={page >= totalPages - 1}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
|
aria-label={t('admin.roleAssign.pagination.next')}
|
|
>
|
|
<ChevronRightIcon />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Revoke Confirmation */}
|
|
{revokeConfirm !== null && (
|
|
<div className="mt-4 overflow-hidden rounded-xl border border-error-500/30 bg-dark-800">
|
|
<div className="border-b border-error-500/20 bg-error-500/5 px-5 py-4">
|
|
<h3 className="text-sm font-semibold text-error-400">
|
|
{t('admin.roleAssign.confirm.title')}
|
|
</h3>
|
|
<p className="mt-1 text-sm text-dark-400">{t('admin.roleAssign.confirm.text')}</p>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-3 px-5 py-3">
|
|
<button
|
|
onClick={() => setRevokeConfirm(null)}
|
|
className="rounded-lg px-4 py-2 text-sm text-dark-300 transition-colors hover:text-dark-100"
|
|
>
|
|
{t('admin.roleAssign.confirm.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={() => revokeMutation.mutate(revokeConfirm)}
|
|
disabled={revokeMutation.isPending}
|
|
className="rounded-lg bg-error-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
|
>
|
|
{revokeMutation.isPending
|
|
? t('admin.roleAssign.confirm.revoking')
|
|
: t('admin.roleAssign.confirm.revoke')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|