From 874ee2682e50d9deca42b794a4be0ae0dd95ab5c Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 03:02:52 +0300 Subject: [PATCH] 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 --- src/App.tsx | 263 +++--- src/api/rbac.ts | 215 +++++ src/components/auth/PermissionGate.tsx | 42 + src/components/auth/PermissionRoute.tsx | 66 ++ src/locales/en.json | 283 +++++- src/locales/fa.json | 274 +++++- src/locales/ru.json | 292 +++++- src/locales/zh.json | 274 +++++- src/pages/AdminAuditLog.tsx | 823 ++++++++++++++++ src/pages/AdminPanel.tsx | 122 ++- src/pages/AdminPolicies.tsx | 1142 +++++++++++++++++++++++ src/pages/AdminRoleAssign.tsx | 718 ++++++++++++++ src/pages/AdminRoles.tsx | 819 ++++++++++++++++ src/store/auth.ts | 9 + src/store/permissions.ts | 99 ++ 15 files changed, 5320 insertions(+), 121 deletions(-) create mode 100644 src/api/rbac.ts create mode 100644 src/components/auth/PermissionGate.tsx create mode 100644 src/components/auth/PermissionRoute.tsx create mode 100644 src/pages/AdminAuditLog.tsx create mode 100644 src/pages/AdminPolicies.tsx create mode 100644 src/pages/AdminRoleAssign.tsx create mode 100644 src/pages/AdminRoles.tsx create mode 100644 src/store/permissions.ts diff --git a/src/App.tsx b/src/App.tsx index 331d790..fb404cb 100644 --- a/src/App.tsx +++ b/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() { + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + + } + /> + + {/* RBAC routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + } /> diff --git a/src/api/rbac.ts b/src/api/rbac.ts new file mode 100644 index 0000000..b504064 --- /dev/null +++ b/src/api/rbac.ts @@ -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; + 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; + priority?: number; +} + +export interface UpdatePolicyPayload { + name?: string; + description?: string | null; + resource?: string; + action?: string; + effect?: 'allow' | 'deny'; + conditions?: Record; + priority?: number; + is_active?: boolean; +} + +export interface AuditLogEntry { + id: number; + user_id: number; + action: string; + resource: string; + resource_id: string | null; + details: Record; + 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 => { + const response = await apiClient.get(`${BASE}/roles`); + return response.data; + }, + + createRole: async (payload: CreateRolePayload): Promise => { + const response = await apiClient.post(`${BASE}/roles`, payload); + return response.data; + }, + + updateRole: async (roleId: number, payload: UpdateRolePayload): Promise => { + const response = await apiClient.put(`${BASE}/roles/${roleId}`, payload); + return response.data; + }, + + deleteRole: async (roleId: number): Promise => { + await apiClient.delete(`${BASE}/roles/${roleId}`); + }, + + // --- Permission Registry --- + + getPermissionRegistry: async (): Promise => { + const response = await apiClient.get(`${BASE}/permissions`); + return response.data; + }, + + // --- Role Users --- + + getRoleUsers: async (roleId: number): Promise => { + const response = await apiClient.get(`${BASE}/roles/${roleId}/users`); + return response.data; + }, + + assignRole: async (payload: AssignRolePayload): Promise => { + const response = await apiClient.post(`${BASE}/assignments`, payload); + return response.data; + }, + + revokeRole: async (assignmentId: number): Promise => { + await apiClient.delete(`${BASE}/assignments/${assignmentId}`); + }, + + // --- Access Policies --- + + getPolicies: async (): Promise => { + const response = await apiClient.get(`${BASE}/policies`); + return response.data; + }, + + createPolicy: async (payload: CreatePolicyPayload): Promise => { + const response = await apiClient.post(`${BASE}/policies`, payload); + return response.data; + }, + + updatePolicy: async (policyId: number, payload: UpdatePolicyPayload): Promise => { + const response = await apiClient.put(`${BASE}/policies/${policyId}`, payload); + return response.data; + }, + + deletePolicy: async (policyId: number): Promise => { + await apiClient.delete(`${BASE}/policies/${policyId}`); + }, + + // --- Audit Log --- + + getAuditLog: async (filters?: AuditLogFilters): Promise => { + const response = await apiClient.get(`${BASE}/audit-log`, { + params: filters, + }); + return response.data; + }, + + exportAuditLog: async (filters?: AuditLogFilters): Promise => { + const response = await apiClient.get(`${BASE}/audit-log/export`, { + params: filters, + responseType: 'blob', + }); + return response.data as Blob; + }, +}; diff --git a/src/components/auth/PermissionGate.tsx b/src/components/auth/PermissionGate.tsx new file mode 100644 index 0000000..3c88b43 --- /dev/null +++ b/src/components/auth/PermissionGate.tsx @@ -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}; +} diff --git a/src/components/auth/PermissionRoute.tsx b/src/components/auth/PermissionRoute.tsx new file mode 100644 index 0000000..5b9454b --- /dev/null +++ b/src/components/auth/PermissionRoute.tsx @@ -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 ; + } + + if (!isAuthenticated) { + saveReturnUrl(); + return ; + } + + if (!isAdmin) { + return ; + } + + // 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 ; + } + } + + return {children}; +} diff --git a/src/locales/en.json b/src/locales/en.json index d4aba78..a8038d5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 8352ad8..a053a05 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -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": { diff --git a/src/locales/ru.json b/src/locales/ru.json index a4f6914..f2b690f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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": { diff --git a/src/locales/zh.json b/src/locales/zh.json index db83042..3e20bbd 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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": "重试" + } } } } diff --git a/src/pages/AdminAuditLog.tsx b/src/pages/AdminAuditLog.tsx new file mode 100644 index 0000000..cdea696 --- /dev/null +++ b/src/pages/AdminAuditLog.tsx @@ -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 = () => ( + + + +); + +const DownloadIcon = () => ( + + + +); + +const FilterIcon = () => ( + + + +); + +const ChevronDownIcon = ({ className }: { className?: string }) => ( + + + +); + +const RefreshIcon = ({ className }: { className?: string }) => ( + + + +); + +const SearchIcon = () => ( + + + +); + +// === 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, +): 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 = { + 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 ( + + {label} + + ); +} + +interface MethodBadgeProps { + method: string; +} + +function MethodBadge({ method }: MethodBadgeProps) { + const colorMap: Record = { + 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 ( + + {method} + + ); +} + +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 ( +
+ {/* Main row */} + + + {/* Expanded details */} + {isExpanded && ( +
+
+ {/* User agent */} + {entry.user_agent && ( +
+

+ {t('admin.auditLog.details.userAgent')} +

+

{entry.user_agent}

+
+ )} + + {/* Request path */} + {requestPath && ( +
+

+ {t('admin.auditLog.details.requestPath')} +

+

{requestPath}

+
+ )} + + {/* IP Address */} + {entry.ip_address && ( +
+

+ {t('admin.auditLog.details.ipAddress')} +

+

{entry.ip_address}

+
+ )} + + {/* Timestamp */} +
+

+ {t('admin.auditLog.details.timestamp')} +

+

{formatAbsoluteTime(entry.created_at)}

+
+ + {/* Before/after diff */} + {'before' in entry.details && entry.details.before != null && ( +
+

+ {t('admin.auditLog.details.before')} +

+
+                  {JSON.stringify(entry.details.before, null, 2)}
+                
+
+ )} + + {'after' in entry.details && entry.details.after != null && ( +
+

+ {t('admin.auditLog.details.after')} +

+
+                  {JSON.stringify(entry.details.after, null, 2)}
+                
+
+ )} +
+ + {/* Full details JSON */} +
+

+ {t('admin.auditLog.details.fullDetails')} +

+
+              {JSON.stringify(entry.details, null, 2)}
+            
+
+
+ )} +
+ ); +} + +// === Main Page === + +export default function AdminAuditLog() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { capabilities } = usePlatform(); + + // Filter state + const [filters, setFilters] = useState(INITIAL_FILTERS); + const [appliedFilters, setAppliedFilters] = useState(INITIAL_FILTERS); + const [filtersOpen, setFiltersOpen] = useState(false); + + // Pagination + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[0]); + + // UI state + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [autoRefresh, setAutoRefresh] = useState(false); + const [exporting, setExporting] = useState(false); + + // Auto-refresh interval ref + const intervalRef = useRef | 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 ( +
+ {/* Header */} +
+
+ {!capabilities.hasBackButton && ( + + )} +
+

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

+

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

+
+
+ +
+ {/* Auto-refresh toggle */} + + + {/* Manual refresh */} + + + {/* Export */} + + + +
+
+ + {/* Filters bar */} +
+ + + {filtersOpen && ( +
+
+ {/* Action search */} +
+ +
+ + + + 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')} + /> +
+
+ + {/* Resource type */} +
+ + +
+ + {/* Status */} +
+ + +
+ + {/* Date from */} +
+ + 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" + /> +
+ + {/* Date to */} +
+ + 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" + /> +
+
+ + {/* Filter actions */} +
+ + +
+
+ )} +
+ + {/* Results summary */} + {!isLoading && !error && ( +
+

+ {t('admin.auditLog.totalEntries', { count: total })} +

+
+ + +
+
+ )} + + {/* Content */} + {isLoading ? ( +
+
+
+ ) : error ? ( +
+

{t('admin.auditLog.errors.loadFailed')}

+ +
+ ) : filteredEntries.length === 0 ? ( +
+

{t('admin.auditLog.noEntries')}

+
+ ) : ( +
+ {filteredEntries.map((entry) => ( + handleToggleExpand(entry.id)} + /> + ))} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + + + {t('admin.auditLog.pagination.pageOf', { + current: page + 1, + total: totalPages, + })} + + + + +
+ )} +
+ ); +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 0f4b551..21577a7 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -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 = () => ( ); +const SecurityGroupIcon = () => ( + + + +); + +const ShieldIcon = () => ( + + + +); + +const UserPlusIcon = () => ( + + + +); + +const DocumentCheckIcon = () => ( + + + +); + +const ClipboardDocumentListIcon = () => ( + + + +); + 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 (
{/* Group Header */} @@ -315,7 +372,7 @@ function GroupSection({ group }: { group: AdminGroup }) { {/* Group Items */}
- {group.items.map((item) => ( + {visibleItems.map((item) => ( ))}
@@ -341,18 +398,21 @@ export default function AdminPanel() { icon: , title: t('admin.nav.dashboard'), description: t('admin.panel.dashboardDesc'), + permission: 'stats:read', }, { to: '/admin/payments', icon: , title: t('admin.nav.payments'), description: t('admin.panel.paymentsDesc'), + permission: 'payments:read', }, { to: '/admin/traffic-usage', icon: , title: t('admin.nav.trafficUsage'), description: t('admin.panel.trafficUsageDesc'), + permission: 'traffic:read', }, ], }, @@ -370,18 +430,21 @@ export default function AdminPanel() { icon: , title: t('admin.nav.users'), description: t('admin.panel.usersDesc'), + permission: 'users:read', }, { to: '/admin/tickets', icon: , title: t('admin.nav.tickets'), description: t('admin.panel.ticketsDesc'), + permission: 'tickets:read', }, { to: '/admin/ban-system', icon: , title: t('admin.nav.banSystem'), description: t('admin.panel.banSystemDesc'), + permission: 'ban_system:read', }, ], }, @@ -399,30 +462,35 @@ export default function AdminPanel() { icon: , title: t('admin.nav.tariffs'), description: t('admin.panel.tariffsDesc'), + permission: 'tariffs:read', }, { to: '/admin/promocodes', icon: , title: t('admin.nav.promocodes'), description: t('admin.panel.promocodesDesc'), + permission: 'promocodes:read', }, { to: '/admin/promo-groups', icon: , title: t('admin.nav.promoGroups'), description: t('admin.panel.promoGroupsDesc'), + permission: 'promo_groups:read', }, { to: '/admin/promo-offers', icon: , title: t('admin.nav.promoOffers'), description: t('admin.panel.promoOffersDesc'), + permission: 'promo_offers:read', }, { to: '/admin/payment-methods', icon: , title: t('admin.nav.paymentMethods'), description: t('admin.panel.paymentMethodsDesc'), + permission: 'payment_methods:read', }, ], }, @@ -440,36 +508,42 @@ export default function AdminPanel() { icon: , title: t('admin.nav.campaigns'), description: t('admin.panel.campaignsDesc'), + permission: 'campaigns:read', }, { to: '/admin/broadcasts', icon: , title: t('admin.nav.broadcasts'), description: t('admin.panel.broadcastsDesc'), + permission: 'broadcasts:read', }, { to: '/admin/pinned-messages', icon: , title: t('admin.nav.pinnedMessages'), description: t('admin.panel.pinnedMessagesDesc'), + permission: 'pinned_messages:read', }, { to: '/admin/wheel', icon: , title: t('admin.nav.wheel'), description: t('admin.panel.wheelDesc'), + permission: 'wheel:read', }, { to: '/admin/partners', icon: , title: t('admin.nav.partners'), description: t('admin.panel.partnersDesc'), + permission: 'partners:read', }, { to: '/admin/withdrawals', icon: , title: t('admin.nav.withdrawals'), description: t('admin.panel.withdrawalsDesc'), + permission: 'withdrawals:read', }, ], }, @@ -487,42 +561,88 @@ export default function AdminPanel() { icon: , title: t('admin.nav.channelSubscriptions'), description: t('admin.panel.channelSubscriptionsDesc'), + permission: 'channels:read', }, { to: '/admin/settings', icon: , title: t('admin.nav.settings'), description: t('admin.panel.settingsDesc'), + permission: 'settings:read', }, { to: '/admin/apps', icon: , title: t('admin.nav.apps'), description: t('admin.panel.appsDesc'), + permission: 'apps:read', }, { to: '/admin/servers', icon: , title: t('admin.nav.servers'), description: t('admin.panel.serversDesc'), + permission: 'servers:read', }, { to: '/admin/remnawave', icon: , title: t('admin.nav.remnawave'), description: t('admin.panel.remnawaveDesc'), + permission: 'remnawave:read', }, { to: '/admin/email-templates', icon: , title: t('admin.nav.emailTemplates'), description: t('admin.panel.emailTemplatesDesc'), + permission: 'email_templates:read', }, { to: '/admin/updates', icon: , title: t('admin.nav.updates'), description: t('admin.panel.updatesDesc'), + permission: 'updates:read', + }, + ], + }, + { + id: 'security', + title: t('admin.groups.security'), + icon: , + 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: , + title: t('admin.nav.roles'), + description: t('admin.panel.rolesDesc'), + permission: 'roles:read', + }, + { + to: '/admin/roles/assign', + icon: , + title: t('admin.nav.roleAssign'), + description: t('admin.panel.roleAssignDesc'), + permission: 'roles:assign', + }, + { + to: '/admin/policies', + icon: , + title: t('admin.nav.policies'), + description: t('admin.panel.policiesDesc'), + permission: 'roles:read', + }, + { + to: '/admin/audit-log', + icon: , + title: t('admin.nav.auditLog'), + description: t('admin.panel.auditLogDesc'), + permission: 'audit_log:read', }, ], }, diff --git a/src/pages/AdminPolicies.tsx b/src/pages/AdminPolicies.tsx new file mode 100644 index 0000000..e2dbef6 --- /dev/null +++ b/src/pages/AdminPolicies.tsx @@ -0,0 +1,1142 @@ +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, + AccessPolicy, + AdminRole, + CreatePolicyPayload, + UpdatePolicyPayload, +} from '@/api/rbac'; +import { PermissionGate } from '@/components/auth/PermissionGate'; +import { usePlatform } from '@/platform/hooks/usePlatform'; + +// === Icons === + +const BackIcon = () => ( + + + +); + +const PlusIcon = () => ( + + + +); + +const EditIcon = () => ( + + + +); + +const TrashIcon = () => ( + + + +); + +const XMarkIcon = () => ( + + + +); + +const ShieldIcon = () => ( + + + +); + +const ClockIcon = () => ( + + + +); + +const GlobeIcon = () => ( + + + +); + +const BoltIcon = () => ( + + + +); + +// === Types === + +interface PolicyConditions { + time_range?: { + start: string; + end: string; + }; + ip_whitelist?: string[]; + rate_limit?: number; +} + +interface PolicyFormData { + name: string; + description: string; + effect: 'allow' | 'deny'; + resource: string; + actions: string[]; + role_id: number | null; + priority: number; + conditions: PolicyConditions; + conditionsEnabled: { + time_range: boolean; + ip_whitelist: boolean; + rate_limit: boolean; + }; +} + +const INITIAL_FORM: PolicyFormData = { + name: '', + description: '', + effect: 'allow', + resource: '', + actions: [], + role_id: null, + priority: 0, + conditions: { + time_range: { start: '09:00', end: '18:00' }, + ip_whitelist: [], + rate_limit: 100, + }, + conditionsEnabled: { + time_range: false, + ip_whitelist: false, + rate_limit: false, + }, +}; + +// === Helpers === + +function parseConditions(raw: Record): PolicyConditions { + const result: PolicyConditions = {}; + + if (raw.time_range && typeof raw.time_range === 'object') { + const tr = raw.time_range as Record; + if (typeof tr.start === 'string' && typeof tr.end === 'string') { + result.time_range = { start: tr.start, end: tr.end }; + } + } + + if (Array.isArray(raw.ip_whitelist)) { + result.ip_whitelist = raw.ip_whitelist.filter((ip): ip is string => typeof ip === 'string'); + } + + if (typeof raw.rate_limit === 'number') { + result.rate_limit = raw.rate_limit; + } + + return result; +} + +function buildConditionsPayload( + conditions: PolicyConditions, + enabled: PolicyFormData['conditionsEnabled'], +): Record { + const result: Record = {}; + + if (enabled.time_range && conditions.time_range) { + result.time_range = conditions.time_range; + } + if (enabled.ip_whitelist && conditions.ip_whitelist && conditions.ip_whitelist.length > 0) { + result.ip_whitelist = conditions.ip_whitelist; + } + if (enabled.rate_limit && conditions.rate_limit !== undefined) { + result.rate_limit = conditions.rate_limit; + } + + return result; +} + +// === Sub-components === + +interface EffectBadgeProps { + effect: 'allow' | 'deny'; + className?: string; +} + +function EffectBadge({ effect, className }: EffectBadgeProps) { + const { t } = useTranslation(); + + const isAllow = effect === 'allow'; + return ( + + {isAllow ? t('admin.policies.effectAllow') : t('admin.policies.effectDeny')} + + ); +} + +interface IpTagInputProps { + values: string[]; + onChange: (values: string[]) => void; +} + +function IpTagInput({ values, onChange }: IpTagInputProps) { + const { t } = useTranslation(); + const [inputValue, setInputValue] = useState(''); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const trimmed = inputValue.trim().replace(/,+$/, ''); + if (trimmed && !values.includes(trimmed)) { + onChange([...values, trimmed]); + } + setInputValue(''); + } else if (e.key === 'Backspace' && !inputValue && values.length > 0) { + onChange(values.slice(0, -1)); + } + }, + [inputValue, values, onChange], + ); + + const removeIp = useCallback( + (ip: string) => { + onChange(values.filter((v) => v !== ip)); + }, + [values, onChange], + ); + + return ( +
+ {values.map((ip) => ( + + {ip} + + + ))} + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + className="min-w-[120px] flex-1 bg-transparent text-sm text-dark-100 placeholder-dark-500 outline-none" + placeholder={values.length === 0 ? t('admin.policies.conditions.ipPlaceholder') : ''} + /> +
+ ); +} + +interface ConditionToggleProps { + label: string; + enabled: boolean; + onToggle: () => void; + children: React.ReactNode; +} + +function ConditionToggle({ label, enabled, onToggle, children }: ConditionToggleProps) { + return ( +
+ + {enabled &&
{children}
} +
+ ); +} + +// === Main Page === + +export default function AdminPolicies() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { capabilities } = usePlatform(); + + // Modal state + const [modalOpen, setModalOpen] = useState(false); + const [editingPolicy, setEditingPolicy] = useState(null); + const [formData, setFormData] = useState(INITIAL_FORM); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [formError, setFormError] = useState(null); + + // Queries + const { + data: policies, + isLoading: policiesLoading, + error: policiesError, + } = useQuery({ + queryKey: ['admin-policies'], + queryFn: rbacApi.getPolicies, + }); + + const { data: roles } = useQuery({ + queryKey: ['admin-roles'], + queryFn: rbacApi.getRoles, + }); + + const { data: permissionRegistry } = useQuery({ + queryKey: ['admin-permission-registry'], + queryFn: rbacApi.getPermissionRegistry, + }); + + // Mutations + const createMutation = useMutation({ + mutationFn: (payload: CreatePolicyPayload) => rbacApi.createPolicy(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-policies'] }); + closeModal(); + }, + onError: () => { + setFormError(t('admin.policies.errors.createFailed')); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, payload }: { id: number; payload: UpdatePolicyPayload }) => + rbacApi.updatePolicy(id, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-policies'] }); + closeModal(); + }, + onError: () => { + setFormError(t('admin.policies.errors.updateFailed')); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: rbacApi.deletePolicy, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-policies'] }); + setDeleteConfirm(null); + }, + }); + + // Derived data + const rolesMap = useMemo(() => { + const map = new Map(); + if (roles) { + for (const role of roles) { + map.set(role.id, role); + } + } + return map; + }, [roles]); + + const resourceSections = useMemo(() => { + if (!permissionRegistry) return []; + return permissionRegistry; + }, [permissionRegistry]); + + const selectedResourceActions = useMemo(() => { + if (!formData.resource || !permissionRegistry) return []; + const section = permissionRegistry.find((s) => s.section === formData.resource); + return section?.actions ?? []; + }, [formData.resource, permissionRegistry]); + + const sortedPolicies = useMemo(() => { + if (!policies) return []; + return [...policies].sort((a, b) => b.priority - a.priority); + }, [policies]); + + // Handlers + const closeModal = useCallback(() => { + setModalOpen(false); + setEditingPolicy(null); + setFormData(INITIAL_FORM); + setFormError(null); + }, []); + + const openCreateModal = useCallback(() => { + setEditingPolicy(null); + setFormData(INITIAL_FORM); + setFormError(null); + setModalOpen(true); + }, []); + + const openEditModal = useCallback((policy: AccessPolicy) => { + const parsed = parseConditions(policy.conditions); + + // Determine the role_id from conditions if present + const roleId = typeof policy.conditions.role_id === 'number' ? policy.conditions.role_id : null; + + // Parse actions — the API stores a single `action` string which may be comma-separated or + // a single action. Normalize to an array. + const actions = policy.action + ? policy.action + .split(',') + .map((a) => a.trim()) + .filter(Boolean) + : []; + + setEditingPolicy(policy); + setFormData({ + name: policy.name, + description: policy.description ?? '', + effect: policy.effect, + resource: policy.resource, + actions, + role_id: roleId, + priority: policy.priority, + conditions: { + time_range: parsed.time_range ?? { start: '09:00', end: '18:00' }, + ip_whitelist: parsed.ip_whitelist ?? [], + rate_limit: parsed.rate_limit ?? 100, + }, + conditionsEnabled: { + time_range: !!parsed.time_range, + ip_whitelist: !!parsed.ip_whitelist && parsed.ip_whitelist.length > 0, + rate_limit: parsed.rate_limit !== undefined, + }, + }); + setFormError(null); + setModalOpen(true); + }, []); + + const handleToggleAction = useCallback((action: string) => { + setFormData((prev) => { + const has = prev.actions.includes(action); + return { + ...prev, + actions: has ? prev.actions.filter((a) => a !== action) : [...prev.actions, action], + }; + }); + }, []); + + const handleResourceChange = useCallback((resource: string) => { + setFormData((prev) => ({ + ...prev, + resource, + actions: [], + })); + }, []); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + + if (!formData.name.trim()) { + setFormError(t('admin.policies.errors.nameRequired')); + return; + } + if (!formData.resource) { + setFormError(t('admin.policies.errors.resourceRequired')); + return; + } + if (formData.actions.length === 0) { + setFormError(t('admin.policies.errors.actionsRequired')); + return; + } + + const conditionsPayload = buildConditionsPayload( + formData.conditions, + formData.conditionsEnabled, + ); + + if (formData.role_id !== null) { + conditionsPayload.role_id = formData.role_id; + } + + const actionString = formData.actions.join(','); + + if (editingPolicy) { + const payload: UpdatePolicyPayload = { + name: formData.name.trim(), + description: formData.description.trim() || null, + effect: formData.effect, + resource: formData.resource, + action: actionString, + conditions: conditionsPayload, + priority: formData.priority, + }; + updateMutation.mutate({ id: editingPolicy.id, payload }); + } else { + const payload: CreatePolicyPayload = { + name: formData.name.trim(), + description: formData.description.trim() || null, + effect: formData.effect, + resource: formData.resource, + action: actionString, + conditions: conditionsPayload, + priority: formData.priority, + }; + createMutation.mutate(payload); + } + }, + [formData, editingPolicy, createMutation, updateMutation, t], + ); + + const isSaving = createMutation.isPending || updateMutation.isPending; + + // Condition icons renderer + const renderConditionIcons = useCallback( + (conditions: Record) => { + const parsed = parseConditions(conditions); + const icons: React.ReactNode[] = []; + + if (parsed.time_range) { + icons.push( + + + {parsed.time_range.start}-{parsed.time_range.end} + , + ); + } + + if (parsed.ip_whitelist && parsed.ip_whitelist.length > 0) { + icons.push( + + + {t('admin.policies.conditions.ipCount', { count: parsed.ip_whitelist.length })} + , + ); + } + + if (parsed.rate_limit !== undefined) { + icons.push( + + + {t('admin.policies.conditions.rateValue', { count: parsed.rate_limit })} + , + ); + } + + return icons; + }, + [t], + ); + + const getRoleName = useCallback( + (conditions: Record): string => { + const roleId = typeof conditions.role_id === 'number' ? conditions.role_id : null; + if (roleId === null) return t('admin.policies.global'); + const role = rolesMap.get(roleId); + return role?.name ?? t('admin.policies.unknownRole'); + }, + [rolesMap, t], + ); + + return ( +
+ {/* Header */} +
+
+ {!capabilities.hasBackButton && ( + + )} +
+

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

+

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

+
+
+ + + +
+ + {/* Stats Overview */} + {sortedPolicies.length > 0 && ( +
+
+
{sortedPolicies.length}
+
{t('admin.policies.stats.total')}
+
+
+
+ {sortedPolicies.filter((p) => p.effect === 'allow').length} +
+
{t('admin.policies.stats.allow')}
+
+
+
+ {sortedPolicies.filter((p) => p.effect === 'deny').length} +
+
{t('admin.policies.stats.deny')}
+
+
+
+ {sortedPolicies.filter((p) => p.is_active).length} +
+
{t('admin.policies.stats.active')}
+
+
+ )} + + {/* Policies List */} + {policiesLoading ? ( +
+
+
+ ) : policiesError ? ( +
+

{t('admin.policies.errors.loadFailed')}

+
+ ) : sortedPolicies.length === 0 ? ( +
+ +

{t('admin.policies.noPolicies')}

+
+ ) : ( +
+ {sortedPolicies.map((policy) => { + const conditionIcons = renderConditionIcons(policy.conditions); + const roleName = getRoleName(policy.conditions); + + return ( +
+
+
+ {/* Policy name + effect badge */} +
+ {policy.name} + + {!policy.is_active && ( + + {t('admin.policies.inactiveBadge')} + + )} +
+ + {/* Resource + actions */} +
+ + {policy.resource} + + : + {policy.action} +
+ + {/* Info row */} +
+ + {t('admin.policies.roleLabel')}: {roleName} + + + {t('admin.policies.priorityLabel')}: {policy.priority} + +
+ + {/* Condition icons */} + {conditionIcons.length > 0 && ( +
{conditionIcons}
+ )} +
+ + {/* Actions */} +
+ + + + + + +
+
+
+ ); + })} +
+ )} + + {/* Create / Edit Modal */} + {modalOpen && ( +
+ {/* Backdrop */} +