diff --git a/src/App.tsx b/src/App.tsx index 331d790..a9214ff 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,12 @@ 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 AdminRoleEdit = lazy(() => import('./pages/AdminRoleEdit')); +const AdminRoleAssign = lazy(() => import('./pages/AdminRoleAssign')); +const AdminPolicies = lazy(() => import('./pages/AdminPolicies')); +const AdminPolicyEdit = lazy(() => import('./pages/AdminPolicyEdit')); +const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog')); function ProtectedRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); @@ -338,541 +345,623 @@ function App() { + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + + } + /> + + {/* RBAC routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + } /> diff --git a/src/api/adminChannels.ts b/src/api/adminChannels.ts index 6749a86..6eb11ef 100644 --- a/src/api/adminChannels.ts +++ b/src/api/adminChannels.ts @@ -8,6 +8,8 @@ export interface RequiredChannel { title: string | null; is_active: boolean; sort_order: number; + disable_trial_on_leave: boolean; + disable_paid_on_leave: boolean; } export interface ChannelListResponse { @@ -19,6 +21,8 @@ export interface CreateChannelRequest { channel_id: string; channel_link?: string; title?: string; + disable_trial_on_leave?: boolean; + disable_paid_on_leave?: boolean; } export interface UpdateChannelRequest { @@ -27,6 +31,8 @@ export interface UpdateChannelRequest { title?: string; is_active?: boolean; sort_order?: number; + disable_trial_on_leave?: boolean; + disable_paid_on_leave?: boolean; } export const adminChannelsApi = { diff --git a/src/api/rbac.ts b/src/api/rbac.ts new file mode 100644 index 0000000..a0af13b --- /dev/null +++ b/src/api/rbac.ts @@ -0,0 +1,223 @@ +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; + actions: string[]; + effect: 'allow' | 'deny'; + conditions: Record; + priority: number; + role_id: number | null; + role_name: string | null; + is_active: boolean; + created_by: number | null; + created_at: string; +} + +export interface CreatePolicyPayload { + name: string; + description?: string | null; + resource: string; + actions: string[]; + effect: 'allow' | 'deny'; + conditions?: Record; + priority?: number; + role_id?: number | null; +} + +export interface UpdatePolicyPayload { + name?: string; + description?: string | null; + resource?: string; + actions?: string[]; + effect?: 'allow' | 'deny'; + conditions?: Record; + priority?: number; + role_id?: number | null; + is_active?: boolean; +} + +export interface AuditLogEntry { + id: number; + user_id: number; + action: string; + resource_type: string | null; + resource_id: string | null; + details: Record | null; + ip_address: string | null; + user_agent: string | null; + status: string; + request_method: string | null; + request_path: string | null; + created_at: string; + user_first_name: string | null; + user_email: string | null; +} + +export interface AuditLogFilters { + user_id?: number; + action?: string; + resource_type?: string; + status?: 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 2b6ffcd..6906b3b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -299,7 +299,10 @@ "extraDevicesIncluded_other": "Incl. {{count}} extra dev.", "baseTariff": "Tariff", "perMonth": "/mo", - "summary": "Summary", + "summary": { + "period": "Period: {{label}}", + "traffic": "Traffic: {{gb}} GB" + }, "total": "Total", "purchase": "Purchase", "step": "Step {{current}} of {{total}}", @@ -507,10 +510,6 @@ "label": "Traffic", "selectVolume": "Select traffic volume", "default": "Default: {{label}}" - }, - "summary": { - "period": "Period: {{label}}", - "traffic": "Traffic: {{gb}} GB" } }, "balance": { @@ -520,7 +519,41 @@ "topUpBalance": "Top Up Balance", "enterAmount": "Enter amount", "paymentMethod": "Payment Method", - "paymentMethods": "Payment Methods", + "paymentMethods": { + "yookassa": { + "description": "Pay via YooKassa" + }, + "cryptobot": { + "description": "Pay with cryptocurrency via CryptoBot" + }, + "telegram_stars": { + "description": "Pay with Telegram Stars" + }, + "heleket": { + "description": "Pay with cryptocurrency via Heleket" + }, + "mulenpay": { + "description": "Pay via MulenPay" + }, + "pal24": { + "description": "Pay via PAL24" + }, + "platega": { + "description": "Pay via Platega" + }, + "wata": { + "description": "Pay via Wata" + }, + "cloudpayments": { + "description": "Pay with bank card via CloudPayments" + }, + "freekassa": { + "description": "Pay via FreeKassa" + }, + "tribute": { + "description": "Pay with bank card via Tribute" + } + }, "transactionHistory": "Transaction History", "noTransactions": "No transactions", "date": "Date", @@ -562,41 +595,6 @@ "topUpToComplete": "Top up your balance to complete the purchase", "missing": "Missing", "noPaymentMethods": "No payment methods available", - "paymentMethods": { - "yookassa": { - "description": "Pay via YooKassa" - }, - "cryptobot": { - "description": "Pay with cryptocurrency via CryptoBot" - }, - "telegram_stars": { - "description": "Pay with Telegram Stars" - }, - "heleket": { - "description": "Pay with cryptocurrency via Heleket" - }, - "mulenpay": { - "description": "Pay via MulenPay" - }, - "pal24": { - "description": "Pay via PAL24" - }, - "platega": { - "description": "Pay via Platega" - }, - "wata": { - "description": "Pay via Wata" - }, - "cloudpayments": { - "description": "Pay with bank card via CloudPayments" - }, - "freekassa": { - "description": "Pay via FreeKassa" - }, - "tribute": { - "description": "Pay with bank card via Tribute" - } - }, "pendingPayments": { "title": "Pending Payments", "pay": "Pay", @@ -835,7 +833,8 @@ "marketing": "Marketing", "system": "System", "tariffs": "Tariffs & Sales", - "users": "Users" + "users": "Users", + "security": "Security" }, "nav": { "title": "Admin", @@ -862,7 +861,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 +893,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", @@ -907,7 +914,7 @@ "total": "Total", "noData": "No traffic data", "loading": "Loading traffic data...", - "noTariff": "\u2014", + "noTariff": "—", "search": "Search by name or username", "allTariffs": "All tariffs", "nodes": "Nodes", @@ -1322,6 +1329,21 @@ "submit": "Add", "save": "Save", "cancel": "Cancel" + }, + "globalSettings": { + "title": "Channel Settings", + "channelRequired": "Require subscription", + "channelRequiredDesc": "Users must subscribe to channels to use the bot", + "disableTrialOnUnsub": "Disable trial on unsubscribe", + "disableTrialOnUnsubDesc": "Revoke trial access when user unsubscribes from any channel", + "requiredForAll": "Required for all users", + "requiredForAllDesc": "Apply channel subscription check to all users, including paid" + }, + "perChannel": { + "disableTrial": "Disable trial on leave", + "disableTrialDesc": "Revoke trial when user leaves this channel", + "disablePaid": "Disable paid on leave", + "disablePaidDesc": "Revoke paid access when user leaves this channel" } }, "settings": { @@ -1623,7 +1645,7 @@ "periodsTabHint": "Add periods and prices for the tariff. Users will be able to choose from the added periods.", "addPeriodTitle": "Add period", "daysLabel": "Days", - "priceLabel": "Price (\u20bd)", + "priceLabel": "Price (₽)", "addButton": "Add", "noPeriodsHint": "No added periods. Add at least one period.", "daysShort": "d.", @@ -1673,7 +1695,7 @@ "dailyType": "Daily", "periodType": "Period-based", "dailyPriceLabel": "Price per day", - "currencyPerDay": "\u20bd/day", + "currencyPerDay": "₽/day", "dailyDeductionDesc": "Charged daily from user's balance", "dailyPriceRequired": "Enter a price per day greater than 0", "cannotSave": "Cannot save tariff:", @@ -2511,6 +2533,342 @@ "purchaseDiscount": "Purchase discount", "purchaseDiscountDesc": "Discount for new users" } + }, + "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", + "permissionSections": { + "users": "Users", + "tickets": "Tickets", + "stats": "Statistics", + "broadcasts": "Broadcasts", + "tariffs": "Tariffs", + "promocodes": "Promo codes", + "promo_groups": "Promo groups", + "promo_offers": "Promo offers", + "campaigns": "Campaigns", + "partners": "Partners", + "withdrawals": "Withdrawals", + "payments": "Payments", + "payment_methods": "Payment methods", + "servers": "Servers", + "remnawave": "Remnawave", + "traffic": "Traffic", + "settings": "Settings", + "roles": "Roles", + "audit_log": "Audit log", + "channels": "Channels", + "ban_system": "Ban system", + "wheel": "Lucky wheel", + "apps": "Apps", + "email_templates": "Email templates", + "pinned_messages": "Pinned messages", + "updates": "Updates" + }, + "permissionActions": { + "read": "Read", + "edit": "Edit", + "create": "Create", + "delete": "Delete", + "block": "Block", + "sync": "Sync", + "reply": "Reply", + "close": "Close", + "settings": "Settings", + "export": "Export", + "send": "Send", + "stats": "Stats", + "approve": "Approve", + "revoke": "Revoke", + "reject": "Reject", + "manage": "Manage", + "ban": "Ban", + "unban": "Unban", + "assign": "Assign", + "promo_group": "Promo group", + "balance": "Balance", + "subscription": "Subscription", + "send_offer": "Send offer", + "referral": "Referral %" + }, + "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", + "deleteFailed": "Failed to delete role" + } + }, + "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", + "revokeFailed": "Failed to revoke 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", + "weekdays": "Days of week", + "day0": "Sun", + "day1": "Mon", + "day2": "Tue", + "day3": "Wed", + "day4": "Thu", + "day5": "Fri", + "day6": "Sat" + }, + "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", + "deleteFailed": "Failed to delete policy" + } + }, + "auditLog": { + "title": "Audit Log", + "subtitle": "Review system activity and access history", + "back": "Back", + "exportCsv": "Export CSV", + "exportError": "Failed to export", + "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", + "queryParams": "Query Parameters", + "requestBody": "Request Body", + "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" + } } }, "adminUpdates": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 61e7267..95b18ff 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -280,7 +280,10 @@ "selectServers": "انتخاب سرورها", "selectDevices": "انتخاب دستگاه‌ها", "perDevice": "/ دستگاه", - "summary": "خلاصه", + "summary": { + "period": "دوره: {{label}}", + "traffic": "ترافیک: {{gb}} GB" + }, "total": "جمع کل", "purchase": "خرید", "step": "مرحله {{current}} از {{total}}", @@ -392,10 +395,6 @@ "selectVolume": "انتخاب حجم ترافیک", "default": "پیش‌فرض: {{label}}" }, - "summary": { - "period": "دوره: {{label}}", - "traffic": "ترافیک: {{gb}} GB" - }, "confirmDeleteAllDevices": "همه دستگاه‌ها حذف شوند؟", "confirmDeleteDevice": "این دستگاه حذف شود؟", "currentTariff": "فعلی", @@ -742,7 +741,11 @@ "pinnedMessages": "پیام‌های سنجاق‌شده", "partners": "شرکا", "withdrawals": "برداشت‌ها", - "channelSubscriptions": "کانال‌های اجباری" + "channelSubscriptions": "کانال‌های اجباری", + "roles": "نقش‌ها", + "roleAssign": "تخصیص نقش", + "policies": "سیاست‌های دسترسی", + "auditLog": "گزارش بازرسی" }, "panel": { "title": "پنل مدیریت", @@ -770,7 +773,11 @@ "pinnedMessagesDesc": "مدیریت پیام‌های سنجاق‌شده", "partnersDesc": "مدیریت درخواست‌های شراکت و کمیسیون‌ها", "withdrawalsDesc": "بررسی و پردازش درخواست‌های برداشت", - "channelSubscriptionsDesc": "مدیریت اشتراک‌های اجباری کانال" + "channelSubscriptionsDesc": "مدیریت اشتراک‌های اجباری کانال", + "rolesDesc": "مدیریت نقش‌ها و مجوزهای مدیران", + "roleAssignDesc": "تخصیص و لغو نقش‌های کاربران", + "policiesDesc": "تنظیم سیاست‌های کنترل دسترسی ABAC", + "auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی" }, "trafficUsage": { "title": "مصرف ترافیک", @@ -787,7 +794,7 @@ "total": "کل", "noData": "داده ترافیکی موجود نیست", "loading": "در حال بارگذاری داده‌های ترافیک...", - "noTariff": "\u2014", + "noTariff": "—", "search": "جستجو بر اساس نام یا نام کاربری", "allTariffs": "همه تعرفه‌ها", "nodes": "نودها", @@ -1025,6 +1032,21 @@ "submit": "افزودن", "save": "ذخیره", "cancel": "لغو" + }, + "globalSettings": { + "title": "تنظیمات کانال", + "channelRequired": "الزام عضویت", + "channelRequiredDesc": "کاربران باید در کانال‌ها عضو شوند تا از ربات استفاده کنند", + "disableTrialOnUnsub": "غیرفعال‌سازی آزمایشی هنگام لغو عضویت", + "disableTrialOnUnsubDesc": "لغو دسترسی آزمایشی هنگام لغو عضویت از هر کانال", + "requiredForAll": "اجباری برای همه", + "requiredForAllDesc": "بررسی عضویت برای همه کاربران، از جمله پولی" + }, + "perChannel": { + "disableTrial": "غیرفعال‌سازی آزمایشی هنگام خروج", + "disableTrialDesc": "لغو دسترسی آزمایشی هنگام خروج از این کانال", + "disablePaid": "غیرفعال‌سازی پولی هنگام خروج", + "disablePaidDesc": "لغو دسترسی پولی هنگام خروج از این کانال" } }, "payments": { @@ -2187,7 +2209,8 @@ "marketing": "بازاریابی", "system": "سیستم", "tariffs": "تعرفه‌ها و فروش", - "users": "کاربران" + "users": "کاربران", + "security": "امنیت" }, "remnawave": { "connected": "متصل", @@ -2301,6 +2324,333 @@ "lightness": "روشنایی", "quickPresets": "پیش‌تنظیم‌های سریع", "saturation": "اشباع" + }, + "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": "مجوزها", + "permissionSections": { + "users": "کاربران", + "tickets": "تیکت‌ها", + "stats": "آمار", + "broadcasts": "پیام‌های همگانی", + "tariffs": "تعرفه‌ها", + "promocodes": "کدهای تخفیف", + "promo_groups": "گروه‌های تبلیغاتی", + "promo_offers": "پیشنهادات تبلیغاتی", + "campaigns": "کمپین‌ها", + "partners": "شرکا", + "withdrawals": "برداشت‌ها", + "payments": "پرداخت‌ها", + "payment_methods": "روش‌های پرداخت", + "servers": "سرورها", + "remnawave": "Remnawave", + "traffic": "ترافیک", + "settings": "تنظیمات", + "roles": "نقش‌ها", + "audit_log": "گزارش حسابرسی", + "channels": "کانال‌ها", + "ban_system": "سیستم مسدودسازی", + "wheel": "چرخ شانس", + "apps": "برنامه‌ها", + "email_templates": "قالب‌های ایمیل", + "pinned_messages": "پیام‌های سنجاق‌شده", + "updates": "به‌روزرسانی‌ها" + }, + "permissionActions": { + "read": "خواندن", + "edit": "ویرایش", + "create": "ایجاد", + "delete": "حذف", + "block": "مسدودسازی", + "sync": "همگام‌سازی", + "reply": "پاسخ", + "close": "بستن", + "settings": "تنظیمات", + "export": "خروجی", + "send": "ارسال", + "stats": "آمار", + "approve": "تأیید", + "revoke": "لغو", + "reject": "رد", + "manage": "مدیریت", + "ban": "مسدود کردن", + "unban": "رفع مسدودیت", + "assign": "اختصاص", + "promo_group": "گروه تبلیغاتی", + "balance": "موجودی", + "subscription": "اشتراک", + "send_offer": "ارسال پیشنهاد", + "referral": "درصد ارجاع" + }, + "toggleSection": "تغییر وضعیت تمام مجوزهای {{section}}", + "selectedPermissions_other": "{{count}} مجوز انتخاب شده", + "cancel": "لغو", + "saving": "در حال ذخیره...", + "save": "ذخیره" + }, + "presets": { + "moderator": "مدیر محتوا", + "marketer": "بازاریاب", + "support": "پشتیبانی" + }, + "confirm": { + "title": "حذف نقش؟", + "text": "این نقش برای همیشه حذف خواهد شد. کاربران دارای این نقش مجوزهای آن را از دست خواهند داد.", + "cancel": "لغو", + "delete": "حذف", + "deleting": "در حال حذف..." + }, + "errors": { + "loadFailed": "بارگذاری نقش‌ها ناموفق بود", + "createFailed": "ایجاد نقش ناموفق بود", + "updateFailed": "به‌روزرسانی نقش ناموفق بود", + "nameRequired": "نام نقش الزامی است", + "deleteFailed": "حذف نقش ناموفق بود" + } + }, + "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": "تخصیص نقش ناموفق بود", + "revokeFailed": "لغو نقش ناموفق بود" + } + }, + "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": "عملیات در ساعت", + "weekdays": "روزهای هفته", + "day0": "یکشنبه", + "day1": "دوشنبه", + "day2": "سه‌شنبه", + "day3": "چهارشنبه", + "day4": "پنجشنبه", + "day5": "جمعه", + "day6": "شنبه" + }, + "confirm": { + "title": "حذف سیاست؟", + "text": "این سیاست برای همیشه حذف خواهد شد. قوانین کنترل دسترسی مجدداً محاسبه خواهند شد.", + "cancel": "لغو", + "delete": "حذف", + "deleting": "در حال حذف..." + }, + "errors": { + "loadFailed": "بارگذاری سیاست‌ها ناموفق بود", + "createFailed": "ایجاد سیاست ناموفق بود", + "updateFailed": "به‌روزرسانی سیاست ناموفق بود", + "nameRequired": "نام سیاست الزامی است", + "resourceRequired": "لطفاً یک منبع انتخاب کنید", + "actionsRequired": "لطفاً حداقل یک عملیات انتخاب کنید", + "deleteFailed": "حذف سیاست ناموفق بود" + } + }, + "auditLog": { + "title": "گزارش بازرسی", + "subtitle": "بررسی فعالیت سیستم و تاریخچه دسترسی", + "back": "بازگشت", + "exportCsv": "خروجی CSV", + "exportError": "خطا در خروجی", + "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": "بعد", + "queryParams": "پارامترهای درخواست", + "requestBody": "داده‌های درخواست", + "fullDetails": "جزئیات کامل" + }, + "time": { + "justNow": "همین الان", + "minutesAgo_other": "{{count}} دقیقه پیش", + "hoursAgo_other": "{{count}} ساعت پیش", + "daysAgo_other": "{{count}} روز پیش" + }, + "pagination": { + "pageSize": "در هر صفحه:", + "pageOf": "{{current}} / {{total}}", + "first": "صفحه اول", + "previous": "صفحه قبل", + "next": "صفحه بعد", + "last": "صفحه آخر" + }, + "errors": { + "loadFailed": "بارگذاری گزارش بازرسی ناموفق بود", + "retry": "تلاش مجدد" + } } }, "adminUpdates": { diff --git a/src/locales/ru.json b/src/locales/ru.json index e10b230..f46066e 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -320,7 +320,10 @@ "extraDevicesIncluded_many": "Вкл. {{count}} доп. устр.", "baseTariff": "Тариф", "perMonth": "/мес", - "summary": "Итого", + "summary": { + "period": "Период: {{label}}", + "traffic": "Трафик: {{gb}} ГБ" + }, "total": "К оплате", "purchase": "Купить", "step": "Шаг {{current}} из {{total}}", @@ -535,10 +538,6 @@ "label": "Трафик", "selectVolume": "Выбрать объём трафика", "default": "По умолчанию: {{label}}" - }, - "summary": { - "period": "Период: {{label}}", - "traffic": "Трафик: {{gb}} ГБ" } }, "balance": { @@ -548,7 +547,41 @@ "topUpBalance": "Пополнение баланса", "enterAmount": "Введите сумму", "paymentMethod": "Способ оплаты", - "paymentMethods": "Способы оплаты", + "paymentMethods": { + "yookassa": { + "description": "Оплата через ЮKassa" + }, + "cryptobot": { + "description": "Оплата криптовалютой через CryptoBot" + }, + "telegram_stars": { + "description": "Оплата через Telegram Stars" + }, + "heleket": { + "description": "Оплата криптовалютой через Heleket" + }, + "mulenpay": { + "description": "Оплата через MulenPay" + }, + "pal24": { + "description": "Оплата через PAL24" + }, + "platega": { + "description": "Оплата через Platega" + }, + "wata": { + "description": "Оплата через Wata" + }, + "cloudpayments": { + "description": "Оплата банковской картой через CloudPayments" + }, + "freekassa": { + "description": "Оплата через FreeKassa" + }, + "tribute": { + "description": "Оплата банковской картой через Tribute" + } + }, "transactionHistory": "История операций", "noTransactions": "Нет операций", "date": "Дата", @@ -590,41 +623,6 @@ "topUpToComplete": "Пополните баланс для завершения покупки", "missing": "Не хватает", "noPaymentMethods": "Способы оплаты недоступны", - "paymentMethods": { - "yookassa": { - "description": "Оплата через ЮKassa" - }, - "cryptobot": { - "description": "Оплата криптовалютой через CryptoBot" - }, - "telegram_stars": { - "description": "Оплата через Telegram Stars" - }, - "heleket": { - "description": "Оплата криптовалютой через Heleket" - }, - "mulenpay": { - "description": "Оплата через MulenPay" - }, - "pal24": { - "description": "Оплата через PAL24" - }, - "platega": { - "description": "Оплата через Platega" - }, - "wata": { - "description": "Оплата через Wata" - }, - "cloudpayments": { - "description": "Оплата банковской картой через CloudPayments" - }, - "freekassa": { - "description": "Оплата через FreeKassa" - }, - "tribute": { - "description": "Оплата банковской картой через Tribute" - } - }, "pendingPayments": { "title": "Ожидающие платежи", "pay": "Оплатить", @@ -884,7 +882,11 @@ "pinnedMessages": "Закреплённые", "partners": "Партнёры", "withdrawals": "Выводы", - "channelSubscriptions": "Обязательные каналы" + "channelSubscriptions": "Обязательные каналы", + "roles": "Роли", + "roleAssign": "Назначение ролей", + "policies": "Политики доступа", + "auditLog": "Журнал аудита" }, "panel": { "title": "Панель администратора", @@ -912,7 +914,11 @@ "pinnedMessagesDesc": "Управление закреплёнными сообщениями", "partnersDesc": "Управление заявками партнёров и комиссиями", "withdrawalsDesc": "Проверка и обработка заявок на вывод", - "channelSubscriptionsDesc": "Управление обязательными подписками на каналы" + "channelSubscriptionsDesc": "Управление обязательными подписками на каналы", + "rolesDesc": "Управление ролями и правами администраторов", + "roleAssignDesc": "Назначение и отзыв ролей пользователей", + "policiesDesc": "Настройка политик контроля доступа ABAC", + "auditLogDesc": "Просмотр активности системы и истории доступа" }, "trafficUsage": { "title": "Расход трафика", @@ -929,7 +935,7 @@ "total": "Всего", "noData": "Нет данных о трафике", "loading": "Загрузка данных о трафике...", - "noTariff": "\u2014", + "noTariff": "—", "search": "Поиск по имени или юзернейму", "allTariffs": "Все тарифы", "nodes": "Ноды", @@ -1348,6 +1354,21 @@ "submit": "Добавить", "save": "Сохранить", "cancel": "Отмена" + }, + "globalSettings": { + "title": "Настройки каналов", + "channelRequired": "Требовать подписку", + "channelRequiredDesc": "Пользователи должны подписаться на каналы для использования бота", + "disableTrialOnUnsub": "Отключать триал при отписке", + "disableTrialOnUnsubDesc": "Отзывать пробный доступ при отписке от любого канала", + "requiredForAll": "Обязательно для всех", + "requiredForAllDesc": "Проверять подписку для всех пользователей, включая платных" + }, + "perChannel": { + "disableTrial": "Отключать триал при выходе", + "disableTrialDesc": "Отзывать пробный доступ при выходе из этого канала", + "disablePaid": "Отключать платный при выходе", + "disablePaidDesc": "Отзывать платный доступ при выходе из этого канала" } }, "settings": { @@ -2064,7 +2085,7 @@ "name": "Название", "namePlaceholder": "Введите название тарифа", "description": "Описание", - "descriptionPlaceholder": "Введите описание тарифа", + "descriptionPlaceholder": "Краткое описание тарифа", "trafficLimit": "Лимит трафика", "trafficHint": "0 = безлимитный трафик", "deviceLimit": "Лимит устройств", @@ -2132,7 +2153,6 @@ "nameExampleDaily": "Например: Суточный", "nameHint": "От 2 до 50 символов", "descriptionLabel": "Описание", - "descriptionPlaceholder": "Краткое описание тарифа", "trafficLimitLabel": "Лимит трафика", "gbUnit": "ГБ", "trafficLimitHint": "0 = безлимитный трафик", @@ -3048,7 +3068,8 @@ "marketing": "Маркетинг", "system": "Система", "tariffs": "Тарифы и продажи", - "users": "Пользователи" + "users": "Пользователи", + "security": "Безопасность" }, "theme": { "accentColor": "Акцентный цвет", @@ -3058,6 +3079,351 @@ "lightness": "Яркость", "quickPresets": "Быстрые пресеты", "saturation": "Насыщенность" + }, + "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": "Разрешения", + "permissionSections": { + "users": "Пользователи", + "tickets": "Тикеты", + "stats": "Статистика", + "broadcasts": "Рассылки", + "tariffs": "Тарифы", + "promocodes": "Промокоды", + "promo_groups": "Промо-группы", + "promo_offers": "Промо-предложения", + "campaigns": "Кампании", + "partners": "Партнёры", + "withdrawals": "Выводы средств", + "payments": "Платежи", + "payment_methods": "Методы оплаты", + "servers": "Серверы", + "remnawave": "Remnawave", + "traffic": "Трафик", + "settings": "Настройки", + "roles": "Роли", + "audit_log": "Журнал аудита", + "channels": "Каналы", + "ban_system": "Система банов", + "wheel": "Колесо фортуны", + "apps": "Приложения", + "email_templates": "Шаблоны писем", + "pinned_messages": "Закреплённые сообщения", + "updates": "Обновления" + }, + "permissionActions": { + "read": "Чтение", + "edit": "Редактирование", + "create": "Создание", + "delete": "Удаление", + "block": "Блокировка", + "sync": "Синхронизация", + "reply": "Ответ", + "close": "Закрытие", + "settings": "Настройки", + "export": "Экспорт", + "send": "Отправка", + "stats": "Статистика", + "approve": "Одобрение", + "revoke": "Отзыв", + "reject": "Отклонение", + "manage": "Управление", + "ban": "Бан", + "unban": "Разбан", + "assign": "Назначение", + "promo_group": "Промо-группа", + "balance": "Баланс", + "subscription": "Подписка", + "send_offer": "Промо-предложение", + "referral": "Реф. процент" + }, + "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": "Укажите название роли", + "deleteFailed": "Не удалось удалить роль" + } + }, + "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": "Не удалось назначить роль", + "revokeFailed": "Не удалось отозвать роль" + } + }, + "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": "действий в час", + "weekdays": "Дни недели", + "day0": "Вс", + "day1": "Пн", + "day2": "Вт", + "day3": "Ср", + "day4": "Чт", + "day5": "Пт", + "day6": "Сб" + }, + "confirm": { + "title": "Удалить политику?", + "text": "Политика будет безвозвратно удалена. Правила контроля доступа будут пересчитаны.", + "cancel": "Отмена", + "delete": "Удалить", + "deleting": "Удаление..." + }, + "errors": { + "loadFailed": "Не удалось загрузить политики", + "createFailed": "Не удалось создать политику", + "updateFailed": "Не удалось обновить политику", + "nameRequired": "Укажите название политики", + "resourceRequired": "Выберите ресурс", + "actionsRequired": "Выберите хотя бы одно действие", + "deleteFailed": "Не удалось удалить политику" + } + }, + "auditLog": { + "title": "Журнал аудита", + "subtitle": "Просмотр активности системы и истории доступа", + "back": "Назад", + "exportCsv": "Экспорт CSV", + "exportError": "Ошибка экспорта", + "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": "После", + "queryParams": "Параметры запроса", + "requestBody": "Тело запроса", + "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": "Попробовать снова" + } } }, "adminUpdates": { diff --git a/src/locales/zh.json b/src/locales/zh.json index b493a0d..b42fac8 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -280,7 +280,10 @@ "selectServers": "选择服务器", "selectDevices": "选择设备", "perDevice": "/ 设备", - "summary": "摘要", + "summary": { + "period": "周期:{{label}}", + "traffic": "流量:{{gb}} GB" + }, "total": "总计", "purchase": "购买", "step": "第 {{current}} 步,共 {{total}} 步", @@ -427,10 +430,6 @@ "resumeBtn": "恢复", "title": "暂停订阅", "willBeCharged": "将扣费" - }, - "summary": { - "period": "周期:{{label}}", - "traffic": "流量:{{gb}} GB" } }, "balance": { @@ -742,7 +741,11 @@ "pinnedMessages": "置顶消息", "partners": "合作伙伴", "withdrawals": "提现", - "channelSubscriptions": "必订频道" + "channelSubscriptions": "必订频道", + "roles": "角色", + "roleAssign": "角色分配", + "policies": "访问策略", + "auditLog": "审计日志" }, "panel": { "title": "管理面板", @@ -770,7 +773,11 @@ "pinnedMessagesDesc": "管理用户置顶消息", "partnersDesc": "管理合作伙伴申请和佣金", "withdrawalsDesc": "审核和处理提现请求", - "channelSubscriptionsDesc": "管理必订频道订阅" + "channelSubscriptionsDesc": "管理必订频道订阅", + "rolesDesc": "管理管理员角色和权限", + "roleAssignDesc": "分配和撤销用户角色", + "policiesDesc": "配置ABAC访问控制策略", + "auditLogDesc": "查看系统活动和访问历史" }, "trafficUsage": { "title": "流量使用", @@ -787,7 +794,7 @@ "total": "总计", "noData": "无流量数据", "loading": "正在加载流量数据...", - "noTariff": "\u2014", + "noTariff": "—", "search": "按名称或用户名搜索", "allTariffs": "所有套餐", "nodes": "节点", @@ -1077,6 +1084,21 @@ "submit": "添加", "save": "保存", "cancel": "取消" + }, + "globalSettings": { + "title": "频道设置", + "channelRequired": "要求订阅", + "channelRequiredDesc": "用户必须订阅频道才能使用机器人", + "disableTrialOnUnsub": "取消订阅时禁用试用", + "disableTrialOnUnsubDesc": "用户取消订阅任何频道时撤销试用权限", + "requiredForAll": "对所有用户强制", + "requiredForAllDesc": "对所有用户(包括付费用户)检查频道订阅" + }, + "perChannel": { + "disableTrial": "退出时禁用试用", + "disableTrialDesc": "用户退出此频道时撤销试用权限", + "disablePaid": "退出时禁用付费", + "disablePaidDesc": "用户退出此频道时撤销付费权限" } }, "settings": { @@ -2186,7 +2208,8 @@ "marketing": "营销", "system": "系统", "tariffs": "套餐与销售", - "users": "用户" + "users": "用户", + "security": "安全" }, "theme": { "accentColor": "强调色", @@ -2300,6 +2323,333 @@ "toPanel": "到面板", "toPanelDesc": "从机器人导出用户到RemnaWave" } + }, + "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": "权限", + "permissionSections": { + "users": "用户", + "tickets": "工单", + "stats": "统计", + "broadcasts": "广播", + "tariffs": "套餐", + "promocodes": "促销码", + "promo_groups": "促销组", + "promo_offers": "促销优惠", + "campaigns": "推广活动", + "partners": "合作伙伴", + "withdrawals": "提现", + "payments": "支付", + "payment_methods": "支付方式", + "servers": "服务器", + "remnawave": "Remnawave", + "traffic": "流量", + "settings": "设置", + "roles": "角色", + "audit_log": "审计日志", + "channels": "频道", + "ban_system": "封禁系统", + "wheel": "幸运转盘", + "apps": "应用", + "email_templates": "邮件模板", + "pinned_messages": "置顶消息", + "updates": "更新" + }, + "permissionActions": { + "read": "读取", + "edit": "编辑", + "create": "创建", + "delete": "删除", + "block": "封锁", + "sync": "同步", + "reply": "回复", + "close": "关闭", + "settings": "设置", + "export": "导出", + "send": "发送", + "stats": "统计", + "approve": "批准", + "revoke": "撤销", + "reject": "拒绝", + "manage": "管理", + "ban": "封禁", + "unban": "解封", + "assign": "分配", + "promo_group": "促销组", + "balance": "余额", + "subscription": "订阅", + "send_offer": "发送优惠", + "referral": "推荐佣金" + }, + "toggleSection": "切换所有 {{section}} 权限", + "selectedPermissions_other": "已选择 {{count}} 个权限", + "cancel": "取消", + "saving": "保存中...", + "save": "保存" + }, + "presets": { + "moderator": "版主", + "marketer": "营销员", + "support": "客服" + }, + "confirm": { + "title": "删除角色?", + "text": "此角色将被永久删除。拥有此角色的用户将失去其权限。", + "cancel": "取消", + "delete": "删除", + "deleting": "删除中..." + }, + "errors": { + "loadFailed": "加载角色失败", + "createFailed": "创建角色失败", + "updateFailed": "更新角色失败", + "nameRequired": "请输入角色名称", + "deleteFailed": "删除角色失败" + } + }, + "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": "角色分配失败", + "revokeFailed": "角色撤销失败" + } + }, + "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": "每小时操作数", + "weekdays": "星期", + "day0": "日", + "day1": "一", + "day2": "二", + "day3": "三", + "day4": "四", + "day5": "五", + "day6": "六" + }, + "confirm": { + "title": "删除策略?", + "text": "此策略将被永久删除。访问控制规则将被重新计算。", + "cancel": "取消", + "delete": "删除", + "deleting": "删除中..." + }, + "errors": { + "loadFailed": "加载策略失败", + "createFailed": "创建策略失败", + "updateFailed": "更新策略失败", + "nameRequired": "请输入策略名称", + "resourceRequired": "请选择资源", + "actionsRequired": "请至少选择一个操作", + "deleteFailed": "删除策略失败" + } + }, + "auditLog": { + "title": "审计日志", + "subtitle": "查看系统活动和访问历史", + "back": "返回", + "exportCsv": "导出CSV", + "exportError": "导出失败", + "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": "之后", + "queryParams": "请求参数", + "requestBody": "请求数据", + "fullDetails": "完整详情" + }, + "time": { + "justNow": "刚刚", + "minutesAgo_other": "{{count}} 分钟前", + "hoursAgo_other": "{{count}} 小时前", + "daysAgo_other": "{{count}} 天前" + }, + "pagination": { + "pageSize": "每页:", + "pageOf": "{{current}} / {{total}}", + "first": "第一页", + "previous": "上一页", + "next": "下一页", + "last": "最后一页" + }, + "errors": { + "loadFailed": "加载审计日志失败", + "retry": "重试" + } } }, "adminUpdates": { diff --git a/src/pages/AdminAuditLog.tsx b/src/pages/AdminAuditLog.tsx new file mode 100644 index 0000000..5a6c409 --- /dev/null +++ b/src/pages/AdminAuditLog.tsx @@ -0,0 +1,880 @@ +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', + 'stats', + 'broadcasts', + 'tariffs', + 'promocodes', + 'promo_groups', + 'promo_offers', + 'campaigns', + 'partners', + 'withdrawals', + 'payments', + 'payment_methods', + 'servers', + 'remnawave', + 'traffic', + 'settings', + 'roles', + 'audit_log', + 'channels', + 'ban_system', + 'wheel', + 'apps', + 'email_templates', + 'pinned_messages', + 'updates', +] 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 translateAction( + action: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + t: any, +): string { + return action + .split(',') + .map((perm: string) => { + const trimmed = perm.trim(); + const [section, act] = trimmed.split(':', 2); + if (!section || !act) return trimmed; + const sectionLabel = t(`admin.roles.form.permissionSections.${section}`, section) as string; + const actionLabel = t(`admin.roles.form.permissionActions.${act}`, act) as string; + return `${sectionLabel}: ${actionLabel}`; + }) + .join(', '); +} + +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 = entry.status; + const method = entry.request_method?.toUpperCase() ?? null; + const requestPath = entry.request_path; + 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 */} + {entry.details && 'before' in entry.details && entry.details.before != null && ( +
+

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

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

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

+
+                  {JSON.stringify(entry.details.after, null, 2)}
+                
+
+ )} + + {/* Query params */} + {entry.details && + 'query_params' in entry.details && + entry.details.query_params != null && ( +
+

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

+
+                    {JSON.stringify(entry.details.query_params, null, 2)}
+                  
+
+ )} + + {/* Request body */} + {entry.details && + 'request_body' in entry.details && + entry.details.request_body != null && ( +
+

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

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

+ {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); + const [exportError, setExportError] = useState(null); + + // 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_type = appliedFilters.resource; + } + if (appliedFilters.status) { + params.status = appliedFilters.status; + } + 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_type = 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); + } catch { + setExportError(t('admin.auditLog.exportError')); + } finally { + setExporting(false); + } + }, [appliedFilters, t]); + + const handlePageSizeChange = useCallback((newSize: number) => { + setPageSize(newSize); + setPage(0); + }, []); + + // Status is filtered server-side via query params + const filteredEntries = entries; + + 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 */} + +
+ {exportError &&

{exportError}

} + +
+
+
+
+ + {/* 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/AdminChannelSubscriptions.tsx b/src/pages/AdminChannelSubscriptions.tsx index ba37fca..6d0f1d1 100644 --- a/src/pages/AdminChannelSubscriptions.tsx +++ b/src/pages/AdminChannelSubscriptions.tsx @@ -8,7 +8,9 @@ import { type CreateChannelRequest, type UpdateChannelRequest, } from '../api/adminChannels'; +import { adminSettingsApi, type SettingDefinition } from '../api/adminSettings'; import { AdminBackButton } from '../components/admin'; +import { Toggle } from '../components/admin/Toggle'; // Icons const ChannelIcon = () => ( @@ -79,17 +81,146 @@ const LinkIcon = () => ( ); +const SettingsIcon = () => ( + + + + +); + +// Setting toggle row for global settings +const CHANNEL_SETTING_KEYS = [ + 'CHANNEL_IS_REQUIRED_SUB', + 'CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE', + 'CHANNEL_REQUIRED_FOR_ALL', +] as const; + +type ChannelSettingKey = (typeof CHANNEL_SETTING_KEYS)[number]; + +const SETTING_I18N_MAP: Record = { + CHANNEL_IS_REQUIRED_SUB: { + label: 'admin.channelSubscriptions.globalSettings.channelRequired', + desc: 'admin.channelSubscriptions.globalSettings.channelRequiredDesc', + }, + CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: { + label: 'admin.channelSubscriptions.globalSettings.disableTrialOnUnsub', + desc: 'admin.channelSubscriptions.globalSettings.disableTrialOnUnsubDesc', + }, + CHANNEL_REQUIRED_FOR_ALL: { + label: 'admin.channelSubscriptions.globalSettings.requiredForAll', + desc: 'admin.channelSubscriptions.globalSettings.requiredForAllDesc', + }, +}; + +function GlobalSettingsSection() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const haptic = useHaptic(); + const notify = useNotify(); + + const { data: settings, isLoading } = useQuery({ + queryKey: ['admin-settings', 'CHANNEL'], + queryFn: () => adminSettingsApi.getSettings('CHANNEL'), + }); + + const updateSettingMutation = useMutation({ + mutationFn: ({ key, value }: { key: string; value: unknown }) => + adminSettingsApi.updateSetting(key, value), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-settings', 'CHANNEL'] }); + haptic.impact('light'); + }, + onError: () => { + haptic.notification('error'); + notify.error(t('common.error')); + }, + }); + + const getSettingByKey = (key: string): SettingDefinition | undefined => + settings?.find((s) => s.key === key); + + const isSettingEnabled = (key: string): boolean => { + const setting = getSettingByKey(key); + if (!setting) return false; + return setting.current === true || setting.current === 'true'; + }; + + const handleToggleSetting = (key: string) => { + const current = isSettingEnabled(key); + updateSettingMutation.mutate({ key, value: !current }); + }; + + if (isLoading) { + return ( +
+
+
+ +
+ {t('common.loading')} +
+
+ ); + } + + return ( +
+
+
+ +
+

+ {t('admin.channelSubscriptions.globalSettings.title')} +

+
+ +
+ {CHANNEL_SETTING_KEYS.map((key) => { + const setting = getSettingByKey(key); + const i18n = SETTING_I18N_MAP[key]; + const enabled = isSettingEnabled(key); + const isUpdating = updateSettingMutation.isPending; + const isReadOnly = setting?.read_only ?? false; + + return ( +
+
+

{t(i18n.label)}

+

{t(i18n.desc)}

+
+ handleToggleSetting(key)} + disabled={isUpdating || isReadOnly || !setting} + /> +
+ ); + })} +
+
+ ); +} + // Channel card component function ChannelCard({ channel, onToggle, onDelete, onEdit, + onUpdate, }: { channel: RequiredChannel; onToggle: (id: number) => void; onDelete: (id: number) => void; onEdit: (channel: RequiredChannel) => void; + onUpdate: (id: number, data: UpdateChannelRequest) => void; }) { const { t } = useTranslation(); @@ -148,6 +279,46 @@ function ChannelCard({
+ {/* Per-channel disable toggles */} +
+
+
+

+ {t('admin.channelSubscriptions.perChannel.disableTrial')} +

+

+ {t('admin.channelSubscriptions.perChannel.disableTrialDesc')} +

+
+ + onUpdate(channel.id, { + disable_trial_on_leave: !channel.disable_trial_on_leave, + }) + } + /> +
+
+
+

+ {t('admin.channelSubscriptions.perChannel.disablePaid')} +

+

+ {t('admin.channelSubscriptions.perChannel.disablePaidDesc')} +

+
+ + onUpdate(channel.id, { + disable_paid_on_leave: !channel.disable_paid_on_leave, + }) + } + /> +
+
+ {/* Action buttons */}
+ {/* Global channel settings */} + + {/* Add form */} {showAddForm && ( ))} 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..bc11e36 --- /dev/null +++ b/src/pages/AdminPolicies.tsx @@ -0,0 +1,487 @@ +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 } 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 ShieldIcon = () => ( + + + +); + +const ClockIcon = () => ( + + + +); + +const GlobeIcon = () => ( + + + +); + +const BoltIcon = () => ( + + + +); + +const CalendarIcon = () => ( + + + +); + +// === Helpers === + +interface PolicyConditions { + time_range?: { start: string; end: string }; + ip_whitelist?: string[]; + rate_limit?: number; + weekdays?: number[]; +} + +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; + } + + if (Array.isArray(raw.weekdays)) { + result.weekdays = raw.weekdays.filter((d): d is number => typeof d === 'number'); + } + + 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')} + + ); +} + +// === Main Page === + +export default function AdminPolicies() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { capabilities } = usePlatform(); + + 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, + }); + + // Mutations + const deleteMutation = useMutation({ + mutationFn: rbacApi.deletePolicy, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-policies'] }); + setDeleteConfirm(null); + }, + onError: () => { + setDeleteConfirm(null); + setFormError(t('admin.policies.errors.deleteFailed')); + }, + }); + + // 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 sortedPolicies = useMemo(() => { + if (!policies) return []; + return [...policies].sort((a, b) => b.priority - a.priority); + }, [policies]); + + // 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 })} + , + ); + } + + if (parsed.weekdays && parsed.weekdays.length > 0 && parsed.weekdays.length < 7) { + const dayOrder = [1, 2, 3, 4, 5, 6, 0]; + const sorted = dayOrder.filter((d) => parsed.weekdays!.includes(d)); + const dayNames = sorted.map((d) => t(`admin.policies.conditions.day${d}`)); + icons.push( + + + {dayNames.join(', ')} + , + ); + } + + return icons; + }, + [t], + ); + + const getRoleName = useCallback( + (policy: AccessPolicy): string => { + if (policy.role_name) return policy.role_name; + if (policy.role_id === null) return t('admin.policies.global'); + const role = rolesMap.get(policy.role_id); + return role?.name ?? t('admin.policies.unknownRole'); + }, + [rolesMap, t], + ); + + return ( +
+ {/* Header */} +
+
+ {!capabilities.hasBackButton && ( + + )} +
+

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

+

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

+
+
+ + + +
+ + {/* Error message */} + {formError && ( +
+

{formError}

+
+ )} + + {/* 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); + + return ( +
+
+
+ {/* Policy name + effect badge */} +
+ {policy.name} + + {!policy.is_active && ( + + {t('admin.policies.inactiveBadge')} + + )} +
+ + {/* Resource + actions */} +
+ + {t( + `admin.roles.form.permissionSections.${policy.resource}`, + policy.resource, + )} + + : + + {(policy.actions ?? []) + .map((a) => t(`admin.roles.form.permissionActions.${a}`, a)) + .join(', ')} + +
+ + {/* Info row */} +
+ + {t('admin.policies.roleLabel')}: {roleName} + + + {t('admin.policies.priorityLabel')}: {policy.priority} + +
+ + {/* Condition icons */} + {conditionIcons.length > 0 && ( +
{conditionIcons}
+ )} +
+ + {/* Actions */} +
+ + + + + + +
+
+
+ ); + })} +
+ )} + + {/* Delete Confirmation */} + {deleteConfirm !== null && ( +
+
setDeleteConfirm(null)} + aria-hidden="true" + /> +
+

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

+

{t('admin.policies.confirm.text')}

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/pages/AdminPolicyEdit.tsx b/src/pages/AdminPolicyEdit.tsx new file mode 100644 index 0000000..a0fc77b --- /dev/null +++ b/src/pages/AdminPolicyEdit.tsx @@ -0,0 +1,776 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { rbacApi, AccessPolicy, CreatePolicyPayload, UpdatePolicyPayload } from '@/api/rbac'; +import { AdminBackButton } from '@/components/admin'; + +// === Types === + +interface PolicyConditions { + time_range?: { start: string; end: string }; + ip_whitelist?: string[]; + rate_limit?: number; + weekdays?: 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; + weekdays: 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, + weekdays: [1, 2, 3, 4, 5], + }, + conditionsEnabled: { + time_range: false, + ip_whitelist: false, + rate_limit: false, + weekdays: 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; + } + + if (Array.isArray(raw.weekdays)) { + result.weekdays = raw.weekdays.filter((d): d is number => typeof d === 'number'); + } + + 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; + } + if (enabled.weekdays && conditions.weekdays && conditions.weekdays.length > 0) { + result.weekdays = conditions.weekdays; + } + + return result; +} + +// === Sub-components === + +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 AdminPolicyEdit() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const queryClient = useQueryClient(); + const isEdit = !!id; + + const [formData, setFormData] = useState(INITIAL_FORM); + const [formError, setFormError] = useState(null); + + // Fetch policy for editing + const { isLoading: isLoadingPolicy } = useQuery({ + queryKey: ['admin-policy', id], + queryFn: () => rbacApi.getPolicies(), + enabled: isEdit, + select: useCallback( + (policies: AccessPolicy[]) => { + const policy = policies.find((p) => p.id === Number(id)); + if (policy) { + const parsed = parseConditions(policy.conditions); + const actions = Array.isArray(policy.actions) ? policy.actions : []; + + setFormData({ + name: policy.name, + description: policy.description ?? '', + effect: policy.effect, + resource: policy.resource, + actions, + role_id: policy.role_id ?? null, + 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, + weekdays: parsed.weekdays ?? [1, 2, 3, 4, 5], + }, + conditionsEnabled: { + time_range: !!parsed.time_range, + ip_whitelist: !!parsed.ip_whitelist && parsed.ip_whitelist.length > 0, + rate_limit: parsed.rate_limit !== undefined, + weekdays: !!parsed.weekdays && parsed.weekdays.length > 0, + }, + }); + } + return policy; + }, + [id], + ), + }); + + 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'] }); + navigate('/admin/policies'); + }, + onError: () => { + setFormError(t('admin.policies.errors.createFailed')); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ policyId, payload }: { policyId: number; payload: UpdatePolicyPayload }) => + rbacApi.updatePolicy(policyId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-policies'] }); + navigate('/admin/policies'); + }, + onError: () => { + setFormError(t('admin.policies.errors.updateFailed')); + }, + }); + + // Derived data + const selectedResourceActions = useMemo(() => { + if (!formData.resource || !permissionRegistry) return []; + const section = permissionRegistry.find((s) => s.section === formData.resource); + return section?.actions ?? []; + }, [formData.resource, permissionRegistry]); + + // Handlers + 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 (isEdit) { + const payload: UpdatePolicyPayload = { + name: formData.name.trim(), + description: formData.description.trim() || null, + effect: formData.effect, + resource: formData.resource, + actions: formData.actions, + conditions: conditionsPayload, + priority: formData.priority, + role_id: formData.role_id, + }; + updateMutation.mutate({ policyId: Number(id), payload }); + } else { + const payload: CreatePolicyPayload = { + name: formData.name.trim(), + description: formData.description.trim() || null, + effect: formData.effect, + resource: formData.resource, + actions: formData.actions, + conditions: conditionsPayload, + priority: formData.priority, + role_id: formData.role_id, + }; + createMutation.mutate(payload); + } + }, + [formData, isEdit, id, createMutation, updateMutation, t], + ); + + const isSaving = createMutation.isPending || updateMutation.isPending; + + // Loading state + if (isEdit && isLoadingPolicy) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+

+ {isEdit ? t('admin.policies.modal.editTitle') : t('admin.policies.modal.createTitle')} +

+
+
+ + {/* Form */} +
+ {/* Basic info */} +
+
+ {/* Name */} +
+ + 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.policies.form.namePlaceholder')} + autoFocus + /> +
+ + {/* Description */} +
+ +