feat: add RBAC permission system to admin cabinet frontend

- Permission store (Zustand) with wildcard matching and role level checks
- PermissionRoute guard for route-level access control
- PermissionGate component for element-level visibility
- Admin Roles page: CRUD, permission editor, role assignment
- Admin Policies page: ABAC policy management (time/IP conditions)
- Admin Audit Log page: filterable log viewer with CSV export
- AdminPanel sidebar navigation gated by permissions
- 4 locale files updated (en, ru, zh, fa) with RBAC translations
- API client module for all RBAC endpoints
This commit is contained in:
Fringg
2026-02-25 03:02:52 +03:00
parent 48be067d1b
commit 874ee2682e
15 changed files with 5320 additions and 121 deletions

823
src/pages/AdminAuditLog.tsx Normal file
View File

@@ -0,0 +1,823 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { rbacApi, AuditLogEntry, AuditLogFilters } from '@/api/rbac';
import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePlatform } from '@/platform/hooks/usePlatform';
// === Icons ===
const BackIcon = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const DownloadIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
const FilterIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
/>
</svg>
);
const ChevronDownIcon = ({ className }: { className?: string }) => (
<svg
className={className || 'h-4 w-4'}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const RefreshIcon = ({ className }: { className?: string }) => (
<svg
className={className || 'h-4 w-4'}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182"
/>
</svg>
);
const SearchIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
// === Constants ===
const RESOURCE_TYPES = [
'users',
'tickets',
'roles',
'policies',
'settings',
'broadcasts',
'tariffs',
'servers',
'payments',
'campaigns',
'promocodes',
] as const;
const STATUS_OPTIONS = ['success', 'denied', 'error'] as const;
const PAGE_SIZE_OPTIONS = [20, 50, 100] as const;
const AUTO_REFRESH_INTERVAL = 30_000;
interface FiltersState {
action: string;
resource: string;
status: string;
dateFrom: string;
dateTo: string;
}
const INITIAL_FILTERS: FiltersState = {
action: '',
resource: '',
status: '',
dateFrom: '',
dateTo: '',
};
// === Utility functions ===
function getStatusFromEntry(entry: AuditLogEntry): string {
const details = entry.details;
if (typeof details.status === 'string') return details.status;
if (typeof details.error === 'string') return 'error';
if (details.denied === true) return 'denied';
return 'success';
}
function getMethodFromEntry(entry: AuditLogEntry): string | null {
const details = entry.details;
if (typeof details.method === 'string') return details.method.toUpperCase();
return null;
}
function getRequestPathFromEntry(entry: AuditLogEntry): string | null {
const details = entry.details;
if (typeof details.path === 'string') return details.path;
if (typeof details.request_path === 'string') return details.request_path;
return null;
}
function formatRelativeTime(
dateString: string,
t: (key: string, opts?: Record<string, unknown>) => string,
): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return t('admin.auditLog.time.justNow');
if (diffMin < 60) return t('admin.auditLog.time.minutesAgo', { count: diffMin });
if (diffHour < 24) return t('admin.auditLog.time.hoursAgo', { count: diffHour });
if (diffDay < 30) return t('admin.auditLog.time.daysAgo', { count: diffDay });
return date.toLocaleDateString();
}
function formatAbsoluteTime(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleString();
}
// === Sub-components ===
interface StatusBadgeProps {
status: string;
label: string;
}
function StatusBadge({ status, label }: StatusBadgeProps) {
const colorMap: Record<string, string> = {
success: 'bg-green-500/20 text-green-400',
denied: 'bg-red-500/20 text-red-400',
error: 'bg-amber-500/20 text-amber-400',
};
return (
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${colorMap[status] || 'bg-dark-600 text-dark-300'}`}
>
{label}
</span>
);
}
interface MethodBadgeProps {
method: string;
}
function MethodBadge({ method }: MethodBadgeProps) {
const colorMap: Record<string, string> = {
GET: 'bg-blue-500/20 text-blue-400',
POST: 'bg-green-500/20 text-green-400',
PUT: 'bg-amber-500/20 text-amber-400',
PATCH: 'bg-amber-500/20 text-amber-400',
DELETE: 'bg-red-500/20 text-red-400',
};
return (
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 font-mono text-xs font-medium ${colorMap[method] || 'bg-dark-600 text-dark-300'}`}
>
{method}
</span>
);
}
interface LogEntryCardProps {
entry: AuditLogEntry;
isExpanded: boolean;
onToggle: () => void;
}
function LogEntryCard({ entry, isExpanded, onToggle }: LogEntryCardProps) {
const { t } = useTranslation();
const status = getStatusFromEntry(entry);
const method = getMethodFromEntry(entry);
const requestPath = getRequestPathFromEntry(entry);
const userName = entry.user_first_name || entry.user_email || t('admin.auditLog.unknownUser');
return (
<div className="rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600">
{/* Main row */}
<button
type="button"
onClick={onToggle}
className="flex w-full flex-col gap-2 p-4 text-left sm:flex-row sm:items-center sm:gap-4"
aria-expanded={isExpanded}
>
{/* Timestamp */}
<div
className="shrink-0 text-sm text-dark-400"
title={formatAbsoluteTime(entry.created_at)}
>
{formatRelativeTime(entry.created_at, t)}
</div>
{/* User */}
<div className="min-w-0 shrink-0">
<span className="text-sm font-medium text-dark-200">{userName}</span>
</div>
{/* Action + status */}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<StatusBadge
status={status}
label={t(`admin.auditLog.status.${status}`, { defaultValue: status })}
/>
<span className="truncate text-sm font-medium text-dark-100">{entry.action}</span>
</div>
{/* Resource */}
<div className="flex shrink-0 items-center gap-2 text-sm text-dark-400">
<span>{entry.resource}</span>
{entry.resource_id && (
<span className="rounded bg-dark-700 px-1.5 py-0.5 font-mono text-xs text-dark-300">
#{entry.resource_id}
</span>
)}
</div>
{/* Method badge */}
{method && (
<div className="shrink-0">
<MethodBadge method={method} />
</div>
)}
{/* IP */}
{entry.ip_address && (
<div className="shrink-0 font-mono text-xs text-dark-500">{entry.ip_address}</div>
)}
{/* Expand indicator */}
<ChevronDownIcon
className={`h-4 w-4 shrink-0 text-dark-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
</button>
{/* Expanded details */}
{isExpanded && (
<div className="border-t border-dark-700 p-4">
<div className="grid gap-4 sm:grid-cols-2">
{/* User agent */}
{entry.user_agent && (
<div>
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.userAgent')}
</p>
<p className="break-all text-sm text-dark-300">{entry.user_agent}</p>
</div>
)}
{/* Request path */}
{requestPath && (
<div>
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.requestPath')}
</p>
<p className="break-all font-mono text-sm text-dark-300">{requestPath}</p>
</div>
)}
{/* IP Address */}
{entry.ip_address && (
<div>
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.ipAddress')}
</p>
<p className="font-mono text-sm text-dark-300">{entry.ip_address}</p>
</div>
)}
{/* Timestamp */}
<div>
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.timestamp')}
</p>
<p className="text-sm text-dark-300">{formatAbsoluteTime(entry.created_at)}</p>
</div>
{/* Before/after diff */}
{'before' in entry.details && entry.details.before != null && (
<div>
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.before')}
</p>
<pre className="max-h-40 overflow-auto rounded-lg bg-dark-900 p-2 text-xs text-dark-300">
{JSON.stringify(entry.details.before, null, 2)}
</pre>
</div>
)}
{'after' in entry.details && entry.details.after != null && (
<div>
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.after')}
</p>
<pre className="max-h-40 overflow-auto rounded-lg bg-dark-900 p-2 text-xs text-dark-300">
{JSON.stringify(entry.details.after, null, 2)}
</pre>
</div>
)}
</div>
{/* Full details JSON */}
<div className="mt-4">
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.fullDetails')}
</p>
<pre className="max-h-60 overflow-auto rounded-lg bg-dark-900 p-3 text-xs text-dark-300">
{JSON.stringify(entry.details, null, 2)}
</pre>
</div>
</div>
)}
</div>
);
}
// === Main Page ===
export default function AdminAuditLog() {
const { t } = useTranslation();
const navigate = useNavigate();
const { capabilities } = usePlatform();
// Filter state
const [filters, setFilters] = useState<FiltersState>(INITIAL_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<FiltersState>(INITIAL_FILTERS);
const [filtersOpen, setFiltersOpen] = useState(false);
// Pagination
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState<number>(PAGE_SIZE_OPTIONS[0]);
// UI state
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [autoRefresh, setAutoRefresh] = useState(false);
const [exporting, setExporting] = useState(false);
// Auto-refresh interval ref
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Build query params
const queryParams = useMemo((): AuditLogFilters => {
const params: AuditLogFilters = {
limit: pageSize,
offset: page * pageSize,
};
if (appliedFilters.action.trim()) {
params.action = appliedFilters.action.trim();
}
if (appliedFilters.resource) {
params.resource = appliedFilters.resource;
}
if (appliedFilters.dateFrom) {
params.date_from = appliedFilters.dateFrom;
}
if (appliedFilters.dateTo) {
params.date_to = appliedFilters.dateTo;
}
return params;
}, [appliedFilters, page, pageSize]);
// Main query
const { data, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['admin-audit-log', queryParams],
queryFn: () => rbacApi.getAuditLog(queryParams),
refetchInterval: autoRefresh ? AUTO_REFRESH_INTERVAL : false,
});
// Auto-refresh visual indicator
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(() => {
// The visual indicator updates are driven by isFetching from react-query
}, AUTO_REFRESH_INTERVAL);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [autoRefresh]);
const entries = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
// Handlers
const handleApplyFilters = useCallback(() => {
setAppliedFilters({ ...filters });
setPage(0);
setExpandedIds(new Set());
}, [filters]);
const handleClearFilters = useCallback(() => {
setFilters(INITIAL_FILTERS);
setAppliedFilters(INITIAL_FILTERS);
setPage(0);
setExpandedIds(new Set());
}, []);
const handleToggleExpand = useCallback((id: number) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const handleExport = useCallback(async () => {
setExporting(true);
try {
const exportParams: AuditLogFilters = {};
if (appliedFilters.action.trim()) exportParams.action = appliedFilters.action.trim();
if (appliedFilters.resource) exportParams.resource = appliedFilters.resource;
if (appliedFilters.dateFrom) exportParams.date_from = appliedFilters.dateFrom;
if (appliedFilters.dateTo) exportParams.date_to = appliedFilters.dateTo;
const blob = await rbacApi.exportAuditLog(exportParams);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} finally {
setExporting(false);
}
}, [appliedFilters]);
const handlePageSizeChange = useCallback((newSize: number) => {
setPageSize(newSize);
setPage(0);
}, []);
// Filter status entries client-side (status is derived from details, not a backend field)
const filteredEntries = useMemo(() => {
if (!appliedFilters.status) return entries;
return entries.filter((entry) => getStatusFromEntry(entry) === appliedFilters.status);
}, [entries, appliedFilters.status]);
const hasActiveFilters = useMemo(() => {
return (
appliedFilters.action.trim() !== '' ||
appliedFilters.resource !== '' ||
appliedFilters.status !== '' ||
appliedFilters.dateFrom !== '' ||
appliedFilters.dateTo !== ''
);
}, [appliedFilters]);
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="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"
aria-label={t('admin.auditLog.back')}
>
<BackIcon />
</button>
)}
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.auditLog.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.auditLog.subtitle')}</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Auto-refresh toggle */}
<button
onClick={() => setAutoRefresh((prev) => !prev)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm transition-colors ${
autoRefresh
? 'border-accent-500/50 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-400 hover:border-dark-600 hover:text-dark-300'
}`}
title={t('admin.auditLog.autoRefresh.tooltip')}
>
<RefreshIcon className={`h-4 w-4 ${isFetching && autoRefresh ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">{t('admin.auditLog.autoRefresh.label')}</span>
</button>
{/* Manual refresh */}
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-dark-700 bg-dark-800 text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-300 disabled:opacity-50"
title={t('admin.auditLog.refresh')}
>
<RefreshIcon className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
</button>
{/* Export */}
<PermissionGate permission="audit_log:export">
<button
onClick={handleExport}
disabled={exporting}
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
<DownloadIcon />
<span className="hidden sm:inline">
{exporting ? t('admin.auditLog.exporting') : t('admin.auditLog.exportCsv')}
</span>
</button>
</PermissionGate>
</div>
</div>
{/* Filters bar */}
<div className="mb-4 rounded-xl border border-dark-700 bg-dark-800">
<button
type="button"
onClick={() => setFiltersOpen((prev) => !prev)}
className="flex w-full items-center justify-between p-4"
>
<div className="flex items-center gap-2">
<FilterIcon />
<span className="text-sm font-medium text-dark-200">
{t('admin.auditLog.filters.title')}
</span>
{hasActiveFilters && (
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs font-medium text-accent-400">
{t('admin.auditLog.filters.active')}
</span>
)}
</div>
<ChevronDownIcon
className={`h-5 w-5 text-dark-400 transition-transform ${filtersOpen ? 'rotate-180' : ''}`}
/>
</button>
{filtersOpen && (
<div className="border-t border-dark-700 p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* Action search */}
<div>
<label
htmlFor="filter-action"
className="mb-1 block text-sm font-medium text-dark-300"
>
{t('admin.auditLog.filters.action')}
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
<SearchIcon />
</span>
<input
id="filter-action"
type="text"
value={filters.action}
onChange={(e) => setFilters((prev) => ({ ...prev, action: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 py-2 pl-9 pr-3 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500"
placeholder={t('admin.auditLog.filters.actionPlaceholder')}
/>
</div>
</div>
{/* Resource type */}
<div>
<label
htmlFor="filter-resource"
className="mb-1 block text-sm font-medium text-dark-300"
>
{t('admin.auditLog.filters.resource')}
</label>
<select
id="filter-resource"
value={filters.resource}
onChange={(e) => setFilters((prev) => ({ ...prev, resource: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500"
>
<option value="">{t('admin.auditLog.filters.allResources')}</option>
{RESOURCE_TYPES.map((type) => (
<option key={type} value={type}>
{t(`admin.auditLog.resourceTypes.${type}`, { defaultValue: type })}
</option>
))}
</select>
</div>
{/* Status */}
<div>
<label
htmlFor="filter-status"
className="mb-1 block text-sm font-medium text-dark-300"
>
{t('admin.auditLog.filters.status')}
</label>
<select
id="filter-status"
value={filters.status}
onChange={(e) => setFilters((prev) => ({ ...prev, status: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500"
>
<option value="">{t('admin.auditLog.filters.allStatuses')}</option>
{STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{t(`admin.auditLog.status.${status}`)}
</option>
))}
</select>
</div>
{/* Date from */}
<div>
<label
htmlFor="filter-date-from"
className="mb-1 block text-sm font-medium text-dark-300"
>
{t('admin.auditLog.filters.dateFrom')}
</label>
<input
id="filter-date-from"
type="date"
value={filters.dateFrom}
onChange={(e) => setFilters((prev) => ({ ...prev, dateFrom: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500"
/>
</div>
{/* Date to */}
<div>
<label
htmlFor="filter-date-to"
className="mb-1 block text-sm font-medium text-dark-300"
>
{t('admin.auditLog.filters.dateTo')}
</label>
<input
id="filter-date-to"
type="date"
value={filters.dateTo}
onChange={(e) => setFilters((prev) => ({ ...prev, dateTo: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500"
/>
</div>
</div>
{/* Filter actions */}
<div className="mt-4 flex items-center gap-3">
<button
onClick={handleApplyFilters}
className="rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600"
>
{t('admin.auditLog.filters.apply')}
</button>
<button
onClick={handleClearFilters}
className="rounded-lg border border-dark-600 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:border-dark-500 hover:text-dark-200"
>
{t('admin.auditLog.filters.clear')}
</button>
</div>
</div>
)}
</div>
{/* Results summary */}
{!isLoading && !error && (
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-dark-400">
{t('admin.auditLog.totalEntries', { count: total })}
</p>
<div className="flex items-center gap-2">
<label htmlFor="page-size" className="text-sm text-dark-500">
{t('admin.auditLog.pagination.pageSize')}
</label>
<select
id="page-size"
value={pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200 outline-none focus:border-accent-500"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
</div>
)}
{/* Content */}
{isLoading ? (
<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>
) : error ? (
<div className="py-12 text-center">
<p className="text-error-400">{t('admin.auditLog.errors.loadFailed')}</p>
<button
onClick={() => refetch()}
className="mt-3 text-sm text-accent-400 transition-colors hover:text-accent-300"
>
{t('admin.auditLog.errors.retry')}
</button>
</div>
) : filteredEntries.length === 0 ? (
<div className="py-12 text-center">
<p className="text-dark-400">{t('admin.auditLog.noEntries')}</p>
</div>
) : (
<div className="space-y-3">
{filteredEntries.map((entry) => (
<LogEntryCard
key={entry.id}
entry={entry}
isExpanded={expandedIds.has(entry.id)}
onToggle={() => handleToggleExpand(entry.id)}
/>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6 flex items-center justify-center gap-2">
<button
onClick={() => setPage(0)}
disabled={page === 0}
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('admin.auditLog.pagination.first')}
>
&laquo;
</button>
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('admin.auditLog.pagination.previous')}
>
&lsaquo;
</button>
<span className="px-3 py-2 text-sm text-dark-300">
{t('admin.auditLog.pagination.pageOf', {
current: page + 1,
total: 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 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('admin.auditLog.pagination.next')}
>
&rsaquo;
</button>
<button
onClick={() => setPage(totalPages - 1)}
disabled={page >= totalPages - 1}
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('admin.auditLog.pagination.last')}
>
&raquo;
</button>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { RemnawaveIcon, ArrowPathIcon } from '../components/icons';
import { usePermissionStore } from '@/store/permissions';
// Group header icons
const AnalyticsGroupIcon = () => (
@@ -253,11 +254,62 @@ const ChevronRightIcon = () => (
</svg>
);
const SecurityGroupIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
const ShieldIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
const UserPlusIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z"
/>
</svg>
);
const DocumentCheckIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.125 2.25h-4.5c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625l-2.625 2.625-1.125-1.125"
/>
</svg>
);
const ClipboardDocumentListIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z"
/>
</svg>
);
interface AdminItem {
to: string;
icon: React.ReactNode;
title: string;
description: string;
permission: string;
}
interface AdminGroup {
@@ -303,6 +355,11 @@ function AdminCard({
}
function GroupSection({ group }: { group: AdminGroup }) {
const hasPermission = usePermissionStore((state) => state.hasPermission);
const visibleItems = group.items.filter((item) => hasPermission(item.permission));
if (visibleItems.length === 0) return null;
return (
<div className="overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/30 backdrop-blur">
{/* Group Header */}
@@ -315,7 +372,7 @@ function GroupSection({ group }: { group: AdminGroup }) {
{/* Group Items */}
<div className="space-y-1.5 p-2">
{group.items.map((item) => (
{visibleItems.map((item) => (
<AdminCard key={item.to} {...item} iconBg={group.iconBg} iconColor={group.iconColor} />
))}
</div>
@@ -341,18 +398,21 @@ export default function AdminPanel() {
icon: <ChartBarIcon />,
title: t('admin.nav.dashboard'),
description: t('admin.panel.dashboardDesc'),
permission: 'stats:read',
},
{
to: '/admin/payments',
icon: <BanknotesIcon />,
title: t('admin.nav.payments'),
description: t('admin.panel.paymentsDesc'),
permission: 'payments:read',
},
{
to: '/admin/traffic-usage',
icon: <ArrowsUpDownIcon />,
title: t('admin.nav.trafficUsage'),
description: t('admin.panel.trafficUsageDesc'),
permission: 'traffic:read',
},
],
},
@@ -370,18 +430,21 @@ export default function AdminPanel() {
icon: <UsersIcon />,
title: t('admin.nav.users'),
description: t('admin.panel.usersDesc'),
permission: 'users:read',
},
{
to: '/admin/tickets',
icon: <ChatBubbleIcon />,
title: t('admin.nav.tickets'),
description: t('admin.panel.ticketsDesc'),
permission: 'tickets:read',
},
{
to: '/admin/ban-system',
icon: <NoSymbolIcon />,
title: t('admin.nav.banSystem'),
description: t('admin.panel.banSystemDesc'),
permission: 'ban_system:read',
},
],
},
@@ -399,30 +462,35 @@ export default function AdminPanel() {
icon: <CreditCardIcon />,
title: t('admin.nav.tariffs'),
description: t('admin.panel.tariffsDesc'),
permission: 'tariffs:read',
},
{
to: '/admin/promocodes',
icon: <TicketIcon />,
title: t('admin.nav.promocodes'),
description: t('admin.panel.promocodesDesc'),
permission: 'promocodes:read',
},
{
to: '/admin/promo-groups',
icon: <UserGroupIcon />,
title: t('admin.nav.promoGroups'),
description: t('admin.panel.promoGroupsDesc'),
permission: 'promo_groups:read',
},
{
to: '/admin/promo-offers',
icon: <GiftIcon />,
title: t('admin.nav.promoOffers'),
description: t('admin.panel.promoOffersDesc'),
permission: 'promo_offers:read',
},
{
to: '/admin/payment-methods',
icon: <BanknotesIcon />,
title: t('admin.nav.paymentMethods'),
description: t('admin.panel.paymentMethodsDesc'),
permission: 'payment_methods:read',
},
],
},
@@ -440,36 +508,42 @@ export default function AdminPanel() {
icon: <MegaphoneIcon />,
title: t('admin.nav.campaigns'),
description: t('admin.panel.campaignsDesc'),
permission: 'campaigns:read',
},
{
to: '/admin/broadcasts',
icon: <PaperAirplaneIcon />,
title: t('admin.nav.broadcasts'),
description: t('admin.panel.broadcastsDesc'),
permission: 'broadcasts:read',
},
{
to: '/admin/pinned-messages',
icon: <PinnedMessageIcon />,
title: t('admin.nav.pinnedMessages'),
description: t('admin.panel.pinnedMessagesDesc'),
permission: 'pinned_messages:read',
},
{
to: '/admin/wheel',
icon: <SparklesIcon />,
title: t('admin.nav.wheel'),
description: t('admin.panel.wheelDesc'),
permission: 'wheel:read',
},
{
to: '/admin/partners',
icon: <HandshakeIcon />,
title: t('admin.nav.partners'),
description: t('admin.panel.partnersDesc'),
permission: 'partners:read',
},
{
to: '/admin/withdrawals',
icon: <BanknotesIcon />,
title: t('admin.nav.withdrawals'),
description: t('admin.panel.withdrawalsDesc'),
permission: 'withdrawals:read',
},
],
},
@@ -487,42 +561,88 @@ export default function AdminPanel() {
icon: <MegaphoneIcon />,
title: t('admin.nav.channelSubscriptions'),
description: t('admin.panel.channelSubscriptionsDesc'),
permission: 'channels:read',
},
{
to: '/admin/settings',
icon: <CogIcon />,
title: t('admin.nav.settings'),
description: t('admin.panel.settingsDesc'),
permission: 'settings:read',
},
{
to: '/admin/apps',
icon: <DevicePhoneMobileIcon />,
title: t('admin.nav.apps'),
description: t('admin.panel.appsDesc'),
permission: 'apps:read',
},
{
to: '/admin/servers',
icon: <ServerStackIcon />,
title: t('admin.nav.servers'),
description: t('admin.panel.serversDesc'),
permission: 'servers:read',
},
{
to: '/admin/remnawave',
icon: <RemnawaveIcon />,
title: t('admin.nav.remnawave'),
description: t('admin.panel.remnawaveDesc'),
permission: 'remnawave:read',
},
{
to: '/admin/email-templates',
icon: <EnvelopeIcon />,
title: t('admin.nav.emailTemplates'),
description: t('admin.panel.emailTemplatesDesc'),
permission: 'email_templates:read',
},
{
to: '/admin/updates',
icon: <ArrowPathIcon className="h-5 w-5" />,
title: t('admin.nav.updates'),
description: t('admin.panel.updatesDesc'),
permission: 'updates:read',
},
],
},
{
id: 'security',
title: t('admin.groups.security'),
icon: <SecurityGroupIcon />,
gradient: 'from-red-500/10 to-orange-500/5',
borderColor: 'border-red-500/20',
iconBg: 'bg-red-500/20',
iconColor: 'text-red-400',
items: [
{
to: '/admin/roles',
icon: <ShieldIcon />,
title: t('admin.nav.roles'),
description: t('admin.panel.rolesDesc'),
permission: 'roles:read',
},
{
to: '/admin/roles/assign',
icon: <UserPlusIcon />,
title: t('admin.nav.roleAssign'),
description: t('admin.panel.roleAssignDesc'),
permission: 'roles:assign',
},
{
to: '/admin/policies',
icon: <DocumentCheckIcon />,
title: t('admin.nav.policies'),
description: t('admin.panel.policiesDesc'),
permission: 'roles:read',
},
{
to: '/admin/audit-log',
icon: <ClipboardDocumentListIcon />,
title: t('admin.nav.auditLog'),
description: t('admin.panel.auditLogDesc'),
permission: 'audit_log:read',
},
],
},

1142
src/pages/AdminPolicies.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,718 @@
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 = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const SearchIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
const ChevronDownIcon = ({ className }: { className?: string }) => (
<svg
className={className || 'h-4 w-4'}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const ChevronLeftIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const ChevronRightIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
);
const XCircleIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const UserPlusIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z"
/>
</svg>
);
// === 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 (
<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 />
</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
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<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);
},
});
// 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 (
<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">
<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 />
</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="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="fixed inset-0 bg-black/60"
onClick={() => setRevokeConfirm(null)}
aria-hidden="true"
/>
<div className="relative w-full max-w-sm rounded-xl border border-dark-700 bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.roleAssign.confirm.title')}
</h3>
<p className="mb-6 text-dark-400">{t('admin.roleAssign.confirm.text')}</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setRevokeConfirm(null)}
className="px-4 py-2 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-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>
)}
</div>
);
}

819
src/pages/AdminRoles.tsx Normal file
View File

@@ -0,0 +1,819 @@
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,
AdminRole,
PermissionSection,
CreateRolePayload,
UpdateRolePayload,
} from '@/api/rbac';
import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePermissionStore } from '@/store/permissions';
import { usePlatform } from '@/platform/hooks/usePlatform';
// === Icons ===
const BackIcon = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const PlusIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const EditIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const ChevronDownIcon = ({ className }: { className?: string }) => (
<svg
className={className || 'h-4 w-4'}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const XMarkIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const ShieldIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
// === Constants ===
const ROLE_COLORS = [
'#6366f1', // indigo
'#8b5cf6', // violet
'#a855f7', // purple
'#ec4899', // pink
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#14b8a6', // teal
'#06b6d4', // cyan
'#3b82f6', // blue
'#6b7280', // gray
];
const PRESETS: Record<string, string[]> = {
moderator: ['users:read', 'users:edit', 'users:block', 'tickets:*', 'ban_system:*'],
marketer: [
'campaigns:*',
'broadcasts:*',
'promocodes:*',
'promo_offers:*',
'promo_groups:*',
'stats:read',
'pinned_messages:*',
'wheel:*',
],
support: ['tickets:read', 'tickets:reply', 'users:read'],
};
// === Types ===
interface RoleFormData {
name: string;
description: string;
level: number;
color: string;
permissions: string[];
}
const INITIAL_FORM: RoleFormData = {
name: '',
description: '',
level: 50,
color: ROLE_COLORS[0],
permissions: [],
};
// === Sub-components ===
interface PermissionMatrixProps {
registry: PermissionSection[];
selectedPermissions: string[];
onToggle: (perm: string) => void;
onToggleSection: (section: string, actions: string[]) => void;
}
function PermissionMatrix({
registry,
selectedPermissions,
onToggle,
onToggleSection,
}: PermissionMatrixProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const toggleExpand = useCallback((section: string) => {
setExpanded((prev) => ({ ...prev, [section]: !prev[section] }));
}, []);
const isSectionFullySelected = useCallback(
(section: string, actions: string[]) => {
return actions.every((action) => {
const perm = `${section}:${action}`;
return selectedPermissions.includes(perm) || selectedPermissions.includes(`${section}:*`);
});
},
[selectedPermissions],
);
const isSectionPartiallySelected = useCallback(
(section: string, actions: string[]) => {
const hasAny = actions.some((action) => {
const perm = `${section}:${action}`;
return selectedPermissions.includes(perm) || selectedPermissions.includes(`${section}:*`);
});
return hasAny && !isSectionFullySelected(section, actions);
},
[selectedPermissions, isSectionFullySelected],
);
const isPermSelected = useCallback(
(section: string, action: string) => {
const perm = `${section}:${action}`;
return selectedPermissions.includes(perm) || selectedPermissions.includes(`${section}:*`);
},
[selectedPermissions],
);
return (
<div className="space-y-2">
<label className="text-sm font-medium text-dark-200">
{t('admin.roles.form.permissions')}
</label>
<div className="max-h-80 space-y-1 overflow-y-auto rounded-lg border border-dark-600 bg-dark-900/50 p-2">
{registry.map((section) => {
const isExpanded = expanded[section.section] ?? false;
const allSelected = isSectionFullySelected(section.section, section.actions);
const partialSelected = isSectionPartiallySelected(section.section, section.actions);
return (
<div
key={section.section}
className="rounded-lg border border-dark-700/50 bg-dark-800/30"
>
{/* Section header */}
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => onToggleSection(section.section, section.actions)}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
allSelected
? 'border-accent-500 bg-accent-500'
: partialSelected
? 'border-accent-500 bg-accent-500/40'
: 'border-dark-500 hover:border-dark-400'
}`}
aria-label={t('admin.roles.form.toggleSection', {
section: section.section,
})}
>
{(allSelected || partialSelected) && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
{allSelected ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14" />
)}
</svg>
)}
</button>
<button
type="button"
onClick={() => toggleExpand(section.section)}
className="flex flex-1 items-center justify-between"
>
<span className="text-sm font-medium text-dark-200">{section.section}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">
{section.actions.filter((a) => isPermSelected(section.section, a)).length}/
{section.actions.length}
</span>
<ChevronDownIcon
className={`h-4 w-4 text-dark-400 transition-transform ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</div>
</button>
</div>
{/* Actions list */}
{isExpanded && (
<div className="border-t border-dark-700/50 px-3 py-2">
<div className="flex flex-wrap gap-2">
{section.actions.map((action) => {
const perm = `${section.section}:${action}`;
const selected = isPermSelected(section.section, action);
return (
<button
key={perm}
type="button"
onClick={() => onToggle(perm)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
selected
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-700/50 text-dark-400 hover:bg-dark-700 hover:text-dark-300'
}`}
aria-pressed={selected}
>
{action}
</button>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// === Main Page ===
export default function AdminRoles() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
const canManageRole = usePermissionStore((s) => s.canManageRole);
// Modal state
const [modalOpen, setModalOpen] = useState(false);
const [editingRole, setEditingRole] = useState<AdminRole | null>(null);
const [formData, setFormData] = useState<RoleFormData>(INITIAL_FORM);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const [formError, setFormError] = useState<string | null>(null);
// Queries
const {
data: roles,
isLoading: rolesLoading,
error: rolesError,
} = useQuery({
queryKey: ['admin-roles'],
queryFn: rbacApi.getRoles,
});
const { data: permissionRegistry } = useQuery({
queryKey: ['admin-permission-registry'],
queryFn: rbacApi.getPermissionRegistry,
});
// Mutations
const createMutation = useMutation({
mutationFn: (payload: CreateRolePayload) => rbacApi.createRole(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-roles'] });
closeModal();
},
onError: () => {
setFormError(t('admin.roles.errors.createFailed'));
},
});
const updateMutation = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: UpdateRolePayload }) =>
rbacApi.updateRole(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-roles'] });
closeModal();
},
onError: () => {
setFormError(t('admin.roles.errors.updateFailed'));
},
});
const deleteMutation = useMutation({
mutationFn: rbacApi.deleteRole,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-roles'] });
setDeleteConfirm(null);
},
});
// Handlers
const closeModal = useCallback(() => {
setModalOpen(false);
setEditingRole(null);
setFormData(INITIAL_FORM);
setFormError(null);
}, []);
const openCreateModal = useCallback(() => {
setEditingRole(null);
setFormData(INITIAL_FORM);
setFormError(null);
setModalOpen(true);
}, []);
const openEditModal = useCallback((role: AdminRole) => {
setEditingRole(role);
setFormData({
name: role.name,
description: role.description || '',
level: role.level,
color: role.color || ROLE_COLORS[0],
permissions: [...role.permissions],
});
setFormError(null);
setModalOpen(true);
}, []);
const handleTogglePermission = useCallback((perm: string) => {
setFormData((prev) => {
const has = prev.permissions.includes(perm);
return {
...prev,
permissions: has ? prev.permissions.filter((p) => p !== perm) : [...prev.permissions, perm],
};
});
}, []);
const handleToggleSection = useCallback((section: string, actions: string[]) => {
setFormData((prev) => {
const allPerms = actions.map((a) => `${section}:${a}`);
const allSelected = allPerms.every(
(p) => prev.permissions.includes(p) || prev.permissions.includes(`${section}:*`),
);
if (allSelected) {
// Deselect all in this section (both individual and wildcard)
const sectionPerms = new Set([...allPerms, `${section}:*`]);
return {
...prev,
permissions: prev.permissions.filter((p) => !sectionPerms.has(p)),
};
}
// Select all: use wildcard
const withoutSection = prev.permissions.filter((p) => !p.startsWith(`${section}:`));
return {
...prev,
permissions: [...withoutSection, `${section}:*`],
};
});
}, []);
const handleApplyPreset = useCallback((presetKey: string) => {
const presetPerms = PRESETS[presetKey];
if (!presetPerms) return;
setFormData((prev) => ({
...prev,
permissions: [...presetPerms],
}));
}, []);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formData.name.trim()) {
setFormError(t('admin.roles.errors.nameRequired'));
return;
}
const payload = {
name: formData.name.trim(),
description: formData.description.trim() || null,
level: formData.level,
permissions: formData.permissions,
color: formData.color,
};
if (editingRole) {
updateMutation.mutate({ id: editingRole.id, payload });
} else {
createMutation.mutate(payload);
}
},
[formData, editingRole, createMutation, updateMutation, t],
);
const isSaving = createMutation.isPending || updateMutation.isPending;
// Sorted roles by level descending
const sortedRoles = useMemo(() => {
if (!roles) return [];
return [...roles].sort((a, b) => b.level - a.level);
}, [roles]);
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="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.roles.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.roles.subtitle')}</p>
</div>
</div>
<PermissionGate permission="roles:create">
<button
onClick={openCreateModal}
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
>
<PlusIcon />
{t('admin.roles.createRole')}
</button>
</PermissionGate>
</div>
{/* Stats Overview */}
{sortedRoles.length > 0 && (
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-dark-100">{sortedRoles.length}</div>
<div className="text-xs text-dark-400">{t('admin.roles.stats.totalRoles')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-accent-400">
{sortedRoles.filter((r) => r.is_active).length}
</div>
<div className="text-xs text-dark-400">{t('admin.roles.stats.active')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-warning-400">
{sortedRoles.filter((r) => r.is_system).length}
</div>
<div className="text-xs text-dark-400">{t('admin.roles.stats.system')}</div>
</div>
</div>
)}
{/* Roles List */}
{rolesLoading ? (
<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>
) : rolesError ? (
<div className="py-12 text-center">
<p className="text-error-400">{t('admin.roles.errors.loadFailed')}</p>
</div>
) : sortedRoles.length === 0 ? (
<div className="py-12 text-center">
<ShieldIcon />
<p className="mt-2 text-dark-400">{t('admin.roles.noRoles')}</p>
</div>
) : (
<div className="space-y-3">
{sortedRoles.map((role) => (
<div
key={role.id}
className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
role.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
}`}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
{/* Role name with color badge */}
<div className="mb-2 flex items-center gap-2">
<span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: role.color || '#6b7280' }}
aria-hidden="true"
/>
<span className="font-medium text-dark-100">{role.name}</span>
{role.is_system && (
<span className="rounded bg-warning-500/20 px-1.5 py-0.5 text-xs text-warning-400">
{t('admin.roles.systemBadge')}
</span>
)}
{!role.is_active && (
<span className="rounded bg-dark-600 px-1.5 py-0.5 text-xs text-dark-400">
{t('admin.roles.inactiveBadge')}
</span>
)}
</div>
{/* Info */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
<span>
{t('admin.roles.levelLabel')}: {role.level}
</span>
{role.description && <span>{role.description}</span>}
<span>{t('admin.roles.usersCount', { count: role.user_count ?? 0 })}</span>
<span>
{t('admin.roles.permissionsCount', {
count: role.permissions.length,
})}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 border-t border-dark-700 pt-3 sm:border-0 sm:pt-0">
<PermissionGate permission="roles:edit">
<button
onClick={() => openEditModal(role)}
disabled={role.is_system || !canManageRole(role.level)}
className="flex-1 rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40 sm:flex-none"
title={t('admin.roles.actions.edit')}
>
<EditIcon />
</button>
</PermissionGate>
<PermissionGate permission="roles:delete">
<button
onClick={() => setDeleteConfirm(role.id)}
disabled={role.is_system || !canManageRole(role.level)}
className="flex-1 rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:cursor-not-allowed disabled:opacity-40 sm:flex-none"
title={t('admin.roles.actions.delete')}
>
<TrashIcon />
</button>
</PermissionGate>
</div>
</div>
</div>
))}
</div>
)}
{/* Create / Edit Modal */}
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-[5vh]">
{/* Backdrop */}
<div className="fixed inset-0 bg-black/60" onClick={closeModal} aria-hidden="true" />
{/* Modal content */}
<div className="relative w-full max-w-lg rounded-xl border border-dark-700 bg-dark-800 p-6 shadow-xl">
{/* Modal header */}
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-semibold text-dark-100">
{editingRole
? t('admin.roles.modal.editTitle')
: t('admin.roles.modal.createTitle')}
</h2>
<button
onClick={closeModal}
className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
aria-label={t('admin.roles.modal.close')}
>
<XMarkIcon />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div>
<label htmlFor="role-name" className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.roles.form.name')}
</label>
<input
id="role-name"
type="text"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500"
placeholder={t('admin.roles.form.namePlaceholder')}
autoFocus
/>
</div>
{/* Description */}
<div>
<label
htmlFor="role-description"
className="mb-1 block text-sm font-medium text-dark-200"
>
{t('admin.roles.form.description')}
</label>
<textarea
id="role-description"
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500"
placeholder={t('admin.roles.form.descriptionPlaceholder')}
rows={2}
/>
</div>
{/* Level */}
<div>
<label
htmlFor="role-level"
className="mb-1 block text-sm font-medium text-dark-200"
>
{t('admin.roles.form.level')}
</label>
<div className="flex items-center gap-3">
<input
id="role-level"
type="range"
min={0}
max={999}
value={formData.level}
onChange={(e) =>
setFormData((prev) => ({
...prev,
level: Number(e.target.value),
}))
}
className="flex-1 accent-accent-500"
/>
<input
type="number"
min={0}
max={999}
value={formData.level}
onChange={(e) =>
setFormData((prev) => ({
...prev,
level: Math.min(999, Math.max(0, Number(e.target.value) || 0)),
}))
}
className="w-20 rounded-lg border border-dark-600 bg-dark-900 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500"
aria-label={t('admin.roles.form.levelValue')}
/>
</div>
<p className="mt-1 text-xs text-dark-500">{t('admin.roles.form.levelHint')}</p>
</div>
{/* Color picker */}
<div>
<label className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.roles.form.color')}
</label>
<div className="flex flex-wrap gap-2">
{ROLE_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setFormData((prev) => ({ ...prev, color }))}
className={`h-7 w-7 rounded-full border-2 transition-transform hover:scale-110 ${
formData.color === color ? 'scale-110 border-white' : 'border-transparent'
}`}
style={{ backgroundColor: color }}
aria-label={color}
/>
))}
</div>
</div>
{/* Preset buttons */}
<div>
<label className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.roles.form.presets')}
</label>
<div className="flex flex-wrap gap-2">
{Object.keys(PRESETS).map((key) => (
<button
key={key}
type="button"
onClick={() => handleApplyPreset(key)}
className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-xs font-medium text-dark-300 transition-colors hover:border-accent-500/50 hover:bg-accent-500/10 hover:text-accent-400"
>
{t(`admin.roles.presets.${key}`)}
</button>
))}
</div>
</div>
{/* Permission Matrix */}
{permissionRegistry && permissionRegistry.length > 0 && (
<PermissionMatrix
registry={permissionRegistry}
selectedPermissions={formData.permissions}
onToggle={handleTogglePermission}
onToggleSection={handleToggleSection}
/>
)}
{/* Selected count */}
<p className="text-xs text-dark-500">
{t('admin.roles.form.selectedPermissions', {
count: formData.permissions.length,
})}
</p>
{/* Error */}
{formError && <p className="text-sm text-error-400">{formError}</p>}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('admin.roles.form.cancel')}
</button>
<button
type="submit"
disabled={isSaving}
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{isSaving ? t('admin.roles.form.saving') : t('admin.roles.form.save')}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation */}
{deleteConfirm !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="fixed inset-0 bg-black/60"
onClick={() => setDeleteConfirm(null)}
aria-hidden="true"
/>
<div className="relative w-full max-w-sm rounded-xl border border-dark-700 bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.roles.confirm.title')}
</h3>
<p className="mb-6 text-dark-400">{t('admin.roles.confirm.text')}</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('admin.roles.confirm.cancel')}
</button>
<button
onClick={() => deleteMutation.mutate(deleteConfirm)}
disabled={deleteMutation.isPending}
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
{deleteMutation.isPending
? t('admin.roles.confirm.deleting')
: t('admin.roles.confirm.delete')}
</button>
</div>
</div>
</div>
)}
</div>
);
}