mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
263
src/App.tsx
263
src/App.tsx
@@ -9,6 +9,7 @@ import {
|
||||
ChannelSubscriptionScreen,
|
||||
BlacklistedScreen,
|
||||
} from './components/blocking';
|
||||
import { PermissionRoute } from '@/components/auth/PermissionRoute';
|
||||
import { saveReturnUrl } from './utils/token';
|
||||
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
||||
// Auth pages - load immediately (small)
|
||||
@@ -89,6 +90,10 @@ const AdminPinnedMessages = lazy(() => import('./pages/AdminPinnedMessages'));
|
||||
const AdminPinnedMessageCreate = lazy(() => import('./pages/AdminPinnedMessageCreate'));
|
||||
const AdminChannelSubscriptions = lazy(() => import('./pages/AdminChannelSubscriptions'));
|
||||
const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview'));
|
||||
const AdminRoles = lazy(() => import('./pages/AdminRoles'));
|
||||
const AdminRoleAssign = lazy(() => import('./pages/AdminRoleAssign'));
|
||||
const AdminPolicies = lazy(() => import('./pages/AdminPolicies'));
|
||||
const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
@@ -338,541 +343,583 @@ function App() {
|
||||
<Route
|
||||
path="/admin/tickets"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="tickets:read">
|
||||
<LazyPage>
|
||||
<AdminTickets />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/tickets/settings"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="tickets:settings">
|
||||
<LazyPage>
|
||||
<AdminTicketSettings />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="settings:read">
|
||||
<LazyPage>
|
||||
<AdminSettings />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/apps"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="apps:read">
|
||||
<LazyPage>
|
||||
<AdminApps />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/wheel"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="wheel:read">
|
||||
<LazyPage>
|
||||
<AdminWheel />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/tariffs"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="tariffs:read">
|
||||
<LazyPage>
|
||||
<AdminTariffs />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/tariffs/create"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="tariffs:read">
|
||||
<LazyPage>
|
||||
<AdminTariffCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/tariffs/:id/edit"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="tariffs:read">
|
||||
<LazyPage>
|
||||
<AdminTariffCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/servers"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="servers:read">
|
||||
<LazyPage>
|
||||
<AdminServers />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/servers/:id/edit"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="servers:read">
|
||||
<LazyPage>
|
||||
<AdminServerEdit />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/dashboard"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="stats:read">
|
||||
<LazyPage>
|
||||
<AdminDashboard />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/ban-system"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="ban_system:read">
|
||||
<LazyPage>
|
||||
<AdminBanSystem />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/broadcasts"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="broadcasts:read">
|
||||
<LazyPage>
|
||||
<AdminBroadcasts />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/broadcasts/create"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="broadcasts:read">
|
||||
<LazyPage>
|
||||
<AdminBroadcastCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promocodes"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="promocodes:read">
|
||||
<LazyPage>
|
||||
<AdminPromocodes />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promocodes/create"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="promocodes:read">
|
||||
<LazyPage>
|
||||
<AdminPromocodeCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promocodes/:id/edit"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="promocodes:read">
|
||||
<LazyPage>
|
||||
<AdminPromocodeCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promocodes/:id/stats"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="promocodes:read">
|
||||
<LazyPage>
|
||||
<AdminPromocodeStats />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promo-groups"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="promo_groups:read">
|
||||
<LazyPage>
|
||||
<AdminPromoGroups />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promo-groups/create"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="promo_groups:read">
|
||||
<LazyPage>
|
||||
<AdminPromoGroupCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promo-groups/:id/edit"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="promo_groups:read">
|
||||
<LazyPage>
|
||||
<AdminPromoGroupCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/campaigns"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="campaigns:read">
|
||||
<LazyPage>
|
||||
<AdminCampaigns />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/campaigns/create"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="campaigns:read">
|
||||
<LazyPage>
|
||||
<AdminCampaignCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/campaigns/:id/stats"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="campaigns:read">
|
||||
<LazyPage>
|
||||
<AdminCampaignStats />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/campaigns/:id/edit"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="campaigns:read">
|
||||
<LazyPage>
|
||||
<AdminCampaignEdit />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="partners:read">
|
||||
<LazyPage>
|
||||
<AdminPartners />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/settings"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="partners:read">
|
||||
<LazyPage>
|
||||
<AdminPartnerSettings />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/applications/:id/review"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="partners:read">
|
||||
<LazyPage>
|
||||
<AdminApplicationReview />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId/commission"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="partners:read">
|
||||
<LazyPage>
|
||||
<AdminPartnerCommission />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId/revoke"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="partners:read">
|
||||
<LazyPage>
|
||||
<AdminPartnerRevoke />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId/campaigns/assign"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="partners:read">
|
||||
<LazyPage>
|
||||
<AdminPartnerCampaignAssign />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="partners:read">
|
||||
<LazyPage>
|
||||
<AdminPartnerDetail />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/withdrawals"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="withdrawals:read">
|
||||
<LazyPage>
|
||||
<AdminWithdrawals />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/withdrawals/:id/reject"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="withdrawals:read">
|
||||
<LazyPage>
|
||||
<AdminWithdrawalReject />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/withdrawals/:id"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="withdrawals:read">
|
||||
<LazyPage>
|
||||
<AdminWithdrawalDetail />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="users:read">
|
||||
<LazyPage>
|
||||
<AdminUsers />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/payments"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="payments:read">
|
||||
<LazyPage>
|
||||
<AdminPayments />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/traffic-usage"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="traffic:read">
|
||||
<LazyPage>
|
||||
<AdminTrafficUsage />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/payment-methods"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="payment_methods:read">
|
||||
<LazyPage>
|
||||
<AdminPaymentMethods />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/payment-methods/:methodId/edit"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="payment_methods:read">
|
||||
<LazyPage>
|
||||
<AdminPaymentMethodEdit />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promo-offers"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="promo_offers:read">
|
||||
<LazyPage>
|
||||
<AdminPromoOffers />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promo-offers/templates/:id/edit"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="promo_offers:read">
|
||||
<LazyPage>
|
||||
<AdminPromoOfferTemplateEdit />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promo-offers/send"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="promo_offers:read">
|
||||
<LazyPage>
|
||||
<AdminPromoOfferSend />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/remnawave"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="remnawave:read">
|
||||
<LazyPage>
|
||||
<AdminRemnawave />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/remnawave/squads/:uuid"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="remnawave:read">
|
||||
<LazyPage>
|
||||
<AdminRemnawaveSquadDetail />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/email-templates"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="email_templates:read">
|
||||
<LazyPage>
|
||||
<AdminEmailTemplates />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/updates"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="updates:read">
|
||||
<LazyPage>
|
||||
<AdminUpdates />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users/:id"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="users:read">
|
||||
<LazyPage>
|
||||
<AdminUserDetail />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/broadcasts/:id"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="broadcasts:read">
|
||||
<LazyPage>
|
||||
<AdminBroadcastDetail />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/pinned-messages"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="pinned_messages:read">
|
||||
<LazyPage>
|
||||
<AdminPinnedMessages />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/pinned-messages/create"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="pinned_messages:read">
|
||||
<LazyPage>
|
||||
<AdminPinnedMessageCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/pinned-messages/:id/edit"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="pinned_messages:read">
|
||||
<LazyPage>
|
||||
<AdminPinnedMessageCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/channel-subscriptions"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="channels:read">
|
||||
<LazyPage>
|
||||
<AdminChannelSubscriptions />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/email-templates/preview/:type/:lang"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PermissionRoute permission="email_templates:read">
|
||||
<LazyPage>
|
||||
<AdminEmailTemplatePreview />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* RBAC routes */}
|
||||
<Route
|
||||
path="/admin/roles"
|
||||
element={
|
||||
<PermissionRoute permission="roles:read">
|
||||
<LazyPage>
|
||||
<AdminRoles />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/roles/assign"
|
||||
element={
|
||||
<PermissionRoute permission="roles:assign">
|
||||
<LazyPage>
|
||||
<AdminRoleAssign />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/policies"
|
||||
element={
|
||||
<PermissionRoute permission="roles:read">
|
||||
<LazyPage>
|
||||
<AdminPolicies />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/audit-log"
|
||||
element={
|
||||
<PermissionRoute permission="audit_log:read">
|
||||
<LazyPage>
|
||||
<AdminAuditLog />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
215
src/api/rbac.ts
Normal file
215
src/api/rbac.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import apiClient from './client';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface AdminRole {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
level: number;
|
||||
permissions: string[];
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
is_system: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_count?: number;
|
||||
}
|
||||
|
||||
export interface CreateRolePayload {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
level: number;
|
||||
permissions: string[];
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateRolePayload {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
level?: number;
|
||||
permissions?: string[];
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface UserRoleAssignment {
|
||||
id: number;
|
||||
user_id: number;
|
||||
role_id: number;
|
||||
role_name: string;
|
||||
assigned_by: number | null;
|
||||
assigned_at: string;
|
||||
expires_at: string | null;
|
||||
user_email: string | null;
|
||||
user_first_name: string | null;
|
||||
user_telegram_id: number | null;
|
||||
}
|
||||
|
||||
export interface AssignRolePayload {
|
||||
user_id: number;
|
||||
role_id: number;
|
||||
expires_at?: string | null;
|
||||
}
|
||||
|
||||
export interface AccessPolicy {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
resource: string;
|
||||
action: string;
|
||||
effect: 'allow' | 'deny';
|
||||
conditions: Record<string, unknown>;
|
||||
priority: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreatePolicyPayload {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
resource: string;
|
||||
action: string;
|
||||
effect: 'allow' | 'deny';
|
||||
conditions?: Record<string, unknown>;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface UpdatePolicyPayload {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
resource?: string;
|
||||
action?: string;
|
||||
effect?: 'allow' | 'deny';
|
||||
conditions?: Record<string, unknown>;
|
||||
priority?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
user_id: number;
|
||||
action: string;
|
||||
resource: string;
|
||||
resource_id: string | null;
|
||||
details: Record<string, unknown>;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
created_at: string;
|
||||
user_email: string | null;
|
||||
user_first_name: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
user_id?: number;
|
||||
action?: string;
|
||||
resource?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
items: AuditLogEntry[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface PermissionSection {
|
||||
section: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
// === API ===
|
||||
|
||||
const BASE = '/cabinet/admin/rbac';
|
||||
|
||||
export const rbacApi = {
|
||||
// --- Roles ---
|
||||
|
||||
getRoles: async (): Promise<AdminRole[]> => {
|
||||
const response = await apiClient.get<AdminRole[]>(`${BASE}/roles`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createRole: async (payload: CreateRolePayload): Promise<AdminRole> => {
|
||||
const response = await apiClient.post<AdminRole>(`${BASE}/roles`, payload);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateRole: async (roleId: number, payload: UpdateRolePayload): Promise<AdminRole> => {
|
||||
const response = await apiClient.put<AdminRole>(`${BASE}/roles/${roleId}`, payload);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteRole: async (roleId: number): Promise<void> => {
|
||||
await apiClient.delete(`${BASE}/roles/${roleId}`);
|
||||
},
|
||||
|
||||
// --- Permission Registry ---
|
||||
|
||||
getPermissionRegistry: async (): Promise<PermissionSection[]> => {
|
||||
const response = await apiClient.get<PermissionSection[]>(`${BASE}/permissions`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// --- Role Users ---
|
||||
|
||||
getRoleUsers: async (roleId: number): Promise<UserRoleAssignment[]> => {
|
||||
const response = await apiClient.get<UserRoleAssignment[]>(`${BASE}/roles/${roleId}/users`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
assignRole: async (payload: AssignRolePayload): Promise<UserRoleAssignment> => {
|
||||
const response = await apiClient.post<UserRoleAssignment>(`${BASE}/assignments`, payload);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
revokeRole: async (assignmentId: number): Promise<void> => {
|
||||
await apiClient.delete(`${BASE}/assignments/${assignmentId}`);
|
||||
},
|
||||
|
||||
// --- Access Policies ---
|
||||
|
||||
getPolicies: async (): Promise<AccessPolicy[]> => {
|
||||
const response = await apiClient.get<AccessPolicy[]>(`${BASE}/policies`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createPolicy: async (payload: CreatePolicyPayload): Promise<AccessPolicy> => {
|
||||
const response = await apiClient.post<AccessPolicy>(`${BASE}/policies`, payload);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePolicy: async (policyId: number, payload: UpdatePolicyPayload): Promise<AccessPolicy> => {
|
||||
const response = await apiClient.put<AccessPolicy>(`${BASE}/policies/${policyId}`, payload);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deletePolicy: async (policyId: number): Promise<void> => {
|
||||
await apiClient.delete(`${BASE}/policies/${policyId}`);
|
||||
},
|
||||
|
||||
// --- Audit Log ---
|
||||
|
||||
getAuditLog: async (filters?: AuditLogFilters): Promise<AuditLogResponse> => {
|
||||
const response = await apiClient.get<AuditLogResponse>(`${BASE}/audit-log`, {
|
||||
params: filters,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportAuditLog: async (filters?: AuditLogFilters): Promise<Blob> => {
|
||||
const response = await apiClient.get(`${BASE}/audit-log/export`, {
|
||||
params: filters,
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data as Blob;
|
||||
},
|
||||
};
|
||||
42
src/components/auth/PermissionGate.tsx
Normal file
42
src/components/auth/PermissionGate.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { usePermissionStore } from '../../store/permissions';
|
||||
|
||||
interface PermissionGateProps {
|
||||
children: React.ReactNode;
|
||||
/** Single permission to check */
|
||||
permission?: string;
|
||||
/** Multiple permissions (OR by default, AND if requireAll is true) */
|
||||
permissions?: string[];
|
||||
/** When true, ALL listed permissions are required instead of ANY */
|
||||
requireAll?: boolean;
|
||||
/** Content to render when the user lacks the required permissions */
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PermissionGate({
|
||||
children,
|
||||
permission,
|
||||
permissions,
|
||||
requireAll = false,
|
||||
fallback = null,
|
||||
}: PermissionGateProps) {
|
||||
const hasPermission = usePermissionStore((state) => state.hasPermission);
|
||||
const hasAnyPermission = usePermissionStore((state) => state.hasAnyPermission);
|
||||
const hasAllPermissions = usePermissionStore((state) => state.hasAllPermissions);
|
||||
|
||||
let hasAccess = false;
|
||||
|
||||
if (permission) {
|
||||
hasAccess = hasPermission(permission);
|
||||
} else if (permissions && permissions.length > 0) {
|
||||
hasAccess = requireAll ? hasAllPermissions(...permissions) : hasAnyPermission(...permissions);
|
||||
} else {
|
||||
// No permissions specified — allow access
|
||||
hasAccess = true;
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
66
src/components/auth/PermissionRoute.tsx
Normal file
66
src/components/auth/PermissionRoute.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Navigate, useLocation } from 'react-router';
|
||||
import { useAuthStore } from '../../store/auth';
|
||||
import { usePermissionStore } from '../../store/permissions';
|
||||
import { saveReturnUrl } from '../../utils/token';
|
||||
import PageLoader from '../common/PageLoader';
|
||||
import Layout from '../layout/Layout';
|
||||
|
||||
interface PermissionRouteProps {
|
||||
children: React.ReactNode;
|
||||
/** Single permission to check */
|
||||
permission?: string;
|
||||
/** Multiple permissions (OR by default, AND if requireAll is true) */
|
||||
permissions?: string[];
|
||||
/** When true, ALL listed permissions are required instead of ANY */
|
||||
requireAll?: boolean;
|
||||
}
|
||||
|
||||
export function PermissionRoute({
|
||||
children,
|
||||
permission,
|
||||
permissions,
|
||||
requireAll = false,
|
||||
}: PermissionRouteProps) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const isLoading = useAuthStore((state) => state.isLoading);
|
||||
const isAdmin = useAuthStore((state) => state.isAdmin);
|
||||
const location = useLocation();
|
||||
|
||||
const permissionsLoaded = usePermissionStore((state) => state.isLoaded);
|
||||
const hasPermission = usePermissionStore((state) => state.hasPermission);
|
||||
const hasAnyPermission = usePermissionStore((state) => state.hasAnyPermission);
|
||||
const hasAllPermissions = usePermissionStore((state) => state.hasAllPermissions);
|
||||
|
||||
// Still loading auth state, or admin but permissions not fetched yet
|
||||
if (isLoading || (isAdmin && !permissionsLoaded)) {
|
||||
return <PageLoader variant="light" />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
saveReturnUrl();
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Check permissions if specified
|
||||
const needsPermissionCheck = permission || (permissions && permissions.length > 0);
|
||||
|
||||
if (needsPermissionCheck) {
|
||||
let hasAccess = false;
|
||||
|
||||
if (permission) {
|
||||
hasAccess = hasPermission(permission);
|
||||
} else if (permissions && permissions.length > 0) {
|
||||
hasAccess = requireAll ? hasAllPermissions(...permissions) : hasAnyPermission(...permissions);
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
}
|
||||
|
||||
return <Layout>{children}</Layout>;
|
||||
}
|
||||
@@ -835,7 +835,8 @@
|
||||
"marketing": "Marketing",
|
||||
"system": "System",
|
||||
"tariffs": "Tariffs & Sales",
|
||||
"users": "Users"
|
||||
"users": "Users",
|
||||
"security": "Security"
|
||||
},
|
||||
"nav": {
|
||||
"title": "Admin",
|
||||
@@ -862,7 +863,11 @@
|
||||
"pinnedMessages": "Pinned Messages",
|
||||
"partners": "Partners",
|
||||
"withdrawals": "Withdrawals",
|
||||
"channelSubscriptions": "Required Channels"
|
||||
"channelSubscriptions": "Required Channels",
|
||||
"roles": "Roles",
|
||||
"roleAssign": "Role Assignment",
|
||||
"policies": "Access Policies",
|
||||
"auditLog": "Audit Log"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Admin Panel",
|
||||
@@ -890,7 +895,11 @@
|
||||
"pinnedMessagesDesc": "Manage pinned messages for users",
|
||||
"partnersDesc": "Manage partner applications and commissions",
|
||||
"withdrawalsDesc": "Review and process withdrawal requests",
|
||||
"channelSubscriptionsDesc": "Manage required channel subscriptions"
|
||||
"channelSubscriptionsDesc": "Manage required channel subscriptions",
|
||||
"rolesDesc": "Manage admin roles and permissions",
|
||||
"roleAssignDesc": "Assign and revoke user roles",
|
||||
"policiesDesc": "Configure ABAC access policies",
|
||||
"auditLogDesc": "Review system activity and access history"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "Traffic Usage",
|
||||
@@ -2920,6 +2929,274 @@
|
||||
"unhealthy": "Unhealthy",
|
||||
"components": "Components",
|
||||
"uptime": "Uptime"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Roles & Permissions",
|
||||
"subtitle": "Manage admin roles and their permissions",
|
||||
"createRole": "Create Role",
|
||||
"noRoles": "No roles found",
|
||||
"systemBadge": "System",
|
||||
"inactiveBadge": "Inactive",
|
||||
"levelLabel": "Level",
|
||||
"usersCount_one": "{{count}} user",
|
||||
"usersCount_other": "{{count}} users",
|
||||
"permissionsCount_one": "{{count}} permission",
|
||||
"permissionsCount_other": "{{count}} permissions",
|
||||
"stats": {
|
||||
"totalRoles": "Total roles",
|
||||
"active": "Active",
|
||||
"system": "System"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "Create Role",
|
||||
"editTitle": "Edit Role",
|
||||
"close": "Close"
|
||||
},
|
||||
"form": {
|
||||
"name": "Role name",
|
||||
"namePlaceholder": "e.g. Moderator",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "What this role is for...",
|
||||
"level": "Priority level",
|
||||
"levelValue": "Level value",
|
||||
"levelHint": "Higher level = more authority. Users can only manage roles with a lower level than their own.",
|
||||
"color": "Color",
|
||||
"presets": "Permission presets",
|
||||
"permissions": "Permissions",
|
||||
"toggleSection": "Toggle all {{section}} permissions",
|
||||
"selectedPermissions_one": "{{count}} permission selected",
|
||||
"selectedPermissions_other": "{{count}} permissions selected",
|
||||
"cancel": "Cancel",
|
||||
"saving": "Saving...",
|
||||
"save": "Save"
|
||||
},
|
||||
"presets": {
|
||||
"moderator": "Moderator",
|
||||
"marketer": "Marketer",
|
||||
"support": "Support"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Delete role?",
|
||||
"text": "This role will be permanently deleted. Users with this role will lose its permissions.",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"deleting": "Deleting..."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Failed to load roles",
|
||||
"createFailed": "Failed to create role",
|
||||
"updateFailed": "Failed to update role",
|
||||
"nameRequired": "Role name is required"
|
||||
}
|
||||
},
|
||||
"roleAssign": {
|
||||
"title": "Role Assignment",
|
||||
"subtitle": "Assign and manage user roles",
|
||||
"assignSection": "Assign Role",
|
||||
"userLabel": "User",
|
||||
"searchPlaceholder": "Search by name, username, Telegram ID, or email...",
|
||||
"clearUser": "Clear selected user",
|
||||
"noUsersFound": "No users found",
|
||||
"roleLabel": "Role",
|
||||
"selectRole": "Select a role...",
|
||||
"expiresLabel": "Expires at (optional)",
|
||||
"expiresHint": "Leave empty for permanent assignment",
|
||||
"assignButton": "Assign",
|
||||
"assigning": "Assigning...",
|
||||
"assignSuccess": "Role assigned successfully",
|
||||
"currentAssignments": "Current Assignments",
|
||||
"totalAssignments_one": "{{count}} assignment total",
|
||||
"totalAssignments_other": "{{count}} assignments total",
|
||||
"noAssignments": "No role assignments found",
|
||||
"unknownUser": "Unknown user",
|
||||
"system": "System",
|
||||
"permanent": "Permanent",
|
||||
"expired": "expired",
|
||||
"revoke": "Revoke",
|
||||
"revokeAriaLabel": "Revoke role {{role}} from {{user}}",
|
||||
"table": {
|
||||
"user": "User",
|
||||
"role": "Role",
|
||||
"assignedBy": "Assigned by",
|
||||
"assignedAt": "Assigned at",
|
||||
"expires": "Expires",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "{{from}}-{{to}} of {{total}}",
|
||||
"prev": "Previous page",
|
||||
"next": "Next page"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Revoke role?",
|
||||
"text": "This user will immediately lose all permissions associated with this role.",
|
||||
"cancel": "Cancel",
|
||||
"revoke": "Revoke",
|
||||
"revoking": "Revoking..."
|
||||
},
|
||||
"errors": {
|
||||
"userRequired": "Please select a user",
|
||||
"roleRequired": "Please select a role",
|
||||
"assignFailed": "Failed to assign role"
|
||||
}
|
||||
},
|
||||
"policies": {
|
||||
"title": "Access Policies",
|
||||
"subtitle": "Manage ABAC access control policies",
|
||||
"createPolicy": "Create Policy",
|
||||
"noPolicies": "No policies found",
|
||||
"inactiveBadge": "Inactive",
|
||||
"effectAllow": "Allow",
|
||||
"effectDeny": "Deny",
|
||||
"global": "Global",
|
||||
"unknownRole": "Unknown role",
|
||||
"roleLabel": "Role",
|
||||
"priorityLabel": "Priority",
|
||||
"stats": {
|
||||
"total": "Total policies",
|
||||
"allow": "Allow",
|
||||
"deny": "Deny",
|
||||
"active": "Active"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "Create Policy",
|
||||
"editTitle": "Edit Policy",
|
||||
"close": "Close"
|
||||
},
|
||||
"form": {
|
||||
"name": "Policy name",
|
||||
"namePlaceholder": "e.g. Block night access",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "What this policy does...",
|
||||
"effect": "Effect",
|
||||
"resource": "Resource",
|
||||
"selectResource": "Select a resource...",
|
||||
"actions": "Actions",
|
||||
"role": "Role (optional)",
|
||||
"globalOption": "Global (all roles)",
|
||||
"roleHint": "Leave as Global to apply the policy to all roles.",
|
||||
"priority": "Priority",
|
||||
"priorityHint": "Higher priority policies are evaluated first. Deny overrides Allow at equal priority.",
|
||||
"conditions": "Conditions",
|
||||
"cancel": "Cancel",
|
||||
"saving": "Saving...",
|
||||
"save": "Save"
|
||||
},
|
||||
"conditions": {
|
||||
"timeRange": "Time range",
|
||||
"timeStart": "Start time",
|
||||
"timeEnd": "End time",
|
||||
"ipWhitelist": "IP whitelist",
|
||||
"ipPlaceholder": "Enter IP and press Enter...",
|
||||
"removeIp": "Remove {{ip}}",
|
||||
"ipCount_one": "{{count}} IP",
|
||||
"ipCount_other": "{{count}} IPs",
|
||||
"rateLimit": "Rate limit",
|
||||
"rateLimitValue": "Rate limit value",
|
||||
"rateValue": "{{count}}/hr",
|
||||
"perHour": "actions per hour"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Delete policy?",
|
||||
"text": "This policy will be permanently deleted. Access control rules will be recalculated.",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"deleting": "Deleting..."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Failed to load policies",
|
||||
"createFailed": "Failed to create policy",
|
||||
"updateFailed": "Failed to update policy",
|
||||
"nameRequired": "Policy name is required",
|
||||
"resourceRequired": "Please select a resource",
|
||||
"actionsRequired": "Please select at least one action"
|
||||
}
|
||||
},
|
||||
"auditLog": {
|
||||
"title": "Audit Log",
|
||||
"subtitle": "Review system activity and access history",
|
||||
"back": "Back",
|
||||
"exportCsv": "Export CSV",
|
||||
"exporting": "Exporting...",
|
||||
"refresh": "Refresh",
|
||||
"unknownUser": "Unknown user",
|
||||
"noEntries": "No audit log entries found",
|
||||
"totalEntries_one": "{{count}} entry",
|
||||
"totalEntries_other": "{{count}} entries",
|
||||
"autoRefresh": {
|
||||
"label": "Auto-refresh",
|
||||
"tooltip": "Auto-refresh every 30 seconds"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filters",
|
||||
"active": "Active",
|
||||
"action": "Action",
|
||||
"actionPlaceholder": "Search by action...",
|
||||
"resource": "Resource type",
|
||||
"allResources": "All resources",
|
||||
"status": "Status",
|
||||
"allStatuses": "All statuses",
|
||||
"dateFrom": "Date from",
|
||||
"dateTo": "Date to",
|
||||
"apply": "Apply",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"status": {
|
||||
"success": "Success",
|
||||
"denied": "Denied",
|
||||
"error": "Error"
|
||||
},
|
||||
"resourceTypes": {
|
||||
"users": "Users",
|
||||
"tickets": "Tickets",
|
||||
"roles": "Roles",
|
||||
"policies": "Policies",
|
||||
"settings": "Settings",
|
||||
"broadcasts": "Broadcasts",
|
||||
"tariffs": "Tariffs",
|
||||
"servers": "Servers",
|
||||
"payments": "Payments",
|
||||
"campaigns": "Campaigns",
|
||||
"promocodes": "Promo Codes"
|
||||
},
|
||||
"details": {
|
||||
"userAgent": "User Agent",
|
||||
"requestPath": "Request Path",
|
||||
"ipAddress": "IP Address",
|
||||
"timestamp": "Timestamp",
|
||||
"before": "Before",
|
||||
"after": "After",
|
||||
"fullDetails": "Full Details"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "Just now",
|
||||
"minutesAgo_one": "{{count}} min ago",
|
||||
"minutesAgo_other": "{{count}} min ago",
|
||||
"hoursAgo_one": "{{count}} hr ago",
|
||||
"hoursAgo_other": "{{count}} hrs ago",
|
||||
"daysAgo_one": "{{count}} day ago",
|
||||
"daysAgo_other": "{{count}} days ago"
|
||||
},
|
||||
"pagination": {
|
||||
"pageSize": "Per page:",
|
||||
"pageOf": "{{current}} / {{total}}",
|
||||
"first": "First page",
|
||||
"previous": "Previous page",
|
||||
"next": "Next page",
|
||||
"last": "Last page"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Failed to load audit log",
|
||||
"retry": "Try again"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
|
||||
@@ -742,7 +742,11 @@
|
||||
"pinnedMessages": "پیامهای سنجاقشده",
|
||||
"partners": "شرکا",
|
||||
"withdrawals": "برداشتها",
|
||||
"channelSubscriptions": "کانالهای اجباری"
|
||||
"channelSubscriptions": "کانالهای اجباری",
|
||||
"roles": "نقشها",
|
||||
"roleAssign": "تخصیص نقش",
|
||||
"policies": "سیاستهای دسترسی",
|
||||
"auditLog": "گزارش بازرسی"
|
||||
},
|
||||
"panel": {
|
||||
"title": "پنل مدیریت",
|
||||
@@ -770,7 +774,11 @@
|
||||
"pinnedMessagesDesc": "مدیریت پیامهای سنجاقشده",
|
||||
"partnersDesc": "مدیریت درخواستهای شراکت و کمیسیونها",
|
||||
"withdrawalsDesc": "بررسی و پردازش درخواستهای برداشت",
|
||||
"channelSubscriptionsDesc": "مدیریت اشتراکهای اجباری کانال"
|
||||
"channelSubscriptionsDesc": "مدیریت اشتراکهای اجباری کانال",
|
||||
"rolesDesc": "مدیریت نقشها و مجوزهای مدیران",
|
||||
"roleAssignDesc": "تخصیص و لغو نقشهای کاربران",
|
||||
"policiesDesc": "تنظیم سیاستهای کنترل دسترسی ABAC",
|
||||
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "مصرف ترافیک",
|
||||
@@ -2202,7 +2210,8 @@
|
||||
"marketing": "بازاریابی",
|
||||
"system": "سیستم",
|
||||
"tariffs": "تعرفهها و فروش",
|
||||
"users": "کاربران"
|
||||
"users": "کاربران",
|
||||
"security": "امنیت"
|
||||
},
|
||||
"remnawave": {
|
||||
"connected": "متصل",
|
||||
@@ -2783,6 +2792,265 @@
|
||||
"node": "گره",
|
||||
"requests": "درخواستها",
|
||||
"ipHistory": "تاریخچه IP"
|
||||
},
|
||||
"roles": {
|
||||
"title": "نقشها و مجوزها",
|
||||
"subtitle": "مدیریت نقشهای مدیران و مجوزهای آنها",
|
||||
"createRole": "ایجاد نقش",
|
||||
"noRoles": "نقشی یافت نشد",
|
||||
"systemBadge": "سیستمی",
|
||||
"inactiveBadge": "غیرفعال",
|
||||
"levelLabel": "سطح",
|
||||
"usersCount_other": "{{count}} کاربر",
|
||||
"permissionsCount_other": "{{count}} مجوز",
|
||||
"stats": {
|
||||
"totalRoles": "کل نقشها",
|
||||
"active": "فعال",
|
||||
"system": "سیستمی"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "ویرایش",
|
||||
"delete": "حذف"
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "ایجاد نقش",
|
||||
"editTitle": "ویرایش نقش",
|
||||
"close": "بستن"
|
||||
},
|
||||
"form": {
|
||||
"name": "نام نقش",
|
||||
"namePlaceholder": "مثلاً مدیر محتوا",
|
||||
"description": "توضیحات",
|
||||
"descriptionPlaceholder": "کاربرد این نقش...",
|
||||
"level": "سطح اولویت",
|
||||
"levelValue": "مقدار سطح",
|
||||
"levelHint": "سطح بالاتر = اختیارات بیشتر. کاربران فقط میتوانند نقشهایی با سطح پایینتر از خود را مدیریت کنند.",
|
||||
"color": "رنگ",
|
||||
"presets": "الگوهای مجوز",
|
||||
"permissions": "مجوزها",
|
||||
"toggleSection": "تغییر وضعیت تمام مجوزهای {{section}}",
|
||||
"selectedPermissions_other": "{{count}} مجوز انتخاب شده",
|
||||
"cancel": "لغو",
|
||||
"saving": "در حال ذخیره...",
|
||||
"save": "ذخیره"
|
||||
},
|
||||
"presets": {
|
||||
"moderator": "مدیر محتوا",
|
||||
"marketer": "بازاریاب",
|
||||
"support": "پشتیبانی"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "حذف نقش؟",
|
||||
"text": "این نقش برای همیشه حذف خواهد شد. کاربران دارای این نقش مجوزهای آن را از دست خواهند داد.",
|
||||
"cancel": "لغو",
|
||||
"delete": "حذف",
|
||||
"deleting": "در حال حذف..."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "بارگذاری نقشها ناموفق بود",
|
||||
"createFailed": "ایجاد نقش ناموفق بود",
|
||||
"updateFailed": "بهروزرسانی نقش ناموفق بود",
|
||||
"nameRequired": "نام نقش الزامی است"
|
||||
}
|
||||
},
|
||||
"roleAssign": {
|
||||
"title": "تخصیص نقش",
|
||||
"subtitle": "تخصیص و مدیریت نقشهای کاربران",
|
||||
"assignSection": "تخصیص نقش",
|
||||
"userLabel": "کاربر",
|
||||
"searchPlaceholder": "جستجو بر اساس نام، نام کاربری، شناسه تلگرام یا ایمیل...",
|
||||
"clearUser": "پاک کردن کاربر انتخابشده",
|
||||
"noUsersFound": "کاربری یافت نشد",
|
||||
"roleLabel": "نقش",
|
||||
"selectRole": "یک نقش انتخاب کنید...",
|
||||
"expiresLabel": "تاریخ انقضا (اختیاری)",
|
||||
"expiresHint": "برای تخصیص دائمی خالی بگذارید",
|
||||
"assignButton": "تخصیص",
|
||||
"assigning": "در حال تخصیص...",
|
||||
"assignSuccess": "نقش با موفقیت تخصیص داده شد",
|
||||
"currentAssignments": "تخصیصهای فعلی",
|
||||
"totalAssignments_other": "{{count}} تخصیص",
|
||||
"noAssignments": "تخصیص نقشی یافت نشد",
|
||||
"unknownUser": "کاربر ناشناخته",
|
||||
"system": "سیستم",
|
||||
"permanent": "دائمی",
|
||||
"expired": "منقضی شده",
|
||||
"revoke": "لغو",
|
||||
"revokeAriaLabel": "لغو نقش {{role}} از {{user}}",
|
||||
"table": {
|
||||
"user": "کاربر",
|
||||
"role": "نقش",
|
||||
"assignedBy": "تخصیصدهنده",
|
||||
"assignedAt": "تاریخ تخصیص",
|
||||
"expires": "انقضا",
|
||||
"actions": "عملیات"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "{{from}}-{{to}} از {{total}}",
|
||||
"prev": "صفحه قبل",
|
||||
"next": "صفحه بعد"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "لغو نقش؟",
|
||||
"text": "این کاربر فوراً تمام مجوزهای مرتبط با این نقش را از دست خواهد داد.",
|
||||
"cancel": "لغو",
|
||||
"revoke": "لغو نقش",
|
||||
"revoking": "در حال لغو..."
|
||||
},
|
||||
"errors": {
|
||||
"userRequired": "لطفاً یک کاربر انتخاب کنید",
|
||||
"roleRequired": "لطفاً یک نقش انتخاب کنید",
|
||||
"assignFailed": "تخصیص نقش ناموفق بود"
|
||||
}
|
||||
},
|
||||
"policies": {
|
||||
"title": "سیاستهای دسترسی",
|
||||
"subtitle": "مدیریت سیاستهای کنترل دسترسی ABAC",
|
||||
"createPolicy": "ایجاد سیاست",
|
||||
"noPolicies": "سیاستی یافت نشد",
|
||||
"inactiveBadge": "غیرفعال",
|
||||
"effectAllow": "اجازه",
|
||||
"effectDeny": "رد",
|
||||
"global": "سراسری",
|
||||
"unknownRole": "نقش ناشناخته",
|
||||
"roleLabel": "نقش",
|
||||
"priorityLabel": "اولویت",
|
||||
"stats": {
|
||||
"total": "کل سیاستها",
|
||||
"allow": "اجازه",
|
||||
"deny": "رد",
|
||||
"active": "فعال"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "ویرایش",
|
||||
"delete": "حذف"
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "ایجاد سیاست",
|
||||
"editTitle": "ویرایش سیاست",
|
||||
"close": "بستن"
|
||||
},
|
||||
"form": {
|
||||
"name": "نام سیاست",
|
||||
"namePlaceholder": "مثلاً مسدودسازی دسترسی شبانه",
|
||||
"description": "توضیحات",
|
||||
"descriptionPlaceholder": "عملکرد این سیاست...",
|
||||
"effect": "اثر",
|
||||
"resource": "منبع",
|
||||
"selectResource": "یک منبع انتخاب کنید...",
|
||||
"actions": "عملیات",
|
||||
"role": "نقش (اختیاری)",
|
||||
"globalOption": "سراسری (همه نقشها)",
|
||||
"roleHint": "سراسری بگذارید تا سیاست برای همه نقشها اعمال شود.",
|
||||
"priority": "اولویت",
|
||||
"priorityHint": "سیاستهای با اولویت بالاتر ابتدا ارزیابی میشوند. رد در اولویت مساوی بر اجازه غلبه میکند.",
|
||||
"conditions": "شرایط",
|
||||
"cancel": "لغو",
|
||||
"saving": "در حال ذخیره...",
|
||||
"save": "ذخیره"
|
||||
},
|
||||
"conditions": {
|
||||
"timeRange": "بازه زمانی",
|
||||
"timeStart": "زمان شروع",
|
||||
"timeEnd": "زمان پایان",
|
||||
"ipWhitelist": "لیست سفید IP",
|
||||
"ipPlaceholder": "IP را وارد کنید و Enter بزنید...",
|
||||
"removeIp": "حذف {{ip}}",
|
||||
"ipCount_other": "{{count}} IP",
|
||||
"rateLimit": "محدودیت نرخ",
|
||||
"rateLimitValue": "مقدار محدودیت نرخ",
|
||||
"rateValue": "{{count}}/ساعت",
|
||||
"perHour": "عملیات در ساعت"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "حذف سیاست؟",
|
||||
"text": "این سیاست برای همیشه حذف خواهد شد. قوانین کنترل دسترسی مجدداً محاسبه خواهند شد.",
|
||||
"cancel": "لغو",
|
||||
"delete": "حذف",
|
||||
"deleting": "در حال حذف..."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "بارگذاری سیاستها ناموفق بود",
|
||||
"createFailed": "ایجاد سیاست ناموفق بود",
|
||||
"updateFailed": "بهروزرسانی سیاست ناموفق بود",
|
||||
"nameRequired": "نام سیاست الزامی است",
|
||||
"resourceRequired": "لطفاً یک منبع انتخاب کنید",
|
||||
"actionsRequired": "لطفاً حداقل یک عملیات انتخاب کنید"
|
||||
}
|
||||
},
|
||||
"auditLog": {
|
||||
"title": "گزارش بازرسی",
|
||||
"subtitle": "بررسی فعالیت سیستم و تاریخچه دسترسی",
|
||||
"back": "بازگشت",
|
||||
"exportCsv": "خروجی CSV",
|
||||
"exporting": "در حال خروجی...",
|
||||
"refresh": "بازخوانی",
|
||||
"unknownUser": "کاربر ناشناخته",
|
||||
"noEntries": "رکوردی در گزارش بازرسی یافت نشد",
|
||||
"totalEntries_other": "{{count}} رکورد",
|
||||
"autoRefresh": {
|
||||
"label": "بازخوانی خودکار",
|
||||
"tooltip": "بازخوانی خودکار هر ۳۰ ثانیه"
|
||||
},
|
||||
"filters": {
|
||||
"title": "فیلترها",
|
||||
"active": "فعال",
|
||||
"action": "عملیات",
|
||||
"actionPlaceholder": "جستجو بر اساس عملیات...",
|
||||
"resource": "نوع منبع",
|
||||
"allResources": "همه منابع",
|
||||
"status": "وضعیت",
|
||||
"allStatuses": "همه وضعیتها",
|
||||
"dateFrom": "از تاریخ",
|
||||
"dateTo": "تا تاریخ",
|
||||
"apply": "اعمال",
|
||||
"clear": "پاک کردن"
|
||||
},
|
||||
"status": {
|
||||
"success": "موفق",
|
||||
"denied": "رد شده",
|
||||
"error": "خطا"
|
||||
},
|
||||
"resourceTypes": {
|
||||
"users": "کاربران",
|
||||
"tickets": "تیکتها",
|
||||
"roles": "نقشها",
|
||||
"policies": "سیاستها",
|
||||
"settings": "تنظیمات",
|
||||
"broadcasts": "پیامرسانی",
|
||||
"tariffs": "تعرفهها",
|
||||
"servers": "سرورها",
|
||||
"payments": "پرداختها",
|
||||
"campaigns": "کمپینها",
|
||||
"promocodes": "کدهای تخفیف"
|
||||
},
|
||||
"details": {
|
||||
"userAgent": "عامل کاربر",
|
||||
"requestPath": "مسیر درخواست",
|
||||
"ipAddress": "آدرس IP",
|
||||
"timestamp": "زمان",
|
||||
"before": "قبل",
|
||||
"after": "بعد",
|
||||
"fullDetails": "جزئیات کامل"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "همین الان",
|
||||
"minutesAgo_other": "{{count}} دقیقه پیش",
|
||||
"hoursAgo_other": "{{count}} ساعت پیش",
|
||||
"daysAgo_other": "{{count}} روز پیش"
|
||||
},
|
||||
"pagination": {
|
||||
"pageSize": "در هر صفحه:",
|
||||
"pageOf": "{{current}} / {{total}}",
|
||||
"first": "صفحه اول",
|
||||
"previous": "صفحه قبل",
|
||||
"next": "صفحه بعد",
|
||||
"last": "صفحه آخر"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "بارگذاری گزارش بازرسی ناموفق بود",
|
||||
"retry": "تلاش مجدد"
|
||||
}
|
||||
}
|
||||
},
|
||||
"blocking": {
|
||||
|
||||
@@ -884,7 +884,11 @@
|
||||
"pinnedMessages": "Закреплённые",
|
||||
"partners": "Партнёры",
|
||||
"withdrawals": "Выводы",
|
||||
"channelSubscriptions": "Обязательные каналы"
|
||||
"channelSubscriptions": "Обязательные каналы",
|
||||
"roles": "Роли",
|
||||
"roleAssign": "Назначение ролей",
|
||||
"policies": "Политики доступа",
|
||||
"auditLog": "Журнал аудита"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Панель администратора",
|
||||
@@ -912,7 +916,11 @@
|
||||
"pinnedMessagesDesc": "Управление закреплёнными сообщениями",
|
||||
"partnersDesc": "Управление заявками партнёров и комиссиями",
|
||||
"withdrawalsDesc": "Проверка и обработка заявок на вывод",
|
||||
"channelSubscriptionsDesc": "Управление обязательными подписками на каналы"
|
||||
"channelSubscriptionsDesc": "Управление обязательными подписками на каналы",
|
||||
"rolesDesc": "Управление ролями и правами администраторов",
|
||||
"roleAssignDesc": "Назначение и отзыв ролей пользователей",
|
||||
"policiesDesc": "Настройка политик контроля доступа ABAC",
|
||||
"auditLogDesc": "Просмотр активности системы и истории доступа"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "Расход трафика",
|
||||
@@ -3063,7 +3071,8 @@
|
||||
"marketing": "Маркетинг",
|
||||
"system": "Система",
|
||||
"tariffs": "Тарифы и продажи",
|
||||
"users": "Пользователи"
|
||||
"users": "Пользователи",
|
||||
"security": "Безопасность"
|
||||
},
|
||||
"theme": {
|
||||
"accentColor": "Акцентный цвет",
|
||||
@@ -3472,6 +3481,283 @@
|
||||
"unhealthy": "Проблема",
|
||||
"components": "Компоненты",
|
||||
"uptime": "Аптайм"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Роли и права",
|
||||
"subtitle": "Управление ролями администраторов и их правами",
|
||||
"createRole": "Создать роль",
|
||||
"noRoles": "Роли не найдены",
|
||||
"systemBadge": "Системная",
|
||||
"inactiveBadge": "Неактивна",
|
||||
"levelLabel": "Уровень",
|
||||
"usersCount_one": "{{count}} пользователь",
|
||||
"usersCount_few": "{{count}} пользователя",
|
||||
"usersCount_many": "{{count}} пользователей",
|
||||
"permissionsCount_one": "{{count}} разрешение",
|
||||
"permissionsCount_few": "{{count}} разрешения",
|
||||
"permissionsCount_many": "{{count}} разрешений",
|
||||
"stats": {
|
||||
"totalRoles": "Всего ролей",
|
||||
"active": "Активные",
|
||||
"system": "Системные"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "Создание роли",
|
||||
"editTitle": "Редактирование роли",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"form": {
|
||||
"name": "Название роли",
|
||||
"namePlaceholder": "например, Модератор",
|
||||
"description": "Описание",
|
||||
"descriptionPlaceholder": "Для чего нужна эта роль...",
|
||||
"level": "Уровень приоритета",
|
||||
"levelValue": "Значение уровня",
|
||||
"levelHint": "Чем выше уровень, тем больше полномочий. Пользователи могут управлять только ролями с уровнем ниже своего.",
|
||||
"color": "Цвет",
|
||||
"presets": "Шаблоны прав",
|
||||
"permissions": "Разрешения",
|
||||
"toggleSection": "Переключить все права {{section}}",
|
||||
"selectedPermissions_one": "{{count}} разрешение выбрано",
|
||||
"selectedPermissions_few": "{{count}} разрешения выбрано",
|
||||
"selectedPermissions_many": "{{count}} разрешений выбрано",
|
||||
"cancel": "Отмена",
|
||||
"saving": "Сохранение...",
|
||||
"save": "Сохранить"
|
||||
},
|
||||
"presets": {
|
||||
"moderator": "Модератор",
|
||||
"marketer": "Маркетолог",
|
||||
"support": "Поддержка"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Удалить роль?",
|
||||
"text": "Роль будет безвозвратно удалена. Пользователи с этой ролью потеряют её права.",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
"deleting": "Удаление..."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Не удалось загрузить роли",
|
||||
"createFailed": "Не удалось создать роль",
|
||||
"updateFailed": "Не удалось обновить роль",
|
||||
"nameRequired": "Укажите название роли"
|
||||
}
|
||||
},
|
||||
"roleAssign": {
|
||||
"title": "Назначение ролей",
|
||||
"subtitle": "Назначение и управление ролями пользователей",
|
||||
"assignSection": "Назначить роль",
|
||||
"userLabel": "Пользователь",
|
||||
"searchPlaceholder": "Поиск по имени, username, Telegram ID или email...",
|
||||
"clearUser": "Очистить выбранного пользователя",
|
||||
"noUsersFound": "Пользователи не найдены",
|
||||
"roleLabel": "Роль",
|
||||
"selectRole": "Выберите роль...",
|
||||
"expiresLabel": "Истекает (необязательно)",
|
||||
"expiresHint": "Оставьте пустым для бессрочного назначения",
|
||||
"assignButton": "Назначить",
|
||||
"assigning": "Назначение...",
|
||||
"assignSuccess": "Роль успешно назначена",
|
||||
"currentAssignments": "Текущие назначения",
|
||||
"totalAssignments_one": "{{count}} назначение",
|
||||
"totalAssignments_few": "{{count}} назначения",
|
||||
"totalAssignments_many": "{{count}} назначений",
|
||||
"noAssignments": "Назначения ролей не найдены",
|
||||
"unknownUser": "Неизвестный пользователь",
|
||||
"system": "Система",
|
||||
"permanent": "Бессрочно",
|
||||
"expired": "истекло",
|
||||
"revoke": "Отозвать",
|
||||
"revokeAriaLabel": "Отозвать роль {{role}} у {{user}}",
|
||||
"table": {
|
||||
"user": "Пользователь",
|
||||
"role": "Роль",
|
||||
"assignedBy": "Назначил",
|
||||
"assignedAt": "Дата назначения",
|
||||
"expires": "Истекает",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "{{from}}-{{to}} из {{total}}",
|
||||
"prev": "Предыдущая страница",
|
||||
"next": "Следующая страница"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Отозвать роль?",
|
||||
"text": "Пользователь немедленно потеряет все права, связанные с этой ролью.",
|
||||
"cancel": "Отмена",
|
||||
"revoke": "Отозвать",
|
||||
"revoking": "Отзыв..."
|
||||
},
|
||||
"errors": {
|
||||
"userRequired": "Выберите пользователя",
|
||||
"roleRequired": "Выберите роль",
|
||||
"assignFailed": "Не удалось назначить роль"
|
||||
}
|
||||
},
|
||||
"policies": {
|
||||
"title": "Политики доступа",
|
||||
"subtitle": "Управление политиками контроля доступа ABAC",
|
||||
"createPolicy": "Создать политику",
|
||||
"noPolicies": "Политики не найдены",
|
||||
"inactiveBadge": "Неактивна",
|
||||
"effectAllow": "Разрешить",
|
||||
"effectDeny": "Запретить",
|
||||
"global": "Глобальная",
|
||||
"unknownRole": "Неизвестная роль",
|
||||
"roleLabel": "Роль",
|
||||
"priorityLabel": "Приоритет",
|
||||
"stats": {
|
||||
"total": "Всего политик",
|
||||
"allow": "Разрешающие",
|
||||
"deny": "Запрещающие",
|
||||
"active": "Активные"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "Создание политики",
|
||||
"editTitle": "Редактирование политики",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"form": {
|
||||
"name": "Название политики",
|
||||
"namePlaceholder": "например, Блокировка ночного доступа",
|
||||
"description": "Описание",
|
||||
"descriptionPlaceholder": "Что делает эта политика...",
|
||||
"effect": "Эффект",
|
||||
"resource": "Ресурс",
|
||||
"selectResource": "Выберите ресурс...",
|
||||
"actions": "Действия",
|
||||
"role": "Роль (необязательно)",
|
||||
"globalOption": "Глобальная (все роли)",
|
||||
"roleHint": "Оставьте Глобальная, чтобы политика применялась ко всем ролям.",
|
||||
"priority": "Приоритет",
|
||||
"priorityHint": "Политики с более высоким приоритетом проверяются первыми. Запрет перекрывает Разрешение при равном приоритете.",
|
||||
"conditions": "Условия",
|
||||
"cancel": "Отмена",
|
||||
"saving": "Сохранение...",
|
||||
"save": "Сохранить"
|
||||
},
|
||||
"conditions": {
|
||||
"timeRange": "Временной диапазон",
|
||||
"timeStart": "Время начала",
|
||||
"timeEnd": "Время окончания",
|
||||
"ipWhitelist": "Белый список IP",
|
||||
"ipPlaceholder": "Введите IP и нажмите Enter...",
|
||||
"removeIp": "Удалить {{ip}}",
|
||||
"ipCount_one": "{{count}} IP",
|
||||
"ipCount_few": "{{count}} IP",
|
||||
"ipCount_many": "{{count}} IP",
|
||||
"rateLimit": "Лимит запросов",
|
||||
"rateLimitValue": "Значение лимита запросов",
|
||||
"rateValue": "{{count}}/ч",
|
||||
"perHour": "действий в час"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Удалить политику?",
|
||||
"text": "Политика будет безвозвратно удалена. Правила контроля доступа будут пересчитаны.",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
"deleting": "Удаление..."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Не удалось загрузить политики",
|
||||
"createFailed": "Не удалось создать политику",
|
||||
"updateFailed": "Не удалось обновить политику",
|
||||
"nameRequired": "Укажите название политики",
|
||||
"resourceRequired": "Выберите ресурс",
|
||||
"actionsRequired": "Выберите хотя бы одно действие"
|
||||
}
|
||||
},
|
||||
"auditLog": {
|
||||
"title": "Журнал аудита",
|
||||
"subtitle": "Просмотр активности системы и истории доступа",
|
||||
"back": "Назад",
|
||||
"exportCsv": "Экспорт CSV",
|
||||
"exporting": "Экспорт...",
|
||||
"refresh": "Обновить",
|
||||
"unknownUser": "Неизвестный пользователь",
|
||||
"noEntries": "Записи журнала аудита не найдены",
|
||||
"totalEntries_one": "{{count}} запись",
|
||||
"totalEntries_few": "{{count}} записи",
|
||||
"totalEntries_many": "{{count}} записей",
|
||||
"autoRefresh": {
|
||||
"label": "Автообновление",
|
||||
"tooltip": "Автообновление каждые 30 секунд"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Фильтры",
|
||||
"active": "Активны",
|
||||
"action": "Действие",
|
||||
"actionPlaceholder": "Поиск по действию...",
|
||||
"resource": "Тип ресурса",
|
||||
"allResources": "Все ресурсы",
|
||||
"status": "Статус",
|
||||
"allStatuses": "Все статусы",
|
||||
"dateFrom": "Дата от",
|
||||
"dateTo": "Дата до",
|
||||
"apply": "Применить",
|
||||
"clear": "Сбросить"
|
||||
},
|
||||
"status": {
|
||||
"success": "Успешно",
|
||||
"denied": "Отказано",
|
||||
"error": "Ошибка"
|
||||
},
|
||||
"resourceTypes": {
|
||||
"users": "Пользователи",
|
||||
"tickets": "Тикеты",
|
||||
"roles": "Роли",
|
||||
"policies": "Политики",
|
||||
"settings": "Настройки",
|
||||
"broadcasts": "Рассылки",
|
||||
"tariffs": "Тарифы",
|
||||
"servers": "Серверы",
|
||||
"payments": "Платежи",
|
||||
"campaigns": "Кампании",
|
||||
"promocodes": "Промокоды"
|
||||
},
|
||||
"details": {
|
||||
"userAgent": "User Agent",
|
||||
"requestPath": "Путь запроса",
|
||||
"ipAddress": "IP-адрес",
|
||||
"timestamp": "Время",
|
||||
"before": "До",
|
||||
"after": "После",
|
||||
"fullDetails": "Полные данные"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "Только что",
|
||||
"minutesAgo_one": "{{count}} мин назад",
|
||||
"minutesAgo_few": "{{count}} мин назад",
|
||||
"minutesAgo_many": "{{count}} мин назад",
|
||||
"hoursAgo_one": "{{count}} ч назад",
|
||||
"hoursAgo_few": "{{count}} ч назад",
|
||||
"hoursAgo_many": "{{count}} ч назад",
|
||||
"daysAgo_one": "{{count}} день назад",
|
||||
"daysAgo_few": "{{count}} дня назад",
|
||||
"daysAgo_many": "{{count}} дней назад"
|
||||
},
|
||||
"pagination": {
|
||||
"pageSize": "На странице:",
|
||||
"pageOf": "{{current}} / {{total}}",
|
||||
"first": "Первая страница",
|
||||
"previous": "Предыдущая страница",
|
||||
"next": "Следующая страница",
|
||||
"last": "Последняя страница"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Не удалось загрузить журнал аудита",
|
||||
"retry": "Попробовать снова"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
|
||||
@@ -742,7 +742,11 @@
|
||||
"pinnedMessages": "置顶消息",
|
||||
"partners": "合作伙伴",
|
||||
"withdrawals": "提现",
|
||||
"channelSubscriptions": "必订频道"
|
||||
"channelSubscriptions": "必订频道",
|
||||
"roles": "角色",
|
||||
"roleAssign": "角色分配",
|
||||
"policies": "访问策略",
|
||||
"auditLog": "审计日志"
|
||||
},
|
||||
"panel": {
|
||||
"title": "管理面板",
|
||||
@@ -770,7 +774,11 @@
|
||||
"pinnedMessagesDesc": "管理用户置顶消息",
|
||||
"partnersDesc": "管理合作伙伴申请和佣金",
|
||||
"withdrawalsDesc": "审核和处理提现请求",
|
||||
"channelSubscriptionsDesc": "管理必订频道订阅"
|
||||
"channelSubscriptionsDesc": "管理必订频道订阅",
|
||||
"rolesDesc": "管理管理员角色和权限",
|
||||
"roleAssignDesc": "分配和撤销用户角色",
|
||||
"policiesDesc": "配置ABAC访问控制策略",
|
||||
"auditLogDesc": "查看系统活动和访问历史"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "流量使用",
|
||||
@@ -2201,7 +2209,8 @@
|
||||
"marketing": "营销",
|
||||
"system": "系统",
|
||||
"tariffs": "套餐与销售",
|
||||
"users": "用户"
|
||||
"users": "用户",
|
||||
"security": "安全"
|
||||
},
|
||||
"theme": {
|
||||
"accentColor": "强调色",
|
||||
@@ -2809,6 +2818,265 @@
|
||||
"node": "节点",
|
||||
"requests": "请求数",
|
||||
"ipHistory": "IP历史"
|
||||
},
|
||||
"roles": {
|
||||
"title": "角色与权限",
|
||||
"subtitle": "管理管理员角色及其权限",
|
||||
"createRole": "创建角色",
|
||||
"noRoles": "未找到角色",
|
||||
"systemBadge": "系统",
|
||||
"inactiveBadge": "未激活",
|
||||
"levelLabel": "级别",
|
||||
"usersCount_other": "{{count}} 个用户",
|
||||
"permissionsCount_other": "{{count}} 个权限",
|
||||
"stats": {
|
||||
"totalRoles": "角色总数",
|
||||
"active": "活跃",
|
||||
"system": "系统"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "编辑",
|
||||
"delete": "删除"
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "创建角色",
|
||||
"editTitle": "编辑角色",
|
||||
"close": "关闭"
|
||||
},
|
||||
"form": {
|
||||
"name": "角色名称",
|
||||
"namePlaceholder": "例如:版主",
|
||||
"description": "描述",
|
||||
"descriptionPlaceholder": "此角色的用途...",
|
||||
"level": "优先级",
|
||||
"levelValue": "级别值",
|
||||
"levelHint": "级别越高,权限越大。用户只能管理级别低于自己的角色。",
|
||||
"color": "颜色",
|
||||
"presets": "权限预设",
|
||||
"permissions": "权限",
|
||||
"toggleSection": "切换所有 {{section}} 权限",
|
||||
"selectedPermissions_other": "已选择 {{count}} 个权限",
|
||||
"cancel": "取消",
|
||||
"saving": "保存中...",
|
||||
"save": "保存"
|
||||
},
|
||||
"presets": {
|
||||
"moderator": "版主",
|
||||
"marketer": "营销员",
|
||||
"support": "客服"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "删除角色?",
|
||||
"text": "此角色将被永久删除。拥有此角色的用户将失去其权限。",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"deleting": "删除中..."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "加载角色失败",
|
||||
"createFailed": "创建角色失败",
|
||||
"updateFailed": "更新角色失败",
|
||||
"nameRequired": "请输入角色名称"
|
||||
}
|
||||
},
|
||||
"roleAssign": {
|
||||
"title": "角色分配",
|
||||
"subtitle": "分配和管理用户角色",
|
||||
"assignSection": "分配角色",
|
||||
"userLabel": "用户",
|
||||
"searchPlaceholder": "按名称、用户名、Telegram ID或邮箱搜索...",
|
||||
"clearUser": "清除已选用户",
|
||||
"noUsersFound": "未找到用户",
|
||||
"roleLabel": "角色",
|
||||
"selectRole": "选择角色...",
|
||||
"expiresLabel": "到期时间(可选)",
|
||||
"expiresHint": "留空表示永久分配",
|
||||
"assignButton": "分配",
|
||||
"assigning": "分配中...",
|
||||
"assignSuccess": "角色分配成功",
|
||||
"currentAssignments": "当前分配",
|
||||
"totalAssignments_other": "共 {{count}} 个分配",
|
||||
"noAssignments": "未找到角色分配",
|
||||
"unknownUser": "未知用户",
|
||||
"system": "系统",
|
||||
"permanent": "永久",
|
||||
"expired": "已过期",
|
||||
"revoke": "撤销",
|
||||
"revokeAriaLabel": "撤销 {{user}} 的角色 {{role}}",
|
||||
"table": {
|
||||
"user": "用户",
|
||||
"role": "角色",
|
||||
"assignedBy": "分配者",
|
||||
"assignedAt": "分配时间",
|
||||
"expires": "到期",
|
||||
"actions": "操作"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "{{from}}-{{to}} / {{total}}",
|
||||
"prev": "上一页",
|
||||
"next": "下一页"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "撤销角色?",
|
||||
"text": "该用户将立即失去与此角色相关的所有权限。",
|
||||
"cancel": "取消",
|
||||
"revoke": "撤销",
|
||||
"revoking": "撤销中..."
|
||||
},
|
||||
"errors": {
|
||||
"userRequired": "请选择用户",
|
||||
"roleRequired": "请选择角色",
|
||||
"assignFailed": "角色分配失败"
|
||||
}
|
||||
},
|
||||
"policies": {
|
||||
"title": "访问策略",
|
||||
"subtitle": "管理ABAC访问控制策略",
|
||||
"createPolicy": "创建策略",
|
||||
"noPolicies": "未找到策略",
|
||||
"inactiveBadge": "未激活",
|
||||
"effectAllow": "允许",
|
||||
"effectDeny": "拒绝",
|
||||
"global": "全局",
|
||||
"unknownRole": "未知角色",
|
||||
"roleLabel": "角色",
|
||||
"priorityLabel": "优先级",
|
||||
"stats": {
|
||||
"total": "策略总数",
|
||||
"allow": "允许",
|
||||
"deny": "拒绝",
|
||||
"active": "活跃"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "编辑",
|
||||
"delete": "删除"
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "创建策略",
|
||||
"editTitle": "编辑策略",
|
||||
"close": "关闭"
|
||||
},
|
||||
"form": {
|
||||
"name": "策略名称",
|
||||
"namePlaceholder": "例如:阻止夜间访问",
|
||||
"description": "描述",
|
||||
"descriptionPlaceholder": "此策略的作用...",
|
||||
"effect": "效果",
|
||||
"resource": "资源",
|
||||
"selectResource": "选择资源...",
|
||||
"actions": "操作",
|
||||
"role": "角色(可选)",
|
||||
"globalOption": "全局(所有角色)",
|
||||
"roleHint": "保留全局以将策略应用于所有角色。",
|
||||
"priority": "优先级",
|
||||
"priorityHint": "优先级高的策略先被评估。同等优先级下,拒绝覆盖允许。",
|
||||
"conditions": "条件",
|
||||
"cancel": "取消",
|
||||
"saving": "保存中...",
|
||||
"save": "保存"
|
||||
},
|
||||
"conditions": {
|
||||
"timeRange": "时间范围",
|
||||
"timeStart": "开始时间",
|
||||
"timeEnd": "结束时间",
|
||||
"ipWhitelist": "IP白名单",
|
||||
"ipPlaceholder": "输入IP并按回车...",
|
||||
"removeIp": "移除 {{ip}}",
|
||||
"ipCount_other": "{{count}} 个IP",
|
||||
"rateLimit": "速率限制",
|
||||
"rateLimitValue": "速率限制值",
|
||||
"rateValue": "{{count}}/小时",
|
||||
"perHour": "每小时操作数"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "删除策略?",
|
||||
"text": "此策略将被永久删除。访问控制规则将被重新计算。",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"deleting": "删除中..."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "加载策略失败",
|
||||
"createFailed": "创建策略失败",
|
||||
"updateFailed": "更新策略失败",
|
||||
"nameRequired": "请输入策略名称",
|
||||
"resourceRequired": "请选择资源",
|
||||
"actionsRequired": "请至少选择一个操作"
|
||||
}
|
||||
},
|
||||
"auditLog": {
|
||||
"title": "审计日志",
|
||||
"subtitle": "查看系统活动和访问历史",
|
||||
"back": "返回",
|
||||
"exportCsv": "导出CSV",
|
||||
"exporting": "导出中...",
|
||||
"refresh": "刷新",
|
||||
"unknownUser": "未知用户",
|
||||
"noEntries": "未找到审计日志条目",
|
||||
"totalEntries_other": "{{count}} 条记录",
|
||||
"autoRefresh": {
|
||||
"label": "自动刷新",
|
||||
"tooltip": "每30秒自动刷新"
|
||||
},
|
||||
"filters": {
|
||||
"title": "筛选",
|
||||
"active": "已激活",
|
||||
"action": "操作",
|
||||
"actionPlaceholder": "按操作搜索...",
|
||||
"resource": "资源类型",
|
||||
"allResources": "所有资源",
|
||||
"status": "状态",
|
||||
"allStatuses": "所有状态",
|
||||
"dateFrom": "开始日期",
|
||||
"dateTo": "结束日期",
|
||||
"apply": "应用",
|
||||
"clear": "清除"
|
||||
},
|
||||
"status": {
|
||||
"success": "成功",
|
||||
"denied": "拒绝",
|
||||
"error": "错误"
|
||||
},
|
||||
"resourceTypes": {
|
||||
"users": "用户",
|
||||
"tickets": "工单",
|
||||
"roles": "角色",
|
||||
"policies": "策略",
|
||||
"settings": "设置",
|
||||
"broadcasts": "群发",
|
||||
"tariffs": "套餐",
|
||||
"servers": "服务器",
|
||||
"payments": "支付",
|
||||
"campaigns": "活动",
|
||||
"promocodes": "促销码"
|
||||
},
|
||||
"details": {
|
||||
"userAgent": "用户代理",
|
||||
"requestPath": "请求路径",
|
||||
"ipAddress": "IP地址",
|
||||
"timestamp": "时间戳",
|
||||
"before": "之前",
|
||||
"after": "之后",
|
||||
"fullDetails": "完整详情"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "刚刚",
|
||||
"minutesAgo_other": "{{count}} 分钟前",
|
||||
"hoursAgo_other": "{{count}} 小时前",
|
||||
"daysAgo_other": "{{count}} 天前"
|
||||
},
|
||||
"pagination": {
|
||||
"pageSize": "每页:",
|
||||
"pageOf": "{{current}} / {{total}}",
|
||||
"first": "第一页",
|
||||
"previous": "上一页",
|
||||
"next": "下一页",
|
||||
"last": "最后一页"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "加载审计日志失败",
|
||||
"retry": "重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
823
src/pages/AdminAuditLog.tsx
Normal file
823
src/pages/AdminAuditLog.tsx
Normal 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')}
|
||||
>
|
||||
«
|
||||
</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')}
|
||||
>
|
||||
‹
|
||||
</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')}
|
||||
>
|
||||
›
|
||||
</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')}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
1142
src/pages/AdminPolicies.tsx
Normal file
File diff suppressed because it is too large
Load Diff
718
src/pages/AdminRoleAssign.tsx
Normal file
718
src/pages/AdminRoleAssign.tsx
Normal 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
819
src/pages/AdminRoles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { apiClient } from '../api/client';
|
||||
import { captureCampaignFromUrl, consumeCampaignSlug } from '../utils/campaign';
|
||||
import { captureReferralFromUrl, consumeReferralCode } from '../utils/referral';
|
||||
import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token';
|
||||
import { usePermissionStore } from './permissions';
|
||||
|
||||
export interface TelegramWidgetData {
|
||||
id: number;
|
||||
@@ -93,6 +94,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
});
|
||||
}
|
||||
tokenStorage.clearTokens();
|
||||
usePermissionStore.getState().reset();
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
@@ -107,13 +109,20 @@ export const useAuthStore = create<AuthState>()(
|
||||
const token = tokenStorage.getAccessToken();
|
||||
if (!token || !isTokenValid(token)) {
|
||||
set({ isAdmin: false });
|
||||
usePermissionStore.getState().reset();
|
||||
return;
|
||||
}
|
||||
// Используем apiClient для единообразной обработки ошибок
|
||||
const response = await apiClient.get<{ is_admin: boolean }>('/cabinet/auth/me/is-admin');
|
||||
set({ isAdmin: response.data.is_admin });
|
||||
if (response.data.is_admin) {
|
||||
await usePermissionStore.getState().fetchPermissions();
|
||||
} else {
|
||||
usePermissionStore.getState().reset();
|
||||
}
|
||||
} catch {
|
||||
set({ isAdmin: false });
|
||||
usePermissionStore.getState().reset();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
99
src/store/permissions.ts
Normal file
99
src/store/permissions.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { create } from 'zustand';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
interface PermissionsResponse {
|
||||
permissions: string[];
|
||||
roles: string[];
|
||||
role_level: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a user permission against a required permission.
|
||||
*
|
||||
* - `*:*` matches everything
|
||||
* - `users:*` matches `users:read`, `users:edit`, etc.
|
||||
* - Exact match otherwise
|
||||
*/
|
||||
function permissionMatches(userPerm: string, required: string): boolean {
|
||||
if (userPerm === '*:*') return true;
|
||||
|
||||
if (userPerm.endsWith(':*')) {
|
||||
const colonIdx = userPerm.indexOf(':');
|
||||
const requiredColonIdx = required.indexOf(':');
|
||||
if (colonIdx === -1 || requiredColonIdx === -1) return false;
|
||||
const section = userPerm.slice(0, colonIdx);
|
||||
const requiredSection = required.slice(0, requiredColonIdx);
|
||||
return section === requiredSection;
|
||||
}
|
||||
|
||||
return userPerm === required;
|
||||
}
|
||||
|
||||
interface PermissionState {
|
||||
permissions: string[];
|
||||
roles: string[];
|
||||
roleLevel: number;
|
||||
isLoaded: boolean;
|
||||
|
||||
fetchPermissions: () => Promise<void>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
hasAnyPermission: (...permissions: string[]) => boolean;
|
||||
hasAllPermissions: (...permissions: string[]) => boolean;
|
||||
canManageRole: (level: number) => boolean;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const usePermissionStore = create<PermissionState>((set, get) => ({
|
||||
permissions: [],
|
||||
roles: [],
|
||||
roleLevel: 0,
|
||||
isLoaded: false,
|
||||
|
||||
fetchPermissions: async () => {
|
||||
try {
|
||||
const response = await apiClient.get<PermissionsResponse>('/cabinet/auth/me/permissions');
|
||||
set({
|
||||
permissions: response.data.permissions,
|
||||
roles: response.data.roles,
|
||||
roleLevel: response.data.role_level,
|
||||
isLoaded: true,
|
||||
});
|
||||
} catch {
|
||||
set({
|
||||
permissions: [],
|
||||
roles: [],
|
||||
roleLevel: 0,
|
||||
isLoaded: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
hasPermission: (permission: string): boolean => {
|
||||
const { permissions } = get();
|
||||
return permissions.some((userPerm) => permissionMatches(userPerm, permission));
|
||||
},
|
||||
|
||||
hasAnyPermission: (...permissions: string[]): boolean => {
|
||||
const { hasPermission } = get();
|
||||
return permissions.some((perm) => hasPermission(perm));
|
||||
},
|
||||
|
||||
hasAllPermissions: (...permissions: string[]): boolean => {
|
||||
const { hasPermission } = get();
|
||||
return permissions.every((perm) => hasPermission(perm));
|
||||
},
|
||||
|
||||
canManageRole: (level: number): boolean => {
|
||||
const { roleLevel } = get();
|
||||
return roleLevel > level;
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
permissions: [],
|
||||
roles: [],
|
||||
roleLevel: 0,
|
||||
isLoaded: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user