From 6b2ce4b262976639d109a6f499a83f710bf92152 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Wed, 10 Sep 2025 08:29:56 +0300 Subject: [PATCH 01/58] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20?= =?UTF-8?q?=D1=81=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/hooks/usePlayers.ts | 2 +- src/widgets/players-table/lib/columns.tsx | 187 ++++++++++++------ src/widgets/players-table/ui/PlayersTable.tsx | 13 +- 3 files changed, 145 insertions(+), 57 deletions(-) diff --git a/src/shared/hooks/usePlayers.ts b/src/shared/hooks/usePlayers.ts index d118c6fc..9717829b 100644 --- a/src/shared/hooks/usePlayers.ts +++ b/src/shared/hooks/usePlayers.ts @@ -16,7 +16,7 @@ export const playersKeys = { export const usePlayers = (search: string) => { return useInfiniteQuery({ - queryKey: playersKeys.all, + queryKey: [...playersKeys.all, search], initialPageParam: 0, queryFn: ({ pageParam }) => playersService.getPlayers({ diff --git a/src/widgets/players-table/lib/columns.tsx b/src/widgets/players-table/lib/columns.tsx index 501b54a2..bc533f5f 100644 --- a/src/widgets/players-table/lib/columns.tsx +++ b/src/widgets/players-table/lib/columns.tsx @@ -1,14 +1,15 @@ 'use client'; -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { createColumnHelper } from '@tanstack/table-core'; import { format } from 'date-fns'; -import { GavelIcon, UserXIcon } from 'lucide-react'; +import { GavelIcon, UserXIcon, MoreVertical, Ban as BanIcon, ShieldCheck } from 'lucide-react'; import { DataTableColumnHeader } from '@/entities/Table'; import { PlayerBaseEntity } from '@/shared/api/contracts'; import { $api } from '@/services/api.service'; import { Button } from '@/shared/ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/shared/ui/dropdown-menu'; import { useBanPlayer, usePardonPlayer, useRemoveUser } from '@/shared/hooks/usePlayers'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip'; import { Card, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'; @@ -26,6 +27,37 @@ enum ColumnHeader { } export const columnsHelper = createColumnHelper(); + +function AvatarCell({ name, uuid }: { name?: string; uuid: string }) { + const [error, setError] = useState(false); + const initials = useMemo(() => { + const n = (name || '').trim(); + if (!n) return '?'; + // Use first 2 symbols to match the example; uppercase + return n.substring(0, 2).toUpperCase(); + }, [name]); + + if (error) { + return ( +
+ {initials} +
+ ); + } + + return ( + skin setError(true)} + /> + ); +} export const useColumns = () => { const banPlayer = useBanPlayer(); const removePlayer = useRemoveUser(); @@ -43,63 +75,114 @@ export const useColumns = () => { }; const columns: any = [ - columnsHelper.accessor('uuid', { - size: 190, - header: ({ column }) => , - cell: ({ getValue }) => getValue(), - }), + // Compact: move UUID into the name cell (shown below) to save a column + // Keeping data visible via tooltip in name cell; remove standalone UUID column + // If table requires an accessor for sorting by uuid, we can keep hidden, but here we remove to save space. columnsHelper.display({ id: 'skin', - size: 100, + size: 160, header: ({ column }) => , cell: ({ row }) => { + const authCount = row.original.authHistory?.length ?? 0; + const banned = row.original.isBanned; + const uuid = row.original.uuid; return ( -
- skin -

{row.original.name}

+
+ {/* Avatar with fallback to initials if image is missing or fails to load */} + +
+
+

{row.original.name}

+ {banned ? ( + + + + + Заблокирован + + ) : ( + + + + + Не заблокирован + + )} + + + {authCount} + + Авторизаций: {authCount} + +
+
+ UUID: + {uuid} +
+
); }, }), + // Compact: sign-in count moved into name cell badge above. + // Keep column very narrow for sorting but hide content textually. columnsHelper.accessor('authHistory', { - size: 100, + size: 60, header: ({ column }) => ( ), - cell: ({ getValue }) => getValue()?.length, + cell: ({ getValue }) => (getValue()?.length ?? 0), }), columnsHelper.accessor('expiredDate', { - size: 300, + size: 160, header: ({ column }) => ( ), - cell: ({ getValue }) => format(getValue(), 'dd.MM.yyyy в HH:mm:ss'), + cell: ({ getValue }) => { + const date = getValue() as any; + const full = date ? format(date, 'dd.MM.yyyy в HH:mm:ss') : '-'; + const rel = date ? timeAgo(date) : '-'; + return ( + + + {rel} + + {full} + + ); + }, }), + // Compact: banned state represented as icon near name; keep column minimal for sorting clarity or remove text columnsHelper.accessor('isBanned', { - size: 100, + size: 60, header: ({ column }) => , - cell: ({ getValue }) => (getValue() ? 'Да' : 'Нет'), + cell: ({ getValue }) => ( + getValue() ? Да : Нет + ), }), columnsHelper.display({ id: 'address', - size: 100, + size: 120, header: ({ column }) => ( ), cell: ({ row }) => { const distinctAddresses = Array.from( - new Set(row.original.authHistory.map((c) => c.address)), + new Set((row.original.authHistory || []).map((c) => c.address)), ); - const distinctDevices = row.original.authHistory; + const distinctDevices = row.original.authHistory || []; + const first = distinctAddresses[0]; + const more = Math.max(0, distinctAddresses.length - 1); return ( -
{distinctAddresses.join(', ')}
+
+ {first ?? '-'} + {more > 0 && ( + +{more} + )} +
@@ -121,42 +204,36 @@ export const useColumns = () => { }), columnsHelper.display({ id: 'actions', - size: 100, + size: 64, header: ({ column }) => ( ), cell: ({ row }) => { return ( -
- {row.original.isBanned ? ( - - ) : ( - - )} - - -
+ + + {row.original.isBanned ? ( + pardonUser(row.original.uuid)}> + Разбанить + + ) : ( + banUser(row.original.uuid)}> + Забанить + + )} + e.preventDefault()}> + Забанить по железу + + removeUser(row.original.uuid)}> + Удалить + + + ); }, }), diff --git a/src/widgets/players-table/ui/PlayersTable.tsx b/src/widgets/players-table/ui/PlayersTable.tsx index ef57668b..ffdcceec 100644 --- a/src/widgets/players-table/ui/PlayersTable.tsx +++ b/src/widgets/players-table/ui/PlayersTable.tsx @@ -25,7 +25,8 @@ import { Button } from '@/shared/ui/button'; export function PlayersTable() { const { ref, inView } = useInView(); const [search, setSearch] = useState(''); - const { data: players, status, error, fetchNextPage, refetch } = usePlayers(search); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const { data: players, status, error, fetchNextPage, refetch } = usePlayers(debouncedSearch); const [sorting, setSorting] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); @@ -57,8 +58,18 @@ export function PlayersTable() { } }, [fetchNextPage, inView]); + // Auto-search when user stops typing (debounce) + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearch(search.trim()); + }, 500); + return () => clearTimeout(handler); + }, [search]); + const onSubmit = (event: React.FormEvent) => { event.preventDefault(); + // Manual submit still supported: immediately apply current search value + setDebouncedSearch(search.trim()); refetch(); }; From 62c1f8ecc6d113d462e20e90ecccb5a6649e5fd0 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Wed, 10 Sep 2025 08:53:00 +0300 Subject: [PATCH 02/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=B6=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=B7=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/api/contracts/user/UserRequests.ts | 1 + src/shared/hooks/usePlayers.ts | 20 ++++++++++++++++++- src/shared/services/PlayersService.ts | 8 ++++++-- src/widgets/players-table/lib/columns.tsx | 8 ++++++-- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/shared/api/contracts/user/UserRequests.ts b/src/shared/api/contracts/user/UserRequests.ts index c613b6b4..f9540069 100644 --- a/src/shared/api/contracts/user/UserRequests.ts +++ b/src/shared/api/contracts/user/UserRequests.ts @@ -36,6 +36,7 @@ export type TGetPlayersResponse = ResponseBaseEntity & { // Бан юзера export type TPostBanPlayersRequest = string[]; +export type TPostBanPlayersOptions = { deviceBlock?: boolean }; export type TPostBanPlayersResponse = ResponseBaseEntity & {}; // Удаление юзера diff --git a/src/shared/hooks/usePlayers.ts b/src/shared/hooks/usePlayers.ts index 9717829b..6f2e5bbf 100644 --- a/src/shared/hooks/usePlayers.ts +++ b/src/shared/hooks/usePlayers.ts @@ -38,7 +38,7 @@ export const useBanPlayer = () => { return useMutation({ mutationKey: playersKeys.banPlayer(), mutationFn: (data: TPostBanPlayersRequest) => playersService.banPlayer(data), - onSuccess: async (data) => { + onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: playersKeys.all }); toast.success('Успешно', { description: `Пользователь заблокирован`, @@ -50,6 +50,24 @@ export const useBanPlayer = () => { }); }; +export const useBanPlayerHardware = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: playersKeys.banPlayer(), + mutationFn: (data: TPostBanPlayersRequest) => playersService.banPlayer(data, { deviceBlock: true }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: playersKeys.all }); + toast.success('Успешно', { + description: `Пользователь заблокирован по железу`, + }); + }, + onError: (error) => { + isAxiosError({ toast, error }); + }, + }); +}; + export const useRemoveUser = () => { const queryClient = useQueryClient(); diff --git a/src/shared/services/PlayersService.ts b/src/shared/services/PlayersService.ts index 63d68efa..2e8c2e2c 100644 --- a/src/shared/services/PlayersService.ts +++ b/src/shared/services/PlayersService.ts @@ -4,6 +4,7 @@ import { TGetPlayersResponse, TPostBanPlayersRequest, TPostBanPlayersResponse, + TPostBanPlayersOptions, } from '@/shared/api/contracts'; class PlayersService { @@ -17,8 +18,11 @@ class PlayersService { return data; } - async banPlayer(body: TPostBanPlayersRequest): Promise { - const { data } = await $api.post(`${this.BASE_URL}/ban`, body); + async banPlayer(body: TPostBanPlayersRequest, options?: TPostBanPlayersOptions): Promise { + const { data } = await $api.post( + `${this.BASE_URL}/ban${options?.deviceBlock ? '?deviceBlock=true' : ''}`, + body, + ); return data; } diff --git a/src/widgets/players-table/lib/columns.tsx b/src/widgets/players-table/lib/columns.tsx index bc533f5f..43fca733 100644 --- a/src/widgets/players-table/lib/columns.tsx +++ b/src/widgets/players-table/lib/columns.tsx @@ -10,7 +10,7 @@ import { PlayerBaseEntity } from '@/shared/api/contracts'; import { $api } from '@/services/api.service'; import { Button } from '@/shared/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/shared/ui/dropdown-menu'; -import { useBanPlayer, usePardonPlayer, useRemoveUser } from '@/shared/hooks/usePlayers'; +import { useBanPlayer, useBanPlayerHardware, usePardonPlayer, useRemoveUser } from '@/shared/hooks/usePlayers'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip'; import { Card, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'; import { timeAgo } from '@/shared/lib/getFormatDate/getFormatDate'; @@ -60,12 +60,16 @@ function AvatarCell({ name, uuid }: { name?: string; uuid: string }) { } export const useColumns = () => { const banPlayer = useBanPlayer(); + const banPlayerHardware = useBanPlayerHardware(); const removePlayer = useRemoveUser(); const pardonPlayer = usePardonPlayer(); const banUser = async (data: string) => { await banPlayer.mutateAsync([data]); }; + const banUserHardware = async (data: string) => { + await banPlayerHardware.mutateAsync([data]); + }; const removeUser = async (data: string) => { await removePlayer.mutateAsync([data]); }; @@ -226,7 +230,7 @@ export const useColumns = () => { Забанить )} - e.preventDefault()}> + banUserHardware(row.original.uuid)}> Забанить по железу removeUser(row.original.uuid)}> From fc12679ad54988f67a01d5b3aa8d154cc2ab812b Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Wed, 10 Sep 2025 09:30:31 +0300 Subject: [PATCH 03/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D0=BD=D1=8F=D1=82=D0=B8=D1=8F=20=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B6=D0=B5=D0=BB=D0=B5=D0=B7=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализована возможность разблокировать пользователей по критерию оборудования. Добавлена новая типизация, обработка запросов и обновление данных в таблице игроков. --- .../api/contracts/user/UserBaseEntitry.ts | 1 + src/shared/api/contracts/user/UserRequests.ts | 3 + src/shared/hooks/usePlayers.ts | 20 +++- src/shared/services/PlayersService.ts | 8 +- src/widgets/players-table/lib/columns.tsx | 106 ++++++++++++++++-- 5 files changed, 124 insertions(+), 14 deletions(-) diff --git a/src/shared/api/contracts/user/UserBaseEntitry.ts b/src/shared/api/contracts/user/UserBaseEntitry.ts index 74a80baf..30867578 100644 --- a/src/shared/api/contracts/user/UserBaseEntitry.ts +++ b/src/shared/api/contracts/user/UserBaseEntitry.ts @@ -6,6 +6,7 @@ export type UserBaseEntity = { export interface PlayerBaseEntity { isBanned: boolean; + isBannedPermanent?: boolean; authHistory: AuthHistoryBaseEntity[]; serverJoinHistory: any[]; name: string; diff --git a/src/shared/api/contracts/user/UserRequests.ts b/src/shared/api/contracts/user/UserRequests.ts index f9540069..e5057f06 100644 --- a/src/shared/api/contracts/user/UserRequests.ts +++ b/src/shared/api/contracts/user/UserRequests.ts @@ -39,6 +39,9 @@ export type TPostBanPlayersRequest = string[]; export type TPostBanPlayersOptions = { deviceBlock?: boolean }; export type TPostBanPlayersResponse = ResponseBaseEntity & {}; +// Разбан юзера +export type TPostPardonPlayersOptions = { deviceUnblock?: boolean }; + // Удаление юзера export type TPostRemovePlayersRequest = string[]; export type TPostRemovePlayersResponse = ResponseBaseEntity & {}; diff --git a/src/shared/hooks/usePlayers.ts b/src/shared/hooks/usePlayers.ts index 6f2e5bbf..725f8f61 100644 --- a/src/shared/hooks/usePlayers.ts +++ b/src/shared/hooks/usePlayers.ts @@ -92,7 +92,7 @@ export const usePardonPlayer = () => { return useMutation({ mutationKey: playersKeys.pardonPlayer(), mutationFn: (data: TPostBanPlayersRequest) => playersService.pardonPlayer(data), - onSuccess: async (data) => { + onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: playersKeys.all }); toast.success('Успешно', { description: `Пользователь разблокирован`, @@ -103,3 +103,21 @@ export const usePardonPlayer = () => { }, }); }; + +export const usePardonPlayerHardware = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: playersKeys.pardonPlayer(), + mutationFn: (data: TPostBanPlayersRequest) => playersService.pardonPlayer(data, { deviceUnblock: true }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: playersKeys.all }); + toast.success('Успешно', { + description: `Пользователь разблокирован по железу`, + }); + }, + onError: (error) => { + isAxiosError({ toast, error }); + }, + }); +}; diff --git a/src/shared/services/PlayersService.ts b/src/shared/services/PlayersService.ts index 2e8c2e2c..9e7451ff 100644 --- a/src/shared/services/PlayersService.ts +++ b/src/shared/services/PlayersService.ts @@ -5,6 +5,7 @@ import { TPostBanPlayersRequest, TPostBanPlayersResponse, TPostBanPlayersOptions, + TPostPardonPlayersOptions, } from '@/shared/api/contracts'; class PlayersService { @@ -27,8 +28,11 @@ class PlayersService { return data; } - async pardonPlayer(body: TPostBanPlayersRequest): Promise { - const { data } = await $api.post(`${this.BASE_URL}/pardon`, body); + async pardonPlayer(body: TPostBanPlayersRequest, options?: TPostPardonPlayersOptions): Promise { + const { data } = await $api.post( + `${this.BASE_URL}/pardon${options?.deviceUnblock ? '?deviceUnblock=true' : ''}`, + body, + ); return data; } diff --git a/src/widgets/players-table/lib/columns.tsx b/src/widgets/players-table/lib/columns.tsx index 43fca733..cd446c75 100644 --- a/src/widgets/players-table/lib/columns.tsx +++ b/src/widgets/players-table/lib/columns.tsx @@ -10,7 +10,7 @@ import { PlayerBaseEntity } from '@/shared/api/contracts'; import { $api } from '@/services/api.service'; import { Button } from '@/shared/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/shared/ui/dropdown-menu'; -import { useBanPlayer, useBanPlayerHardware, usePardonPlayer, useRemoveUser } from '@/shared/hooks/usePlayers'; +import { useBanPlayer, useBanPlayerHardware, usePardonPlayer, usePardonPlayerHardware, useRemoveUser } from '@/shared/hooks/usePlayers'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip'; import { Card, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'; import { timeAgo } from '@/shared/lib/getFormatDate/getFormatDate'; @@ -22,6 +22,7 @@ enum ColumnHeader { SESSION_END_DATE = 'Дата завершения сессии', UUID = 'UUID', BANNED = 'Заблокирован', + BANNED_HARDWARE = 'Заблокирован по железу', IP_ADDRESS = 'IP Адрес', ACTIONS = 'Действия', } @@ -63,6 +64,7 @@ export const useColumns = () => { const banPlayerHardware = useBanPlayerHardware(); const removePlayer = useRemoveUser(); const pardonPlayer = usePardonPlayer(); + const pardonPlayerHardware = usePardonPlayerHardware(); const banUser = async (data: string) => { await banPlayer.mutateAsync([data]); @@ -78,6 +80,10 @@ export const useColumns = () => { await pardonPlayer.mutateAsync([data]); }; + const pardonUserHardware = async (data: string) => { + await pardonPlayerHardware.mutateAsync([data]); + }; + const columns: any = [ // Compact: move UUID into the name cell (shown below) to save a column // Keeping data visible via tooltip in name cell; remove standalone UUID column @@ -156,13 +162,73 @@ export const useColumns = () => { ); }, }), - // Compact: banned state represented as icon near name; keep column minimal for sorting clarity or remove text - columnsHelper.accessor('isBanned', { - size: 60, - header: ({ column }) => , - cell: ({ getValue }) => ( - getValue() ? Да : Нет + // Merged status column: combines regular ban and hardware ban into one cell + columnsHelper.display({ + id: 'status', + size: 120, + header: ({ column }) => ( + ), + sortingFn: (a, b) => { + const getHw = (r: any) => { + if (r?.isBannedPermanent) return 2; + const legacy = (r?.isDeviceBanned ?? r?.deviceBlocked ?? r?.isHardwareBanned ?? r?.isBannedByHardware ?? r?.bannedByHardware) ? 1 : 0; + return legacy; + }; + const getBan = (r: any) => (r?.isBanned ? 1 : 0); + // Priority: permanent hardware ban (2) > regular hardware ban (1) > regular ban (1) > none (0) + const rankA = getHw(a.original) + getBan(a.original); + const rankB = getHw(b.original) + getBan(b.original); + return rankB - rankA; + }, + cell: ({ row }) => { + const anyRow: any = row.original as any; + const isBanned = !!anyRow?.isBanned; + const isHwPermanent = !!anyRow?.isBannedPermanent; + const isHwLegacy = !!( + anyRow?.isDeviceBanned ?? + anyRow?.deviceBlocked ?? + anyRow?.isHardwareBanned ?? + anyRow?.isBannedByHardware ?? + anyRow?.bannedByHardware + ); + const isHw = isHwPermanent || isHwLegacy; + + if (!isBanned && !isHw) { + return ( +
+ + + + Нет +
+ ); + } + + return ( +
+ {isBanned && ( + + + + + Блокировка аккаунта + + )} + {isHw && ( + + + + + {isHwPermanent ? 'Перманентная блокировка по железу' : 'Блокировка по железу'} + + )} + + {isHw && isBanned ? (isHwPermanent ? 'Бан + Железо (перманентно)' : 'Бан + Железо') : isHw ? (isHwPermanent ? 'Железо (перманентно)' : 'Железо') : 'Бан'} + +
+ ); + }, }), columnsHelper.display({ id: 'address', @@ -213,6 +279,17 @@ export const useColumns = () => { ), cell: ({ row }) => { + const anyRow: any = row.original as any; + const isHwPermanent = !!anyRow?.isBannedPermanent; + const isHwLegacy = !!( + anyRow?.isDeviceBanned ?? + anyRow?.deviceBlocked ?? + anyRow?.isHardwareBanned ?? + anyRow?.isBannedByHardware ?? + anyRow?.bannedByHardware + ); + const isHw = isHwPermanent || isHwLegacy; + const isBanned = !!anyRow?.isBanned; return ( @@ -221,7 +298,7 @@ export const useColumns = () => { - {row.original.isBanned ? ( + {isBanned ? ( pardonUser(row.original.uuid)}> Разбанить @@ -230,9 +307,16 @@ export const useColumns = () => { Забанить
)} - banUserHardware(row.original.uuid)}> - Забанить по железу - + {isHwPermanent && ( + pardonUserHardware(row.original.uuid)}> + Разбанить по железу + + )} + {!isHwPermanent && ( + banUserHardware(row.original.uuid)}> + Забанить по железу + + )} removeUser(row.original.uuid)}> Удалить From 7d7ce0314296c727c2eb31330e5234645043c96c Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Wed, 10 Sep 2025 09:52:15 +0300 Subject: [PATCH 04/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=82=D0=B8=D0=BF=D0=B0=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D1=87=D0=B8=D0=BA=D0=B0=20=D0=B8=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D1=84=D0=B5=D0=B9=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализована валидация типа загрузчика для профилей, благодаря которой скрываются вкладки и функции, недоступные для пользователя с типом Vanilla. Также внесены изменения в интерфейс: автоматическое переключение на главную вкладку при недоступной "Моды". --- .../contracts/profiles/ProfileBaseEntity.ts | 1 + src/views/profile/ui/Profile.tsx | 30 ++++++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/shared/api/contracts/profiles/ProfileBaseEntity.ts b/src/shared/api/contracts/profiles/ProfileBaseEntity.ts index feaae51e..1f04e822 100644 --- a/src/shared/api/contracts/profiles/ProfileBaseEntity.ts +++ b/src/shared/api/contracts/profiles/ProfileBaseEntity.ts @@ -33,6 +33,7 @@ export type ProfileExtendedBaseEntity = { hasUpdate: boolean; isEnabled: boolean; state: EntityState; + loader: number; files: ProfileFileBaseEntity[]; whiteListFiles: ProfileFileBaseEntity[]; whiteListFolders: ProfileFolderBaseEntity[]; diff --git a/src/views/profile/ui/Profile.tsx b/src/views/profile/ui/Profile.tsx index be575eb2..6bc4cf81 100644 --- a/src/views/profile/ui/Profile.tsx +++ b/src/views/profile/ui/Profile.tsx @@ -39,6 +39,7 @@ import { FolderTable } from '@/widgets/folder-table'; import { GamePlayers } from '@/widgets/game-players'; import { useGamePlayerStore } from '@/widgets/game-players/lib/store'; import { GameMods } from '@/widgets/game-mods'; +import { GameLoaderType } from '@/shared/api/contracts'; export const ProfilePage = ({ params }: { params: { name: string } }) => { const account = getStorageProfile(); @@ -74,6 +75,15 @@ export const ProfilePage = ({ params }: { params: { name: string } }) => { } }, [profile, setPlayers]); + const isVanilla = profile?.loader === GameLoaderType.VANILLA; + + useEffect(() => { + if (isVanilla && activeTab === 'mods') { + setActiveTab('main'); + router.push(`/dashboard/profile/${params.name}?tab=main`, { scroll: false }); + } + }, [isVanilla, activeTab, router, params.name]); + if (isPending || !profile) return ; const onSubmitDeleteFiles = () => { @@ -140,9 +150,11 @@ export const ProfilePage = ({ params }: { params: { name: string } }) => { Игроки - - Моды - + {!isVanilla && ( + + Моды + + )}
{
- -
- -
-
+ {!isVanilla && ( + +
+ +
+
+ )} ); From 652560827c265eaae756201b69b556c058e43aec Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Wed, 10 Sep 2025 10:00:12 +0300 Subject: [PATCH 05/58] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20=D1=81=D1=82=D0=BE=D0=BB=D0=B1=D1=86=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D1=8B=20=D0=BE=D0=BA=D0=BE=D0=BD=D1=87=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Изменен подход к форматированию даты: добавлена проверка корректности даты и удалены всплывающие подсказки с относительным временем. Теперь отображается только форматированная дата или символ "-", если данные отсутствуют. --- src/widgets/players-table/lib/columns.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/widgets/players-table/lib/columns.tsx b/src/widgets/players-table/lib/columns.tsx index cd446c75..13ae6655 100644 --- a/src/widgets/players-table/lib/columns.tsx +++ b/src/widgets/players-table/lib/columns.tsx @@ -149,17 +149,11 @@ export const useColumns = () => { ), cell: ({ getValue }) => { - const date = getValue() as any; - const full = date ? format(date, 'dd.MM.yyyy в HH:mm:ss') : '-'; - const rel = date ? timeAgo(date) : '-'; - return ( - - - {rel} - - {full} - - ); + const raw = getValue() as unknown as string | Date | number | null | undefined; + const dateObj = raw ? new Date(raw as any) : null; + const isValid = !!(dateObj && !isNaN(dateObj.getTime())); + const full = isValid ? format(dateObj as Date, 'dd.MM.yyyy в HH:mm:ss') : '-'; + return {full}; }, }), // Merged status column: combines regular ban and hardware ban into one cell From e692514acbb4d80f6a747d31000f40629c7064cf Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Thu, 11 Sep 2025 08:57:42 +0300 Subject: [PATCH 06/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=20=D1=81=20?= =?UTF-8?q?=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D1=8F=D0=BC=D0=B8=20=D0=B8=D0=B3?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B0=20=D0=B8=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D1=8B=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализован компонент диалога для отображения подробностей игрока, включая текстуры, историю авторизаций и список IP-адресов. Обновлены действия в таблице: добавлена возможность просмотра карточки игрока, улучшено управление блокировками и добавлено удаление игроков. --- src/widgets/players-table/lib/columns.tsx | 325 +++++++++++++++++----- 1 file changed, 260 insertions(+), 65 deletions(-) diff --git a/src/widgets/players-table/lib/columns.tsx b/src/widgets/players-table/lib/columns.tsx index 13ae6655..ed400ce1 100644 --- a/src/widgets/players-table/lib/columns.tsx +++ b/src/widgets/players-table/lib/columns.tsx @@ -3,17 +3,35 @@ import React, { useMemo, useState } from 'react'; import { createColumnHelper } from '@tanstack/table-core'; import { format } from 'date-fns'; -import { GavelIcon, UserXIcon, MoreVertical, Ban as BanIcon, ShieldCheck } from 'lucide-react'; +import { Ban as BanIcon, GavelIcon, MoreVertical, ShieldCheck, User, Trash } from 'lucide-react'; import { DataTableColumnHeader } from '@/entities/Table'; import { PlayerBaseEntity } from '@/shared/api/contracts'; import { $api } from '@/services/api.service'; import { Button } from '@/shared/ui/button'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/shared/ui/dropdown-menu'; -import { useBanPlayer, useBanPlayerHardware, usePardonPlayer, usePardonPlayerHardware, useRemoveUser } from '@/shared/hooks/usePlayers'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/shared/ui/dropdown-menu'; +import { + useBanPlayer, + useBanPlayerHardware, + usePardonPlayer, + usePardonPlayerHardware, + useRemoveUser, +} from '@/shared/hooks/usePlayers'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip'; import { Card, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'; import { timeAgo } from '@/shared/lib/getFormatDate/getFormatDate'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog'; enum ColumnHeader { ICON = '', @@ -59,6 +77,200 @@ function AvatarCell({ name, uuid }: { name?: string; uuid: string }) { /> ); } +function PlayerDetailsDialog({ + player, + open, + onOpenChange, +}: { + player: PlayerBaseEntity; + open: boolean; + onOpenChange: (o: boolean) => void; +}) { + const uuid = player.uuid; + const sessionDate = player.expiredDate ? new Date(player.expiredDate) : null; + const isValidDate = !!(sessionDate && !isNaN(sessionDate.getTime())); + const sessionStr = isValidDate ? format(sessionDate as Date, 'dd.MM.yyyy в HH:mm:ss') : '-'; + const addresses = Array.from( + new Set((player.authHistory || []).map((c) => c.address).filter(Boolean)), + ); + + return ( + + + + + skin + {player.name} + + UUID: {player.uuid} + +
+
+
+
Статус
+
+ {player.isBanned ? ( + + Заблокирован + + ) : ( + + Не заблокирован + + )} +
+
+
+
Сессия истекает
+
{sessionStr}
+
+
+
Текстуры
+
+ {player.textureSkinUrl && ( + skin + )} + {player.textureCloakUrl && ( + cloak + )} +
+
+ {addresses.length > 0 && ( +
+
IP адреса
+
{addresses.join(', ')}
+
+ )} +
+
+
+
Авторизации
+
+ {(player.authHistory || []).map((h, idx) => ( +
+
{h.device || 'Неизвестное устройство'}
+
+ {h.address || '-'} / {timeAgo(h.date)} / {h.protocol} +
+
+ ))} + {(!player.authHistory || player.authHistory.length === 0) && ( +
Нет данных.
+ )} +
+
+ {Array.isArray((player as any).serverJoinHistory) && ( +
+
История входов на сервер
+
+ {(player as any).serverJoinHistory?.length || 0} записей +
+
+ )} +
+
+
+
+ ); +} + +function ActionsCell({ row }: { row: any }) { + const banPlayer = useBanPlayer(); + const banPlayerHardware = useBanPlayerHardware(); + const removePlayer = useRemoveUser(); + const pardonPlayer = usePardonPlayer(); + const pardonPlayerHardware = usePardonPlayerHardware(); + + const banUser = async (data: string) => { + await banPlayer.mutateAsync([data]); + }; + const banUserHardware = async (data: string) => { + await banPlayerHardware.mutateAsync([data]); + }; + const removeUser = async (data: string) => { + await removePlayer.mutateAsync([data]); + }; + const pardonUser = async (data: string) => { + await pardonPlayer.mutateAsync([data]); + }; + const pardonUserHardware = async (data: string) => { + await pardonPlayerHardware.mutateAsync([data]); + }; + + const anyRow: any = row.original as any; + const isHwPermanent = !!anyRow?.isBannedPermanent; + const isHwLegacy = !!( + anyRow?.isDeviceBanned ?? + anyRow?.deviceBlocked ?? + anyRow?.isHardwareBanned ?? + anyRow?.isBannedByHardware ?? + anyRow?.bannedByHardware + ); + const isBanned = !!anyRow?.isBanned; + + const [open, setOpen] = useState(false); + + return ( + <> + + + + + + setOpen(true)}> + Карточка игрока + + {isBanned ? ( + pardonUser(row.original.uuid)}> + Разбанить + + ) : ( + banUser(row.original.uuid)}> + Забанить + + )} + {isHwPermanent && ( + pardonUserHardware(row.original.uuid)}> + Разбанить по железу + + )} + {!isHwPermanent && ( + banUserHardware(row.original.uuid)}> + Забанить по железу + + )} + removeUser(row.original.uuid)}> + Удалить + + + + + + ); +} + export const useColumns = () => { const banPlayer = useBanPlayer(); const banPlayerHardware = useBanPlayerHardware(); @@ -102,25 +314,33 @@ export const useColumns = () => {
-

{row.original.name}

+

+ {row.original.name} +

{banned ? ( - + + + Заблокирован ) : ( - + + + Не заблокирован )} - {authCount} + + {authCount} + Авторизаций: {authCount} @@ -141,7 +361,7 @@ export const useColumns = () => { header: ({ column }) => ( ), - cell: ({ getValue }) => (getValue()?.length ?? 0), + cell: ({ getValue }) => getValue()?.length ?? 0, }), columnsHelper.accessor('expiredDate', { size: 160, @@ -160,13 +380,18 @@ export const useColumns = () => { columnsHelper.display({ id: 'status', size: 120, - header: ({ column }) => ( - - ), + header: ({ column }) => , sortingFn: (a, b) => { const getHw = (r: any) => { if (r?.isBannedPermanent) return 2; - const legacy = (r?.isDeviceBanned ?? r?.deviceBlocked ?? r?.isHardwareBanned ?? r?.isBannedByHardware ?? r?.bannedByHardware) ? 1 : 0; + const legacy = + r?.isDeviceBanned ?? + r?.deviceBlocked ?? + r?.isHardwareBanned ?? + r?.isBannedByHardware ?? + r?.bannedByHardware + ? 1 + : 0; return legacy; }; const getBan = (r: any) => (r?.isBanned ? 1 : 0); @@ -204,7 +429,9 @@ export const useColumns = () => { {isBanned && ( - + + + Блокировка аккаунта @@ -212,13 +439,28 @@ export const useColumns = () => { {isHw && ( - + + + - {isHwPermanent ? 'Перманентная блокировка по железу' : 'Блокировка по железу'} + + {isHwPermanent ? 'Перманентная блокировка по железу' : 'Блокировка по железу'} + )} - {isHw && isBanned ? (isHwPermanent ? 'Бан + Железо (перманентно)' : 'Бан + Железо') : isHw ? (isHwPermanent ? 'Железо (перманентно)' : 'Железо') : 'Бан'} + {isHw && isBanned + ? isHwPermanent + ? 'Бан + Железо (перманентно)' + : 'Бан + Железо' + : isHw + ? isHwPermanent + ? 'Железо (перманентно)' + : 'Железо' + : 'Бан'}
); @@ -243,9 +485,7 @@ export const useColumns = () => {
{first ?? '-'} - {more > 0 && ( - +{more} - )} + {more > 0 && +{more}}
@@ -272,52 +512,7 @@ export const useColumns = () => { header: ({ column }) => ( ), - cell: ({ row }) => { - const anyRow: any = row.original as any; - const isHwPermanent = !!anyRow?.isBannedPermanent; - const isHwLegacy = !!( - anyRow?.isDeviceBanned ?? - anyRow?.deviceBlocked ?? - anyRow?.isHardwareBanned ?? - anyRow?.isBannedByHardware ?? - anyRow?.bannedByHardware - ); - const isHw = isHwPermanent || isHwLegacy; - const isBanned = !!anyRow?.isBanned; - return ( - - - - - - {isBanned ? ( - pardonUser(row.original.uuid)}> - Разбанить - - ) : ( - banUser(row.original.uuid)}> - Забанить - - )} - {isHwPermanent && ( - pardonUserHardware(row.original.uuid)}> - Разбанить по железу - - )} - {!isHwPermanent && ( - banUserHardware(row.original.uuid)}> - Забанить по железу - - )} - removeUser(row.original.uuid)}> - Удалить - - - - ); - }, + cell: ({ row }) => , }), ]; From e3193ffe9560ccfabab4e495639ee3fb936ed351 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Thu, 11 Sep 2025 08:59:52 +0300 Subject: [PATCH 07/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализован диалог подтверждения перед удалением игрока из таблицы, включающий текст и детали игрока. Добавлена блокировка кнопок во время выполнения удаления и улучшена обработка этого процесса для предотвращения ошибок. --- src/widgets/players-table/lib/columns.tsx | 32 ++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/widgets/players-table/lib/columns.tsx b/src/widgets/players-table/lib/columns.tsx index ed400ce1..22d7b3aa 100644 --- a/src/widgets/players-table/lib/columns.tsx +++ b/src/widgets/players-table/lib/columns.tsx @@ -221,6 +221,18 @@ function ActionsCell({ row }: { row: any }) { const isBanned = !!anyRow?.isBanned; const [open, setOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + + const onConfirmDelete = async () => { + try { + setDeleting(true); + await removeUser(row.original.uuid); + setConfirmOpen(false); + } finally { + setDeleting(false); + } + }; return ( <> @@ -257,7 +269,7 @@ function ActionsCell({ row }: { row: any }) { Забанить по железу )} - removeUser(row.original.uuid)}> + setConfirmOpen(true)}> Удалить @@ -267,6 +279,24 @@ function ActionsCell({ row }: { row: any }) { open={open} onOpenChange={setOpen} /> + + + + Подтверждение удаления + + Вы уверены, что хотите удалить игрока "{row.original.name}" (UUID: {row.original.uuid})? Это действие необратимо. + + +
+ + +
+
+
); } From 17011e8afa058363f71139ae2a50efd2bd633ce3 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Thu, 11 Sep 2025 09:05:30 +0300 Subject: [PATCH 08/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B?= =?UTF-8?q?=20=D0=B8=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализованы дополнительные фильтры поиска: UUID, IP, HWID, сортировка, блокировка и количество игроков на страницу. Улучшена работа формы поиска, добавлена поддержка отложенного выполнения запросов через debounce и новое управление состояниями. Обновлены типы и функции API для обработки новых фильтров. --- src/shared/api/contracts/user/UserRequests.ts | 9 +- src/shared/hooks/usePlayers.ts | 27 +++- src/widgets/players-table/ui/PlayersTable.tsx | 137 +++++++++++++++--- 3 files changed, 149 insertions(+), 24 deletions(-) diff --git a/src/shared/api/contracts/user/UserRequests.ts b/src/shared/api/contracts/user/UserRequests.ts index e5057f06..e47c64e8 100644 --- a/src/shared/api/contracts/user/UserRequests.ts +++ b/src/shared/api/contracts/user/UserRequests.ts @@ -28,7 +28,14 @@ export type TPostSignInResponse = ResponseBaseEntity & { export type TGetPlayersRequest = { take: number; offset: number; - findName: string; + findName?: string; + findUuid?: string; + findIp?: string; + findHwid?: string; + onlyBlocked?: boolean; + onlyDeviceBlocked?: boolean; + sortBy?: 0 | 1 | 2; + sortDesc?: boolean; }; export type TGetPlayersResponse = ResponseBaseEntity & { data: PlayerBaseEntity[]; diff --git a/src/shared/hooks/usePlayers.ts b/src/shared/hooks/usePlayers.ts index 725f8f61..0d0cd63a 100644 --- a/src/shared/hooks/usePlayers.ts +++ b/src/shared/hooks/usePlayers.ts @@ -14,20 +14,35 @@ export const playersKeys = { removePlayer: () => [...modsKeys.all, 'pardonPlayer'] as const, }; -export const usePlayers = (search: string) => { +export type PlayersFilters = { + take?: number; + findName?: string; + findUuid?: string; + findIp?: string; + findHwid?: string; + onlyBlocked?: boolean; + onlyDeviceBlocked?: boolean; + sortBy?: 0 | 1 | 2; + sortDesc?: boolean; +}; + +export const usePlayers = (filters: string | PlayersFilters) => { + const normalized: PlayersFilters = typeof filters === 'string' ? { findName: filters } : filters || {}; + const take = normalized.take ?? 20; + return useInfiniteQuery({ - queryKey: [...playersKeys.all, search], + queryKey: [...playersKeys.all, normalized], initialPageParam: 0, queryFn: ({ pageParam }) => playersService.getPlayers({ - take: 20, + take, offset: pageParam, - findName: search, + ...normalized, }), select: (data) => data.pages.flatMap((page) => page.data), getNextPageParam: (lastPage, allPages) => { - if (lastPage.data.length < 10) return undefined; - return allPages.length * 10; + if (lastPage.data.length < take) return undefined; + return allPages.length * take; }, }); }; diff --git a/src/widgets/players-table/ui/PlayersTable.tsx b/src/widgets/players-table/ui/PlayersTable.tsx index ffdcceec..cb70639b 100644 --- a/src/widgets/players-table/ui/PlayersTable.tsx +++ b/src/widgets/players-table/ui/PlayersTable.tsx @@ -21,12 +21,34 @@ import { usePlayers } from '@/shared/hooks/usePlayers'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/shared/ui/table'; import { Input } from '@/shared/ui/input'; import { Button } from '@/shared/ui/button'; +import { Checkbox } from '@/shared/ui/checkbox'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'; export function PlayersTable() { const { ref, inView } = useInView(); const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); - const { data: players, status, error, fetchNextPage, refetch } = usePlayers(debouncedSearch); + const [uuid, setUuid] = useState(''); + const [ip, setIp] = useState(''); + const [hwid, setHwid] = useState(''); + const [onlyBlocked, setOnlyBlocked] = useState(false); + const [onlyDeviceBlocked, setOnlyDeviceBlocked] = useState(false); + const [sortBy, setSortBy] = useState<0 | 1 | 2 | undefined>(undefined); + const [sortDesc, setSortDesc] = useState(false); + const [take, setTake] = useState(20); + + const [debouncedFilters, setDebouncedFilters] = useState({ + findName: '', + findUuid: '', + findIp: '', + findHwid: '', + onlyBlocked: false, + onlyDeviceBlocked: false, + sortBy: undefined as 0 | 1 | 2 | undefined, + sortDesc: false, + take: 20, + }); + + const { data: players, status, error, fetchNextPage, refetch } = usePlayers(debouncedFilters); const [sorting, setSorting] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); @@ -61,15 +83,34 @@ export function PlayersTable() { // Auto-search when user stops typing (debounce) useEffect(() => { const handler = setTimeout(() => { - setDebouncedSearch(search.trim()); + setDebouncedFilters({ + findName: search.trim() || undefined, + findUuid: uuid.trim() || undefined, + findIp: ip.trim() || undefined, + findHwid: hwid.trim() || undefined, + onlyBlocked, + onlyDeviceBlocked, + sortBy, + sortDesc, + take, + } as any); }, 500); return () => clearTimeout(handler); - }, [search]); + }, [search, uuid, ip, hwid, onlyBlocked, onlyDeviceBlocked, sortBy, sortDesc, take]); const onSubmit = (event: React.FormEvent) => { event.preventDefault(); - // Manual submit still supported: immediately apply current search value - setDebouncedSearch(search.trim()); + setDebouncedFilters({ + findName: search.trim() || undefined, + findUuid: uuid.trim() || undefined, + findIp: ip.trim() || undefined, + findHwid: hwid.trim() || undefined, + onlyBlocked, + onlyDeviceBlocked, + sortBy, + sortDesc, + take, + } as any); refetch(); }; @@ -81,17 +122,79 @@ export function PlayersTable() { Error: {error.message} ) : (
-
- setSearch(e.target.value)} - /> - + +
+ setSearch(e.target.value)} + /> + setUuid(e.target.value)} + /> + setIp(e.target.value)} + /> + setHwid(e.target.value)} + /> +
+
+ + +
+ Сортировать по + + +
+
+ На страницу + +
+
+ +
+
{players && (
From 0b56b4d7d40d519a257c32fe584ad7e6278e9cda Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Thu, 11 Sep 2025 09:09:10 +0300 Subject: [PATCH 09/58] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=B8=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D1=8B=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Форма фильтров полностью переработана: добавлен компонент Card для организации структуры, улучшена визуализация полей и фильтров с иконками. Реализовано отображение активных фильтров в виде тегов и возможность сброса значений фильтров, добавлены новые элементы управления и улучшен UX для удобства пользователя. --- src/widgets/players-table/ui/PlayersTable.tsx | 232 ++++++++++++------ 1 file changed, 157 insertions(+), 75 deletions(-) diff --git a/src/widgets/players-table/ui/PlayersTable.tsx b/src/widgets/players-table/ui/PlayersTable.tsx index cb70639b..836ad9bc 100644 --- a/src/widgets/players-table/ui/PlayersTable.tsx +++ b/src/widgets/players-table/ui/PlayersTable.tsx @@ -11,7 +11,7 @@ import { VisibilityState, } from '@tanstack/react-table'; import { useInView } from 'react-intersection-observer'; -import { SearchIcon } from 'lucide-react'; +import { Cpu, Fingerprint, Hash, SearchIcon, User as UserIcon, X, Filter } from 'lucide-react'; import { useColumns } from '../lib/columns'; @@ -23,6 +23,7 @@ import { Input } from '@/shared/ui/input'; import { Button } from '@/shared/ui/button'; import { Checkbox } from '@/shared/ui/checkbox'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'; +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'; export function PlayersTable() { const { ref, inView } = useInView(); @@ -122,80 +123,161 @@ export function PlayersTable() { Error: {error.message} ) : (
-
-
- setSearch(e.target.value)} - /> - setUuid(e.target.value)} - /> - setIp(e.target.value)} - /> - setHwid(e.target.value)} - /> -
-
- - -
- Сортировать по - - -
-
- На страницу - -
-
- -
-
-
+ + + + Фильтры игроков + + + +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+ + setUuid(e.target.value)} + className="pl-9" + /> +
+
+ + setIp(e.target.value)} + className="pl-9" + /> +
+
+ + setHwid(e.target.value)} + className="pl-9" + /> +
+
+ +
+ + +
+ Сортировать по + + +
+
+ На страницу + +
+
+ + +
+
+ + {/* Active filters summary */} + {(search || uuid || ip || hwid || onlyBlocked || onlyDeviceBlocked || sortBy !== undefined || sortDesc) && ( +
+ {search && ( + + Ник: {search} + + + )} + {uuid && ( + + UUID: {uuid} + + + )} + {ip && ( + + IP: {ip} + + + )} + {hwid && ( + + HWID: {hwid} + + + )} + {onlyBlocked && ( + Только заблокированные + )} + {onlyDeviceBlocked && ( + Только по устройству + )} + {sortBy !== undefined && ( + Сортировка: {sortBy === 0 ? 'Имя' : sortBy === 1 ? 'Авторизации' : 'Сессия'} + )} + {sortDesc && По убыванию} +
+ )} +
+
+
{players && (
From aeaf92f22a46454ee4f9cda681279e135a763d48 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Thu, 11 Sep 2025 09:12:24 +0300 Subject: [PATCH 10/58] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B8=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=D1=81=D0=B1?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Изменен подход к компоновке элементов фильтров: добавлены новые CSS-классы для организации сетки и улучшена визуализация кнопки сброса. Теперь элементы фильтров подстраиваются под разную ширину экрана, улучшая адаптивность интерфейса. --- src/widgets/players-table/ui/PlayersTable.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/widgets/players-table/ui/PlayersTable.tsx b/src/widgets/players-table/ui/PlayersTable.tsx index 836ad9bc..a3013c3f 100644 --- a/src/widgets/players-table/ui/PlayersTable.tsx +++ b/src/widgets/players-table/ui/PlayersTable.tsx @@ -178,15 +178,17 @@ export function PlayersTable() { -
- - +
+
+ + +
Сортировать по
-
+
From b3333d8db498e9eb6075c0b870232faaf9d973d6 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Thu, 11 Sep 2025 09:52:23 +0300 Subject: [PATCH 11/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BB=D0=B0=D1=83=D0=BD=D1=87=D0=B5=D1=80=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализован индикатор статуса запущенного лаунчера в таблице игроков, а также обновлены соответствующие типы. Это улучшает информативность интерфейса и позволяет быстро оценить активность игроков. --- .../api/contracts/user/UserBaseEntitry.ts | 1 + src/widgets/players-table/lib/columns.tsx | 28 +++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/shared/api/contracts/user/UserBaseEntitry.ts b/src/shared/api/contracts/user/UserBaseEntitry.ts index 30867578..3fd7e5c1 100644 --- a/src/shared/api/contracts/user/UserBaseEntitry.ts +++ b/src/shared/api/contracts/user/UserBaseEntitry.ts @@ -7,6 +7,7 @@ export type UserBaseEntity = { export interface PlayerBaseEntity { isBanned: boolean; isBannedPermanent?: boolean; + isLauncherStarted?: boolean; authHistory: AuthHistoryBaseEntity[]; serverJoinHistory: any[]; name: string; diff --git a/src/widgets/players-table/lib/columns.tsx b/src/widgets/players-table/lib/columns.tsx index 22d7b3aa..24a751ea 100644 --- a/src/widgets/players-table/lib/columns.tsx +++ b/src/widgets/players-table/lib/columns.tsx @@ -3,7 +3,7 @@ import React, { useMemo, useState } from 'react'; import { createColumnHelper } from '@tanstack/table-core'; import { format } from 'date-fns'; -import { Ban as BanIcon, GavelIcon, MoreVertical, ShieldCheck, User, Trash } from 'lucide-react'; +import { Ban as BanIcon, GavelIcon, MoreVertical, ShieldCheck, Trash, User } from 'lucide-react'; import { DataTableColumnHeader } from '@/entities/Table'; import { PlayerBaseEntity } from '@/shared/api/contracts'; @@ -123,6 +123,12 @@ function PlayerDetailsDialog({ )}
+ {player.isLauncherStarted && ( +
+ + Лаунчер запущен +
+ )}
Сессия истекает
@@ -270,8 +276,8 @@ function ActionsCell({ row }: { row: any }) { )} setConfirmOpen(true)}> - Удалить - + Удалить + Подтверждение удаления - Вы уверены, что хотите удалить игрока "{row.original.name}" (UUID: {row.original.uuid})? Это действие необратимо. + Вы уверены, что хотите удалить игрока "{row.original.name}" (UUID:{' '} + {row.original.uuid})? Это действие необратимо.
-
@@ -374,6 +386,12 @@ export const useColumns = () => { Авторизаций: {authCount} + {row.original.isLauncherStarted && ( +
+ + Лаунчер запущен +
+ )}
UUID: From 06d4bfedd6e2c12f49dcde7d704058cc556b81c1 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Thu, 11 Sep 2025 09:57:13 +0300 Subject: [PATCH 12/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D1=83=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализована многовкладочная структура в карточке игрока с использованием компонента Tabs, позволяющая организовать информацию по категориям: обзор, текстуры, авторизации, сети/IP и сервер. Это улучшает навигацию, повышает удобство использования интерфейса и делает данные более структурированными. --- src/widgets/players-table/lib/columns.tsx | 119 +++++++++++++++------- 1 file changed, 81 insertions(+), 38 deletions(-) diff --git a/src/widgets/players-table/lib/columns.tsx b/src/widgets/players-table/lib/columns.tsx index 24a751ea..67b318f2 100644 --- a/src/widgets/players-table/lib/columns.tsx +++ b/src/widgets/players-table/lib/columns.tsx @@ -32,6 +32,7 @@ import { DialogHeader, DialogTitle, } from '@/shared/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'; enum ColumnHeader { ICON = '', @@ -108,32 +109,58 @@ function PlayerDetailsDialog({ UUID: {player.uuid} -
-
-
-
Статус
-
- {player.isBanned ? ( - - Заблокирован - - ) : ( - - Не заблокирован - + {/* Tabs layout for player card */} +
+ + + Обзор + Текстуры + Авторизации + Сети/IP + Сервер + + +
+ +
+
+
+
Статус
+
+ {player.isBanned ? ( + + Заблокирован + + ) : ( + + Не заблокирован + + )} +
+ {player.isLauncherStarted && ( +
+ + Лаунчер запущен +
+ )} +
+
+
Сессия истекает
+
{sessionStr}
+
+
+
+ {addresses.length > 0 && ( +
+
IP адреса
+
{addresses.join(', ')}
+
)}
- {player.isLauncherStarted && ( -
- - Лаунчер запущен -
- )} -
-
-
Сессия истекает
-
{sessionStr}
+
+ +
Текстуры
@@ -151,19 +178,17 @@ function PlayerDetailsDialog({ className="h-16 w-16 rounded border object-cover" /> )} + {!player.textureSkinUrl && !player.textureCloakUrl && ( +
Нет текстур.
+ )}
- {addresses.length > 0 && ( -
-
IP адреса
-
{addresses.join(', ')}
-
- )} -
-
+ + +
Авторизации
-
+
{(player.authHistory || []).map((h, idx) => (
{h.device || 'Неизвестное устройство'}
@@ -177,15 +202,33 @@ function PlayerDetailsDialog({ )}
- {Array.isArray((player as any).serverJoinHistory) && ( -
-
История входов на сервер
-
- {(player as any).serverJoinHistory?.length || 0} записей -
+ + + + {addresses.length > 0 ? ( +
+ {addresses.map((a) => ( + + {a} + + ))} +
+ ) : ( +
Нет IP адресов.
+ )} +
+ + + {Array.isArray((player as any).serverJoinHistory) ? ( +
+ {(player as any).serverJoinHistory?.length || 0} записей
+ ) : ( +
Нет данных.
)} +
+
From 54f342a88c4ec0a2e3b2f30387bc046311342f59 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Thu, 11 Sep 2025 10:31:04 +0300 Subject: [PATCH 13/58] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=81=D1=82=D0=B8=D0=BB=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D0=BA=D0=B8=20=D1=83=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B5=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены функции для отображения иконок устройств в истории авторизаций, улучшена визуализация данных (добавлен HWID, доработан вид протокола, адресов и времени). Также переработаны стили диалога: добавлены ограничения высоты и скроллинг для улучшения читаемости. --- src/shared/ui/dialog.tsx | 2 +- src/widgets/players-table/lib/columns.tsx | 52 ++++++++++++++++++++--- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/shared/ui/dialog.tsx b/src/shared/ui/dialog.tsx index 1486537c..efb4dd80 100644 --- a/src/shared/ui/dialog.tsx +++ b/src/shared/ui/dialog.tsx @@ -38,7 +38,7 @@ const DialogContent = forwardRef< c.address).filter(Boolean)), ); + const getDeviceIcon = (device?: string) => { + const d = (device || '').toLowerCase(); + if ( + d.includes('phone') || + d.includes('mobile') || + d.includes('android') || + d.includes('iphone') || + d.includes('ios') + ) + return ; + if (d.includes('tablet') || d.includes('ipad')) + return ; + if (d.includes('laptop') || d.includes('notebook')) + return ; + return ; + }; + return ( - + Сервер -
+
@@ -190,10 +207,31 @@ function PlayerDetailsDialog({
Авторизации
{(player.authHistory || []).map((h, idx) => ( -
-
{h.device || 'Неизвестное устройство'}
-
- {h.address || '-'} / {timeAgo(h.date)} / {h.protocol} +
+
+ {getDeviceIcon(h.device)} +
+
+
+
+ {h.device || 'Неизвестное устройство'} +
+ {h.hwid && ( + + HWID: {h.hwid} + + )} +
+
+ {h.address || '-'} +
+
+ {timeAgo(h.date)} + {h.protocol} +
))} From 33dc62c355bbdec4680aea7c76fa661d96af44c6 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Thu, 11 Sep 2025 10:34:15 +0300 Subject: [PATCH 14/58] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0=20=D0=B4=D0=BE=2025.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Изменен номер версии в `package.json` и `package-lock.json` для подготовки к развертыванию обновления. Это обеспечивает согласованность версий и помогает управлять релизами. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6389d091..dd122069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gml-frontend", - "version": "25.1", + "version": "25.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gml-frontend", - "version": "25.1", + "version": "25.2", "dependencies": { "@faker-js/faker": "9.0.0", "@hookform/resolvers": "3.9.0", diff --git a/package.json b/package.json index cb894977..b3e0eb88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gml-frontend", - "version": "25.1", + "version": "25.2", "private": true, "scripts": { "dev": "next dev", From a2a26a764586cebc95168f7b679fa7b06c895dac Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Fri, 12 Sep 2025 11:56:01 +0300 Subject: [PATCH 15/58] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA?= =?UTF-8?q?=D0=B8=20`withCredentials`=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обновлены параметры подключения во всех используемых хабах (`useConnectionHub`) и сервисе API: опция `withCredentials` установлена в `true`, а заголовок `Access-Control-Allow-Credentials` удален. Это улучшает безопасность и корректность взаимодействия с сервером, обеспечивая передачу учетных данных с запросами. --- src/services/api.service.ts | 4 ++-- src/widgets/client-hub/lib/useConnectionHub.ts | 3 +-- .../generate-launcher-dialog/lib/hooks/useConnectionHub.ts | 3 +-- src/widgets/notifications-hub/lib/useConnectionHub.ts | 3 +-- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/services/api.service.ts b/src/services/api.service.ts index 691f09d9..11fa2e1d 100644 --- a/src/services/api.service.ts +++ b/src/services/api.service.ts @@ -3,13 +3,13 @@ import axios, { CreateAxiosDefaults, HttpStatusCode } from 'axios'; import { getStorageAccessToken, removeStorageProfile, - removeStorageTokens, removeStorageRecloudIDAccessToken, + removeStorageTokens, } from '@/shared/services'; const OPTIONS: CreateAxiosDefaults = { baseURL: process.env.NEXT_PUBLIC_BACKEND_URL, - headers: { 'Access-Control-Allow-Credentials': '*' }, + withCredentials: true, }; export const $api = axios.create(OPTIONS); diff --git a/src/widgets/client-hub/lib/useConnectionHub.ts b/src/widgets/client-hub/lib/useConnectionHub.ts index 79891bcd..aea8d201 100644 --- a/src/widgets/client-hub/lib/useConnectionHub.ts +++ b/src/widgets/client-hub/lib/useConnectionHub.ts @@ -42,8 +42,7 @@ export const useConnectionHub = (props: ConnectionHubProps) => { try { const connection = new HubConnectionBuilder() .withUrl(CONNECTION_URL(accessToken), { - headers: { 'Access-Control-Allow-Credentials': '*' }, - withCredentials: false, + withCredentials: true, }) .withAutomaticReconnect() .build(); diff --git a/src/widgets/generate-launcher-dialog/lib/hooks/useConnectionHub.ts b/src/widgets/generate-launcher-dialog/lib/hooks/useConnectionHub.ts index 2bb16364..ddc0b03d 100644 --- a/src/widgets/generate-launcher-dialog/lib/hooks/useConnectionHub.ts +++ b/src/widgets/generate-launcher-dialog/lib/hooks/useConnectionHub.ts @@ -37,8 +37,7 @@ export const useConnectionHub = () => { try { const connection = new HubConnectionBuilder() .withUrl(CONNECTION_URL(accessToken), { - headers: { 'Access-Control-Allow-Credentials': '*' }, - withCredentials: false, + withCredentials: true, }) .withAutomaticReconnect() .build(); diff --git a/src/widgets/notifications-hub/lib/useConnectionHub.ts b/src/widgets/notifications-hub/lib/useConnectionHub.ts index f69f8e28..d2635ee4 100644 --- a/src/widgets/notifications-hub/lib/useConnectionHub.ts +++ b/src/widgets/notifications-hub/lib/useConnectionHub.ts @@ -33,8 +33,7 @@ export const useConnectionHub = () => { try { const connection = new HubConnectionBuilder() .withUrl(CONNECTION_URL(accessToken), { - headers: { 'Access-Control-Allow-Credentials': '*' }, - withCredentials: false, + withCredentials: true, }) .withAutomaticReconnect() .build(); From c2801cc82278f7e8f51ad75af6ffee55622119aa Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Fri, 12 Sep 2025 14:27:33 +0300 Subject: [PATCH 16/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20CRUD=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2,=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D1=80=D0=B8=D1=86=D0=B0,=20=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20RBAC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализованы компоненты для управления ролями, правами и схемой назначения RBAC. Включены вкладки для пользователей, матрицы прав, ролей и прав, а также основные функции: создание, редактирование, удаление и назначение. Это позволяет настраивать полномочия пользователей и улучшает администрирование системы. --- src/shared/api/rbac.ts | 77 +++ src/views/settings/ui/RolesPermissionsTab.tsx | 561 ++++++++++++++++++ src/views/settings/ui/Settings.tsx | 10 + 3 files changed, 648 insertions(+) create mode 100644 src/shared/api/rbac.ts create mode 100644 src/views/settings/ui/RolesPermissionsTab.tsx diff --git a/src/shared/api/rbac.ts b/src/shared/api/rbac.ts new file mode 100644 index 00000000..01f337e2 --- /dev/null +++ b/src/shared/api/rbac.ts @@ -0,0 +1,77 @@ +import { $api } from '@/services/api.service'; + +export type RoleDto = { id: number; name: string; description?: string | null }; +export type PermissionDto = { id: number; name: string; description?: string | null }; +export type RoleWithPerms = RoleDto & { permissions?: PermissionDto[] }; +export type RbacUser = { + id: number; + login?: string | null; + email?: string | null; + roles?: RoleDto[]; + permissions?: PermissionDto[]; +}; + +export const rbacApi = { + // Roles + getRoles: async (): Promise => { + const { data } = await $api.get('/roles'); + return data?.data ?? data ?? []; + }, + createRole: async (payload: Omit): Promise => { + const { data } = await $api.post('/roles', payload); + return data?.data ?? data; + }, + updateRole: async (id: number, payload: Omit): Promise => { + const { data } = await $api.put(`/roles/${id}`, payload); + return data?.data ?? data; + }, + deleteRole: async (id: number): Promise => { + await $api.delete(`/roles/${id}`); + }, + + // Permissions + getPermissions: async (): Promise => { + const { data } = await $api.get('/permissions'); + return data?.data ?? data ?? []; + }, + createPermission: async (payload: Omit): Promise => { + const { data } = await $api.post('/permissions', payload); + return data?.data ?? data; + }, + updatePermission: async ( + id: number, + payload: Omit, + ): Promise => { + const { data } = await $api.put(`/permissions/${id}`, payload); + return data?.data ?? data; + }, + deletePermission: async (id: number): Promise => { + await $api.delete(`/permissions/${id}`); + }, + + // Role-Permission mapping + assignPermToRole: async (roleId: number, permId: number): Promise => { + await $api.post(`/roles/${roleId}/permissions/${permId}`); + }, + removePermFromRole: async (roleId: number, permId: number): Promise => { + await $api.delete(`/roles/${roleId}/permissions/${permId}`); + }, + + // User-Role mapping + assignRoleToUser: async (userId: number, roleId: number): Promise => { + await $api.post(`/users/${userId}/roles/${roleId}`); + }, + removeRoleFromUser: async (userId: number, roleId: number): Promise => { + await $api.delete(`/users/${userId}/roles/${roleId}`); + }, + + // Aggregates + getUsersRbac: async (): Promise => { + const { data } = await $api.get('/users/rbac'); + return data?.data ?? data ?? []; + }, + getRolesDetails: async (): Promise => { + const { data } = await $api.get('/roles/details'); + return data?.data ?? data ?? []; + }, +}; diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx new file mode 100644 index 00000000..4a331a33 --- /dev/null +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -0,0 +1,561 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; + +import { PermissionDto, rbacApi, RbacUser, RoleDto, RoleWithPerms } from '@/shared/api/rbac'; +import { Button } from '@/shared/ui/button'; +import { Input } from '@/shared/ui/input'; +import { Label } from '@/shared/ui/label'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/shared/ui/table'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/shared/ui/dropdown-menu'; +import { Badge } from '@/shared/ui/badge'; +import { Checkbox } from '@/shared/ui/checkbox'; + +const emptyRole: Omit = { name: '', description: '' }; +const emptyPerm: Omit = { name: '', description: '' }; + +export const RolesPermissionsTab: React.FC = () => { + const [roles, setRoles] = useState([]); + const [perms, setPerms] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [editingRole, setEditingRole] = useState(null); + const [roleForm, setRoleForm] = useState>(emptyRole); + + const [editingPerm, setEditingPerm] = useState(null); + const [permForm, setPermForm] = useState>(emptyPerm); + + const [selectedRoleId, setSelectedRoleId] = useState(null); + const [permToAssign, setPermToAssign] = useState(''); + + const [userId, setUserId] = useState(''); + const [roleToAssign, setRoleToAssign] = useState(''); + + const [rolesDetails, setRolesDetails] = useState([]); + const [users, setUsers] = useState([]); + const [selectedUserId, setSelectedUserId] = useState(null); + + const selectedRole = useMemo( + () => roles.find((r) => r.id === selectedRoleId) || null, + [roles, selectedRoleId], + ); + const selectedUser = useMemo( + () => users.find((u) => u.id === selectedUserId) || null, + [users, selectedUserId], + ); + const selectUser = (u: RbacUser) => { + setSelectedUserId(u.id); + setUserId(String(u.id)); + }; + + const loadAll = async () => { + setLoading(true); + setError(null); + try { + const [r, p, rd, u] = await Promise.all([ + rbacApi.getRoles(), + rbacApi.getPermissions(), + rbacApi.getRolesDetails(), + rbacApi.getUsersRbac(), + ]); + setRoles(r); + setPerms(p); + setRolesDetails(rd); + setUsers(u); + if (!selectedRoleId && r.length) setSelectedRoleId(r[0].id); + if (!selectedUserId && u?.length) setSelectedUserId(u[0].id); + } catch (e: any) { + setError(e?.message ?? 'Ошибка загрузки данных'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadAll(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const submitRole = async () => { + if (!roleForm.name?.trim()) return; + setLoading(true); + try { + if (editingRole) { + const updated = await rbacApi.updateRole(editingRole.id, roleForm); + setRoles((prev) => prev.map((r) => (r.id === editingRole.id ? updated : r))); + setEditingRole(null); + } else { + const created = await rbacApi.createRole(roleForm); + setRoles((prev) => [created, ...prev]); + if (!selectedRoleId) setSelectedRoleId(created.id); + } + setRoleForm(emptyRole); + } catch (e: any) { + setError(e?.message ?? 'Ошибка сохранения роли'); + } finally { + setLoading(false); + } + }; + + const submitPerm = async () => { + if (!permForm.name?.trim()) return; + setLoading(true); + try { + if (editingPerm) { + const updated = await rbacApi.updatePermission(editingPerm.id, permForm); + setPerms((prev) => prev.map((p) => (p.id === editingPerm.id ? updated : p))); + setEditingPerm(null); + } else { + const created = await rbacApi.createPermission(permForm); + setPerms((prev) => [created, ...prev]); + } + setPermForm(emptyPerm); + } catch (e: any) { + setError(e?.message ?? 'Ошибка сохранения права'); + } finally { + setLoading(false); + } + }; + + const deleteRole = async (id: number) => { + setLoading(true); + try { + await rbacApi.deleteRole(id); + setRoles((prev) => prev.filter((r) => r.id !== id)); + if (selectedRoleId === id) setSelectedRoleId(null); + } catch (e: any) { + setError(e?.message ?? 'Ошибка удаления роли'); + } finally { + setLoading(false); + } + }; + + const deletePerm = async (id: number) => { + setLoading(true); + try { + await rbacApi.deletePermission(id); + setPerms((prev) => prev.filter((p) => p.id !== id)); + } catch (e: any) { + setError(e?.message ?? 'Ошибка удаления права'); + } finally { + setLoading(false); + } + }; + + const refreshRolesDetails = async () => { + try { + const rd = await rbacApi.getRolesDetails(); + setRolesDetails(rd); + } catch {} + }; + const refreshUsers = async () => { + try { + const u = await rbacApi.getUsersRbac(); + setUsers(u); + if (!selectedUserId && u?.length) setSelectedUserId(u[0].id); + } catch {} + }; + + const hasRolePerm = (roleId: number, permId: number) => { + const role = rolesDetails.find((r) => r.id === roleId); + if (!role?.permissions) return false; + return role.permissions.some((p) => p.id === permId); + }; + + const toggleRolePerm = async (roleId: number, permId: number, checked: boolean) => { + setLoading(true); + try { + if (checked) { + await rbacApi.assignPermToRole(roleId, permId); + } else { + await rbacApi.removePermFromRole(roleId, permId); + } + await refreshRolesDetails(); + } catch (e: any) { + setError(e?.message ?? 'Ошибка обновления прав роли'); + } finally { + setLoading(false); + } + }; + + const assignPerm = async () => { + if (!selectedRoleId || !permToAssign) return; + setLoading(true); + try { + await rbacApi.assignPermToRole(selectedRoleId, Number(permToAssign)); + await refreshRolesDetails(); + } catch (e: any) { + setError(e?.message ?? 'Ошибка назначения права роли'); + } finally { + setLoading(false); + } + }; + + const removePerm = async () => { + if (!selectedRoleId || !permToAssign) return; + setLoading(true); + try { + await rbacApi.removePermFromRole(selectedRoleId, Number(permToAssign)); + await refreshRolesDetails(); + } catch (e: any) { + setError(e?.message ?? 'Ошибка снятия права с роли'); + } finally { + setLoading(false); + } + }; + + const assignRoleUser = async () => { + const uid = Number(userId); + if (!uid || !roleToAssign) return; + setLoading(true); + try { + await rbacApi.assignRoleToUser(uid, Number(roleToAssign)); + await refreshUsers(); + if (!selectedUserId) setSelectedUserId(uid); + } catch (e: any) { + setError(e?.message ?? 'Ошибка назначения роли пользователю'); + } finally { + setLoading(false); + } + }; + + const removeRoleUser = async () => { + const uid = Number(userId); + if (!uid || !roleToAssign) return; + setLoading(true); + try { + await rbacApi.removeRoleFromUser(uid, Number(roleToAssign)); + await refreshUsers(); + } catch (e: any) { + setError(e?.message ?? 'Ошибка снятия роли с пользователя'); + } finally { + setLoading(false); + } + }; + + const permsByGroup = useMemo(() => { + const map = new Map(); + for (const p of perms) { + 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[], + ][]; + }, [perms]); + + const rolesReversed = useMemo(() => [...roles], [roles]); + + return ( +
+ {error && ( +
+ {error} +
+ )} + + + Пользователи + Роли + Матрица + Права + + + +
+

Пользователи

+ +
+
+
+ + + ID + Логин/Email + Роли пользователя + Права + Действия + + + + {users.map((u) => ( + + {u.id} + {u.login ?? u.email ?? '-'} + + {u.roles?.map((r) => r.name).join(', ') || '-'} + + + {u.permissions?.map((p) => p.name).join(', ') || '-'} + + + + + + + +
+
Роли
+
+ {roles.map((r) => { + const active = u.roles?.some((ur) => ur.id === r.id); + return ( + { + e.preventDefault(); + e.stopPropagation(); + setSelectedUserId(u.id); + try { + if (active) { + await rbacApi.removeRoleFromUser(u.id, r.id); + } else { + await rbacApi.assignRoleToUser(u.id, r.id); + } + await refreshUsers(); + } catch (err: any) { + setError( + err?.message ?? 'Ошибка обновления ролей пользователя', + ); + } + }} + > + {r.name} + + ); + })} +
+
+
+
+
+
+ ))} + {!users.length && ( + + + Нет пользователей + + + )} +
+
+
+ + + +
+

Матрица прав

+ +
+
+ + + + Право + {rolesReversed.map((r) => ( + + {r.name} + + ))} + + + + {permsByGroup.map(([group, list]) => ( + + + + {group} + + + {list.map((p) => ( + + +
{p.name}
+ {p.description && ( +
{p.description}
+ )} +
+ {rolesReversed.map((r) => ( + + toggleRolePerm(r.id, p.id, !!v)} + /> + + ))} +
+ ))} +
+ ))} + {!perms.length && ( + + + Нет прав + + + )} +
+
+
+
+ + +

Роли (CRUD)

+
+
+ + setRoleForm({ ...roleForm, name: e.target.value })} + /> +
+
+ + setRoleForm({ ...roleForm, description: e.target.value })} + /> +
+ + {editingRole && ( + + )} +
+
+ {roles.map((r) => ( +
+
+
{r.name}
+ {r.description && ( +
{r.description}
+ )} +
+
+ + +
+
+ ))} + {!roles.length &&
Ролей пока нет.
} +
+
+ + +

Права (CRUD)

+
+
+ + setPermForm({ ...permForm, name: e.target.value })} + /> +
+
+ + setPermForm({ ...permForm, description: e.target.value })} + /> +
+ + {editingPerm && ( + + )} +
+
+ {perms.map((p) => ( +
+
+
{p.name}
+ {p.description && ( +
{p.description}
+ )} +
+
+ + +
+
+ ))} + {!perms.length &&
Прав пока нет.
} +
+
+ +
+ ); +}; diff --git a/src/views/settings/ui/Settings.tsx b/src/views/settings/ui/Settings.tsx index 45a2d4c9..9b210e3c 100644 --- a/src/views/settings/ui/Settings.tsx +++ b/src/views/settings/ui/Settings.tsx @@ -1,5 +1,9 @@ +"use client"; + import React from 'react'; +import { RolesPermissionsTab } from './RolesPermissionsTab'; + import { EditSettingsPlatformForm } from '@/features/edit-settings-platform-form'; import { Breadcrumbs } from '@/shared/ui/Breadcrumbs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'; @@ -26,10 +30,16 @@ export const SettingsPage = () => { Основные + + Роли + + + +
From d905e606127f52241d56e4ee550462331d5ab54d Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Fri, 12 Sep 2025 15:02:21 +0300 Subject: [PATCH 17/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8?= =?UTF-8?q?=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B0=D0=BC=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализованы методы для проверки наличия всех или некоторых прав из группы у роли, а также функция переключения группы прав с обновлением состояния. Обновлен интерфейс таблицы для отображения групп с чекбоксами, что улучшает управление правами внутри групп и упрощает работу администраторов. --- src/views/settings/ui/RolesPermissionsTab.tsx | 52 +++++++++++++++++-- src/views/settings/ui/Settings.tsx | 4 +- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index 4a331a33..5b837bef 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -253,6 +253,43 @@ export const RolesPermissionsTab: React.FC = () => { ][]; }, [perms]); + const roleHasAllGroupPerms = (roleId: number, groupPerms: PermissionDto[]) => { + return groupPerms.every((p) => hasRolePerm(roleId, p.id)); + }; + const roleHasSomeGroupPerms = (roleId: number, groupPerms: PermissionDto[]) => { + return groupPerms.some((p) => hasRolePerm(roleId, p.id)); + }; + const toggleRoleGroup = async ( + roleId: number, + groupPerms: PermissionDto[], + checked: boolean, + ) => { + setLoading(true); + try { + const ops: Promise[] = []; + if (checked) { + for (const p of groupPerms) { + if (!hasRolePerm(roleId, p.id)) { + ops.push(rbacApi.assignPermToRole(roleId, p.id)); + } + } + } else { + for (const p of groupPerms) { + if (hasRolePerm(roleId, p.id)) { + ops.push(rbacApi.removePermFromRole(roleId, p.id)); + } + } + } + if (ops.length) { + await Promise.all(ops); + } + await refreshRolesDetails(); + } catch (e: any) { + setError(e?.message ?? 'Ошибка обновления группы прав'); + } finally { + setLoading(false); + } + }; const rolesReversed = useMemo(() => [...roles], [roles]); return ( @@ -389,10 +426,19 @@ export const RolesPermissionsTab: React.FC = () => { {permsByGroup.map(([group, list]) => ( - - - {group} + + +
{group}
+
Все из группы
+ {rolesReversed.map((r) => ( + + toggleRoleGroup(r.id, list, !!v)} + /> + + ))}
{list.map((p) => ( diff --git a/src/views/settings/ui/Settings.tsx b/src/views/settings/ui/Settings.tsx index 9b210e3c..8ddc2b38 100644 --- a/src/views/settings/ui/Settings.tsx +++ b/src/views/settings/ui/Settings.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import React from 'react'; @@ -19,7 +19,7 @@ export const SettingsPage = () => {

Настройки

-
+
Date: Fri, 12 Sep 2025 15:05:49 +0300 Subject: [PATCH 18/58] =?UTF-8?q?=D0=9E=D0=B1=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D1=8B=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B2=20API=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BE=D0=B1=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B0=D1=85=20=D0=B2=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена обработка ошибок в интерцепторе ответа API с использованием тостов (sonner) для отображения сообщений об ошибках. Удалено отображение ошибок в компоненте `RolesPermissionsTab`, так как уведомления теперь централизованы. Это улучшает пользовательский опыт и унифицирует обработку ошибок в приложении. --- src/services/api.service.ts | 49 ++++++++++++++++--- src/views/settings/ui/RolesPermissionsTab.tsx | 5 -- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/services/api.service.ts b/src/services/api.service.ts index 11fa2e1d..d77f4ace 100644 --- a/src/services/api.service.ts +++ b/src/services/api.service.ts @@ -1,4 +1,5 @@ import axios, { CreateAxiosDefaults, HttpStatusCode } from 'axios'; +import { toast } from 'sonner'; import { getStorageAccessToken, @@ -21,12 +22,44 @@ $api.interceptors.request.use((config) => { return config; }); -$api.interceptors.response.use((response) => { - if (response.status === HttpStatusCode.Unauthorized) { - removeStorageProfile(); - removeStorageTokens(); - removeStorageRecloudIDAccessToken(); - } +$api.interceptors.response.use( + (response) => { + if (response.status === HttpStatusCode.Unauthorized) { + removeStorageProfile(); + removeStorageTokens(); + removeStorageRecloudIDAccessToken(); + } - return response; -}); + return response; + }, + (error) => { + try { + const status = error?.response?.status; + const data = error?.response?.data ?? {}; + const errors: string[] | undefined = data?.errors; + const message: string | undefined = data?.message || error?.message; + + if (Array.isArray(errors) && errors.length) { + errors.forEach((e: any) => { + const text = typeof e === 'string' ? e : JSON.stringify(e); + toast.error(text); + }); + } else if (message) { + toast.error(message); + } else { + toast.error('Произошла ошибка запроса'); + } + + if (status === HttpStatusCode.Unauthorized) { + removeStorageProfile(); + removeStorageTokens(); + removeStorageRecloudIDAccessToken(); + } + } catch (_) { + // Fallback toast if parsing fails + toast.error('Произошла ошибка запроса'); + } + + return Promise.reject(error); + }, +); diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index 5b837bef..9d844819 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -294,11 +294,6 @@ export const RolesPermissionsTab: React.FC = () => { return (
- {error && ( -
- {error} -
- )} Пользователи From ec816c2da3fa7c3807ecfa2400fd16334980716c Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 07:33:58 +0300 Subject: [PATCH 19/58] =?UTF-8?q?=D0=A3=D0=BF=D1=80=D0=BE=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BE=D0=BA=20=D0=B2=20?= =?UTF-8?q?`RolesPermissionsTab`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удалены уточнения "CRUD" в заголовках вкладок "Роли" и "Права". Это делает интерфейс визуально чище и облегчает восприятие пользователем. --- src/views/settings/ui/RolesPermissionsTab.tsx | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index 9d844819..bb61b73f 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -1,16 +1,16 @@ 'use client'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from "react"; -import { PermissionDto, rbacApi, RbacUser, RoleDto, RoleWithPerms } from '@/shared/api/rbac'; -import { Button } from '@/shared/ui/button'; -import { Input } from '@/shared/ui/input'; -import { Label } from '@/shared/ui/label'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/shared/ui/table'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/shared/ui/dropdown-menu'; -import { Badge } from '@/shared/ui/badge'; -import { Checkbox } from '@/shared/ui/checkbox'; +import { PermissionDto, rbacApi, RbacUser, RoleDto, RoleWithPerms } from "@/shared/api/rbac"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Label } from "@/shared/ui/label"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu"; +import { Badge } from "@/shared/ui/badge"; +import { Checkbox } from "@/shared/ui/checkbox"; const emptyRole: Omit = { name: '', description: '' }; const emptyPerm: Omit = { name: '', description: '' }; @@ -259,11 +259,7 @@ export const RolesPermissionsTab: React.FC = () => { const roleHasSomeGroupPerms = (roleId: number, groupPerms: PermissionDto[]) => { return groupPerms.some((p) => hasRolePerm(roleId, p.id)); }; - const toggleRoleGroup = async ( - roleId: number, - groupPerms: PermissionDto[], - checked: boolean, - ) => { + const toggleRoleGroup = async (roleId: number, groupPerms: PermissionDto[], checked: boolean) => { setLoading(true); try { const ops: Promise[] = []; @@ -316,7 +312,6 @@ export const RolesPermissionsTab: React.FC = () => { ID Логин/Email Роли пользователя - Права Действия @@ -468,7 +463,7 @@ export const RolesPermissionsTab: React.FC = () => { -

Роли (CRUD)

+

Роли

@@ -533,7 +528,7 @@ export const RolesPermissionsTab: React.FC = () => { -

Права (CRUD)

+

Права

From b56af1c0f91649b7f564601177decba0f84861ef Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 07:38:15 +0300 Subject: [PATCH 20/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=82=D1=83=D0=BB=D1=82=D0=B8=D0=BF=D1=8B?= =?UTF-8?q?=20=D1=81=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=D0=BC=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9=20=D0=B2=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализован компонент `RoleWithPermsHover`, отображающий подсказку с правами роли при наведении на ее название. Это улучшает интерфейс, упрощая просмотр связанных данных, и делает работу с таблицей пользователей более удобной. --- src/views/settings/ui/RolesPermissionsTab.tsx | 71 +++++++++++++++---- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index bb61b73f..b98cf11c 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -1,16 +1,17 @@ 'use client'; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from 'react'; -import { PermissionDto, rbacApi, RbacUser, RoleDto, RoleWithPerms } from "@/shared/api/rbac"; -import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; -import { Label } from "@/shared/ui/label"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu"; -import { Badge } from "@/shared/ui/badge"; -import { Checkbox } from "@/shared/ui/checkbox"; +import { PermissionDto, rbacApi, RbacUser, RoleDto, RoleWithPerms } from '@/shared/api/rbac'; +import { Button } from '@/shared/ui/button'; +import { Input } from '@/shared/ui/input'; +import { Label } from '@/shared/ui/label'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/shared/ui/table'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/shared/ui/dropdown-menu'; +import { Badge } from '@/shared/ui/badge'; +import { Checkbox } from '@/shared/ui/checkbox'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip'; const emptyRole: Omit = { name: '', description: '' }; const emptyPerm: Omit = { name: '', description: '' }; @@ -288,6 +289,36 @@ export const RolesPermissionsTab: React.FC = () => { }; const rolesReversed = useMemo(() => [...roles], [roles]); + const RoleWithPermsHover: React.FC<{ + roleId: number; + roleName?: string | null; + rolesDetails: RoleWithPerms[]; + }> = ({ roleId, roleName, rolesDetails }) => { + const rd = rolesDetails.find((r) => r.id === roleId); + const perms = rd?.permissions ?? []; + return ( + + + {roleName || '—'} + + +
+
Права роли: {roleName}
+ {perms.length ? ( +
    + {perms.map((p) => ( +
  • {p.name}
  • + ))} +
+ ) : ( +
Нет прав
+ )} +
+
+
+ ); + }; + return (
@@ -324,10 +355,20 @@ export const RolesPermissionsTab: React.FC = () => { {u.id} {u.login ?? u.email ?? '-'} - {u.roles?.map((r) => r.name).join(', ') || '-'} - - - {u.permissions?.map((p) => p.name).join(', ') || '-'} + {u.roles?.length ? ( +
+ {u.roles.map((r) => ( + + ))} +
+ ) : ( + '-' + )}
@@ -384,7 +425,7 @@ export const RolesPermissionsTab: React.FC = () => { ))} {!users.length && ( - + Нет пользователей From 2962b0d27753210b1507bd005e817b3cc9449481 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 07:39:02 +0300 Subject: [PATCH 21/58] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20"=D0=A0=D0=BE=D0=BB=D0=B8=20=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Изменено название вкладки в настройках с "Роли" на "Роли и права". Это уточнение делает интерфейс более информативным и отражает содержимое функции. --- src/views/settings/ui/Settings.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/views/settings/ui/Settings.tsx b/src/views/settings/ui/Settings.tsx index 8ddc2b38..e7888959 100644 --- a/src/views/settings/ui/Settings.tsx +++ b/src/views/settings/ui/Settings.tsx @@ -1,13 +1,13 @@ 'use client'; -import React from 'react'; +import React from "react"; -import { RolesPermissionsTab } from './RolesPermissionsTab'; +import { RolesPermissionsTab } from "./RolesPermissionsTab"; -import { EditSettingsPlatformForm } from '@/features/edit-settings-platform-form'; -import { Breadcrumbs } from '@/shared/ui/Breadcrumbs'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'; -import { DASHBOARD_PAGES } from '@/shared/routes'; +import { EditSettingsPlatformForm } from "@/features/edit-settings-platform-form"; +import { Breadcrumbs } from "@/shared/ui/Breadcrumbs"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import { DASHBOARD_PAGES } from "@/shared/routes"; export const SettingsPage = () => { return ( @@ -31,7 +31,7 @@ export const SettingsPage = () => { Основные - Роли + Роли и права From 10046ee97061c1a926fbe3cd2acf11b7e12cf258 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 07:49:01 +0300 Subject: [PATCH 22/58] =?UTF-8?q?=D0=9E=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=BE=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20"=D0=90=D0=B4=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=BE=D1=80=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D0=B9"=20=D1=80=D0=BE=D0=BB=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена проверка на роль "Администратор" для ограничения ее редактирования, удаления и изменения прав. Это предотвращает некорректные действия над системой и делает управление более безопасным. --- src/views/settings/ui/RolesPermissionsTab.tsx | 29 +++++++++++++++++-- src/views/settings/ui/Settings.tsx | 12 ++++---- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index b98cf11c..a9c4a1e5 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -289,6 +289,8 @@ export const RolesPermissionsTab: React.FC = () => { }; const rolesReversed = useMemo(() => [...roles], [roles]); + const isAdminRole = (r: { name?: string | null }) => (r.name ?? '').toLowerCase() === 'admin'; + const RoleWithPermsHover: React.FC<{ roleId: number; roleName?: string | null; @@ -466,7 +468,12 @@ export const RolesPermissionsTab: React.FC = () => { toggleRoleGroup(r.id, list, !!v)} + disabled={isAdminRole(r)} + className={isAdminRole(r) ? 'opacity-50 pointer-events-none' : ''} + onCheckedChange={(v: any) => { + if (isAdminRole(r)) return; + toggleRoleGroup(r.id, list, !!v); + }} /> ))} @@ -483,7 +490,12 @@ export const RolesPermissionsTab: React.FC = () => { toggleRolePerm(r.id, p.id, !!v)} + disabled={isAdminRole(r)} + className={isAdminRole(r) ? 'opacity-50 pointer-events-none' : ''} + onCheckedChange={(v: any) => { + if (isAdminRole(r)) return; + toggleRolePerm(r.id, p.id, !!v); + }} /> ))} @@ -551,14 +563,25 @@ export const RolesPermissionsTab: React.FC = () => {
-
diff --git a/src/views/settings/ui/Settings.tsx b/src/views/settings/ui/Settings.tsx index e7888959..3a25e5ed 100644 --- a/src/views/settings/ui/Settings.tsx +++ b/src/views/settings/ui/Settings.tsx @@ -1,13 +1,13 @@ 'use client'; -import React from "react"; +import React from 'react'; -import { RolesPermissionsTab } from "./RolesPermissionsTab"; +import { RolesPermissionsTab } from './RolesPermissionsTab'; -import { EditSettingsPlatformForm } from "@/features/edit-settings-platform-form"; -import { Breadcrumbs } from "@/shared/ui/Breadcrumbs"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; -import { DASHBOARD_PAGES } from "@/shared/routes"; +import { EditSettingsPlatformForm } from '@/features/edit-settings-platform-form'; +import { Breadcrumbs } from '@/shared/ui/Breadcrumbs'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'; +import { DASHBOARD_PAGES } from '@/shared/routes'; export const SettingsPage = () => { return ( From 08c47150bf51086d764c76651d1198db8ebbf284 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 07:58:03 +0300 Subject: [PATCH 23/58] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=80=D0=BE=D0=BB=D1=8F=D0=BC=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены диалоги для создания, редактирования и удаления ролей, а также выпадающее меню с действиями. Это делает интерфейс более удобным, упрощает взаимодействие с ролями и улучшает пользовательский опыт. --- src/views/settings/ui/RolesPermissionsTab.tsx | 158 +++++++++++------- 1 file changed, 100 insertions(+), 58 deletions(-) diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index a9c4a1e5..91b71666 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useEffect, useMemo, useState } from 'react'; +import { MoreVertical, Pencil, Trash } from 'lucide-react'; import { PermissionDto, rbacApi, RbacUser, RoleDto, RoleWithPerms } from '@/shared/api/rbac'; import { Button } from '@/shared/ui/button'; @@ -8,10 +9,11 @@ import { Input } from '@/shared/ui/input'; import { Label } from '@/shared/ui/label'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/shared/ui/table'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/shared/ui/dropdown-menu'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem } from '@/shared/ui/dropdown-menu'; import { Badge } from '@/shared/ui/badge'; import { Checkbox } from '@/shared/ui/checkbox'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip'; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'; const emptyRole: Omit = { name: '', description: '' }; const emptyPerm: Omit = { name: '', description: '' }; @@ -24,6 +26,9 @@ export const RolesPermissionsTab: React.FC = () => { const [editingRole, setEditingRole] = useState(null); const [roleForm, setRoleForm] = useState>(emptyRole); + const [roleModalOpen, setRoleModalOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [roleToDelete, setRoleToDelete] = useState(null); const [editingPerm, setEditingPerm] = useState(null); const [permForm, setPermForm] = useState>(emptyPerm); @@ -93,6 +98,7 @@ export const RolesPermissionsTab: React.FC = () => { if (!selectedRoleId) setSelectedRoleId(created.id); } setRoleForm(emptyRole); + setRoleModalOpen(false); } catch (e: any) { setError(e?.message ?? 'Ошибка сохранения роли'); } finally { @@ -517,39 +523,11 @@ export const RolesPermissionsTab: React.FC = () => {

Роли

-
-
- - setRoleForm({ ...roleForm, name: e.target.value })} - /> -
-
- - setRoleForm({ ...roleForm, description: e.target.value })} - /> -
- - {editingRole && ( - - )}
{roles.map((r) => ( @@ -560,35 +538,99 @@ export const RolesPermissionsTab: React.FC = () => {
{r.description}
)}
-
- - -
+ + + + + + { + if (isAdminRole(r)) return; + setEditingRole(r); + setRoleForm({ name: r.name, description: r.description ?? '' }); + setRoleModalOpen(true); + }} + > + Редактировать + + { + if (isAdminRole(r)) return; + setRoleToDelete(r); + setConfirmOpen(true); + }} + > + Удалить + + +
))} {!roles.length &&
Ролей пока нет.
}
+ + + + + {editingRole ? 'Редактировать роль' : 'Создать роль'} + +
+
+ + setRoleForm({ ...roleForm, name: e.target.value })} + /> +
+
+ + setRoleForm({ ...roleForm, description: e.target.value })} + /> +
+
+ + + + +
+
+ + + + + Подтверждение удаления + +
+ Вы уверены, что хотите удалить роль «{roleToDelete?.name}»? +
+ + + + +
+
From 8f7d62cdccd4cb624784037351aa4a906971ad09 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 08:02:24 +0300 Subject: [PATCH 24/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализовано модальное окно для создания и редактирования прав, а также обновлена структура интерфейса управления. Теперь права сгруппированы, добавлена функциональность обновления с помощью кнопки, что делает взаимодействие пользователя более удобным и интуитивным. --- src/views/settings/ui/RolesPermissionsTab.tsx | 161 ++++++++++++------ 1 file changed, 106 insertions(+), 55 deletions(-) diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index 91b71666..53cf7f18 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect, useMemo, useState } from 'react'; -import { MoreVertical, Pencil, Trash } from 'lucide-react'; +import { MoreVertical, Pencil, Trash, RefreshCw } from 'lucide-react'; import { PermissionDto, rbacApi, RbacUser, RoleDto, RoleWithPerms } from '@/shared/api/rbac'; import { Button } from '@/shared/ui/button'; @@ -32,6 +32,7 @@ export const RolesPermissionsTab: React.FC = () => { const [editingPerm, setEditingPerm] = useState(null); const [permForm, setPermForm] = useState>(emptyPerm); + const [permModalOpen, setPermModalOpen] = useState(false); const [selectedRoleId, setSelectedRoleId] = useState(null); const [permToAssign, setPermToAssign] = useState(''); @@ -119,6 +120,7 @@ export const RolesPermissionsTab: React.FC = () => { setPerms((prev) => [created, ...prev]); } setPermForm(emptyPerm); + setPermModalOpen(false); } catch (e: any) { setError(e?.message ?? 'Ошибка сохранения права'); } finally { @@ -634,68 +636,117 @@ export const RolesPermissionsTab: React.FC = () => { -

Права

-
-
- - setPermForm({ ...permForm, name: e.target.value })} - /> -
-
- - setPermForm({ ...permForm, description: e.target.value })} - /> -
- - {editingPerm && ( - - )} + +
-
- {perms.map((p) => ( -
-
-
{p.name}
- {p.description && ( -
{p.description}
- )} + + {/* Groups */} +
+ {permsByGroup.map(([group, list]) => ( +
+
+
+ {group} + {list.length} +
-
- - +
+ {list.map((p) => ( +
+
+
{p.name}
+ {p.description && ( +
{p.description}
+ )} +
+ + + + + + { + setEditingPerm(p); + setPermForm({ name: p.name, description: p.description ?? '' }); + setPermModalOpen(true); + }} + > + Редактировать + + deletePerm(p.id)}> + Удалить + + + +
+ ))} + {!list.length && ( +
Нет прав в группе.
+ )}
))} {!perms.length &&
Прав пока нет.
}
+ + {/* Create/Edit Permission Modal */} + + + + {editingPerm ? 'Редактировать право' : 'Создать право'} + +
+
+ + setPermForm({ ...permForm, name: e.target.value })} + /> +
+
+ + setPermForm({ ...permForm, description: e.target.value })} + /> +
+
+ + + + +
+
From 9ad3b530345f1a7e6e4ed62f2b80e00a16c6a408 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 08:07:43 +0300 Subject: [PATCH 25/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B8=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=D0=B8=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BC=D0=BD=D1=8B=D1=85=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены стили и метки для обозначения системных прав в таблице и списке. Системные права теперь помечены красной линией и бейджем "SYSTEM", а их изменение или удаление запрещено. Это улучшает UX, предотвращая некорректные изменения критически важных прав. --- src/views/settings/ui/RolesPermissionsTab.tsx | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index 53cf7f18..9cd17c8a 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -487,9 +487,22 @@ export const RolesPermissionsTab: React.FC = () => { ))} {list.map((p) => ( - + -
{p.name}
+
+
{p.name}
+ {p.isSystem && ( + + + SYSTEM + + Системное право. Изменение запрещено. + + )} +
{p.description && (
{p.description}
)} @@ -498,10 +511,10 @@ export const RolesPermissionsTab: React.FC = () => { { - if (isAdminRole(r)) return; + if (isAdminRole(r) || p.isSystem) return; toggleRolePerm(r.id, p.id, !!v); }} /> @@ -660,9 +673,22 @@ export const RolesPermissionsTab: React.FC = () => {
{list.map((p) => ( -
+
-
{p.name}
+
+
{p.name}
+ {p.isSystem && ( + + + SYSTEM + + Системное право. Изменение запрещено. + + )} +
{p.description && (
{p.description}
)} @@ -679,7 +705,9 @@ export const RolesPermissionsTab: React.FC = () => { { + if (p.isSystem) return; setEditingPerm(p); setPermForm({ name: p.name, description: p.description ?? '' }); setPermModalOpen(true); @@ -687,7 +715,10 @@ export const RolesPermissionsTab: React.FC = () => { > Редактировать - deletePerm(p.id)}> + { if (!p.isSystem) deletePerm(p.id); }} + > Удалить From 56696eb4a22a783ae56eb9575a256d07c4d7a312 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 08:13:17 +0300 Subject: [PATCH 26/58] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B2=20=D0=B2=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Рефакторинг проверки системных прав с использованием новой функции `isSystemPerm`. Это повышает читаемость кода и консистентность обработки атрибутов системных прав в разных частях интерфейса. Также исправлены стили и отступы для улучшения визуального восприятия. --- src/views/settings/ui/RolesPermissionsTab.tsx | 114 +++++++++++++----- 1 file changed, 86 insertions(+), 28 deletions(-) diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index 9cd17c8a..f82aa10a 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect, useMemo, useState } from 'react'; -import { MoreVertical, Pencil, Trash, RefreshCw } from 'lucide-react'; +import { MoreVertical, Pencil, RefreshCw, Trash } from 'lucide-react'; import { PermissionDto, rbacApi, RbacUser, RoleDto, RoleWithPerms } from '@/shared/api/rbac'; import { Button } from '@/shared/ui/button'; @@ -9,7 +9,12 @@ import { Input } from '@/shared/ui/input'; import { Label } from '@/shared/ui/label'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/shared/ui/table'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem } from '@/shared/ui/dropdown-menu'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/shared/ui/dropdown-menu'; import { Badge } from '@/shared/ui/badge'; import { Checkbox } from '@/shared/ui/checkbox'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip'; @@ -27,8 +32,8 @@ export const RolesPermissionsTab: React.FC = () => { const [editingRole, setEditingRole] = useState(null); const [roleForm, setRoleForm] = useState>(emptyRole); const [roleModalOpen, setRoleModalOpen] = useState(false); - const [confirmOpen, setConfirmOpen] = useState(false); - const [roleToDelete, setRoleToDelete] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [roleToDelete, setRoleToDelete] = useState(null); const [editingPerm, setEditingPerm] = useState(null); const [permForm, setPermForm] = useState>(emptyPerm); @@ -298,6 +303,7 @@ export const RolesPermissionsTab: React.FC = () => { const rolesReversed = useMemo(() => [...roles], [roles]); const isAdminRole = (r: { name?: string | null }) => (r.name ?? '').toLowerCase() === 'admin'; + const isSystemPerm = (p: Partial | undefined | null) => !!(p as any)?.isSystem; const RoleWithPermsHover: React.FC<{ roleId: number; @@ -487,19 +493,20 @@ export const RolesPermissionsTab: React.FC = () => { ))} {list.map((p) => ( - +
{p.name}
- {p.isSystem && ( + {isSystemPerm(p) && ( - SYSTEM + + SYSTEM + - Системное право. Изменение запрещено. + + Системное право. Изменение запрещено. + )}
@@ -511,10 +518,10 @@ export const RolesPermissionsTab: React.FC = () => { { - if (isAdminRole(r) || p.isSystem) return; + if (isAdminRole(r)) return; toggleRolePerm(r.id, p.id, !!v); }} /> @@ -540,7 +547,14 @@ export const RolesPermissionsTab: React.FC = () => {

Роли

Управление ролями
-
@@ -618,7 +632,15 @@ export const RolesPermissionsTab: React.FC = () => {
-
- - @@ -655,7 +692,15 @@ export const RolesPermissionsTab: React.FC = () => { -
@@ -675,15 +720,17 @@ export const RolesPermissionsTab: React.FC = () => { {list.map((p) => (
{p.name}
- {p.isSystem && ( + {isSystemPerm(p) && ( - SYSTEM + + SYSTEM + Системное право. Изменение запрещено. @@ -705,9 +752,9 @@ export const RolesPermissionsTab: React.FC = () => { { - if (p.isSystem) return; + if (isSystemPerm(p)) return; setEditingPerm(p); setPermForm({ name: p.name, description: p.description ?? '' }); setPermModalOpen(true); @@ -716,8 +763,10 @@ export const RolesPermissionsTab: React.FC = () => { Редактировать { if (!p.isSystem) deletePerm(p.id); }} + className={isSystemPerm(p) ? 'opacity-50 pointer-events-none' : ''} + onClick={() => { + if (!isSystemPerm(p)) deletePerm(p.id); + }} > Удалить @@ -726,7 +775,9 @@ export const RolesPermissionsTab: React.FC = () => {
))} {!list.length && ( -
Нет прав в группе.
+
+ Нет прав в группе. +
)}
@@ -772,7 +823,14 @@ export const RolesPermissionsTab: React.FC = () => { > Отмена - From 4cb3740d88255a0a20a59a3adfaed6d2b0efe93c Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 08:29:14 +0300 Subject: [PATCH 27/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=B4=D0=B5=D0=BA=D0=BE=D0=B4=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20base64Url=20=D0=B8=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализована функция `base64UrlDecode` для корректной обработки base64Url-строк, включая поддержку Node.js окружения. Обновлен метод `getTokenExpiredTime` с учетом ошибок, что делает его более устойчивым и предотвращает некорректный парсинг данных токена. --- src/shared/lib/utils.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/shared/lib/utils.ts b/src/shared/lib/utils.ts index 8495031b..419e0399 100644 --- a/src/shared/lib/utils.ts +++ b/src/shared/lib/utils.ts @@ -17,10 +17,33 @@ const isValidToken = (token: string) => { return tokenRegex.test(token); }; +const base64UrlDecode = (input: string): string => { + try { + const padLength = (4 - (input.length % 4)) % 4; + const base64 = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(padLength); + if (typeof atob === 'function') { + return decodeURIComponent( + Array.prototype.map + .call(atob(base64), (c: string) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join(''), + ); + } + // Node.js fallback + return Buffer.from(base64, 'base64').toString('utf-8'); + } catch { + throw new Error('Invalid base64url payload'); + } +}; + const getTokenExpiredTime = (token: string) => { const tokenParts = token.split('.'); - const payload = JSON.parse(atob(tokenParts[1])); - return payload.exp; + try { + const json = base64UrlDecode(tokenParts[1] ?? ''); + const payload = JSON.parse(json); + return payload?.exp as number | undefined; + } catch { + return undefined; + } }; export const isTokenExpired = (token?: string): boolean => { From 5bd4db4491242da030d73c487797dcee64ee39ff Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 08:38:58 +0300 Subject: [PATCH 28/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализована форма для создания пользователей с валидацией данных в модальном окне. Добавлена функция `createUser` в API для взаимодействия с сервером. Изменения улучшают управление пользователями и делают процесс создания нового пользователя удобнее. --- src/shared/api/rbac.ts | 5 + src/views/settings/ui/RolesPermissionsTab.tsx | 170 +++++++++++++++++- 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/src/shared/api/rbac.ts b/src/shared/api/rbac.ts index 01f337e2..4d05e0a8 100644 --- a/src/shared/api/rbac.ts +++ b/src/shared/api/rbac.ts @@ -12,6 +12,11 @@ export type RbacUser = { }; export const rbacApi = { + // Users + createUser: async (payload: { login: string; password: string; email?: string }): Promise => { + const { data } = await $api.post('/users/signup', payload); + return (data?.data ?? data) as RbacUser; + }, // Roles getRoles: async (): Promise => { const { data } = await $api.get('/roles'); diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index f82aa10a..b98bb0c6 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -49,6 +49,59 @@ export const RolesPermissionsTab: React.FC = () => { const [users, setUsers] = useState([]); const [selectedUserId, setSelectedUserId] = useState(null); + const [userModalOpen, setUserModalOpen] = useState(false); + const [newUser, setNewUser] = useState<{ login: string; password: string; email?: string }>({ + login: '', + password: '', + email: '', + }); + const [userErrors, setUserErrors] = useState<{ login?: string; password?: string; email?: string }>({}); + + const validateUser = (u: { login: string; password: string; email?: string }) => { + const errs: { login?: string; password?: string; email?: string } = {}; + + // Login + const login = (u.login ?? '').trim(); + if (!login) { + errs.login = 'Пожалуйста, укажите login.'; + } else if (login.length < 3 || login.length > 50) { + errs.login = 'Login должен быть от 3 до 50 символов.'; + } else if (!/^[a-zA-Z0-9]*$/.test(login)) { + errs.login = 'Login должен состоять только из букв и цифр.'; + } + + // Password + const pwd = u.password ?? ''; + if (!pwd) { + errs.password = 'Пожалуйста, укажите пароль.'; + } else if (pwd.length < 5 || pwd.length > 100) { + errs.password = 'Пароль должен быть от 5 до 100 символов.'; + } else { + if (!/[A-Z]/.test(pwd)) errs.password = 'Пароль должен содержать хотя бы одну заглавную букву.'; + else if (!/[a-z]/.test(pwd)) errs.password = 'Пароль должен содержать хотя бы одну строчную букву.'; + else if (!/[0-9]/.test(pwd)) errs.password = 'Пароль должен содержать хотя бы одну цифру.'; + } + + // Email + const email = (u.email ?? '').trim(); + if (!email) { + errs.email = 'Пожалуйста, укажите адрес электронной почты.'; + } else { + // Basic email check + const emailRegex = /^(?:[\w!#$%&'*+/=?`{|}~^.-]+)@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}$/; + if (!emailRegex.test(email)) { + errs.email = 'Пожалуйста, укажите действующий адрес электронной почты.'; + } + } + + return errs; + }; + + const isUserFormValid = (u: { login: string; password: string; email?: string }) => { + const errs = validateUser(u); + return !errs.login && !errs.password && !errs.email; + }; + const selectedRole = useMemo( () => roles.find((r) => r.id === selectedRoleId) || null, [roles, selectedRoleId], @@ -249,6 +302,28 @@ export const RolesPermissionsTab: React.FC = () => { } }; + const submitUser = async () => { + const errs = validateUser(newUser); + setUserErrors(errs); + if (errs.login || errs.password || errs.email) return; + setLoading(true); + try { + await rbacApi.createUser({ + login: newUser.login.trim(), + password: newUser.password, + email: newUser.email?.trim() || undefined, + }); + setUserModalOpen(false); + setNewUser({ login: '', password: '', email: '' }); + setUserErrors({}); + await refreshUsers(); + } catch (e: any) { + setError(e?.message ?? 'Ошибка создания пользователя'); + } finally { + setLoading(false); + } + }; + const permsByGroup = useMemo(() => { const map = new Map(); for (const p of perms) { @@ -348,9 +423,22 @@ export const RolesPermissionsTab: React.FC = () => {

Пользователи

- +
+ + +
@@ -449,6 +537,82 @@ export const RolesPermissionsTab: React.FC = () => {
+ + + + + Создать пользователя + +
+
+ + { + const next = { ...newUser, login: e.target.value }; + setNewUser(next); + setUserErrors(validateUser(next)); + }} + /> + {userErrors.login && ( +
{userErrors.login}
+ )} +
+
+ + { + const next = { ...newUser, password: e.target.value }; + setNewUser(next); + setUserErrors(validateUser(next)); + }} + /> + {userErrors.password && ( +
{userErrors.password}
+ )} +
+
+ + { + const next = { ...newUser, email: e.target.value }; + setNewUser(next); + setUserErrors(validateUser(next)); + }} + /> + {userErrors.email && ( +
{userErrors.email}
+ )} +
+
+ + + + +
+
From 170b77120bbbd9817c6665f372a14fee55308e25 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 08:45:54 +0300 Subject: [PATCH 29/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена возможность удаления пользователей через диалог подтверждения. Реализованы функции `deleteUser` в интерфейсе и API, а также обновлена таблица пользователей для отображения новой кнопки действия. Эти изменения повышают удобство управления пользователями и добавляют важную функцию для удаления. --- src/shared/api/rbac.ts | 3 + src/views/settings/ui/RolesPermissionsTab.tsx | 68 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/shared/api/rbac.ts b/src/shared/api/rbac.ts index 4d05e0a8..1dc2c413 100644 --- a/src/shared/api/rbac.ts +++ b/src/shared/api/rbac.ts @@ -17,6 +17,9 @@ export const rbacApi = { const { data } = await $api.post('/users/signup', payload); return (data?.data ?? data) as RbacUser; }, + deleteUser: async (userId: number): Promise => { + await $api.delete(`/users/${userId}`); + }, // Roles getRoles: async (): Promise => { const { data } = await $api.get('/roles'); diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index b98bb0c6..b0d9ad87 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -48,6 +48,8 @@ export const RolesPermissionsTab: React.FC = () => { const [rolesDetails, setRolesDetails] = useState([]); const [users, setUsers] = useState([]); const [selectedUserId, setSelectedUserId] = useState(null); + const [deleteUserOpen, setDeleteUserOpen] = useState(false); + const [deleteUserTarget, setDeleteUserTarget] = useState(null); const [userModalOpen, setUserModalOpen] = useState(false); const [newUser, setNewUser] = useState<{ login: string; password: string; email?: string }>({ @@ -302,6 +304,19 @@ export const RolesPermissionsTab: React.FC = () => { } }; + const deleteUser = async (id: number) => { + setLoading(true); + try { + await rbacApi.deleteUser(id); + await refreshUsers(); + if (selectedUserId === id) setSelectedUserId(null); + } catch (e: any) { + setError(e?.message ?? 'Ошибка удаления пользователя'); + } finally { + setLoading(false); + } + }; + const submitUser = async () => { const errs = validateUser(newUser); setUserErrors(errs); @@ -489,6 +504,23 @@ export const RolesPermissionsTab: React.FC = () => { +
+
Действия
+
+ +
+
Роли
@@ -538,6 +570,42 @@ export const RolesPermissionsTab: React.FC = () => {
+ + + + Подтверждение удаления пользователя + +
+ Вы уверены, что хотите удалить пользователя «{deleteUserTarget?.login ?? deleteUserTarget?.email ?? deleteUserTarget?.id}»? +
+ + + + +
+
+ From f6782cb1b83d90414828038dd1bef50c12d05c92 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 08:56:22 +0300 Subject: [PATCH 30/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0?= =?UTF-8?q?=20"API=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B8"=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D0=B0=20=D1=84=D1=83?= =?UTF-8?q?=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализована новая вкладка "API ключи" в настройках, использующая существующую форму `EditSettingsPlatformForm` для минимизации изменений. Также добавлен выбор роли пользователя при создании, с предварительным выбором первой доступной роли, что упрощает управление правами. Эти изменения улучшают удобство интерфейса и расширяют функционал. --- .../ui/EditSettingsPlatformForm.tsx | 2 +- src/shared/api/rbac.ts | 8 +++-- src/views/settings/ui/ApiKeysTab.tsx | 19 +++++++++++ src/views/settings/ui/RolesPermissionsTab.tsx | 32 ++++++++++++++++--- src/views/settings/ui/Settings.tsx | 7 ++++ 5 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 src/views/settings/ui/ApiKeysTab.tsx diff --git a/src/features/edit-settings-platform-form/ui/EditSettingsPlatformForm.tsx b/src/features/edit-settings-platform-form/ui/EditSettingsPlatformForm.tsx index 73791df4..19cfed4a 100644 --- a/src/features/edit-settings-platform-form/ui/EditSettingsPlatformForm.tsx +++ b/src/features/edit-settings-platform-form/ui/EditSettingsPlatformForm.tsx @@ -24,7 +24,7 @@ import { Skeleton } from '@/shared/ui/skeleton'; import curseforge from '@/assets/logos/curseforge.ico'; import vk from '@/assets/logos/vk.svg'; -export const EditSettingsPlatformForm = () => { +export const EditSettingsPlatformForm: React.FC<{ showOnlyApiKeys?: boolean }> = ({ showOnlyApiKeys = false }) => { const { data: platform, isLoading } = useSettingsPlatform(); const { mutateAsync, isPending } = useEditSettingsPlatform(); diff --git a/src/shared/api/rbac.ts b/src/shared/api/rbac.ts index 1dc2c413..44a3803b 100644 --- a/src/shared/api/rbac.ts +++ b/src/shared/api/rbac.ts @@ -13,8 +13,12 @@ export type RbacUser = { export const rbacApi = { // Users - createUser: async (payload: { login: string; password: string; email?: string }): Promise => { - const { data } = await $api.post('/users/signup', payload); + createUser: async ( + payload: { login: string; password: string; email?: string }, + roleName?: string, + ): Promise => { + const qs = roleName && roleName.trim() ? `?role=${encodeURIComponent(roleName.trim())}` : ''; + const { data } = await $api.post(`/users/signup${qs}`, payload); return (data?.data ?? data) as RbacUser; }, deleteUser: async (userId: number): Promise => { diff --git a/src/views/settings/ui/ApiKeysTab.tsx b/src/views/settings/ui/ApiKeysTab.tsx new file mode 100644 index 00000000..35274820 --- /dev/null +++ b/src/views/settings/ui/ApiKeysTab.tsx @@ -0,0 +1,19 @@ +"use client"; + +import React from "react"; + +import { EditSettingsPlatformForm } from "@/features/edit-settings-platform-form"; + +// This tab reuses EditSettingsPlatformForm as-is to minimize code changes. + +export const ApiKeysTab: React.FC = () => { + // The existing EditSettingsPlatformForm already contains fields for CurseForge and VK API keys. + // To minimize changes, we simply reuse that form here; ideally, form could accept "section" prop. + return ( +
+ +
+ ); +}; + +export default ApiKeysTab; diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index b0d9ad87..80c3c220 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -19,6 +19,7 @@ import { Badge } from '@/shared/ui/badge'; import { Checkbox } from '@/shared/ui/checkbox'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'; const emptyRole: Omit = { name: '', description: '' }; const emptyPerm: Omit = { name: '', description: '' }; @@ -57,6 +58,7 @@ export const RolesPermissionsTab: React.FC = () => { password: '', email: '', }); + const [newUserRole, setNewUserRole] = useState(''); const [userErrors, setUserErrors] = useState<{ login?: string; password?: string; email?: string }>({}); const validateUser = (u: { login: string; password: string; email?: string }) => { @@ -323,13 +325,17 @@ export const RolesPermissionsTab: React.FC = () => { if (errs.login || errs.password || errs.email) return; setLoading(true); try { - await rbacApi.createUser({ - login: newUser.login.trim(), - password: newUser.password, - email: newUser.email?.trim() || undefined, - }); + await rbacApi.createUser( + { + login: newUser.login.trim(), + password: newUser.password, + email: newUser.email?.trim() || undefined, + }, + newUserRole?.trim() || undefined, + ); setUserModalOpen(false); setNewUser({ login: '', password: '', email: '' }); + setNewUserRole(''); setUserErrors({}); await refreshUsers(); } catch (e: any) { @@ -447,6 +453,8 @@ export const RolesPermissionsTab: React.FC = () => { onClick={() => { setNewUser({ login: '', password: '', email: '' }); setUserErrors({}); + // Preselect first available role by name; if none, keep empty to default Admin on backend + setNewUserRole(roles[0]?.name ?? ''); setUserModalOpen(true); }} disabled={loading} @@ -662,6 +670,19 @@ export const RolesPermissionsTab: React.FC = () => {
{userErrors.email}
)}
+
+ + +
From 638fdb76c4e18946dafeb3dbc721a699c062e450 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Mon, 15 Sep 2025 08:57:26 +0300 Subject: [PATCH 31/58] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BE=D1=82=D1=81=D1=82=D1=83=D0=BF?= =?UTF-8?q?=D1=8B=20=D0=B8=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BA=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=82=D0=BE=D1=80=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Произведено форматирование кода для повышения читаемости, включая выравнивание отступов и переработку функций валидации паролей. Эти изменения улучшают поддерживаемость кода и обеспечивают более четкое форматирование текста в интерфейсе. --- src/views/settings/ui/RolesPermissionsTab.tsx | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/src/views/settings/ui/RolesPermissionsTab.tsx b/src/views/settings/ui/RolesPermissionsTab.tsx index 80c3c220..94a3a1ac 100644 --- a/src/views/settings/ui/RolesPermissionsTab.tsx +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -59,7 +59,11 @@ export const RolesPermissionsTab: React.FC = () => { email: '', }); const [newUserRole, setNewUserRole] = useState(''); - const [userErrors, setUserErrors] = useState<{ login?: string; password?: string; email?: string }>({}); + const [userErrors, setUserErrors] = useState<{ + login?: string; + password?: string; + email?: string; + }>({}); const validateUser = (u: { login: string; password: string; email?: string }) => { const errs: { login?: string; password?: string; email?: string } = {}; @@ -81,8 +85,10 @@ export const RolesPermissionsTab: React.FC = () => { } else if (pwd.length < 5 || pwd.length > 100) { errs.password = 'Пароль должен быть от 5 до 100 символов.'; } else { - if (!/[A-Z]/.test(pwd)) errs.password = 'Пароль должен содержать хотя бы одну заглавную букву.'; - else if (!/[a-z]/.test(pwd)) errs.password = 'Пароль должен содержать хотя бы одну строчную букву.'; + if (!/[A-Z]/.test(pwd)) + errs.password = 'Пароль должен содержать хотя бы одну заглавную букву.'; + else if (!/[a-z]/.test(pwd)) + errs.password = 'Пароль должен содержать хотя бы одну строчную букву.'; else if (!/[0-9]/.test(pwd)) errs.password = 'Пароль должен содержать хотя бы одну цифру.'; } @@ -437,8 +443,8 @@ export const RolesPermissionsTab: React.FC = () => { Пользователи Роли - Матрица Права + Матрица @@ -512,23 +518,23 @@ export const RolesPermissionsTab: React.FC = () => { -
-
Действия
-
- -
-
+
+
Действия
+
+ +
+
Роли
@@ -584,7 +590,8 @@ export const RolesPermissionsTab: React.FC = () => { Подтверждение удаления пользователя
- Вы уверены, что хотите удалить пользователя «{deleteUserTarget?.login ?? deleteUserTarget?.email ?? deleteUserTarget?.id}»? + Вы уверены, что хотите удалить пользователя « + {deleteUserTarget?.login ?? deleteUserTarget?.email ?? deleteUserTarget?.id}»?
+ +
+ + +
+ {config.name} {config.version} +
+
+ {/* THE PAGE CONTENT END */} +
+ ); +} From 49f6b63e03e22797143f0fb7729fa5231432a9c4 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Fri, 26 Sep 2025 09:12:25 +0300 Subject: [PATCH 38/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D1=8B=20"=D0=9D=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA?= =?UTF-8?q?=D0=B0"=20=D0=B8=20"=D0=9F=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA"=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B0=20MNT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализованы новые страницы для раздела MNT, включая общие компоненты и лэйауты. Страница "Настройка" предоставляет форму для указания адреса API сервера, тогда как "Первый запуск" содержит начальную информацию для пользователей. Улучшена структура кода с акцентом на переиспользуемость компонентов. --- src/app/mnt/layout.tsx | 9 ++++++ src/app/mnt/page.tsx | 9 ++---- src/app/mnt/setup/layout.tsx | 9 ++++++ src/app/mnt/setup/page.tsx | 58 ++++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 src/app/mnt/layout.tsx create mode 100644 src/app/mnt/setup/layout.tsx create mode 100644 src/app/mnt/setup/page.tsx diff --git a/src/app/mnt/layout.tsx b/src/app/mnt/layout.tsx new file mode 100644 index 00000000..4049b5f8 --- /dev/null +++ b/src/app/mnt/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react'; + +export const metadata = { + title: 'Gml Первый запуск', +}; + +export default function MntLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/src/app/mnt/page.tsx b/src/app/mnt/page.tsx index cf15bc19..e7e4dd09 100644 --- a/src/app/mnt/page.tsx +++ b/src/app/mnt/page.tsx @@ -1,4 +1,7 @@ +"use client"; + import Image from 'next/image'; +import { useRouter } from 'next/navigation'; import { config } from '@/core/configs'; import { @@ -13,13 +16,7 @@ import { Button, buttonVariants } from '@/shared/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip'; import logo from '@/assets/logos/logo.svg'; -export const metadata = { - title: 'Gml Первый запуск', -}; -"use client"; - -import { useRouter } from 'next/navigation'; export default function MntPage() { const router = useRouter(); diff --git a/src/app/mnt/setup/layout.tsx b/src/app/mnt/setup/layout.tsx new file mode 100644 index 00000000..451c93c4 --- /dev/null +++ b/src/app/mnt/setup/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react'; + +export const metadata = { + title: 'Gml Настройка', +}; + +export default function MntSetupLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/src/app/mnt/setup/page.tsx b/src/app/mnt/setup/page.tsx new file mode 100644 index 00000000..34cc1366 --- /dev/null +++ b/src/app/mnt/setup/page.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +import { config } from '@/core/configs'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/shared/ui/card'; +import { Button } from '@/shared/ui/button'; +import Image from 'next/image'; +import logo from '@/assets/logos/logo.svg'; + +export default function MntSetupPage() { + const router = useRouter(); + const [backendUrl, setBackendUrl] = useState(''); + + return ( +
+ Gml Frontend +

{config.name} Установка

+ +
+ + + Адрес бекенда + Укажите адрес API сервера {config.name}. + + + + + +
+ + +
+
+
+
+
+ ); +} From e894938c81c08f2ff659c200d761c502bc2a7857 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Fri, 26 Sep 2025 09:30:47 +0300 Subject: [PATCH 39/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20"=D0=9B=D0=B8=D1=86=D0=B5=D0=BD=D0=B7=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5"=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B4=D0=B5=D0=BB=D0=B0=20MNT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Создана новая страница для отображения лицензионного соглашения, содержащая юридическую информацию и текст соглашения. Добавлены кнопки действий для принятия условий и перехода к следующему этапу настройки. --- src/app/mnt/license/page.tsx | 202 +++++++++++++++++++++++++++++++++++ src/app/mnt/page.tsx | 10 +- 2 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 src/app/mnt/license/page.tsx diff --git a/src/app/mnt/license/page.tsx b/src/app/mnt/license/page.tsx new file mode 100644 index 00000000..d710f2e7 --- /dev/null +++ b/src/app/mnt/license/page.tsx @@ -0,0 +1,202 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/shared/ui/card'; +import { Button } from '@/shared/ui/button'; +import logo from '@/assets/logos/recloud.png'; +import { config } from '@/core/configs'; + +export default function LicensePage() { + const router = useRouter(); + return ( +
+ Recloud +

+ Лицензионное соглашение для Gml® Recloud LLC® +

+ +
+ + + ООО «Реклауд» + + +

+ Мы — компания «Реклауд», занимаемся разработкой программного обеспечения. Подробности + о нас и наших продуктах вы можете найти на сайте{' '} + + recloud.tech + + . По всем вопросам:{' '} + + suport@recloud.tech + +

+ +
+
+ ОГРН: 1242900005083{' '} + (от 10 сентября 2024 г.) +
+
+ ИНН/КПП: 2901318875 / 290101001 +
+
+ Юридический адрес: 163045, Архангельская область, г Архангельск, Талажское ш, + д. 9, офис 6 +
+
+ +

+ Перед продолжением установки, пожалуйста, ознакомьтесь с лицензионным соглашением{' '} + {config.name}. Ниже приведён англоязычный текст для справки, в котором упоминания + JetBrains заменены на Recloud, где это применимо. Нажимая «Принять и продолжить», вы + подтверждаете согласие с его условиями. +

+
+ {`Лицензионное соглашение для Recloud® ${config.name}® + + ВАЖНО! ВНИМАТЕЛЬНО ПРОЧИТАЙТЕ: ЭТО ЮРИДИЧЕСКОЕ СОГЛАШЕНИЕ. ЗАГРУЖАЯ, УСТАНАВЛИВАЯ, КОПИРУЯ, СОХРАНЯЯ НА СВОЕМ КОМПЬЮТЕРЕ ИЛИ ИНЫМ ОБРАЗОМ ИСПОЛЬЗУЯ ЭТО ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ, ВЫ (ЛИЦЕНЗИАТ, КАК ОПРЕДЕЛЕНО НИЖЕ) СТАНОВИТЕСЬ СТОРОНОЙ ДАННОГО СОГЛАШЕНИЯ И СОГЛАШАЕТЕСЬ СОБЛЮДАТЬ ВСЕ УСЛОВИЯ И ПОЛОЖЕНИЯ ДАННОГО СОГЛАШЕНИЯ. + + ЕСЛИ ВЫ НЕ СОГЛАСНЫ С УСЛОВИЯМИ И ПОЛОЖЕНИЯМИ НАСТОЯЩЕГО СОГЛАШЕНИЯ, ВАМ НЕ СЛЕДУЕТ ЗАГРУЖАТЬ, УСТАНАВЛИВАТЬ И ИСПОЛЬЗОВАТЬ ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ. + + Примечание: В случае, если условия настоящего Соглашения противоречат условиям какого-либо индивидуально согласованного соглашения между Recloud и клиентом, преимущественную силу имеют условия последнего. + + 1. СТОРОНЫ + (а) "Лицензиар" означает ООО «Реклауд», зарегистрированное по адресу: 163045, Архангельская область, г Архангельск, Талажское ш, д. 9, офис 6, ОГРН: 1242900005083, ИНН/КПП: 2901318875/290101001. + + (б) "Лицензиат" означает физическое или юридическое лицо, осуществляющее права в соответствии с условиями настоящего Соглашения. Для юридических лиц "Лицензиат" включает любое юридическое лицо, которое контролирует, контролируется или находится под общим контролем с Лицензиатом. Для целей данного определения "контроль" означает (а) право, прямое или косвенное, определять направление или управление таким юридическим лицом, будь то по договору или иным образом, или (б) владение пятьюдесятью процентами (50%) или более находящихся в обращении акций или бенефициарного владения таким юридическим лицом. + + 2. ОПРЕДЕЛЕНИЯ + (а) "Игровые профили и сборочный агент Программных средств" означает вспомогательную часть Программного обеспечения, которая выполняет функции, запрашиваемые Сервером, как указано в документации к Программному обеспечению. + + (б) "Лицензия сборочного агента" означает лицензию на использование Сборочного агента в соответствии с настоящим Соглашением. + + (в) "Лицензионный сертификат" означает доказательство лицензии, предоставленное Лицензиаром Лицензиату в электронной или печатной форме. + + (г) "Лицензионный ключ" означает уникальный код-ключ, который позволяет Лицензиату запускать Программное обеспечение. Только Лицензиар и/или его представители имеют право производить Лицензионные ключи для Программного обеспечения. + + (д) "Сервер" означает серверную часть Программного обеспечения, которая обеспечивает администрирование учетных записей Пользователей и выполняет другие услуги, как указано в документации к Программному обеспечению. + + (е) "Программное обеспечение" означает программу в двоичной форме, включая ее документацию, обновления, предоставляемые в соответствии с настоящим Соглашением, и любые программы третьих лиц, которые принадлежат и лицензируются сторонами, отличными от Лицензиара, и которые либо интегрированы, либо являются частью продукта (совместно "Программное обеспечение третьих лиц"). + + (ж) "Пользователь" означает (i) если Лицензиат является физическим лицом, исключительно Лицензиата; (ii) если Лицензиат является юридическим лицом, любое лицо, которое уполномочено Лицензиатом использовать Программное обеспечение при выполнении обязанностей в рамках его работы или задания у Лицензиата, включая его сотрудника, независимого подрядчика, временного работника и любое третье лицо. + + 3. ПРАВО СОБСТВЕННОСТИ + (а) Программное обеспечение является собственностью Лицензиара или его поставщиков. Программное обеспечение лицензируется, а не продается. Право собственности и авторские права на Программное обеспечение, полностью и частично, и все его копии, а также все модификации, улучшения, производные и другие изменения Программного обеспечения, независимо от того, кто внес какие-либо модификации, если таковые имеются, являются и останутся исключительной собственностью Лицензиара и его поставщиков. + + (б) Программное обеспечение защищено законом об авторском праве Российской Федерации и положениями международных договоров. Более того, структура, организация и код, воплощенные в Программном обеспечении, являются ценными и конфиденциальными коммерческими тайнами Лицензиара и его поставщиков и защищены законами об интеллектуальной собственности и международными договорами. Лицензиат соглашается соблюдать закон об авторском праве и все другие применимые законы Российской Федерации. + + 4. ПРЕДОСТАВЛЕНИЕ ЛИЦЕНЗИИ + В соответствии с условиями, положениями и ограничениями, изложенными в настоящем Соглашении, включая любые поправки к нему, Лицензиар настоящим предоставляет Лицензиату неисключительную, непередаваемую лицензию на использование Программного обеспечения следующим образом: + + (а) Лицензиат может: + + (i) создавать и использовать неограниченное количество игровых профилей и сборочных агентов на своих серверах; + + (ii) создавать и использовать неограниченное количество учетных записей Пользователей; + + (iii) делать резервные копии Программного обеспечения для архивных целей. + + (б) Лицензиат не может: + + (i) продавать, распространять, обременять, дарить, давать взаймы, сдавать в аренду, сублицензировать или иным образом передавать Программное обеспечение или любые его части кому-либо без предварительного письменного согласия Лицензиара; + + (ii) осуществлять обратное проектирование, декомпилировать, дизассемблировать, модифицировать, переводить, предпринимать попытки раскрыть исходный код Программного обеспечения или создавать производные работы из Программного обеспечения; + + (iii) использовать один и тот же Лицензионный ключ для запуска нескольких экземпляров Программного обеспечения. + + 5. ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ТРЕТЬИХ ЛИЦ + + (а) Лицензиат соглашается соблюдать условия и положения, содержащиеся в лицензиях на Программное обеспечение третьих лиц в отношении соответствующего Программного обеспечения третьих лиц. Лицензиат может ознакомиться со всеми такими лицензиями и/или уведомлениями о Программном обеспечении третьих лиц в документации к Программному обеспечению в разделе "Лицензионное соглашение с третьими лицами". + + (б) Лицензиат соглашается и признает, что Разделы 9 и 10 настоящего Соглашения также регулируют использование Лицензиатом Программного обеспечения третьих лиц. Лицензиар не несет никакой ответственности в отношении любого Программного обеспечения третьих лиц, и Лицензиат будет обращаться исключительно к лицензиару(ам) Программного обеспечения третьих лиц за любым средством правовой защиты. Лицензиар не заявляет никаких прав на Программное обеспечение третьих лиц, и оно принадлежит исключительно лицензиару(ам) Программного обеспечения третьих лиц. + + (в) Лицензиар не предоставляет никаких гарантий, явных или подразумеваемых, включая, помимо прочего, подразумеваемые гарантии товарной пригодности, пригодности для определенной цели, права собственности и ненарушения прав, в отношении любого программного обеспечения третьих лиц. + + 6. ОГРАНИЧЕННОЕ ИСПОЛЬЗОВАНИЕ В ТЕЧЕНИЕ ОЦЕНОЧНОГО ПЕРИОДА + + (а) В соответствии с условиями настоящего Соглашения Лицензиату предоставляется право установить и запустить Программное обеспечение для целей оценки без какой-либо оплаты на период в шестьдесят (60) дней, если иное не указано Лицензиаром ("Оценочный период"). + + (б) Использование Лицензиатом Программного обеспечения в течение Оценочного периода должно быть ограничено внутренней оценкой с единственной целью определения, соответствует ли Программное обеспечение требованиям Лицензиата и желает ли Лицензиат продолжить использование Программного обеспечения. + + (в) По истечении Оценочного периода Лицензиат должен сделать одно из следующего: (i) получить лицензию для постоянного использования Программного обеспечения или (ii) удалить Программное обеспечение. + + 7. ОБНОВЛЕНИЯ + + 7.1 Лицензиар будет предоставлять общедоступные обновления Программного обеспечения Лицензиату в течение срока действия настоящего Соглашения. + + 7.2 Лицензиар может прекратить предоставление обновлений, если: + + (A) Лицензиат существенно нарушил настоящее Соглашение и не устранил такое нарушение в течение тридцати (30) дней с момента письменного уведомления об этом; + + (B) Лицензиар обязан сделать это по закону (например, если предоставление Программного обеспечения Лицензиату является или становится незаконным); или + + (C) Лицензиар решил прекратить предоставление Программного обеспечения, полностью или частично. + + 7.3 Лицензиар приложит разумные усилия для уведомления Лицензиата по электронной почте за тридцать (30) дней до прекращения предоставления обновлений в случаях, указанных в пунктах 7.2 (B) и 7.2 (C). + + 8. ПОКУПКА И ОПЛАТА + + Лицензиат соглашается с условиями покупки Программного обеспечения, опубликованными на веб-сайте Лицензиара по адресу recloud.tech. Лицензиат оплатит Лицензиару лицензионный сбор и другие сборы (если применимо), как указано в счете или другой документации о покупке. Лицензиар может взимать с Лицензиата проценты за любой платеж, просроченный более чем на тридцать (30) дней, по ставке полтора процента (1,5%) в месяц или максимальной сумме, разрешенной законом, в зависимости от того, что меньше. + + 9. ОГРАНИЧЕННАЯ ГАРАНТИЯ + + ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ ЛИЦЕНЗИАТУ "КАК ЕСТЬ" И БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ. ЛИЦЕНЗИАР НЕ ДАЕТ НИКАКИХ ГАРАНТИЙ ОТНОСИТЕЛЬНО ЕГО ИСПОЛЬЗОВАНИЯ ИЛИ ПРОИЗВОДИТЕЛЬНОСТИ. В МАКСИМАЛЬНОЙ СТЕПЕНИ, РАЗРЕШЕННОЙ ПРИМЕНИМЫМ ЗАКОНОДАТЕЛЬСТВОМ, ЛИЦЕНЗИАР И ЕГО АФФИЛИРОВАННЫЕ ЛИЦА, ПОСТАВЩИКИ И РЕСЕЛЛЕРЫ ОТКАЗЫВАЮТСЯ ОТ ВСЕХ ДРУГИХ ГАРАНТИЙ И УСЛОВИЙ, ЯВНЫХ ИЛИ ПОДРАЗУМЕВАЕМЫХ, ВКЛЮЧАЯ, НО НЕ ОГРАНИЧИВАЯСЬ, ПОДРАЗУМЕВАЕМЫЕ ГАРАНТИИ ТОВАРНОЙ ПРИГОДНОСТИ, ПРИГОДНОСТИ ДЛЯ ОПРЕДЕЛЕННОЙ ЦЕЛИ, ПРАВА СОБСТВЕННОСТИ И НЕНАРУШЕНИЯ ПРАВ, В ОТНОШЕНИИ ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ И ПРЕДОСТАВЛЕНИЯ ИЛИ НЕПРЕДОСТАВЛЕНИЯ УСЛУГ ПОДДЕРЖКИ. + + 10. ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ ЗА УЩЕРБ + + (A) В МАКСИМАЛЬНОЙ СТЕПЕНИ, РАЗРЕШЕННОЙ ПРИМЕНИМЫМ ЗАКОНОДАТЕЛЬСТВОМ, НИ ПРИ КАКИХ ОБСТОЯТЕЛЬСТВАХ ЛИЦЕНЗИАР ИЛИ ЕГО АФФИЛИРОВАННЫЕ ЛИЦА, ЛИЦЕНЗИАРЫ, ПОСТАВЩИКИ ИЛИ РЕСЕЛЛЕРЫ НЕ НЕСУТ ОТВЕТСТВЕННОСТИ ПЕРЕД ЛИЦЕНЗИАТОМ НИ ПО КАКОЙ ТЕОРИИ ЗА ЛЮБЫЕ УБЫТКИ, ПОНЕСЕННЫЕ ЛИЦЕНЗИАТОМ ИЛИ ЛЮБЫМ ПОЛЬЗОВАТЕЛЕМ ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ, ИЛИ ЗА ЛЮБЫЕ СПЕЦИАЛЬНЫЕ, СЛУЧАЙНЫЕ, КОСВЕННЫЕ, ПОСЛЕДУЮЩИЕ ИЛИ ПОДОБНЫЕ УБЫТКИ (ВКЛЮЧАЯ, НО НЕ ОГРАНИЧИВАЯСЬ, УБЫТКИ ОТ ПОТЕРИ ПРИБЫЛИ ИЛИ КОНФИДЕНЦИАЛЬНОЙ ИЛИ ДРУГОЙ ИНФОРМАЦИИ, ОТ ПРЕРЫВАНИЯ БИЗНЕСА, ОТ ЛИЧНОГО ВРЕДА, ОТ ПОТЕРИ КОНФИДЕНЦИАЛЬНОСТИ, ОТ НЕИСПОЛНЕНИЯ КАКИХ-ЛИБО ОБЯЗАННОСТЕЙ, ВКЛЮЧАЯ ДОБРОСОВЕСТНОСТЬ ИЛИ РАЗУМНУЮ ОСТОРОЖНОСТЬ, ОТ НЕБРЕЖНОСТИ И ОТ ЛЮБЫХ ДРУГИХ ДЕНЕЖНЫХ ИЛИ ИНЫХ УБЫТКОВ), ВОЗНИКАЮЩИЕ В РЕЗУЛЬТАТЕ ИСПОЛЬЗОВАНИЯ ИЛИ НЕВОЗМОЖНОСТИ ИСПОЛЬЗОВАНИЯ ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ, ИЛИ ПРЕДОСТАВЛЕНИЯ ИЛИ НЕПРЕДОСТАВЛЕНИЯ УСЛУГ ПОДДЕРЖКИ, ДАЖЕ ЕСЛИ ЛИЦЕНЗИАР БЫЛ УВЕДОМЛЕН О ВОЗМОЖНОСТИ ТАКИХ УБЫТКОВ, И НЕЗАВИСИМО ОТ ПРАВОВОЙ ИЛИ СПРАВЕДЛИВОЙ ТЕОРИИ (ДОГОВОР, ДЕЛИКТ ИЛИ ИНОЕ), НА КОТОРОЙ ОСНОВАНО ТРЕБОВАНИЕ. + + (B) В ЛЮБОМ СЛУЧАЕ ПОЛНАЯ ОТВЕТСТВЕННОСТЬ ЛИЦЕНЗИАРА ПО ЛЮБОМУ ПОЛОЖЕНИЮ НАСТОЯЩЕГО СОГЛАШЕНИЯ БУДЕТ ОГРАНИЧЕНА СУММОЙ, ФАКТИЧЕСКИ УПЛАЧЕННОЙ ЛИЦЕНЗИАТОМ ЗА ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПО НАСТОЯЩЕМУ СОГЛАШЕНИЮ. + + 11. ЭКСПОРТНЫЕ ПРАВИЛА + + Лицензиат соглашается и принимает, что Программное обеспечение может подпадать под действие законов об импорте и экспорте любой страны, включая законы Европейского Союза и Соединенных Штатов (в частности, Правила экспортного контроля (EAR)). Лицензиат подтверждает, что он не является гражданином, подданным или резидентом и не находится под контролем правительств Кубы, Ирана, Северной Кореи, Судана или Сирии и не является иным образом ограниченным конечным пользователем, как определено применимыми законами экспортного контроля. Кроме того, Лицензиат подтверждает, что он не будет загружать или иным образом экспортировать или реэкспортировать Программное обеспечение или любые связанные технические данные прямо или косвенно в вышеупомянутые страны или гражданам, подданным или резидентам этих стран, или любому другому ограниченному конечному пользователю или для любого ограниченного конечного использования. + + 12. СРОК ДЕЙСТВИЯ И ПРЕКРАЩЕНИЕ + + (а) Если иное не предусмотрено в Лицензионном сертификате, лицензия, предоставленная по настоящему Соглашению, является бессрочной. + + (б) Если Лицензиат не соблюдает условия и положения настоящего Соглашения, Лицензиар может прекратить действие настоящего Соглашения и права и лицензии Лицензиата на использование Программного обеспечения. Лицензиат может прекратить действие настоящего Соглашения в любое время, уведомив Лицензиара. После прекращения действия настоящего Соглашения Лицензиат должен удалить Программное обеспечение со своих компьютеров и архивов. ЛИЦЕНЗИАТ СОГЛАШАЕТСЯ, ЧТО ПОСЛЕ ПРЕКРАЩЕНИЯ ДЕЙСТВИЯ НАСТОЯЩЕГО СОГЛАШЕНИЯ ПО ЛЮБОЙ ПРИЧИНЕ ЛИЦЕНЗИАР МОЖЕТ ПРИНЯТЬ МЕРЫ, ЧТОБЫ ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ БОЛЬШЕ НЕ РАБОТАЛО. + + 13. МАРКЕТИНГ + + Лицензиат соглашается быть идентифицированным как клиент Лицензиара и что Лицензиар может ссылаться на Лицензиата по имени, торговому наименованию и товарному знаку, если применимо, и может кратко описывать бизнес Лицензиата в маркетинговых материалах Лицензиара и на веб-сайте Лицензиара. Настоящим Лицензиат предоставляет Лицензиару лицензию на использование имени Лицензиата и любых торговых наименований и товарных знаков Лицензиата исключительно в связи с правами, предоставленными Лицензиару в соответствии с настоящим разделом о маркетинге. + + 14. ОБЩИЕ ПОЛОЖЕНИЯ + + (а) Лицензиар оставляет за собой право в любое время прекратить поддержку Программного обеспечения и изменять цены, характеристики, спецификации, возможности, функции, условия лицензирования, даты выпуска, общую доступность или другие характеристики Программного обеспечения. + + (б) Настоящее Соглашение, включая соглашения о Программном обеспечении третьих лиц, представляет собой полное соглашение между сторонами относительно использования Лицензиатом Программного обеспечения и заменяет собой все предыдущие или одновременные устные или письменные заявления, сообщения или рекламу в отношении Программного обеспечения. Никакой заказ на покупку, другой заказывающий документ или любой рукописный или напечатанный текст, который претендует на изменение или дополнение печатного текста настоящего Соглашения или любого графика, не добавит и не изменит условия настоящего Соглашения, если он не подписан как Лицензиатом, так и Лицензиаром. + + (в) Отказ любой из сторон от любого условия или положения настоящего Соглашения или любого нарушения такового в любом случае не отменяет такое условие или положение или любое последующее нарушение. Положения настоящего Соглашения, которые требуют или предполагают исполнение после истечения срока действия или прекращения настоящего Соглашения, подлежат исполнению независимо от такого истечения срока действия или прекращения. + + (г) Настоящее Соглашение регулируется законодательством Российской Федерации без учета принципов коллизионного права. Лицензиат соглашается с тем, что любой судебный процесс, связанный с настоящим Соглашением, может проводиться только в судах Российской Федерации и подчиняется их юрисдикции. + + (д) Заголовки вставлены только для удобства и не должны никоим образом влиять на значение или толкование настоящего Соглашения. Если какое-либо положение настоящего Соглашения будет признано недействительным, остальная часть настоящего Соглашения останется в полной силе и действии. Любая из сторон - Лицензиар или Лицензиат - может уступить настоящее Соглашение в случае слияния или продажи практически всех своих соответствующих активов другому юридическому лицу. Настоящее Соглашение является обязательным и действует в интересах сторон, их правопреемников и цессионариев. + + По вопросам исключений или изменений к данному Соглашению, пожалуйста, обращайтесь к Лицензиару: + Email: support@recloud.tech + Веб-сайт: https://recloud.tech`} +
+
+ + + + +
+
+
+ ); +} diff --git a/src/app/mnt/page.tsx b/src/app/mnt/page.tsx index e7e4dd09..6bced766 100644 --- a/src/app/mnt/page.tsx +++ b/src/app/mnt/page.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; @@ -16,7 +16,9 @@ import { Button, buttonVariants } from '@/shared/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip'; import logo from '@/assets/logos/logo.svg'; - +{ + config.name; +} export default function MntPage() { const router = useRouter(); @@ -82,7 +84,9 @@ export default function MntPage() { - +
From e4b7935c3a86cef7ee3d89ade7c02843713349d7 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Fri, 26 Sep 2025 09:34:33 +0300 Subject: [PATCH 40/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D0=BE=D0=B2?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B0=20MNT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализован компонент `ClientTransition` для анимаций перехода страниц в разделе MNT, что улучшает визуальное восприятие. Обновлены стили с добавлением эффектов `FadeIn` и `SlideIn`. В форме настройки добавлены новые поля для указания параметров проекта, включая наименование. --- src/app/globals.css | 15 +++++++++++++++ src/app/mnt/ClientTransition.tsx | 21 +++++++++++++++++++++ src/app/mnt/layout.tsx | 4 +++- src/app/mnt/setup/page.tsx | 12 ++++++++++-- 4 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 src/app/mnt/ClientTransition.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 8fd5b9a4..c774e2b0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -88,6 +88,21 @@ } } +/* Page transition animations for installer (/mnt) */ +.page-transition { + animation: pageFadeIn .25s ease-out, pageSlideIn .25s ease-out; +} + +@keyframes pageFadeIn { + from { opacity: 0 } + to { opacity: 1 } +} + +@keyframes pageSlideIn { + from { transform: translateY(8px) } + to { transform: translateY(0) } +} + .card-container { opacity: .5; transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; diff --git a/src/app/mnt/ClientTransition.tsx b/src/app/mnt/ClientTransition.tsx new file mode 100644 index 00000000..0772d8f5 --- /dev/null +++ b/src/app/mnt/ClientTransition.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { ReactNode, useEffect, useState } from 'react'; +import { usePathname } from 'next/navigation'; + +export function ClientTransition({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const [key, setKey] = useState(pathname); + + useEffect(() => { + setKey(pathname); + }, [pathname]); + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/app/mnt/layout.tsx b/src/app/mnt/layout.tsx index 4049b5f8..fa87230d 100644 --- a/src/app/mnt/layout.tsx +++ b/src/app/mnt/layout.tsx @@ -1,9 +1,11 @@ import { ReactNode } from 'react'; +import { ClientTransition } from './ClientTransition'; + export const metadata = { title: 'Gml Первый запуск', }; export default function MntLayout({ children }: { children: ReactNode }) { - return <>{children}; + return {children}; } diff --git a/src/app/mnt/setup/page.tsx b/src/app/mnt/setup/page.tsx index 34cc1366..5cf4aa8f 100644 --- a/src/app/mnt/setup/page.tsx +++ b/src/app/mnt/setup/page.tsx @@ -28,10 +28,18 @@ export default function MntSetupPage() {
- Адрес бекенда - Укажите адрес API сервера {config.name}. + Настройки проекта + Укажите основные параметры проекта. + + + +
From 7190e951b23a91c27f4d0180d769967c57fbfc60 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Fri, 26 Sep 2025 10:00:03 +0300 Subject: [PATCH 42/58] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B2=D0=B2=D0=BE=D0=B4=D0=B0=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BE?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В форме страницы "Настройка" добавлены состояния для обработки значений полей, включая имя проекта, учетные данные администратора и подтверждение пароля. Реализована валидация совпадения пароля и подтверждения, а также отправка данных на сервер с обработкой ошибок и индикатором загрузки. --- src/app/mnt/setup/page.tsx | 68 ++++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/src/app/mnt/setup/page.tsx b/src/app/mnt/setup/page.tsx index 01204773..12c7e1ea 100644 --- a/src/app/mnt/setup/page.tsx +++ b/src/app/mnt/setup/page.tsx @@ -2,23 +2,61 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import Image from 'next/image'; import { config } from '@/core/configs'; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/shared/ui/card'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/shared/ui/card'; import { Button } from '@/shared/ui/button'; -import Image from 'next/image'; import logo from '@/assets/logos/logo.svg'; export default function MntSetupPage() { const router = useRouter(); const [backendUrl, setBackendUrl] = useState(''); + const [projectName, setProjectName] = useState(''); + const [adminUsername, setAdminUsername] = useState(''); + const [adminPassword, setAdminPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + + async function onSave() { + if (!backendUrl) return; + if (adminPassword !== confirmPassword) { + const { toast } = await import('sonner'); + toast.error('Пароль и подтверждение не совпадают'); + return; + } + const base = backendUrl.replace(/\/$/, ''); + const url = `${base}/api/v1/settings/install`; + const body = { + ProjectName: projectName, + BackendAddress: backendUrl, + AdminUsername: adminUsername, + AdminPassword: adminPassword, + ConfirmPassword: confirmPassword, + }; + try { + setSubmitting(true); + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + credentials: 'include', + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Request failed with status ${res.status}`); + } + router.push('/dashboard'); + } catch (e: any) { + // Fallback alert; project also has sonner Toaster globally mounted + const msg = e?.message || 'Не удалось выполнить установку'; + // dynamic import to avoid adding dependency at module scope + const { toast } = await import('sonner'); + toast.error(msg); + } finally { + setSubmitting(false); + } + } return (
@@ -38,6 +76,8 @@ export default function MntSetupPage() { id="projectName" className="w-full rounded-md border bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" placeholder="Мой проект" + value={projectName} + onChange={(e) => setProjectName(e.target.value)} /> @@ -82,7 +128,9 @@ export default function MntSetupPage() { - +
From a03cb73f27602b5b0d7dbf6533b96ebab8124da6 Mon Sep 17 00:00:00 2001 From: "Terentev A. A." Date: Fri, 26 Sep 2025 10:03:42 +0300 Subject: [PATCH 43/58] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=B0=20=D0=B1=D0=B5=D0=BA=D0=B5=D0=BD=D0=B4=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлено использование переменной окружения `NEXT_PUBLIC_BACKEND_URL` для задания значения по умолчанию адреса API сервера. Также обновлен плейсхолдер в поле ввода, чтобы он соответствовал текущему адресу по умолчанию. --- src/app/mnt/setup/page.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/mnt/setup/page.tsx b/src/app/mnt/setup/page.tsx index 12c7e1ea..efee88c7 100644 --- a/src/app/mnt/setup/page.tsx +++ b/src/app/mnt/setup/page.tsx @@ -5,13 +5,20 @@ import { useRouter } from 'next/navigation'; import Image from 'next/image'; import { config } from '@/core/configs'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/shared/ui/card'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/shared/ui/card'; import { Button } from '@/shared/ui/button'; import logo from '@/assets/logos/logo.svg'; export default function MntSetupPage() { const router = useRouter(); - const [backendUrl, setBackendUrl] = useState(''); + const [backendUrl, setBackendUrl] = useState(process.env.NEXT_PUBLIC_BACKEND_URL || ''); const [projectName, setProjectName] = useState(''); const [adminUsername, setAdminUsername] = useState(''); const [adminPassword, setAdminPassword] = useState(''); @@ -82,10 +89,11 @@ export default function MntSetupPage() {