diff --git a/src/features/applications/api/index.ts b/src/features/applications/api/index.ts new file mode 100644 index 0000000..d337e56 --- /dev/null +++ b/src/features/applications/api/index.ts @@ -0,0 +1 @@ +export * from './useApplications'; diff --git a/src/features/applications/api/useApplications.ts b/src/features/applications/api/useApplications.ts new file mode 100644 index 0000000..be8fac7 --- /dev/null +++ b/src/features/applications/api/useApplications.ts @@ -0,0 +1,55 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { applicationsApi } from '@/shared/api/applications'; +import { ExternalApplicationCreateDto } from '@/shared/api/contracts'; + +// Query key for applications +const APPLICATIONS_QUERY_KEY = ['applications']; + +/** + * Hook to fetch all applications + */ +export const useApplications = () => { + return useQuery({ + queryKey: APPLICATIONS_QUERY_KEY, + queryFn: () => applicationsApi.getApplications(), + }); +}; + +/** + * Hook to create a new application + */ +export const useCreateApplication = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: ExternalApplicationCreateDto) => applicationsApi.createApplication(data), + onSuccess: (data) => { + toast.success('Приложение успешно создано'); + queryClient.invalidateQueries({ queryKey: APPLICATIONS_QUERY_KEY }); + return data; + }, + onError: (error: any) => { + toast.error(error?.message || 'Ошибка при создании приложения'); + }, + }); +}; + +/** + * Hook to delete an application + */ +export const useDeleteApplication = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => applicationsApi.deleteApplication(id), + onSuccess: () => { + toast.success('Приложение успешно удалено'); + queryClient.invalidateQueries({ queryKey: APPLICATIONS_QUERY_KEY }); + }, + onError: (error: any) => { + toast.error(error?.message || 'Ошибка при удалении приложения'); + }, + }); +}; diff --git a/src/features/applications/index.ts b/src/features/applications/index.ts new file mode 100644 index 0000000..308f5ae --- /dev/null +++ b/src/features/applications/index.ts @@ -0,0 +1 @@ +export * from './api'; \ No newline at end of file diff --git a/src/shared/api/applications.ts b/src/shared/api/applications.ts new file mode 100644 index 0000000..e1b72bf --- /dev/null +++ b/src/shared/api/applications.ts @@ -0,0 +1,34 @@ +import { $api } from '@/services/api.service'; +import { + ExternalApplicationCreateDto, + ExternalApplicationListDto, + ExternalApplicationReadDto, + TGetApplicationsResponse, + TPostApplicationResponse, + TDeleteApplicationResponse +} from '@/shared/api/contracts'; + +export const applicationsApi = { + /** + * Get all applications for the current user + */ + async getApplications(): Promise { + const { data } = await $api.get('/applications'); + return data.data; + }, + + /** + * Create a new external application + */ + async createApplication(payload: ExternalApplicationCreateDto): Promise { + const { data } = await $api.post('/applications', payload); + return data.data; + }, + + /** + * Delete an external application + */ + async deleteApplication(id: string): Promise { + await $api.delete(`/applications/${id}`); + } +}; \ No newline at end of file diff --git a/src/shared/api/contracts/applications/requests.ts b/src/shared/api/contracts/applications/requests.ts new file mode 100644 index 0000000..e2569fe --- /dev/null +++ b/src/shared/api/contracts/applications/requests.ts @@ -0,0 +1,22 @@ +import { + ExternalApplicationListDto, + ExternalApplicationReadDto, + ExternalApplicationCreateDto +} from './schemas'; + +import { ResponseBaseEntity } from '@/shared/api/schemas'; + +// GET /api/v1/applications +export type TGetApplicationsResponse = ResponseBaseEntity & { + data: ExternalApplicationListDto[]; +}; + +// POST /api/v1/applications +export type TPostApplicationRequest = ExternalApplicationCreateDto; + +export type TPostApplicationResponse = ResponseBaseEntity & { + data: ExternalApplicationReadDto; +}; + +// DELETE /api/v1/applications/{id} +export type TDeleteApplicationResponse = ResponseBaseEntity; diff --git a/src/shared/api/contracts/applications/schemas.ts b/src/shared/api/contracts/applications/schemas.ts new file mode 100644 index 0000000..bdc0c56 --- /dev/null +++ b/src/shared/api/contracts/applications/schemas.ts @@ -0,0 +1,25 @@ +export interface PermissionDto { + id: number; + name: string; + description?: string; +} + +export interface ExternalApplicationBaseEntity { + id: string; + name: string; + createdAtUtc: string; +} + +export interface ExternalApplicationListDto extends ExternalApplicationBaseEntity { + permissions: PermissionDto[]; +} + +export interface ExternalApplicationReadDto extends ExternalApplicationBaseEntity { + token: string; + permissions: PermissionDto[]; +} + +export interface ExternalApplicationCreateDto { + name: string; + permissionIds: number[]; +} diff --git a/src/shared/api/contracts/index.ts b/src/shared/api/contracts/index.ts index 2dae0c3..8887266 100644 --- a/src/shared/api/contracts/index.ts +++ b/src/shared/api/contracts/index.ts @@ -25,3 +25,7 @@ export * from '@/shared/api/contracts/gameservers/zod'; // Контракты для уведомлений export * from '@/shared/api/contracts/notification/schemas'; export * from '@/shared/api/contracts/notification/requests'; + +// Applications +export * from '@/shared/api/contracts/applications/schemas'; +export * from '@/shared/api/contracts/applications/requests'; diff --git a/src/views/settings/ui/ApplicationsTab.tsx b/src/views/settings/ui/ApplicationsTab.tsx new file mode 100644 index 0000000..8efa718 --- /dev/null +++ b/src/views/settings/ui/ApplicationsTab.tsx @@ -0,0 +1,403 @@ +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import { MoreVertical, Plus, Trash, Copy, CheckCircle } from 'lucide-react'; + +import { + useApplications, + useCreateApplication, + useDeleteApplication, +} from '@/features/applications'; +import { ExternalApplicationCreateDto, ExternalApplicationReadDto } from '@/shared/api/contracts'; +import { rbacApi, PermissionDto } from '@/shared/api/rbac'; +import { Button } from '@/shared/ui/button'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/shared/ui/table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/shared/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/shared/ui/dialog'; +import { Label } from '@/shared/ui/label'; +import { Input } from '@/shared/ui/input'; +import { Icons } from '@/shared/ui/icons'; +import { MultiSelect } from '@/shared/ui/multi-select'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/shared/ui/alert-dialog'; + +export const ApplicationsTab: React.FC = () => { + // Fetch applications data + const { data: applications = [], isLoading: isLoadingApplications, error } = useApplications(); + + // Create application mutation + const { mutateAsync: createApplication, isPending: isCreating } = useCreateApplication(); + + // Delete application mutation + const { mutateAsync: deleteApplication, isPending: isDeleting } = useDeleteApplication(); + + // State for permissions + const [permissions, setPermissions] = useState([]); + const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); + + const [createModalOpen, setCreateModalOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [successDialogOpen, setSuccessDialogOpen] = useState(false); + const [createdApplication, setCreatedApplication] = useState( + null, + ); + const [applicationToDelete, setApplicationToDelete] = useState<{ + id: string; + name: string; + } | null>(null); + const [newApplication, setNewApplication] = useState({ + name: '', + permissionIds: [], + }); + + // Fetch permissions when component mounts + useEffect(() => { + const fetchPermissions = async () => { + setIsLoadingPermissions(true); + try { + const perms = await rbacApi.getPermissions(); + setPermissions(perms); + } catch (error) { + console.error('Failed to fetch permissions:', error); + } finally { + setIsLoadingPermissions(false); + } + }; + + fetchPermissions(); + }, []); + + // Group permissions by their prefix (before the dot in the permission name) + const permsByGroup = useMemo(() => { + const map = new Map(); + for (const p of permissions) { + const name = p.name ?? ''; + const group = name.includes('.') ? name.split('.')[0] : 'other'; + const list = map.get(group) ?? []; + list.push(p); + map.set(group, list); + } + // sort groups alphabetically, and items within group by name + return Array.from(map.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([g, list]) => [g, list.sort((x, y) => (x.name ?? '').localeCompare(y.name ?? ''))]) as [ + string, + PermissionDto[], + ][]; + }, [permissions]); + + // Convert permissions to format expected by MultiSelect with groups + const permissionOptions = useMemo(() => { + return permsByGroup.flatMap(([group, perms]) => [ + // Group header - using a custom component would be better, but we'll use a special label format + { + label: `▼ ${group.toUpperCase()} - Группа прав`, + value: `group-${group}`, + icon: undefined, + }, + // Individual permissions with descriptions and indentation + ...perms.map((perm) => ({ + label: perm.description ? ` • ${perm.name} - ${perm.description}` : ` • ${perm.name}`, + value: perm.id.toString(), + icon: undefined, + })), + ]); + }, [permsByGroup]); + + // Handle permission selection changes + const handlePermissionChange = (selectedValues: string[]) => { + // Filter out group headers (values starting with "group-") + const filteredValues = selectedValues.filter((val) => !val.startsWith('group-')); + + // Convert string IDs back to numbers + const permissionIds = filteredValues.map((val) => parseInt(val, 10)).filter((id) => !isNaN(id)); // Filter out any NaN values + + setNewApplication((prev) => ({ ...prev, permissionIds })); + }; + + const handleCreateApplication = async () => { + const createdApp = await createApplication(newApplication); + setCreatedApplication(createdApp); + setNewApplication({ name: '', permissionIds: [] }); + setCreateModalOpen(false); + setSuccessDialogOpen(true); + }; + + const handleDeleteApplication = async () => { + if (!applicationToDelete) return; + + await deleteApplication(applicationToDelete.id); + setApplicationToDelete(null); + setDeleteDialogOpen(false); + }; + + return ( +
+
+

Внешние приложения

+ +
+ +
+ + + + ID + Название + Права + Дата создания + Действия + + + + {isLoadingApplications ? ( + + + + Загрузка приложений... + + + ) : applications.length === 0 ? ( + + + Нет созданных приложений + + + ) : ( + applications.map((app) => ( + + {app.id.substring(0, 8)}... + {app.name} + + {app.permissions.length > 0 ? ( +
+ {/* Group permissions by prefix */} + {(() => { + // Group permissions by prefix + const groupedPerms = new Map(); + for (const p of app.permissions) { + const name = p.name ?? ''; + const group = name.includes('.') ? name.split('.')[0] : 'other'; + const list = groupedPerms.get(group) ?? []; + list.push(p); + groupedPerms.set(group, list); + } + + // Sort groups and permissions within groups + return Array.from(groupedPerms.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([group, perms]) => ( +
+
+ {group.toUpperCase()} +
+
    + {perms + .sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) + .map((p) => ( +
  • + {p.name} + {p.description && ( + + {' '} + {p.description} + + )} +
  • + ))} +
+
+ )); + })()} +
+ ) : ( + 'Нет прав' + )} +
+ {new Date(app.createdAtUtc).toLocaleDateString()} + + + + + + + { + setApplicationToDelete({ id: app.id, name: app.name }); + setDeleteDialogOpen(true); + }} + > + + Удалить + + + + +
+ )) + )} +
+
+
+ + {/* Create Application Modal */} + + + + Создать новое приложение + +
+
+ + setNewApplication({ ...newApplication, name: e.target.value })} + placeholder="Введите название приложения" + /> +
+
+ + + {isLoadingPermissions && ( +
+ + Загрузка прав доступа... +
+ )} +
+
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + + + + Удалить приложение + + Вы уверены, что хотите удалить приложение "{applicationToDelete?.name}"? Это действие + нельзя отменить. + + + + Отмена + + {isDeleting && } + Удалить + + + + + + {/* Success Dialog */} + + + + + + Успех + + + Приложение "{createdApplication?.name}" успешно создано + + +
+ +
+
+ {createdApplication?.token} + +
+
+

+ Сохраните этот токен в надежном месте. Он будет показан только один раз. +

+
+ + + +
+
+
+ ); +}; diff --git a/src/views/settings/ui/Settings.tsx b/src/views/settings/ui/Settings.tsx index 83da885..9f8f7e8 100644 --- a/src/views/settings/ui/Settings.tsx +++ b/src/views/settings/ui/Settings.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { RolesPermissionsTab } from './RolesPermissionsTab'; +import { ApplicationsTab } from './ApplicationsTab'; import { ApiKeysTab } from './ApiKeysTab'; import { EditSettingsPlatformForm } from '@/features/edit-settings-platform-form'; @@ -31,10 +32,21 @@ export const SettingsPage = () => { Основные - + Роли и права + + + Приложения - Beta + + Beta + {/**/} @@ -47,6 +59,9 @@ export const SettingsPage = () => { + + +