Merge pull request #237 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-02-25 06:36:09 +03:00
committed by GitHub
20 changed files with 6338 additions and 380 deletions

View File

@@ -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() {
<Route
path="/admin/tickets"
element={
<AdminRoute>
<PermissionRoute permission="tickets:read">
<LazyPage>
<AdminTickets />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/tickets/settings"
element={
<AdminRoute>
<PermissionRoute permission="tickets:settings">
<LazyPage>
<AdminTicketSettings />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/settings"
element={
<AdminRoute>
<PermissionRoute permission="settings:read">
<LazyPage>
<AdminSettings />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/apps"
element={
<AdminRoute>
<PermissionRoute permission="apps:read">
<LazyPage>
<AdminApps />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/wheel"
element={
<AdminRoute>
<PermissionRoute permission="wheel:read">
<LazyPage>
<AdminWheel />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/tariffs"
element={
<AdminRoute>
<PermissionRoute permission="tariffs:read">
<LazyPage>
<AdminTariffs />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/tariffs/create"
element={
<AdminRoute>
<PermissionRoute permission="tariffs:read">
<LazyPage>
<AdminTariffCreate />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/tariffs/:id/edit"
element={
<AdminRoute>
<PermissionRoute permission="tariffs:read">
<LazyPage>
<AdminTariffCreate />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/servers"
element={
<AdminRoute>
<PermissionRoute permission="servers:read">
<LazyPage>
<AdminServers />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/servers/:id/edit"
element={
<AdminRoute>
<PermissionRoute permission="servers:read">
<LazyPage>
<AdminServerEdit />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/dashboard"
element={
<AdminRoute>
<PermissionRoute permission="stats:read">
<LazyPage>
<AdminDashboard />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/ban-system"
element={
<AdminRoute>
<PermissionRoute permission="ban_system:read">
<LazyPage>
<AdminBanSystem />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/broadcasts"
element={
<AdminRoute>
<PermissionRoute permission="broadcasts:read">
<LazyPage>
<AdminBroadcasts />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/broadcasts/create"
element={
<AdminRoute>
<PermissionRoute permission="broadcasts:read">
<LazyPage>
<AdminBroadcastCreate />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/promocodes"
element={
<AdminRoute>
<PermissionRoute permission="promocodes:read">
<LazyPage>
<AdminPromocodes />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/promocodes/create"
element={
<AdminRoute>
<PermissionRoute permission="promocodes:read">
<LazyPage>
<AdminPromocodeCreate />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/promocodes/:id/edit"
element={
<AdminRoute>
<PermissionRoute permission="promocodes:read">
<LazyPage>
<AdminPromocodeCreate />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/promocodes/:id/stats"
element={
<AdminRoute>
<PermissionRoute permission="promocodes:read">
<LazyPage>
<AdminPromocodeStats />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/promo-groups"
element={
<AdminRoute>
<PermissionRoute permission="promo_groups:read">
<LazyPage>
<AdminPromoGroups />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/promo-groups/create"
element={
<AdminRoute>
<PermissionRoute permission="promo_groups:read">
<LazyPage>
<AdminPromoGroupCreate />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/promo-groups/:id/edit"
element={
<AdminRoute>
<PermissionRoute permission="promo_groups:read">
<LazyPage>
<AdminPromoGroupCreate />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/campaigns"
element={
<AdminRoute>
<PermissionRoute permission="campaigns:read">
<LazyPage>
<AdminCampaigns />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/campaigns/create"
element={
<AdminRoute>
<PermissionRoute permission="campaigns:read">
<LazyPage>
<AdminCampaignCreate />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/campaigns/:id/stats"
element={
<AdminRoute>
<PermissionRoute permission="campaigns:read">
<LazyPage>
<AdminCampaignStats />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/campaigns/:id/edit"
element={
<AdminRoute>
<PermissionRoute permission="campaigns:read">
<LazyPage>
<AdminCampaignEdit />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/partners"
element={
<AdminRoute>
<PermissionRoute permission="partners:read">
<LazyPage>
<AdminPartners />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/partners/settings"
element={
<AdminRoute>
<PermissionRoute permission="partners:read">
<LazyPage>
<AdminPartnerSettings />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/partners/applications/:id/review"
element={
<AdminRoute>
<PermissionRoute permission="partners:read">
<LazyPage>
<AdminApplicationReview />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/partners/:userId/commission"
element={
<AdminRoute>
<PermissionRoute permission="partners:read">
<LazyPage>
<AdminPartnerCommission />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/partners/:userId/revoke"
element={
<AdminRoute>
<PermissionRoute permission="partners:read">
<LazyPage>
<AdminPartnerRevoke />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/partners/:userId/campaigns/assign"
element={
<AdminRoute>
<PermissionRoute permission="partners:read">
<LazyPage>
<AdminPartnerCampaignAssign />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/partners/:userId"
element={
<AdminRoute>
<PermissionRoute permission="partners:read">
<LazyPage>
<AdminPartnerDetail />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/withdrawals"
element={
<AdminRoute>
<PermissionRoute permission="withdrawals:read">
<LazyPage>
<AdminWithdrawals />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/withdrawals/:id/reject"
element={
<AdminRoute>
<PermissionRoute permission="withdrawals:read">
<LazyPage>
<AdminWithdrawalReject />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/withdrawals/:id"
element={
<AdminRoute>
<PermissionRoute permission="withdrawals:read">
<LazyPage>
<AdminWithdrawalDetail />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/users"
element={
<AdminRoute>
<PermissionRoute permission="users:read">
<LazyPage>
<AdminUsers />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/payments"
element={
<AdminRoute>
<PermissionRoute permission="payments:read">
<LazyPage>
<AdminPayments />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/traffic-usage"
element={
<AdminRoute>
<PermissionRoute permission="traffic:read">
<LazyPage>
<AdminTrafficUsage />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/payment-methods"
element={
<AdminRoute>
<PermissionRoute permission="payment_methods:read">
<LazyPage>
<AdminPaymentMethods />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/payment-methods/:methodId/edit"
element={
<AdminRoute>
<PermissionRoute permission="payment_methods:read">
<LazyPage>
<AdminPaymentMethodEdit />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/promo-offers"
element={
<AdminRoute>
<PermissionRoute permission="promo_offers:read">
<LazyPage>
<AdminPromoOffers />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/promo-offers/templates/:id/edit"
element={
<AdminRoute>
<PermissionRoute permission="promo_offers:read">
<LazyPage>
<AdminPromoOfferTemplateEdit />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/promo-offers/send"
element={
<AdminRoute>
<PermissionRoute permission="promo_offers:read">
<LazyPage>
<AdminPromoOfferSend />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/remnawave"
element={
<AdminRoute>
<PermissionRoute permission="remnawave:read">
<LazyPage>
<AdminRemnawave />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/remnawave/squads/:uuid"
element={
<AdminRoute>
<PermissionRoute permission="remnawave:read">
<LazyPage>
<AdminRemnawaveSquadDetail />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/email-templates"
element={
<AdminRoute>
<PermissionRoute permission="email_templates:read">
<LazyPage>
<AdminEmailTemplates />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/updates"
element={
<AdminRoute>
<PermissionRoute permission="updates:read">
<LazyPage>
<AdminUpdates />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/users/:id"
element={
<AdminRoute>
<PermissionRoute permission="users:read">
<LazyPage>
<AdminUserDetail />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/broadcasts/:id"
element={
<AdminRoute>
<PermissionRoute permission="broadcasts:read">
<LazyPage>
<AdminBroadcastDetail />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/pinned-messages"
element={
<AdminRoute>
<PermissionRoute permission="pinned_messages:read">
<LazyPage>
<AdminPinnedMessages />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/pinned-messages/create"
element={
<AdminRoute>
<PermissionRoute permission="pinned_messages:read">
<LazyPage>
<AdminPinnedMessageCreate />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/pinned-messages/:id/edit"
element={
<AdminRoute>
<PermissionRoute permission="pinned_messages:read">
<LazyPage>
<AdminPinnedMessageCreate />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/channel-subscriptions"
element={
<AdminRoute>
<PermissionRoute permission="channels:read">
<LazyPage>
<AdminChannelSubscriptions />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
<Route
path="/admin/email-templates/preview/:type/:lang"
element={
<AdminRoute>
<PermissionRoute permission="email_templates:read">
<LazyPage>
<AdminEmailTemplatePreview />
</LazyPage>
</AdminRoute>
</PermissionRoute>
}
/>
{/* RBAC routes */}
<Route
path="/admin/roles"
element={
<PermissionRoute permission="roles:read">
<LazyPage>
<AdminRoles />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/roles/create"
element={
<PermissionRoute permission="roles:create">
<LazyPage>
<AdminRoleEdit />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/roles/:id/edit"
element={
<PermissionRoute permission="roles:edit">
<LazyPage>
<AdminRoleEdit />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/roles/assign"
element={
<PermissionRoute permission="roles:assign">
<LazyPage>
<AdminRoleAssign />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/policies"
element={
<PermissionRoute permission="roles:read">
<LazyPage>
<AdminPolicies />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/policies/create"
element={
<PermissionRoute permission="roles:create">
<LazyPage>
<AdminPolicyEdit />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/policies/:id/edit"
element={
<PermissionRoute permission="roles:edit">
<LazyPage>
<AdminPolicyEdit />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/audit-log"
element={
<PermissionRoute permission="audit_log:read">
<LazyPage>
<AdminAuditLog />
</LazyPage>
</PermissionRoute>
}
/>

View File

@@ -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 = {

223
src/api/rbac.ts Normal file
View File

@@ -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<string, unknown>;
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<string, unknown>;
priority?: number;
role_id?: number | null;
}
export interface UpdatePolicyPayload {
name?: string;
description?: string | null;
resource?: string;
actions?: string[];
effect?: 'allow' | 'deny';
conditions?: Record<string, unknown>;
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<string, unknown> | 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<AdminRole[]> => {
const response = await apiClient.get<AdminRole[]>(`${BASE}/roles`);
return response.data;
},
createRole: async (payload: CreateRolePayload): Promise<AdminRole> => {
const response = await apiClient.post<AdminRole>(`${BASE}/roles`, payload);
return response.data;
},
updateRole: async (roleId: number, payload: UpdateRolePayload): Promise<AdminRole> => {
const response = await apiClient.put<AdminRole>(`${BASE}/roles/${roleId}`, payload);
return response.data;
},
deleteRole: async (roleId: number): Promise<void> => {
await apiClient.delete(`${BASE}/roles/${roleId}`);
},
// --- Permission Registry ---
getPermissionRegistry: async (): Promise<PermissionSection[]> => {
const response = await apiClient.get<PermissionSection[]>(`${BASE}/permissions`);
return response.data;
},
// --- Role Users ---
getRoleUsers: async (roleId: number): Promise<UserRoleAssignment[]> => {
const response = await apiClient.get<UserRoleAssignment[]>(`${BASE}/roles/${roleId}/users`);
return response.data;
},
assignRole: async (payload: AssignRolePayload): Promise<UserRoleAssignment> => {
const response = await apiClient.post<UserRoleAssignment>(`${BASE}/assignments`, payload);
return response.data;
},
revokeRole: async (assignmentId: number): Promise<void> => {
await apiClient.delete(`${BASE}/assignments/${assignmentId}`);
},
// --- Access Policies ---
getPolicies: async (): Promise<AccessPolicy[]> => {
const response = await apiClient.get<AccessPolicy[]>(`${BASE}/policies`);
return response.data;
},
createPolicy: async (payload: CreatePolicyPayload): Promise<AccessPolicy> => {
const response = await apiClient.post<AccessPolicy>(`${BASE}/policies`, payload);
return response.data;
},
updatePolicy: async (policyId: number, payload: UpdatePolicyPayload): Promise<AccessPolicy> => {
const response = await apiClient.put<AccessPolicy>(`${BASE}/policies/${policyId}`, payload);
return response.data;
},
deletePolicy: async (policyId: number): Promise<void> => {
await apiClient.delete(`${BASE}/policies/${policyId}`);
},
// --- Audit Log ---
getAuditLog: async (filters?: AuditLogFilters): Promise<AuditLogResponse> => {
const response = await apiClient.get<AuditLogResponse>(`${BASE}/audit-log`, {
params: filters,
});
return response.data;
},
exportAuditLog: async (filters?: AuditLogFilters): Promise<Blob> => {
const response = await apiClient.get(`${BASE}/audit-log/export`, {
params: filters,
responseType: 'blob',
});
return response.data as Blob;
},
};

View File

@@ -0,0 +1,42 @@
import { usePermissionStore } from '../../store/permissions';
interface PermissionGateProps {
children: React.ReactNode;
/** Single permission to check */
permission?: string;
/** Multiple permissions (OR by default, AND if requireAll is true) */
permissions?: string[];
/** When true, ALL listed permissions are required instead of ANY */
requireAll?: boolean;
/** Content to render when the user lacks the required permissions */
fallback?: React.ReactNode;
}
export function PermissionGate({
children,
permission,
permissions,
requireAll = false,
fallback = null,
}: PermissionGateProps) {
const hasPermission = usePermissionStore((state) => state.hasPermission);
const hasAnyPermission = usePermissionStore((state) => state.hasAnyPermission);
const hasAllPermissions = usePermissionStore((state) => state.hasAllPermissions);
let hasAccess = false;
if (permission) {
hasAccess = hasPermission(permission);
} else if (permissions && permissions.length > 0) {
hasAccess = requireAll ? hasAllPermissions(...permissions) : hasAnyPermission(...permissions);
} else {
// No permissions specified — allow access
hasAccess = true;
}
if (!hasAccess) {
return <>{fallback}</>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,66 @@
import { Navigate, useLocation } from 'react-router';
import { useAuthStore } from '../../store/auth';
import { usePermissionStore } from '../../store/permissions';
import { saveReturnUrl } from '../../utils/token';
import PageLoader from '../common/PageLoader';
import Layout from '../layout/Layout';
interface PermissionRouteProps {
children: React.ReactNode;
/** Single permission to check */
permission?: string;
/** Multiple permissions (OR by default, AND if requireAll is true) */
permissions?: string[];
/** When true, ALL listed permissions are required instead of ANY */
requireAll?: boolean;
}
export function PermissionRoute({
children,
permission,
permissions,
requireAll = false,
}: PermissionRouteProps) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
const isAdmin = useAuthStore((state) => state.isAdmin);
const location = useLocation();
const permissionsLoaded = usePermissionStore((state) => state.isLoaded);
const hasPermission = usePermissionStore((state) => state.hasPermission);
const hasAnyPermission = usePermissionStore((state) => state.hasAnyPermission);
const hasAllPermissions = usePermissionStore((state) => state.hasAllPermissions);
// Still loading auth state, or admin but permissions not fetched yet
if (isLoading || (isAdmin && !permissionsLoaded)) {
return <PageLoader variant="light" />;
}
if (!isAuthenticated) {
saveReturnUrl();
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
if (!isAdmin) {
return <Navigate to="/" replace />;
}
// Check permissions if specified
const needsPermissionCheck = permission || (permissions && permissions.length > 0);
if (needsPermissionCheck) {
let hasAccess = false;
if (permission) {
hasAccess = hasPermission(permission);
} else if (permissions && permissions.length > 0) {
hasAccess = requireAll ? hasAllPermissions(...permissions) : hasAnyPermission(...permissions);
}
if (!hasAccess) {
return <Navigate to="/admin" replace />;
}
}
return <Layout>{children}</Layout>;
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

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

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

View File

@@ -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 = () => (
</svg>
);
const SettingsIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
// 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<ChannelSettingKey, { label: string; desc: string }> = {
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 (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-6">
<div className="flex items-center gap-3">
<div className="animate-spin">
<RefreshIcon />
</div>
<span className="text-sm text-dark-400">{t('common.loading')}</span>
</div>
</div>
);
}
return (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="mb-4 flex items-center gap-2.5">
<div className="text-dark-300">
<SettingsIcon />
</div>
<h2 className="text-sm font-semibold text-dark-100">
{t('admin.channelSubscriptions.globalSettings.title')}
</h2>
</div>
<div className="space-y-1">
{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 (
<div
key={key}
className="flex items-center justify-between gap-4 rounded-lg px-3 py-2.5 transition-colors hover:bg-dark-700/30"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-dark-200">{t(i18n.label)}</p>
<p className="mt-0.5 text-xs text-dark-400">{t(i18n.desc)}</p>
</div>
<Toggle
checked={enabled}
onChange={() => handleToggleSetting(key)}
disabled={isUpdating || isReadOnly || !setting}
/>
</div>
);
})}
</div>
</div>
);
}
// 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({
</div>
</div>
{/* Per-channel disable toggles */}
<div className="mt-3 space-y-2 border-t border-dark-700/50 pt-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-dark-300">
{t('admin.channelSubscriptions.perChannel.disableTrial')}
</p>
<p className="text-xs text-dark-500">
{t('admin.channelSubscriptions.perChannel.disableTrialDesc')}
</p>
</div>
<Toggle
checked={channel.disable_trial_on_leave}
onChange={() =>
onUpdate(channel.id, {
disable_trial_on_leave: !channel.disable_trial_on_leave,
})
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-dark-300">
{t('admin.channelSubscriptions.perChannel.disablePaid')}
</p>
<p className="text-xs text-dark-500">
{t('admin.channelSubscriptions.perChannel.disablePaidDesc')}
</p>
</div>
<Toggle
checked={channel.disable_paid_on_leave}
onChange={() =>
onUpdate(channel.id, {
disable_paid_on_leave: !channel.disable_paid_on_leave,
})
}
/>
</div>
</div>
{/* Action buttons */}
<div className="mt-3 flex flex-wrap gap-2 border-t border-dark-700/50 pt-3">
<button
@@ -545,6 +716,9 @@ export default function AdminChannelSubscriptions() {
</div>
</div>
{/* Global channel settings */}
<GlobalSettingsSection />
{/* Add form */}
{showAddForm && (
<AddChannelForm
@@ -588,6 +762,7 @@ export default function AdminChannelSubscriptions() {
onToggle={handleToggle}
onDelete={handleDelete}
onEdit={handleEdit}
onUpdate={handleUpdate}
/>
))}
</div>

View File

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

487
src/pages/AdminPolicies.tsx Normal file
View File

@@ -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 = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const PlusIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const EditIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const ShieldIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
const ClockIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const GlobeIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
);
const BoltIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
/>
</svg>
);
const CalendarIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
);
// === Helpers ===
interface PolicyConditions {
time_range?: { start: string; end: string };
ip_whitelist?: string[];
rate_limit?: number;
weekdays?: number[];
}
function parseConditions(raw: Record<string, unknown>): PolicyConditions {
const result: PolicyConditions = {};
if (raw.time_range && typeof raw.time_range === 'object') {
const tr = raw.time_range as Record<string, unknown>;
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 (
<span
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold ${
isAllow
? 'border-green-500/30 bg-green-500/10 text-green-400'
: 'border-red-500/30 bg-red-500/10 text-red-400'
} ${className ?? ''}`}
>
{isAllow ? t('admin.policies.effectAllow') : t('admin.policies.effectDeny')}
</span>
);
}
// === Main Page ===
export default function AdminPolicies() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const [formError, setFormError] = useState<string | null>(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<number, AdminRole>();
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<string, unknown>) => {
const parsed = parseConditions(conditions);
const icons: React.ReactNode[] = [];
if (parsed.time_range) {
icons.push(
<span
key="time"
className="inline-flex items-center gap-1 rounded bg-dark-700 px-1.5 py-0.5 text-xs text-dark-300"
title={t('admin.policies.conditions.timeRange')}
>
<ClockIcon />
{parsed.time_range.start}-{parsed.time_range.end}
</span>,
);
}
if (parsed.ip_whitelist && parsed.ip_whitelist.length > 0) {
icons.push(
<span
key="ip"
className="inline-flex items-center gap-1 rounded bg-dark-700 px-1.5 py-0.5 text-xs text-dark-300"
title={t('admin.policies.conditions.ipWhitelist')}
>
<GlobeIcon />
{t('admin.policies.conditions.ipCount', { count: parsed.ip_whitelist.length })}
</span>,
);
}
if (parsed.rate_limit !== undefined) {
icons.push(
<span
key="rate"
className="inline-flex items-center gap-1 rounded bg-dark-700 px-1.5 py-0.5 text-xs text-dark-300"
title={t('admin.policies.conditions.rateLimit')}
>
<BoltIcon />
{t('admin.policies.conditions.rateValue', { count: parsed.rate_limit })}
</span>,
);
}
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(
<span
key="weekdays"
className="inline-flex items-center gap-1 rounded bg-dark-700 px-1.5 py-0.5 text-xs text-dark-300"
title={t('admin.policies.conditions.weekdays')}
>
<CalendarIcon />
{dayNames.join(', ')}
</span>,
);
}
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 (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.policies.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.policies.subtitle')}</p>
</div>
</div>
<PermissionGate permission="roles:create">
<button
onClick={() => navigate('/admin/policies/create')}
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
>
<PlusIcon />
{t('admin.policies.createPolicy')}
</button>
</PermissionGate>
</div>
{/* Error message */}
{formError && (
<div className="mb-4 rounded-lg border border-error-500/30 bg-error-500/10 p-3">
<p className="text-sm text-error-400">{formError}</p>
</div>
)}
{/* Stats Overview */}
{sortedPolicies.length > 0 && (
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-dark-100">{sortedPolicies.length}</div>
<div className="text-xs text-dark-400">{t('admin.policies.stats.total')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-green-400">
{sortedPolicies.filter((p) => p.effect === 'allow').length}
</div>
<div className="text-xs text-dark-400">{t('admin.policies.stats.allow')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-red-400">
{sortedPolicies.filter((p) => p.effect === 'deny').length}
</div>
<div className="text-xs text-dark-400">{t('admin.policies.stats.deny')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-accent-400">
{sortedPolicies.filter((p) => p.is_active).length}
</div>
<div className="text-xs text-dark-400">{t('admin.policies.stats.active')}</div>
</div>
</div>
)}
{/* Policies List */}
{policiesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : policiesError ? (
<div className="py-12 text-center">
<p className="text-error-400">{t('admin.policies.errors.loadFailed')}</p>
</div>
) : sortedPolicies.length === 0 ? (
<div className="py-12 text-center">
<ShieldIcon />
<p className="mt-2 text-dark-400">{t('admin.policies.noPolicies')}</p>
</div>
) : (
<div className="space-y-3">
{sortedPolicies.map((policy) => {
const conditionIcons = renderConditionIcons(policy.conditions);
const roleName = getRoleName(policy);
return (
<div
key={policy.id}
className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
policy.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
}`}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
{/* Policy name + effect badge */}
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="font-medium text-dark-100">{policy.name}</span>
<EffectBadge effect={policy.effect} />
{!policy.is_active && (
<span className="rounded bg-dark-600 px-1.5 py-0.5 text-xs text-dark-400">
{t('admin.policies.inactiveBadge')}
</span>
)}
</div>
{/* Resource + actions */}
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span className="rounded bg-dark-700 px-2 py-0.5 text-xs text-accent-400">
{t(
`admin.roles.form.permissionSections.${policy.resource}`,
policy.resource,
)}
</span>
<span className="text-dark-500">:</span>
<span className="text-xs text-dark-300">
{(policy.actions ?? [])
.map((a) => t(`admin.roles.form.permissionActions.${a}`, a))
.join(', ')}
</span>
</div>
{/* Info row */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
<span>
{t('admin.policies.roleLabel')}: {roleName}
</span>
<span>
{t('admin.policies.priorityLabel')}: {policy.priority}
</span>
</div>
{/* Condition icons */}
{conditionIcons.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">{conditionIcons}</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 border-t border-dark-700 pt-3 sm:border-0 sm:pt-0">
<PermissionGate permission="roles:edit">
<button
onClick={() => navigate(`/admin/policies/${policy.id}/edit`)}
className="flex-1 rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 sm:flex-none"
title={t('admin.policies.actions.edit')}
>
<EditIcon />
</button>
</PermissionGate>
<PermissionGate permission="roles:delete">
<button
onClick={() => setDeleteConfirm(policy.id)}
className="flex-1 rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400 sm:flex-none"
title={t('admin.policies.actions.delete')}
>
<TrashIcon />
</button>
</PermissionGate>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Delete Confirmation */}
{deleteConfirm !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="fixed inset-0 bg-black/60"
onClick={() => setDeleteConfirm(null)}
aria-hidden="true"
/>
<div className="relative w-full max-w-sm rounded-xl border border-dark-700 bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.policies.confirm.title')}
</h3>
<p className="mb-6 text-dark-400">{t('admin.policies.confirm.text')}</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('admin.policies.confirm.cancel')}
</button>
<button
onClick={() => deleteMutation.mutate(deleteConfirm)}
disabled={deleteMutation.isPending}
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
{deleteMutation.isPending
? t('admin.policies.confirm.deleting')
: t('admin.policies.confirm.delete')}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<string, unknown>): PolicyConditions {
const result: PolicyConditions = {};
if (raw.time_range && typeof raw.time_range === 'object') {
const tr = raw.time_range as Record<string, unknown>;
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<string, unknown> {
const result: Record<string, unknown> = {};
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<HTMLInputElement>) => {
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 (
<div className="flex flex-wrap items-center gap-1.5 rounded-lg border border-dark-600 bg-dark-900 px-3 py-2">
{values.map((ip) => (
<span
key={ip}
className="inline-flex items-center gap-1 rounded-md bg-dark-700 px-2 py-0.5 text-xs text-dark-200"
>
{ip}
<button
type="button"
onClick={() => removeIp(ip)}
className="text-dark-400 transition-colors hover:text-dark-200"
aria-label={t('admin.policies.conditions.removeIp', { ip })}
>
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
<input
type="text"
value={inputValue}
onChange={(e) => 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') : ''}
/>
</div>
);
}
interface ConditionToggleProps {
label: string;
enabled: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function ConditionToggle({ label, enabled, onToggle, children }: ConditionToggleProps) {
return (
<div className="rounded-lg border border-dark-700/50 bg-dark-800/30">
<button
type="button"
onClick={onToggle}
className="flex w-full items-center justify-between px-3 py-2"
>
<span className="text-sm font-medium text-dark-200">{label}</span>
<div
className={`relative h-5 w-9 rounded-full transition-colors ${
enabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<div
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
enabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</div>
</button>
{enabled && <div className="border-t border-dark-700/50 px-3 py-2.5">{children}</div>}
</div>
);
}
// === 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<PolicyFormData>(INITIAL_FORM);
const [formError, setFormError] = useState<string | null>(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 (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
return (
<div className="animate-fade-in space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<AdminBackButton to="/admin/policies" />
<div>
<h1 className="text-xl font-semibold text-dark-100">
{isEdit ? t('admin.policies.modal.editTitle') : t('admin.policies.modal.createTitle')}
</h1>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic info */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
<div className="space-y-4">
{/* Name */}
<div>
<label htmlFor="policy-name" className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.policies.form.name')}
</label>
<input
id="policy-name"
type="text"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500"
placeholder={t('admin.policies.form.namePlaceholder')}
autoFocus
/>
</div>
{/* Description */}
<div>
<label
htmlFor="policy-description"
className="mb-1 block text-sm font-medium text-dark-200"
>
{t('admin.policies.form.description')}
</label>
<textarea
id="policy-description"
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500"
placeholder={t('admin.policies.form.descriptionPlaceholder')}
rows={2}
/>
</div>
{/* Effect toggle */}
<div>
<label className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.policies.form.effect')}
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setFormData((prev) => ({ ...prev, effect: 'allow' }))}
className={`flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
formData.effect === 'allow'
? 'border-green-500/50 bg-green-500/10 text-green-400'
: 'border-dark-600 bg-dark-900 text-dark-400 hover:border-dark-500'
}`}
>
{t('admin.policies.effectAllow')}
</button>
<button
type="button"
onClick={() => setFormData((prev) => ({ ...prev, effect: 'deny' }))}
className={`flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
formData.effect === 'deny'
? 'border-red-500/50 bg-red-500/10 text-red-400'
: 'border-dark-600 bg-dark-900 text-dark-400 hover:border-dark-500'
}`}
>
{t('admin.policies.effectDeny')}
</button>
</div>
</div>
</div>
</div>
{/* Resource & Actions */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
<div className="space-y-4">
{/* Resource dropdown */}
<div>
<label
htmlFor="policy-resource"
className="mb-1 block text-sm font-medium text-dark-200"
>
{t('admin.policies.form.resource')}
</label>
<select
id="policy-resource"
value={formData.resource}
onChange={(e) => handleResourceChange(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 outline-none transition-colors focus:border-accent-500"
>
<option value="">{t('admin.policies.form.selectResource')}</option>
{permissionRegistry?.map((section) => (
<option key={section.section} value={section.section}>
{t(`admin.roles.form.permissionSections.${section.section}`, section.section)}
</option>
))}
</select>
</div>
{/* Actions checkboxes */}
{formData.resource && selectedResourceActions.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.policies.form.actions')}
</label>
<div className="flex flex-wrap gap-2 rounded-lg border border-dark-600 bg-dark-900/50 p-3">
{selectedResourceActions.map((action) => {
const selected = formData.actions.includes(action);
return (
<button
key={action}
type="button"
onClick={() => handleToggleAction(action)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
selected
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-700/50 text-dark-400 hover:bg-dark-700 hover:text-dark-300'
}`}
aria-pressed={selected}
>
{t(`admin.roles.form.permissionActions.${action}`, action)}
</button>
);
})}
</div>
</div>
)}
{/* Role dropdown */}
<div>
<label htmlFor="policy-role" className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.policies.form.role')}
</label>
<select
id="policy-role"
value={formData.role_id ?? ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
role_id: e.target.value ? Number(e.target.value) : null,
}))
}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 outline-none transition-colors focus:border-accent-500"
>
<option value="">{t('admin.policies.form.globalOption')}</option>
{roles?.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
<p className="mt-1 text-xs text-dark-500">{t('admin.policies.form.roleHint')}</p>
</div>
{/* Priority */}
<div>
<label
htmlFor="policy-priority"
className="mb-1 block text-sm font-medium text-dark-200"
>
{t('admin.policies.form.priority')}
</label>
<input
id="policy-priority"
type="number"
min={0}
max={999}
value={formData.priority}
onChange={(e) =>
setFormData((prev) => ({
...prev,
priority: Math.min(999, Math.max(0, Number(e.target.value) || 0)),
}))
}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 outline-none transition-colors focus:border-accent-500"
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.policies.form.priorityHint')}</p>
</div>
</div>
</div>
{/* Conditions */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
<label className="mb-3 block text-sm font-medium text-dark-200">
{t('admin.policies.form.conditions')}
</label>
<div className="space-y-2">
{/* Time range */}
<ConditionToggle
label={t('admin.policies.conditions.timeRange')}
enabled={formData.conditionsEnabled.time_range}
onToggle={() =>
setFormData((prev) => ({
...prev,
conditionsEnabled: {
...prev.conditionsEnabled,
time_range: !prev.conditionsEnabled.time_range,
},
}))
}
>
<div className="flex items-center gap-2">
<input
type="time"
value={formData.conditions.time_range?.start ?? '09:00'}
onChange={(e) =>
setFormData((prev) => ({
...prev,
conditions: {
...prev.conditions,
time_range: {
start: e.target.value,
end: prev.conditions.time_range?.end ?? '18:00',
},
},
}))
}
className="rounded-lg border border-dark-600 bg-dark-900 px-2 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
aria-label={t('admin.policies.conditions.timeStart')}
/>
<span className="text-dark-500">-</span>
<input
type="time"
value={formData.conditions.time_range?.end ?? '18:00'}
onChange={(e) =>
setFormData((prev) => ({
...prev,
conditions: {
...prev.conditions,
time_range: {
start: prev.conditions.time_range?.start ?? '09:00',
end: e.target.value,
},
},
}))
}
className="rounded-lg border border-dark-600 bg-dark-900 px-2 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
aria-label={t('admin.policies.conditions.timeEnd')}
/>
</div>
</ConditionToggle>
{/* IP whitelist */}
<ConditionToggle
label={t('admin.policies.conditions.ipWhitelist')}
enabled={formData.conditionsEnabled.ip_whitelist}
onToggle={() =>
setFormData((prev) => ({
...prev,
conditionsEnabled: {
...prev.conditionsEnabled,
ip_whitelist: !prev.conditionsEnabled.ip_whitelist,
},
}))
}
>
<IpTagInput
values={formData.conditions.ip_whitelist ?? []}
onChange={(ips) =>
setFormData((prev) => ({
...prev,
conditions: {
...prev.conditions,
ip_whitelist: ips,
},
}))
}
/>
</ConditionToggle>
{/* Rate limit */}
<ConditionToggle
label={t('admin.policies.conditions.rateLimit')}
enabled={formData.conditionsEnabled.rate_limit}
onToggle={() =>
setFormData((prev) => ({
...prev,
conditionsEnabled: {
...prev.conditionsEnabled,
rate_limit: !prev.conditionsEnabled.rate_limit,
},
}))
}
>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
max={10000}
value={formData.conditions.rate_limit ?? 100}
onChange={(e) =>
setFormData((prev) => ({
...prev,
conditions: {
...prev.conditions,
rate_limit: Math.max(1, Number(e.target.value) || 1),
},
}))
}
className="w-24 rounded-lg border border-dark-600 bg-dark-900 px-2 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
aria-label={t('admin.policies.conditions.rateLimitValue')}
/>
<span className="text-xs text-dark-500">
{t('admin.policies.conditions.perHour')}
</span>
</div>
</ConditionToggle>
{/* Weekdays */}
<ConditionToggle
label={t('admin.policies.conditions.weekdays')}
enabled={formData.conditionsEnabled.weekdays}
onToggle={() =>
setFormData((prev) => ({
...prev,
conditionsEnabled: {
...prev.conditionsEnabled,
weekdays: !prev.conditionsEnabled.weekdays,
},
}))
}
>
<div className="flex flex-wrap gap-1.5">
{[1, 2, 3, 4, 5, 6, 0].map((day) => {
const selected = (formData.conditions.weekdays ?? []).includes(day);
return (
<button
key={day}
type="button"
onClick={() =>
setFormData((prev) => {
const current = prev.conditions.weekdays ?? [];
const next = selected
? current.filter((d) => d !== day)
: [...current, day];
return {
...prev,
conditions: { ...prev.conditions, weekdays: next },
};
})
}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
selected
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-700/50 text-dark-400 hover:bg-dark-700 hover:text-dark-300'
}`}
aria-pressed={selected}
>
{t(`admin.policies.conditions.day${day}`)}
</button>
);
})}
</div>
</ConditionToggle>
</div>
</div>
{/* Error & Submit */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
{formError && <p className="mb-4 text-sm text-error-400">{formError}</p>}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => navigate('/admin/policies')}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('admin.policies.form.cancel')}
</button>
<button
type="submit"
disabled={isSaving}
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{isSaving ? t('admin.policies.form.saving') : t('admin.policies.form.save')}
</button>
</div>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,717 @@
import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { rbacApi, type AdminRole, type AssignRolePayload } from '@/api/rbac';
import { adminUsersApi, type UserListItem } from '@/api/adminUsers';
import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePermissionStore } from '@/store/permissions';
import { usePlatform } from '@/platform/hooks/usePlatform';
// === Icons ===
const BackIcon = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const SearchIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
const ChevronDownIcon = ({ className }: { className?: string }) => (
<svg
className={className || 'h-4 w-4'}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const ChevronLeftIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const ChevronRightIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
);
const XCircleIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const UserPlusIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z"
/>
</svg>
);
// === Constants ===
const PAGE_SIZE = 10;
// === Sub-components ===
interface UserSearchDropdownProps {
searchQuery: string;
onSearchChange: (value: string) => void;
onSelectUser: (user: UserListItem) => void;
selectedUser: UserListItem | null;
onClearUser: () => void;
}
function UserSearchDropdown({
searchQuery,
onSearchChange,
onSelectUser,
selectedUser,
onClearUser,
}: UserSearchDropdownProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const { data: searchResults, isLoading: searching } = useQuery({
queryKey: ['admin-user-search', searchQuery],
queryFn: () => adminUsersApi.getUsers({ search: searchQuery, limit: 10 }),
enabled: searchQuery.length >= 2 && !selectedUser,
staleTime: 30_000,
});
const handleSelect = useCallback(
(user: UserListItem) => {
onSelectUser(user);
setIsOpen(false);
},
[onSelectUser],
);
if (selectedUser) {
return (
<div className="flex items-center gap-2 rounded-lg border border-dark-600 bg-dark-900 px-3 py-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-xs font-medium text-white">
{selectedUser.first_name?.[0] || selectedUser.username?.[0] || '?'}
</div>
<div className="min-w-0 flex-1">
<span className="text-sm text-dark-100">{selectedUser.full_name}</span>
{selectedUser.username && (
<span className="ml-1.5 text-xs text-dark-500">@{selectedUser.username}</span>
)}
</div>
<button
type="button"
onClick={onClearUser}
className="shrink-0 text-dark-400 transition-colors hover:text-dark-200"
aria-label={t('admin.roleAssign.clearUser')}
>
<XCircleIcon />
</button>
</div>
);
}
return (
<div className="relative">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => {
onSearchChange(e.target.value);
setIsOpen(true);
}}
onFocus={() => {
if (searchQuery.length >= 2) setIsOpen(true);
}}
placeholder={t('admin.roleAssign.searchPlaceholder')}
className="w-full rounded-lg border border-dark-600 bg-dark-900 py-2 pl-10 pr-3 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
<SearchIcon />
</div>
</div>
{isOpen && searchQuery.length >= 2 && (
<>
{/* Backdrop to close dropdown */}
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} aria-hidden="true" />
<div className="absolute left-0 right-0 top-full z-20 mt-1 max-h-60 overflow-y-auto rounded-lg border border-dark-600 bg-dark-800 shadow-xl">
{searching ? (
<div className="flex items-center justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : !searchResults || searchResults.users.length === 0 ? (
<div className="px-3 py-4 text-center text-sm text-dark-400">
{t('admin.roleAssign.noUsersFound')}
</div>
) : (
searchResults.users.map((user) => (
<button
key={user.id}
type="button"
onClick={() => handleSelect(user)}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-dark-700"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-xs font-medium text-white">
{user.first_name?.[0] || user.username?.[0] || '?'}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-dark-100">
{user.full_name}
</div>
<div className="flex items-center gap-2 text-xs text-dark-400">
{user.username && <span>@{user.username}</span>}
<span>ID: {user.telegram_id}</span>
</div>
</div>
</button>
))
)}
</div>
</>
)}
</div>
);
}
interface RoleBadgeProps {
name: string;
color: string | null;
}
function RoleBadge({ name, color }: RoleBadgeProps) {
return (
<span
className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium"
style={{
borderColor: color ? `${color}40` : undefined,
backgroundColor: color ? `${color}20` : undefined,
color: color || undefined,
}}
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: color || '#6b7280' }}
aria-hidden="true"
/>
{name}
</span>
);
}
// === Main Page ===
export default function AdminRoleAssign() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
const canManageRole = usePermissionStore((s) => s.canManageRole);
// Assign form state
const [searchQuery, setSearchQuery] = useState('');
const [selectedUser, setSelectedUser] = useState<UserListItem | null>(null);
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
const [expiresAt, setExpiresAt] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [formSuccess, setFormSuccess] = useState<string | null>(null);
// Pagination state
const [page, setPage] = useState(0);
// Revoke confirm
const [revokeConfirm, setRevokeConfirm] = useState<number | null>(null);
// Queries
const { data: roles, isLoading: rolesLoading } = useQuery({
queryKey: ['admin-roles'],
queryFn: rbacApi.getRoles,
});
// Fetch assignments for each role in parallel, then flatten
const { data: allAssignments, isLoading: assignmentsLoading } = useQuery({
queryKey: ['admin-role-assignments', roles?.map((r) => r.id)],
queryFn: async () => {
if (!roles || roles.length === 0) return [];
const results = await Promise.all(
roles.map((role) =>
rbacApi.getRoleUsers(role.id).then((assignments) =>
assignments.map((a) => ({
...a,
role_color: role.color,
role_level: role.level,
})),
),
),
);
return results.flat();
},
enabled: !!roles && roles.length > 0,
});
// Available roles filtered by canManageRole
const assignableRoles = useMemo(() => {
if (!roles) return [];
return roles.filter((r) => r.is_active && canManageRole(r.level));
}, [roles, canManageRole]);
// Roles map for quick lookup
const rolesMap = useMemo(() => {
if (!roles) return new Map<number, AdminRole>();
return new Map(roles.map((r) => [r.id, r]));
}, [roles]);
// Paginated assignments sorted by assigned_at descending
const sortedAssignments = useMemo(() => {
if (!allAssignments) return [];
return [...allAssignments].sort(
(a, b) => new Date(b.assigned_at).getTime() - new Date(a.assigned_at).getTime(),
);
}, [allAssignments]);
const totalPages = Math.ceil(sortedAssignments.length / PAGE_SIZE);
const paginatedAssignments = useMemo(() => {
const start = page * PAGE_SIZE;
return sortedAssignments.slice(start, start + PAGE_SIZE);
}, [sortedAssignments, page]);
// Mutations
const assignMutation = useMutation({
mutationFn: (payload: AssignRolePayload) => rbacApi.assignRole(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-role-assignments'] });
queryClient.invalidateQueries({ queryKey: ['admin-roles'] });
setSelectedUser(null);
setSearchQuery('');
setSelectedRoleId(null);
setExpiresAt('');
setFormError(null);
setFormSuccess(t('admin.roleAssign.assignSuccess'));
setTimeout(() => setFormSuccess(null), 3000);
},
onError: () => {
setFormError(t('admin.roleAssign.errors.assignFailed'));
},
});
const revokeMutation = useMutation({
mutationFn: rbacApi.revokeRole,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-role-assignments'] });
queryClient.invalidateQueries({ queryKey: ['admin-roles'] });
setRevokeConfirm(null);
},
onError: () => {
setRevokeConfirm(null);
setFormError(t('admin.roleAssign.errors.revokeFailed'));
},
});
// Handlers
const handleSelectUser = useCallback((user: UserListItem) => {
setSelectedUser(user);
setSearchQuery('');
setFormError(null);
setFormSuccess(null);
}, []);
const handleClearUser = useCallback(() => {
setSelectedUser(null);
setSearchQuery('');
}, []);
const handleAssign = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
setFormSuccess(null);
if (!selectedUser) {
setFormError(t('admin.roleAssign.errors.userRequired'));
return;
}
if (!selectedRoleId) {
setFormError(t('admin.roleAssign.errors.roleRequired'));
return;
}
const payload: AssignRolePayload = {
user_id: selectedUser.id,
role_id: selectedRoleId,
expires_at: expiresAt || null,
};
assignMutation.mutate(payload);
},
[selectedUser, selectedRoleId, expiresAt, assignMutation, t],
);
const formatDate = useCallback((dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}, []);
const isExpired = useCallback((expiresAtStr: string | null) => {
if (!expiresAtStr) return false;
return new Date(expiresAtStr).getTime() < Date.now();
}, []);
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex items-center gap-3">
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.roleAssign.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.roleAssign.subtitle')}</p>
</div>
</div>
{/* Assign Section */}
<PermissionGate permission="roles:assign">
<div className="mb-6 rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-5">
<h2 className="mb-4 flex items-center gap-2 text-base font-semibold text-dark-100">
<UserPlusIcon />
{t('admin.roleAssign.assignSection')}
</h2>
<form onSubmit={handleAssign} className="space-y-4">
{/* User search */}
<div>
<label className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.roleAssign.userLabel')}
</label>
<UserSearchDropdown
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectUser={handleSelectUser}
selectedUser={selectedUser}
onClearUser={handleClearUser}
/>
</div>
{/* Role dropdown + Expiry in row */}
<div className="flex flex-col gap-4 sm:flex-row">
{/* Role dropdown */}
<div className="flex-1">
<label
htmlFor="assign-role"
className="mb-1 block text-sm font-medium text-dark-200"
>
{t('admin.roleAssign.roleLabel')}
</label>
<div className="relative">
<select
id="assign-role"
value={selectedRoleId ?? ''}
onChange={(e) =>
setSelectedRoleId(e.target.value ? Number(e.target.value) : null)
}
className="w-full appearance-none rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 pr-8 text-dark-100 outline-none transition-colors focus:border-accent-500"
>
<option value="">{t('admin.roleAssign.selectRole')}</option>
{assignableRoles.map((role) => (
<option key={role.id} value={role.id}>
{role.name} (L{role.level})
</option>
))}
</select>
<ChevronDownIcon className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-400" />
</div>
</div>
{/* Expiry date */}
<div className="flex-1">
<label
htmlFor="assign-expires"
className="mb-1 block text-sm font-medium text-dark-200"
>
{t('admin.roleAssign.expiresLabel')}
</label>
<input
id="assign-expires"
type="datetime-local"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 outline-none transition-colors [color-scheme:dark] focus:border-accent-500"
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.roleAssign.expiresHint')}</p>
</div>
</div>
{/* Error / Success */}
{formError && <p className="text-sm text-error-400">{formError}</p>}
{formSuccess && <p className="text-sm text-success-400">{formSuccess}</p>}
{/* Submit */}
<div className="flex justify-end">
<button
type="submit"
disabled={assignMutation.isPending || !selectedUser || !selectedRoleId}
className="flex items-center gap-2 rounded-lg bg-accent-500 px-5 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{assignMutation.isPending
? t('admin.roleAssign.assigning')
: t('admin.roleAssign.assignButton')}
</button>
</div>
</form>
</div>
</PermissionGate>
{/* Current Assignments Table */}
<div className="rounded-xl border border-dark-700 bg-dark-800">
<div className="border-b border-dark-700 px-4 py-3 sm:px-5">
<h2 className="text-base font-semibold text-dark-100">
{t('admin.roleAssign.currentAssignments')}
</h2>
{sortedAssignments.length > 0 && (
<p className="text-xs text-dark-400">
{t('admin.roleAssign.totalAssignments', { count: sortedAssignments.length })}
</p>
)}
</div>
{rolesLoading || assignmentsLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : sortedAssignments.length === 0 ? (
<div className="py-12 text-center">
<p className="text-dark-400">{t('admin.roleAssign.noAssignments')}</p>
</div>
) : (
<>
{/* Desktop table header */}
<div className="hidden border-b border-dark-700 px-4 py-2.5 sm:grid sm:grid-cols-12 sm:gap-4 sm:px-5">
<div className="col-span-3 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.roleAssign.table.user')}
</div>
<div className="col-span-2 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.roleAssign.table.role')}
</div>
<div className="col-span-2 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.roleAssign.table.assignedBy')}
</div>
<div className="col-span-2 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.roleAssign.table.assignedAt')}
</div>
<div className="col-span-2 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.roleAssign.table.expires')}
</div>
<div className="col-span-1 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.roleAssign.table.actions')}
</div>
</div>
{/* Rows */}
<div className="divide-y divide-dark-700/50">
{paginatedAssignments.map((assignment) => {
const role = rolesMap.get(assignment.role_id);
const canRevoke = role ? canManageRole(role.level) : false;
const expired = isExpired(assignment.expires_at);
return (
<div
key={assignment.id}
className={`px-4 py-3 sm:grid sm:grid-cols-12 sm:items-center sm:gap-4 sm:px-5 ${
expired ? 'opacity-50' : ''
}`}
>
{/* User */}
<div className="col-span-3 mb-2 flex items-center gap-2.5 sm:mb-0">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-xs font-medium text-white">
{assignment.user_first_name?.[0] || '?'}
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-dark-100">
{assignment.user_first_name || t('admin.roleAssign.unknownUser')}
</div>
<div className="truncate text-xs text-dark-500">
{assignment.user_email ||
(assignment.user_telegram_id
? `TG: ${assignment.user_telegram_id}`
: `ID: ${assignment.user_id}`)}
</div>
</div>
</div>
{/* Role */}
<div className="col-span-2 mb-2 sm:mb-0">
<span className="mr-1.5 text-xs text-dark-500 sm:hidden">
{t('admin.roleAssign.table.role')}:
</span>
<RoleBadge name={assignment.role_name} color={assignment.role_color} />
</div>
{/* Assigned by */}
<div className="col-span-2 mb-2 text-sm text-dark-400 sm:mb-0">
<span className="mr-1.5 text-xs text-dark-500 sm:hidden">
{t('admin.roleAssign.table.assignedBy')}:
</span>
{assignment.assigned_by
? `#${assignment.assigned_by}`
: t('admin.roleAssign.system')}
</div>
{/* Assigned at */}
<div className="col-span-2 mb-2 text-sm text-dark-400 sm:mb-0">
<span className="mr-1.5 text-xs text-dark-500 sm:hidden">
{t('admin.roleAssign.table.assignedAt')}:
</span>
{formatDate(assignment.assigned_at)}
</div>
{/* Expires */}
<div className="col-span-2 mb-2 sm:mb-0">
<span className="mr-1.5 text-xs text-dark-500 sm:hidden">
{t('admin.roleAssign.table.expires')}:
</span>
{assignment.expires_at ? (
<span
className={`text-sm ${expired ? 'text-error-400' : 'text-warning-400'}`}
>
{formatDate(assignment.expires_at)}
{expired && (
<span className="ml-1 text-xs">({t('admin.roleAssign.expired')})</span>
)}
</span>
) : (
<span className="text-sm text-dark-500">
{t('admin.roleAssign.permanent')}
</span>
)}
</div>
{/* Actions */}
<div className="col-span-1 flex justify-end sm:justify-start">
<PermissionGate permission="roles:assign">
<button
onClick={() => setRevokeConfirm(assignment.id)}
disabled={!canRevoke}
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:cursor-not-allowed disabled:opacity-40"
title={t('admin.roleAssign.revoke')}
aria-label={t('admin.roleAssign.revokeAriaLabel', {
user: assignment.user_first_name || assignment.user_id,
role: assignment.role_name,
})}
>
<XCircleIcon />
</button>
</PermissionGate>
</div>
</div>
);
})}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-dark-700 px-4 py-3 sm:px-5">
<div className="text-sm text-dark-400">
{t('admin.roleAssign.pagination.showing', {
from: page * PAGE_SIZE + 1,
to: Math.min((page + 1) * PAGE_SIZE, sortedAssignments.length),
total: sortedAssignments.length,
})}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-lg border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
aria-label={t('admin.roleAssign.pagination.prev')}
>
<ChevronLeftIcon />
</button>
<span className="px-3 py-2 text-sm text-dark-300">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="rounded-lg border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
aria-label={t('admin.roleAssign.pagination.next')}
>
<ChevronRightIcon />
</button>
</div>
</div>
)}
</>
)}
</div>
{/* Revoke Confirmation */}
{revokeConfirm !== null && (
<div className="mt-4 overflow-hidden rounded-xl border border-error-500/30 bg-dark-800">
<div className="border-b border-error-500/20 bg-error-500/5 px-5 py-4">
<h3 className="text-sm font-semibold text-error-400">
{t('admin.roleAssign.confirm.title')}
</h3>
<p className="mt-1 text-sm text-dark-400">{t('admin.roleAssign.confirm.text')}</p>
</div>
<div className="flex items-center justify-end gap-3 px-5 py-3">
<button
onClick={() => setRevokeConfirm(null)}
className="rounded-lg px-4 py-2 text-sm text-dark-300 transition-colors hover:text-dark-100"
>
{t('admin.roleAssign.confirm.cancel')}
</button>
<button
onClick={() => revokeMutation.mutate(revokeConfirm)}
disabled={revokeMutation.isPending}
className="rounded-lg bg-error-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
{revokeMutation.isPending
? t('admin.roleAssign.confirm.revoking')
: t('admin.roleAssign.confirm.revoke')}
</button>
</div>
</div>
)}
</div>
);
}

538
src/pages/AdminRoleEdit.tsx Normal file
View File

@@ -0,0 +1,538 @@
import { useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { rbacApi, PermissionSection, CreateRolePayload, UpdateRolePayload } from '@/api/rbac';
import { AdminBackButton } from '@/components/admin';
// === Icons ===
const ChevronDownIcon = ({ className }: { className?: string }) => (
<svg
className={className || 'h-4 w-4'}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
// === Constants ===
const ROLE_COLORS = [
'#6366f1',
'#8b5cf6',
'#a855f7',
'#ec4899',
'#ef4444',
'#f97316',
'#eab308',
'#22c55e',
'#14b8a6',
'#06b6d4',
'#3b82f6',
'#6b7280',
];
const PRESETS: Record<string, string[]> = {
moderator: ['users:read', 'users:edit', 'users:block', 'tickets:*', 'ban_system:*'],
marketer: [
'campaigns:*',
'broadcasts:*',
'promocodes:*',
'promo_offers:*',
'promo_groups:*',
'stats:read',
'pinned_messages:*',
'wheel:*',
],
support: ['tickets:read', 'tickets:reply', 'users:read'],
};
// === Types ===
interface RoleFormData {
name: string;
description: string;
level: number;
color: string;
permissions: string[];
}
const INITIAL_FORM: RoleFormData = {
name: '',
description: '',
level: 50,
color: ROLE_COLORS[0],
permissions: [],
};
// === Sub-components ===
interface PermissionMatrixProps {
registry: PermissionSection[];
selectedPermissions: string[];
onToggle: (perm: string) => void;
onToggleSection: (section: string, actions: string[]) => void;
}
function PermissionMatrix({
registry,
selectedPermissions,
onToggle,
onToggleSection,
}: PermissionMatrixProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const toggleExpand = useCallback((section: string) => {
setExpanded((prev) => ({ ...prev, [section]: !prev[section] }));
}, []);
const isSectionFullySelected = useCallback(
(section: string, actions: string[]) => {
return actions.every((action) => {
const perm = `${section}:${action}`;
return selectedPermissions.includes(perm) || selectedPermissions.includes(`${section}:*`);
});
},
[selectedPermissions],
);
const isSectionPartiallySelected = useCallback(
(section: string, actions: string[]) => {
const hasAny = actions.some((action) => {
const perm = `${section}:${action}`;
return selectedPermissions.includes(perm) || selectedPermissions.includes(`${section}:*`);
});
return hasAny && !isSectionFullySelected(section, actions);
},
[selectedPermissions, isSectionFullySelected],
);
const isPermSelected = useCallback(
(section: string, action: string) => {
const perm = `${section}:${action}`;
return selectedPermissions.includes(perm) || selectedPermissions.includes(`${section}:*`);
},
[selectedPermissions],
);
return (
<div className="space-y-2">
<label className="text-sm font-medium text-dark-200">
{t('admin.roles.form.permissions')}
</label>
<div className="space-y-1 rounded-lg border border-dark-600 bg-dark-900/50 p-2">
{registry.map((section) => {
const isExpanded = expanded[section.section] ?? false;
const allSelected = isSectionFullySelected(section.section, section.actions);
const partialSelected = isSectionPartiallySelected(section.section, section.actions);
return (
<div
key={section.section}
className="rounded-lg border border-dark-700/50 bg-dark-800/30"
>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => onToggleSection(section.section, section.actions)}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
allSelected
? 'border-accent-500 bg-accent-500'
: partialSelected
? 'border-accent-500 bg-accent-500/40'
: 'border-dark-500 hover:border-dark-400'
}`}
aria-label={t('admin.roles.form.toggleSection', { section: section.section })}
>
{(allSelected || partialSelected) && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
{allSelected ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14" />
)}
</svg>
)}
</button>
<button
type="button"
onClick={() => toggleExpand(section.section)}
className="flex flex-1 items-center justify-between"
>
<span className="text-sm font-medium text-dark-200">
{t(`admin.roles.form.permissionSections.${section.section}`, section.section)}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">
{section.actions.filter((a) => isPermSelected(section.section, a)).length}/
{section.actions.length}
</span>
<ChevronDownIcon
className={`h-4 w-4 text-dark-400 transition-transform ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</div>
</button>
</div>
{isExpanded && (
<div className="border-t border-dark-700/50 px-3 py-2">
<div className="flex flex-wrap gap-2">
{section.actions.map((action) => {
const perm = `${section.section}:${action}`;
const selected = isPermSelected(section.section, action);
return (
<button
key={perm}
type="button"
onClick={() => onToggle(perm)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
selected
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-700/50 text-dark-400 hover:bg-dark-700 hover:text-dark-300'
}`}
aria-pressed={selected}
>
{t(`admin.roles.form.permissionActions.${action}`, action)}
</button>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// === Main Page ===
export default function AdminRoleEdit() {
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const isEdit = !!id;
const [formData, setFormData] = useState<RoleFormData>(INITIAL_FORM);
const [formError, setFormError] = useState<string | null>(null);
// Fetch role for editing
const { isLoading: isLoadingRole } = useQuery({
queryKey: ['admin-role', id],
queryFn: () => rbacApi.getRoles(),
enabled: isEdit,
select: useCallback(
(roles: import('@/api/rbac').AdminRole[]) => {
const role = roles.find((r) => r.id === Number(id));
if (role) {
setFormData({
name: role.name,
description: role.description || '',
level: role.level,
color: role.color || ROLE_COLORS[0],
permissions: [...role.permissions],
});
}
return role;
},
[id],
),
});
const { data: permissionRegistry } = useQuery({
queryKey: ['admin-permission-registry'],
queryFn: rbacApi.getPermissionRegistry,
});
// Mutations
const createMutation = useMutation({
mutationFn: (payload: CreateRolePayload) => rbacApi.createRole(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-roles'] });
navigate('/admin/roles');
},
onError: () => {
setFormError(t('admin.roles.errors.createFailed'));
},
});
const updateMutation = useMutation({
mutationFn: ({ roleId, payload }: { roleId: number; payload: UpdateRolePayload }) =>
rbacApi.updateRole(roleId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-roles'] });
navigate('/admin/roles');
},
onError: () => {
setFormError(t('admin.roles.errors.updateFailed'));
},
});
// Handlers
const handleTogglePermission = useCallback((perm: string) => {
setFormData((prev) => {
const has = prev.permissions.includes(perm);
return {
...prev,
permissions: has ? prev.permissions.filter((p) => p !== perm) : [...prev.permissions, perm],
};
});
}, []);
const handleToggleSection = useCallback((section: string, actions: string[]) => {
setFormData((prev) => {
const allPerms = actions.map((a) => `${section}:${a}`);
const allSelected = allPerms.every(
(p) => prev.permissions.includes(p) || prev.permissions.includes(`${section}:*`),
);
if (allSelected) {
const sectionPerms = new Set([...allPerms, `${section}:*`]);
return {
...prev,
permissions: prev.permissions.filter((p) => !sectionPerms.has(p)),
};
}
const withoutSection = prev.permissions.filter((p) => !p.startsWith(`${section}:`));
return {
...prev,
permissions: [...withoutSection, `${section}:*`],
};
});
}, []);
const handleApplyPreset = useCallback((presetKey: string) => {
const presetPerms = PRESETS[presetKey];
if (!presetPerms) return;
setFormData((prev) => ({
...prev,
permissions: [...presetPerms],
}));
}, []);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formData.name.trim()) {
setFormError(t('admin.roles.errors.nameRequired'));
return;
}
const payload = {
name: formData.name.trim(),
description: formData.description.trim() || null,
level: formData.level,
permissions: formData.permissions,
color: formData.color,
};
if (isEdit) {
updateMutation.mutate({ roleId: Number(id), payload });
} else {
createMutation.mutate(payload);
}
},
[formData, isEdit, id, createMutation, updateMutation, t],
);
const isSaving = createMutation.isPending || updateMutation.isPending;
// Loading state
if (isEdit && isLoadingRole) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
return (
<div className="animate-fade-in space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<AdminBackButton to="/admin/roles" />
<div>
<h1 className="text-xl font-semibold text-dark-100">
{isEdit ? t('admin.roles.modal.editTitle') : t('admin.roles.modal.createTitle')}
</h1>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
<div className="space-y-4">
{/* Name */}
<div>
<label htmlFor="role-name" className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.roles.form.name')}
</label>
<input
id="role-name"
type="text"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500"
placeholder={t('admin.roles.form.namePlaceholder')}
autoFocus
/>
</div>
{/* Description */}
<div>
<label
htmlFor="role-description"
className="mb-1 block text-sm font-medium text-dark-200"
>
{t('admin.roles.form.description')}
</label>
<textarea
id="role-description"
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500"
placeholder={t('admin.roles.form.descriptionPlaceholder')}
rows={2}
/>
</div>
{/* Level */}
<div>
<label htmlFor="role-level" className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.roles.form.level')}
</label>
<div className="flex items-center gap-3">
<input
id="role-level"
type="range"
min={0}
max={999}
value={formData.level}
onChange={(e) =>
setFormData((prev) => ({ ...prev, level: Number(e.target.value) }))
}
className="flex-1 accent-accent-500"
/>
<input
type="number"
min={0}
max={999}
value={formData.level}
onChange={(e) =>
setFormData((prev) => ({
...prev,
level: Math.min(999, Math.max(0, Number(e.target.value) || 0)),
}))
}
className="w-20 rounded-lg border border-dark-600 bg-dark-900 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500"
aria-label={t('admin.roles.form.levelValue')}
/>
</div>
<p className="mt-1 text-xs text-dark-500">{t('admin.roles.form.levelHint')}</p>
</div>
{/* Color picker */}
<div>
<label className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.roles.form.color')}
</label>
<div className="flex flex-wrap gap-2">
{ROLE_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setFormData((prev) => ({ ...prev, color }))}
className={`h-7 w-7 rounded-full border-2 transition-transform hover:scale-110 ${
formData.color === color ? 'scale-110 border-white' : 'border-transparent'
}`}
style={{ backgroundColor: color }}
aria-label={color}
/>
))}
</div>
</div>
{/* Preset buttons */}
<div>
<label className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.roles.form.presets')}
</label>
<div className="flex flex-wrap gap-2">
{Object.keys(PRESETS).map((key) => (
<button
key={key}
type="button"
onClick={() => handleApplyPreset(key)}
className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-xs font-medium text-dark-300 transition-colors hover:border-accent-500/50 hover:bg-accent-500/10 hover:text-accent-400"
>
{t(`admin.roles.presets.${key}`)}
</button>
))}
</div>
</div>
</div>
</div>
{/* Permission Matrix */}
{permissionRegistry && permissionRegistry.length > 0 && (
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
<PermissionMatrix
registry={permissionRegistry}
selectedPermissions={formData.permissions}
onToggle={handleTogglePermission}
onToggleSection={handleToggleSection}
/>
<p className="mt-2 text-xs text-dark-500">
{t('admin.roles.form.selectedPermissions', { count: formData.permissions.length })}
</p>
</div>
)}
{/* Error & Submit */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
{formError && <p className="mb-4 text-sm text-error-400">{formError}</p>}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => navigate('/admin/roles')}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('admin.roles.form.cancel')}
</button>
<button
type="submit"
disabled={isSaving}
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{isSaving ? t('admin.roles.form.saving') : t('admin.roles.form.save')}
</button>
</div>
</div>
</form>
</div>
);
}

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

@@ -0,0 +1,282 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { rbacApi } from '@/api/rbac';
import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePermissionStore } from '@/store/permissions';
import { usePlatform } from '@/platform/hooks/usePlatform';
// === Icons ===
const BackIcon = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const PlusIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const EditIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const ShieldIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
// === Main Page ===
export default function AdminRoles() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
const canManageRole = usePermissionStore((s) => s.canManageRole);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const [formError, setFormError] = useState<string | null>(null);
// Queries
const {
data: roles,
isLoading: rolesLoading,
error: rolesError,
} = useQuery({
queryKey: ['admin-roles'],
queryFn: rbacApi.getRoles,
});
// Mutations
const deleteMutation = useMutation({
mutationFn: rbacApi.deleteRole,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-roles'] });
setDeleteConfirm(null);
},
onError: () => {
setDeleteConfirm(null);
setFormError(t('admin.roles.errors.deleteFailed'));
},
});
// Sorted roles by level descending
const sortedRoles = useMemo(() => {
if (!roles) return [];
return [...roles].sort((a, b) => b.level - a.level);
}, [roles]);
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.roles.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.roles.subtitle')}</p>
</div>
</div>
<PermissionGate permission="roles:create">
<button
onClick={() => navigate('/admin/roles/create')}
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
>
<PlusIcon />
{t('admin.roles.createRole')}
</button>
</PermissionGate>
</div>
{/* Error message */}
{formError && (
<div className="mb-4 rounded-lg border border-error-500/30 bg-error-500/10 p-3">
<p className="text-sm text-error-400">{formError}</p>
</div>
)}
{/* Stats Overview */}
{sortedRoles.length > 0 && (
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-dark-100">{sortedRoles.length}</div>
<div className="text-xs text-dark-400">{t('admin.roles.stats.totalRoles')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-accent-400">
{sortedRoles.filter((r) => r.is_active).length}
</div>
<div className="text-xs text-dark-400">{t('admin.roles.stats.active')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-warning-400">
{sortedRoles.filter((r) => r.is_system).length}
</div>
<div className="text-xs text-dark-400">{t('admin.roles.stats.system')}</div>
</div>
</div>
)}
{/* Roles List */}
{rolesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : rolesError ? (
<div className="py-12 text-center">
<p className="text-error-400">{t('admin.roles.errors.loadFailed')}</p>
</div>
) : sortedRoles.length === 0 ? (
<div className="py-12 text-center">
<ShieldIcon />
<p className="mt-2 text-dark-400">{t('admin.roles.noRoles')}</p>
</div>
) : (
<div className="space-y-3">
{sortedRoles.map((role) => (
<div
key={role.id}
className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
role.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
}`}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
{/* Role name with color badge */}
<div className="mb-2 flex items-center gap-2">
<span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: role.color || '#6b7280' }}
aria-hidden="true"
/>
<span className="font-medium text-dark-100">{role.name}</span>
{role.is_system && (
<span className="rounded bg-warning-500/20 px-1.5 py-0.5 text-xs text-warning-400">
{t('admin.roles.systemBadge')}
</span>
)}
{!role.is_active && (
<span className="rounded bg-dark-600 px-1.5 py-0.5 text-xs text-dark-400">
{t('admin.roles.inactiveBadge')}
</span>
)}
</div>
{/* Info */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
<span>
{t('admin.roles.levelLabel')}: {role.level}
</span>
{role.description && <span>{role.description}</span>}
<span>{t('admin.roles.usersCount', { count: role.user_count ?? 0 })}</span>
<span>
{t('admin.roles.permissionsCount', {
count: role.permissions.length,
})}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 border-t border-dark-700 pt-3 sm:border-0 sm:pt-0">
<PermissionGate permission="roles:edit">
<button
onClick={() => navigate(`/admin/roles/${role.id}/edit`)}
disabled={!canManageRole(role.level)}
className="flex-1 rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40 sm:flex-none"
title={t('admin.roles.actions.edit')}
>
<EditIcon />
</button>
</PermissionGate>
<PermissionGate permission="roles:delete">
<button
onClick={() => setDeleteConfirm(role.id)}
disabled={role.is_system || !canManageRole(role.level)}
className="flex-1 rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:cursor-not-allowed disabled:opacity-40 sm:flex-none"
title={t('admin.roles.actions.delete')}
>
<TrashIcon />
</button>
</PermissionGate>
</div>
</div>
</div>
))}
</div>
)}
{/* Delete Confirmation */}
{deleteConfirm !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="fixed inset-0 bg-black/60"
onClick={() => setDeleteConfirm(null)}
aria-hidden="true"
/>
<div className="relative w-full max-w-sm rounded-xl border border-dark-700 bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.roles.confirm.title')}
</h3>
<p className="mb-6 text-dark-400">{t('admin.roles.confirm.text')}</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('admin.roles.confirm.cancel')}
</button>
<button
onClick={() => deleteMutation.mutate(deleteConfirm)}
disabled={deleteMutation.isPending}
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
{deleteMutation.isPending
? t('admin.roles.confirm.deleting')
: t('admin.roles.confirm.delete')}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -20,6 +20,7 @@ import { promoOffersApi } from '../api/promoOffers';
import { ticketsApi } from '../api/tickets';
import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
import { usePermissionStore } from '../store/permissions';
// ============ Helpers ============
@@ -135,6 +136,7 @@ export default function AdminUserDetail() {
const navigate = useNavigate();
const notify = useNotify();
const { id } = useParams<{ id: string }>();
const hasPermission = usePermissionStore((s) => s.hasPermission);
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
const locale = localeMap[i18n.language] || 'ru-RU';
@@ -384,9 +386,9 @@ export default function AdminUserDetail() {
useEffect(() => {
if (activeTab === 'info') {
loadReferrals();
loadPromoGroups();
if (hasPermission('users:promo_group')) loadPromoGroups();
}
if (activeTab === 'sync') loadSyncStatus();
if (activeTab === 'sync' && hasPermission('users:sync')) loadSyncStatus();
if (activeTab === 'subscription') {
loadTariffs();
loadSubscriptionData();
@@ -400,6 +402,7 @@ export default function AdminUserDetail() {
loadReferrals,
loadSubscriptionData,
loadPromoGroups,
hasPermission,
]);
const handleUpdateBalance = async (isAdd: boolean) => {
@@ -831,23 +834,25 @@ export default function AdminUserDetail() {
className="scrollbar-hide -mx-4 mb-6 flex gap-2 overflow-x-auto px-4 py-1"
style={{ WebkitOverflowScrolling: 'touch' }}
>
{(['info', 'subscription', 'balance', 'sync', 'tickets'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`shrink-0 whitespace-nowrap rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
activeTab === tab
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
}`}
>
{tab === 'info' && t('admin.users.detail.tabs.info')}
{tab === 'subscription' && t('admin.users.detail.tabs.subscription')}
{tab === 'balance' && t('admin.users.detail.tabs.balance')}
{tab === 'sync' && t('admin.users.detail.tabs.sync')}
{tab === 'tickets' && t('admin.users.detail.tabs.tickets')}
</button>
))}
{(['info', 'subscription', 'balance', 'sync', 'tickets'] as const)
.filter((tab) => tab !== 'sync' || hasPermission('users:sync'))
.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`shrink-0 whitespace-nowrap rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
activeTab === tab
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
}`}
>
{tab === 'info' && t('admin.users.detail.tabs.info')}
{tab === 'subscription' && t('admin.users.detail.tabs.subscription')}
{tab === 'balance' && t('admin.users.detail.tabs.balance')}
{tab === 'sync' && t('admin.users.detail.tabs.sync')}
{tab === 'tickets' && t('admin.users.detail.tabs.tickets')}
</button>
))}
</div>
{/* Content */}
@@ -930,14 +935,16 @@ export default function AdminUserDetail() {
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-dark-500">{t('admin.users.detail.promoGroup')}</span>
<button
onClick={() => setEditingPromoGroup(!editingPromoGroup)}
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
>
{editingPromoGroup
? t('common.cancel')
: t('admin.users.detail.changePromoGroup')}
</button>
{hasPermission('users:promo_group') && (
<button
onClick={() => setEditingPromoGroup(!editingPromoGroup)}
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
>
{editingPromoGroup
? t('common.cancel')
: t('admin.users.detail.changePromoGroup')}
</button>
)}
</div>
{editingPromoGroup ? (
<div className="mt-2 space-y-2">
@@ -982,17 +989,19 @@ export default function AdminUserDetail() {
<span className="text-sm font-medium text-dark-200">
{t('admin.users.detail.referral.title')}
</span>
<button
onClick={() => {
if (!editingReferralCommission) {
setReferralCommissionValue(user.referral.commission_percent ?? '');
}
setEditingReferralCommission(!editingReferralCommission);
}}
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
>
{editingReferralCommission ? t('common.cancel') : t('common.edit')}
</button>
{hasPermission('users:referral') && (
<button
onClick={() => {
if (!editingReferralCommission) {
setReferralCommissionValue(user.referral.commission_percent ?? '');
}
setEditingReferralCommission(!editingReferralCommission);
}}
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
>
{editingReferralCommission ? t('common.cancel') : t('common.edit')}
</button>
)}
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div>
@@ -1351,72 +1360,78 @@ export default function AdminUserDetail() {
)}
{/* Actions */}
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 font-medium text-dark-200">
{t('admin.users.detail.subscription.actions')}
</div>
<div className="space-y-3">
<select
value={subAction}
onChange={(e) => setSubAction(e.target.value)}
className="input"
>
<option value="extend">{t('admin.users.detail.subscription.extend')}</option>
<option value="change_tariff">
{t('admin.users.detail.subscription.changeTariff')}
</option>
<option value="cancel">{t('admin.users.detail.subscription.cancel')}</option>
<option value="activate">
{t('admin.users.detail.subscription.activate')}
</option>
</select>
{subAction === 'extend' && (
<input
type="number"
value={subDays}
onChange={createNumberInputHandler(setSubDays, 1)}
placeholder={t('admin.users.detail.subscription.days')}
className="input"
min={1}
max={3650}
/>
)}
{subAction === 'change_tariff' && (
{hasPermission('users:subscription') && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 font-medium text-dark-200">
{t('admin.users.detail.subscription.actions')}
</div>
<div className="space-y-3">
<select
value={selectedTariffId || ''}
onChange={(e) =>
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
}
value={subAction}
onChange={(e) => setSubAction(e.target.value)}
className="input"
>
<option value="">
{t('admin.users.detail.subscription.selectTariff')}
<option value="extend">
{t('admin.users.detail.subscription.extend')}
</option>
<option value="change_tariff">
{t('admin.users.detail.subscription.changeTariff')}
</option>
<option value="cancel">
{t('admin.users.detail.subscription.cancel')}
</option>
<option value="activate">
{t('admin.users.detail.subscription.activate')}
</option>
{tariffs.map((tariffItem) => (
<option key={tariffItem.id} value={tariffItem.id}>
{tariffItem.name}{' '}
{!tariffItem.is_available &&
t('admin.users.detail.subscription.unavailable')}
</option>
))}
</select>
)}
<button
onClick={() => handleUpdateSubscription()}
disabled={actionLoading}
className="btn-primary w-full"
>
{actionLoading
? t('admin.users.actions.applying')
: t('admin.users.actions.apply')}
</button>
{subAction === 'extend' && (
<input
type="number"
value={subDays}
onChange={createNumberInputHandler(setSubDays, 1)}
placeholder={t('admin.users.detail.subscription.days')}
className="input"
min={1}
max={3650}
/>
)}
{subAction === 'change_tariff' && (
<select
value={selectedTariffId || ''}
onChange={(e) =>
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
}
className="input"
>
<option value="">
{t('admin.users.detail.subscription.selectTariff')}
</option>
{tariffs.map((tariffItem) => (
<option key={tariffItem.id} value={tariffItem.id}>
{tariffItem.name}{' '}
{!tariffItem.is_available &&
t('admin.users.detail.subscription.unavailable')}
</option>
))}
</select>
)}
<button
onClick={() => handleUpdateSubscription()}
disabled={actionLoading}
className="btn-primary w-full"
>
{actionLoading
? t('admin.users.actions.applying')
: t('admin.users.actions.apply')}
</button>
</div>
</div>
</div>
)}
</>
) : (
) : hasPermission('users:subscription') ? (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-4 text-center text-dark-400">
{t('admin.users.detail.subscription.noActive')}
@@ -1456,6 +1471,12 @@ export default function AdminUserDetail() {
</button>
</div>
</div>
) : (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="text-center text-dark-400">
{t('admin.users.detail.subscription.noActive')}
</div>
</div>
)}
{/* Panel Info */}
@@ -1781,39 +1802,41 @@ export default function AdminUserDetail() {
</div>
{/* Add/subtract form */}
<div className="space-y-3 rounded-xl bg-dark-800/50 p-4">
<input
type="number"
value={balanceAmount}
onChange={createNumberInputHandler(setBalanceAmount)}
placeholder={t('admin.users.detail.balance.amountPlaceholder')}
className="input"
/>
<input
type="text"
value={balanceDescription}
onChange={(e) => setBalanceDescription(e.target.value)}
placeholder={t('admin.users.detail.balance.descriptionPlaceholder')}
className="input"
maxLength={500}
/>
<div className="flex gap-2">
<button
onClick={() => handleUpdateBalance(true)}
disabled={actionLoading || balanceAmount === ''}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50"
>
<PlusIcon /> {t('admin.users.detail.balance.add')}
</button>
<button
onClick={() => handleUpdateBalance(false)}
disabled={actionLoading || balanceAmount === ''}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-error-500 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
<MinusIcon /> {t('admin.users.detail.balance.subtract')}
</button>
{hasPermission('users:balance') && (
<div className="space-y-3 rounded-xl bg-dark-800/50 p-4">
<input
type="number"
value={balanceAmount}
onChange={createNumberInputHandler(setBalanceAmount)}
placeholder={t('admin.users.detail.balance.amountPlaceholder')}
className="input"
/>
<input
type="text"
value={balanceDescription}
onChange={(e) => setBalanceDescription(e.target.value)}
placeholder={t('admin.users.detail.balance.descriptionPlaceholder')}
className="input"
maxLength={500}
/>
<div className="flex gap-2">
<button
onClick={() => handleUpdateBalance(true)}
disabled={actionLoading || balanceAmount === ''}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50"
>
<PlusIcon /> {t('admin.users.detail.balance.add')}
</button>
<button
onClick={() => handleUpdateBalance(false)}
disabled={actionLoading || balanceAmount === ''}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-error-500 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
<MinusIcon /> {t('admin.users.detail.balance.subtract')}
</button>
</div>
</div>
</div>
)}
{/* Active promo offer */}
{user.promo_offer_discount_percent > 0 && (
@@ -1862,38 +1885,40 @@ export default function AdminUserDetail() {
)}
{/* Send promo offer */}
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.sendOffer')}
{hasPermission('users:send_offer') && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.sendOffer')}
</div>
<div className="space-y-3">
<input
type="number"
value={offerDiscountPercent}
onChange={createNumberInputHandler(setOfferDiscountPercent, 1)}
placeholder={t('admin.users.detail.discountPercent')}
className="input"
min={1}
max={100}
/>
<input
type="number"
value={offerValidHours}
onChange={createNumberInputHandler(setOfferValidHours, 1)}
placeholder={t('admin.users.detail.validHours')}
className="input"
min={1}
max={8760}
/>
<button
onClick={handleSendOffer}
disabled={offerSending || offerDiscountPercent === '' || offerValidHours === ''}
className="btn-primary w-full disabled:opacity-50"
>
{offerSending ? t('common.loading') : t('admin.users.detail.sendOffer')}
</button>
</div>
</div>
<div className="space-y-3">
<input
type="number"
value={offerDiscountPercent}
onChange={createNumberInputHandler(setOfferDiscountPercent, 1)}
placeholder={t('admin.users.detail.discountPercent')}
className="input"
min={1}
max={100}
/>
<input
type="number"
value={offerValidHours}
onChange={createNumberInputHandler(setOfferValidHours, 1)}
placeholder={t('admin.users.detail.validHours')}
className="input"
min={1}
max={8760}
/>
<button
onClick={handleSendOffer}
disabled={offerSending || offerDiscountPercent === '' || offerValidHours === ''}
className="btn-primary w-full disabled:opacity-50"
>
{offerSending ? t('common.loading') : t('admin.users.detail.sendOffer')}
</button>
</div>
</div>
)}
{/* Recent transactions */}
{user.recent_transactions.length > 0 && (

View File

@@ -6,6 +6,7 @@ import { apiClient } from '../api/client';
import { captureCampaignFromUrl, consumeCampaignSlug } from '../utils/campaign';
import { captureReferralFromUrl, consumeReferralCode } from '../utils/referral';
import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token';
import { usePermissionStore } from './permissions';
export interface TelegramWidgetData {
id: number;
@@ -93,6 +94,7 @@ export const useAuthStore = create<AuthState>()(
});
}
tokenStorage.clearTokens();
usePermissionStore.getState().reset();
set({
accessToken: null,
refreshToken: null,
@@ -107,13 +109,20 @@ export const useAuthStore = create<AuthState>()(
const token = tokenStorage.getAccessToken();
if (!token || !isTokenValid(token)) {
set({ isAdmin: false });
usePermissionStore.getState().reset();
return;
}
// Используем apiClient для единообразной обработки ошибок
const response = await apiClient.get<{ is_admin: boolean }>('/cabinet/auth/me/is-admin');
set({ isAdmin: response.data.is_admin });
if (response.data.is_admin) {
await usePermissionStore.getState().fetchPermissions();
} else {
usePermissionStore.getState().reset();
}
} catch {
set({ isAdmin: false });
usePermissionStore.getState().reset();
}
},

99
src/store/permissions.ts Normal file
View File

@@ -0,0 +1,99 @@
import { create } from 'zustand';
import apiClient from '../api/client';
interface PermissionsResponse {
permissions: string[];
roles: string[];
role_level: number;
}
/**
* Match a user permission against a required permission.
*
* - `*:*` matches everything
* - `users:*` matches `users:read`, `users:edit`, etc.
* - Exact match otherwise
*/
function permissionMatches(userPerm: string, required: string): boolean {
if (userPerm === '*:*') return true;
if (userPerm.endsWith(':*')) {
const colonIdx = userPerm.indexOf(':');
const requiredColonIdx = required.indexOf(':');
if (colonIdx === -1 || requiredColonIdx === -1) return false;
const section = userPerm.slice(0, colonIdx);
const requiredSection = required.slice(0, requiredColonIdx);
return section === requiredSection;
}
return userPerm === required;
}
interface PermissionState {
permissions: string[];
roles: string[];
roleLevel: number;
isLoaded: boolean;
fetchPermissions: () => Promise<void>;
hasPermission: (permission: string) => boolean;
hasAnyPermission: (...permissions: string[]) => boolean;
hasAllPermissions: (...permissions: string[]) => boolean;
canManageRole: (level: number) => boolean;
reset: () => void;
}
export const usePermissionStore = create<PermissionState>((set, get) => ({
permissions: [],
roles: [],
roleLevel: 0,
isLoaded: false,
fetchPermissions: async () => {
try {
const response = await apiClient.get<PermissionsResponse>('/cabinet/auth/me/permissions');
set({
permissions: response.data.permissions,
roles: response.data.roles,
roleLevel: response.data.role_level,
isLoaded: true,
});
} catch {
set({
permissions: [],
roles: [],
roleLevel: 0,
isLoaded: true,
});
}
},
hasPermission: (permission: string): boolean => {
const { permissions } = get();
return permissions.some((userPerm) => permissionMatches(userPerm, permission));
},
hasAnyPermission: (...permissions: string[]): boolean => {
const { hasPermission } = get();
return permissions.some((perm) => hasPermission(perm));
},
hasAllPermissions: (...permissions: string[]): boolean => {
const { hasPermission } = get();
return permissions.every((perm) => hasPermission(perm));
},
canManageRole: (level: number): boolean => {
const { roleLevel } = get();
return roleLevel > level;
},
reset: () => {
set({
permissions: [],
roles: [],
roleLevel: 0,
isLoaded: false,
});
},
}));