diff --git a/.github/workflows/docker-package-publish.yml b/.github/workflows/docker-package-publish.yml index 3d453a63..fb28c1fa 100644 --- a/.github/workflows/docker-package-publish.yml +++ b/.github/workflows/docker-package-publish.yml @@ -7,11 +7,11 @@ name: Docker on: push: - branches: ['master'] + branches: [ 'master', 'develop' ] # Publish semver tags as releases. tags: [ 'v*.*.*', 'v*.*.*-beta*' ] pull_request: - branches: ['master'] + branches: [ 'master', 'develop' ] env: # Use docker.io for Docker Hub if empty diff --git a/.gitignore b/.gitignore index 835463c6..e1999086 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ next-env.d.ts /blob-report/ /playwright/.cache/ /.yarn + +certificates \ No newline at end of file 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..74a62457 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "gml-frontend", - "version": "25.1", + "version": "25.2", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --experimental-https", "build": "next build", "start": "next start", "lint": "next lint", 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 new file mode 100644 index 00000000..fa87230d --- /dev/null +++ b/src/app/mnt/layout.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from 'react'; + +import { ClientTransition } from './ClientTransition'; + +export const metadata = { + title: 'Gml Первый запуск', +}; + +export default function MntLayout({ children }: { children: ReactNode }) { + return {children}; +} 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 new file mode 100644 index 00000000..6bced766 --- /dev/null +++ b/src/app/mnt/page.tsx @@ -0,0 +1,100 @@ +'use client'; + +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; + +import { config } from '@/core/configs'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/shared/ui/card'; +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(); + return ( +
+ Gml Frontend +

{config.name} Первый запуск

+ + + + {/* THE PAGE CONTENT BEGIN */} +
+ + + Перед началом + + Пожалуйста, ознакомьтесь с настройками ниже перед первым запуском {config.name}. + + + +

+ Сервер {config.name} хранит конфигурацию сервера, определения проектов, результаты + сборок и кеши на диске в Каталоге данных.{' '} + + + + ? + + + + Подробнее в документации:{' '} + + открыть + + + +

+ +

+ Расположение каталога данных:{' '} + + /data/GmlBackend + +

+
+ +
+ + +
+
+
+
+ {config.name} {config.version} +
+
+ {/* THE PAGE CONTENT END */} +
+ ); +} 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..45e748fd --- /dev/null +++ b/src/app/mnt/setup/page.tsx @@ -0,0 +1,287 @@ +'use client'; + +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 { Button } from '@/shared/ui/button'; +import { Alert, AlertDescription, AlertTitle } from '@/shared/ui/alert'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip'; +import logo from '@/assets/logos/logo.svg'; + +export default function MntSetupPage() { + const router = useRouter(); + const [backendUrl, setBackendUrl] = useState( + typeof window !== 'undefined' ? `${window.location.origin}/api/v1` : '', + ); + const [projectName, setProjectName] = useState(''); + const [adminUsername, setAdminUsername] = useState(''); + const [adminEmail, setAdminEmail] = useState(''); + const [adminPassword, setAdminPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [fetchFailed, setFetchFailed] = 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}/settings/install`; + const body = { + ProjectName: projectName, + BackendAddress: backendUrl, + AdminUsername: adminUsername, + AdminEmail: adminEmail, + AdminPassword: adminPassword, + ConfirmPassword: confirmPassword, + }; + try { + setSubmitting(true); + setFetchFailed(false); + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + credentials: 'include', + }); + if (!res.ok) { + // Try to parse JSON error and build a readable message + let readable = `Request failed with status ${res.status}`; + const contentType = res.headers.get('content-type') || ''; + try { + if (contentType.includes('application/json')) { + const data: any = await res.json(); + const base = (typeof data?.message === 'string' && data.message) || ''; + const details = Array.isArray(data?.errors) + ? data.errors.filter((x: any) => typeof x === 'string') + : []; + if (base || details.length) { + readable = [base, details.join('\n')].filter(Boolean).join('\n'); + } + } else { + const text = await res.text(); + // Some backends return JSON string as text; try to parse + try { + const data: any = JSON.parse(text); + const base = (typeof data?.message === 'string' && data.message) || ''; + const details = Array.isArray(data?.errors) + ? data.errors.filter((x: any) => typeof x === 'string') + : []; + readable = [base, details.join('\n')].filter(Boolean).join('\n') || text || readable; + } catch { + readable = text || readable; + } + } + } catch { + // ignore parsing errors and use default readable + } + throw new Error(readable); + } + // Success: backend returns accessToken similar to signup. Save user like signup flow + try { + const json = await res.json(); + const accessToken: string | undefined = json?.data?.accessToken; + if (accessToken) { + // Dynamically import to avoid SSR issues + const { setStorageAccessToken, setStorageProfile } = await import('@/shared/services'); + setStorageAccessToken(accessToken); + const payloadRaw = accessToken.split('.')[1]; + const payload = atob(payloadRaw); + const profile = JSON.parse(payload); + setStorageProfile(profile); + } + } catch { + // ignore if response body is empty or malformed; navigation will still proceed + } + router.push('/dashboard'); + } catch (e: any) { + const message: string = e?.message || ''; + if (message.toLowerCase().includes('failed to fetch')) { + setFetchFailed(true); + } + // Fallback alert; project also has sonner Toaster globally mounted + const msg = message || 'Не удалось выполнить установку'; + const { toast } = await import('sonner'); + toast.error(msg); + } finally { + setSubmitting(false); + } + } + + // Compute protocols and mismatch for tooltip/visual warning + const pageProto = typeof window !== 'undefined' ? window.location.protocol.replace(':', '') : ''; + let backendProto = ''; + try { + if (backendUrl) { + const u = new URL(backendUrl.startsWith('http') ? backendUrl : `https://${backendUrl}`); + backendProto = u.protocol.replace(':', ''); + } + } catch { + backendProto = ''; + } + const mismatch = Boolean(pageProto && backendProto && pageProto !== backendProto); + + return ( +
+ Gml Frontend +

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

+ +
+ + + Настройки проекта + Укажите основные параметры проекта. + + + + + + + + + + + + {fetchFailed && ( + + Не удалось выполнить запрос (Failed to fetch) + + Убедитесь, что ваш бекенд работает и сертификат валиден. Для проверки откройте + адрес здоровья бекенда — вы должны увидеть "Healthy". Если вы видите + Healthy, вернитесь на эту страницу и продолжите установку. + + + + )} +
+ + +
+
+
+
+
+ ); +} diff --git a/src/features/edit-settings-platform-form/ui/EditSettingsPlatformForm.tsx b/src/features/edit-settings-platform-form/ui/EditSettingsPlatformForm.tsx index 73791df4..8207e1dd 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(); @@ -58,7 +58,7 @@ export const EditSettingsPlatformForm = () => { } }, [platform, isLoading, form]); - const currentProtocol = extractProtocol(process.env.NEXT_PUBLIC_BACKEND_URL); + const currentProtocol = typeof window !== 'undefined' ? extractProtocol(`${window.location.origin}/api/v1`) : undefined; const watchRegistration = form.watch('registrationIsEnabled'); const watchStorageType = form.watch('storageType'); diff --git a/src/services/api.service.ts b/src/services/api.service.ts index 691f09d9..0d7dc789 100644 --- a/src/services/api.service.ts +++ b/src/services/api.service.ts @@ -1,32 +1,136 @@ -import axios, { CreateAxiosDefaults, HttpStatusCode } from 'axios'; +import axios, { CreateAxiosDefaults, HttpStatusCode, InternalAxiosRequestConfig } from 'axios'; +import { toast } from 'sonner'; import { getStorageAccessToken, removeStorageProfile, - removeStorageTokens, removeStorageRecloudIDAccessToken, + removeStorageTokens, } from '@/shared/services'; +import { authService } from '@/shared/services'; + +const getBaseUrl = () => { + if (typeof window !== 'undefined') { + return window.location.origin + '/api/v1'; + } + // SSR or build time fallback: use relative path so browser will resolve current origin + return '/api/v1'; +}; const OPTIONS: CreateAxiosDefaults = { - baseURL: process.env.NEXT_PUBLIC_BACKEND_URL, - headers: { 'Access-Control-Allow-Credentials': '*' }, + baseURL: getBaseUrl(), + withCredentials: true, }; export const $api = axios.create(OPTIONS); -$api.interceptors.request.use((config) => { - const accessToken = getStorageAccessToken(); - config.headers.set('Authorization', `Bearer ${accessToken}`); +// Single-flight guard to avoid parallel refresh calls +let tokenRefreshPromise: Promise | null = null; +let lastTokenRefreshAtSec: number | null = null; // when the last successful refresh finished +const REFRESH_SKEW_SECONDS = 60; // refresh if less than 60s remains +const REFRESH_COOLDOWN_SECONDS = 30; // do not refresh again within this window + +function getTokenExpFromJwt(token: string | null): number | null { + if (!token) return null; + try { + const payloadRaw = token.split('.')[1]; + if (!payloadRaw) return null; + const json = typeof atob !== 'undefined' + ? atob(payloadRaw) + : (globalThis as any)?.Buffer?.from(payloadRaw, 'base64')?.toString('utf-8'); + const obj = JSON.parse(json) as { exp?: number }; + return typeof obj.exp === 'number' ? obj.exp : null; + } catch (_) { + return null; + } +} + +async function ensureTokenFresh() { + const token = getStorageAccessToken(); + const exp = getTokenExpFromJwt(token); + if (!exp) return; // cannot determine, skip + const nowSec = Math.floor(Date.now() / 1000); - return config; -}); + // Prevent refreshing on every request by applying a short cooldown + if (lastTokenRefreshAtSec && nowSec - lastTokenRefreshAtSec < REFRESH_COOLDOWN_SECONDS) { + return; + } + + const secondsLeft = exp - nowSec; + if (secondsLeft > REFRESH_SKEW_SECONDS) return; // still valid enough -$api.interceptors.response.use((response) => { - if (response.status === HttpStatusCode.Unauthorized) { - removeStorageProfile(); - removeStorageTokens(); - removeStorageRecloudIDAccessToken(); + if (!tokenRefreshPromise) { + tokenRefreshPromise = authService + .refresh() + .then(() => { + // mark the time of last successful refresh + lastTokenRefreshAtSec = Math.floor(Date.now() / 1000); + }) + .finally(() => { + tokenRefreshPromise = null; + }); } + await tokenRefreshPromise; +} + +$api.interceptors.request.use( + async (config: InternalAxiosRequestConfig) => { + // Skip refresh loop for the refresh endpoint itself + const url = (config.url || '').toString(); + // Skip proactive refresh for auth endpoints to avoid breaking initial sign in/signup + const skipProactive = url.endsWith('/users/refresh') || url.endsWith('/users/signin') || url.endsWith('/users/signup'); + if (!skipProactive) { + // Proactively refresh if token is about to expire + await ensureTokenFresh(); + } + + const accessToken = getStorageAccessToken(); + if (accessToken) { + config.headers.set('Authorization', `Bearer ${accessToken}`); + } + return config; + }, + (error) => Promise.reject(error), +); + +$api.interceptors.response.use( + (response) => { + if (response.status === HttpStatusCode.Unauthorized) { + removeStorageProfile(); + removeStorageTokens(); + removeStorageRecloudIDAccessToken(); + } + + 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 response; -}); + return Promise.reject(error); + }, +); 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/shared/api/contracts/user/UserBaseEntitry.ts b/src/shared/api/contracts/user/UserBaseEntitry.ts index 74a80baf..10501ada 100644 --- a/src/shared/api/contracts/user/UserBaseEntitry.ts +++ b/src/shared/api/contracts/user/UserBaseEntitry.ts @@ -1,11 +1,28 @@ +export type ApiUserBaseEntity = { + sub: string; + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier': string; + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name': string; + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': string; + 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role': string; + perm: string[]; + nbf: number; + exp: number; + iss: string; + aud: string; +}; + export type UserBaseEntity = { - id: number; - login: string; + name: string; email: string; + role: string; + perm: string[]; + exp: number; }; export interface PlayerBaseEntity { isBanned: boolean; + isBannedPermanent?: boolean; + isLauncherStarted?: 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 c613b6b4..3b128f71 100644 --- a/src/shared/api/contracts/user/UserRequests.ts +++ b/src/shared/api/contracts/user/UserRequests.ts @@ -14,13 +14,14 @@ export type TPostSignUpResponse = ResponseBaseEntity & { }; // Авторизация -export type TPostSignInRequest = { +export type ApiPostSignInRequest = { login: string; password: string; }; -export type TPostSignInResponse = ResponseBaseEntity & { - data: UserBaseEntity & { +export type ApiPostSignInResponse = ResponseBaseEntity & { + data: { accessToken: string; + expiresIn: number; }; }; @@ -28,7 +29,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[]; @@ -36,8 +44,12 @@ export type TGetPlayersResponse = ResponseBaseEntity & { // Бан юзера 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/api/rbac.ts b/src/shared/api/rbac.ts new file mode 100644 index 00000000..44a3803b --- /dev/null +++ b/src/shared/api/rbac.ts @@ -0,0 +1,89 @@ +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 = { + // Users + 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 => { + await $api.delete(`/users/${userId}`); + }, + // 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/shared/hooks/useAuthorization.ts b/src/shared/hooks/useAuthorization.ts index 54744d9d..f54a8bcb 100644 --- a/src/shared/hooks/useAuthorization.ts +++ b/src/shared/hooks/useAuthorization.ts @@ -2,9 +2,9 @@ import { useRouter } from 'next/navigation'; import { useMutation } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { TPostSignInRequest, TPostSignUpRequest } from '@/shared/api/contracts'; +import { ApiPostSignInRequest, TPostSignUpRequest } from '@/shared/api/contracts'; import { DASHBOARD_PAGES } from '@/shared/routes'; -import { authService } from '@/shared/services'; +import { authService, getStorageAccessToken } from '@/shared/services'; import { isAxiosError } from '@/shared/lib/isAxiosError/isAxiosError'; export const useRegistration = () => { @@ -13,12 +13,19 @@ export const useRegistration = () => { return useMutation({ mutationKey: ['signup'], mutationFn: (data: TPostSignUpRequest) => authService.signUp(data), - onSuccess: () => { + onSuccess: async () => { toast.success('Успешная регистрация', { description: 'Добро пожаловать в платформу', }); - route.push(DASHBOARD_PAGES.PROFILES); + const waitForCookie = async (timeoutMs = 1000, stepMs = 50) => { + const start = Date.now(); + while (!getStorageAccessToken() && Date.now() - start < timeoutMs) { + await new Promise((r) => setTimeout(r, stepMs)); + } + }; + await waitForCookie(); + route.replace(DASHBOARD_PAGES.PROFILES); }, onError: (error) => { isAxiosError({ @@ -35,12 +42,20 @@ export const useLogin = () => { return useMutation({ mutationKey: ['signin'], - mutationFn: (data: TPostSignInRequest) => authService.signIn(data), - onSuccess: () => { + mutationFn: (data: ApiPostSignInRequest) => authService.signIn(data), + onSuccess: async () => { toast.success('Успешная авторизация', { description: 'Добро пожаловать в платформу', }); - route.push(DASHBOARD_PAGES.PROFILES); + + const waitForCookie = async (timeoutMs = 1000, stepMs = 50) => { + const start = Date.now(); + while (!getStorageAccessToken() && Date.now() - start < timeoutMs) { + await new Promise((r) => setTimeout(r, stepMs)); + } + }; + await waitForCookie(); + route.replace(DASHBOARD_PAGES.PROFILES); }, onError: (error) => { isAxiosError({ diff --git a/src/shared/hooks/usePlayers.ts b/src/shared/hooks/usePlayers.ts index d118c6fc..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, + 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; }, }); }; @@ -38,7 +53,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 +65,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(); @@ -74,7 +107,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: `Пользователь разблокирован`, @@ -85,3 +118,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/lib/utils.ts b/src/shared/lib/utils.ts index 8495031b..135abcfe 100644 --- a/src/shared/lib/utils.ts +++ b/src/shared/lib/utils.ts @@ -10,17 +10,44 @@ export const enumValues = >(enumEntity: T) => export const emptyArray = (length: number) => Array(length).fill(1); -export const getApiBaseUrl = () => (process.env.NEXT_PUBLIC_BACKEND_URL as string)?.slice(0, -7); +export const getApiBaseUrl = () => { + if (typeof window !== 'undefined') return window.location.origin; + // Fallback for SSR, will be corrected on client + return ''; +}; const isValidToken = (token: string) => { const tokenRegex = /^[a-zA-Z0-9-_]+?\.[a-zA-Z0-9-_]+?\.([a-zA-Z0-9-_]+)?$/; 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 => { diff --git a/src/shared/services/AuthService.ts b/src/shared/services/AuthService.ts index 58532778..3525b83f 100644 --- a/src/shared/services/AuthService.ts +++ b/src/shared/services/AuthService.ts @@ -1,33 +1,69 @@ import { $api } from '@/services/api.service'; import { - TPostSignInRequest, - TPostSignInResponse, + ApiPostSignInRequest, + ApiPostSignInResponse, + ApiUserBaseEntity, TPostSignUpRequest, TPostSignUpResponse, } from '@/shared/api/contracts'; -import { removeStorageTokens, removeStorageRecloudIDAccessToken, setStorageAccessToken, setStorageProfile } from '@/shared/services'; +import { + removeStorageRecloudIDAccessToken, + removeStorageTokens, + setStorageAccessToken, + setStorageProfile, +} from '@/shared/services'; class AuthService { private BASE_URL = '/users'; async signUp(body: TPostSignUpRequest): Promise { const { data } = await $api.post(`${this.BASE_URL}/signup`, body); - const { accessToken, ...profile } = data.data; + const { accessToken } = data.data; setStorageAccessToken(accessToken); + + const payloadRaw = accessToken.split('.')[1]; + const payload = atob(payloadRaw); + + const profile = JSON.parse(payload) as ApiUserBaseEntity; + setStorageProfile(profile); return data; } - async signIn(body: TPostSignInRequest): Promise { - const { data } = await $api.post(`${this.BASE_URL}/signin`, body); - const { accessToken, ...profile } = data.data; + async signIn(body: ApiPostSignInRequest): Promise { + const { data } = await $api.post(`${this.BASE_URL}/signin`, body); + const { accessToken } = data.data; setStorageAccessToken(accessToken); + + const payloadRaw = accessToken.split('.')[1]; + const payload = atob(payloadRaw); + + const profile = JSON.parse(payload) as ApiUserBaseEntity; + setStorageProfile(profile); return data; } + async refresh(): Promise<{ accessToken: string; expiresIn: number }> { + // The API returns ResponseMessage + const { data } = await $api.get<{ + data: { accessToken: string; expiresIn: number }; + }>(`${this.BASE_URL}/refresh`); + + const { accessToken, expiresIn } = data.data; + setStorageAccessToken(accessToken); + + // Update profile from JWT payload so that exp is current + const payloadRaw = accessToken.split('.')[1]; + const payload = atob(payloadRaw); + const profile = JSON.parse(payload) as ApiUserBaseEntity; + setStorageProfile(profile); + + return { accessToken, expiresIn }; + } + async logout() { removeStorageTokens(); removeStorageRecloudIDAccessToken(); diff --git a/src/shared/services/AuthTokenService.ts b/src/shared/services/AuthTokenService.ts index 78bc0d35..12cd3533 100644 --- a/src/shared/services/AuthTokenService.ts +++ b/src/shared/services/AuthTokenService.ts @@ -1,13 +1,22 @@ import Cookies from 'js-cookie'; -import { UserBaseEntity } from '@/shared/api/contracts'; +import { ApiUserBaseEntity, UserBaseEntity } from '@/shared/api/contracts'; export const getStorageProfile = () => { const profile = Cookies.get('profile'); return profile ? (JSON.parse(profile) as UserBaseEntity) : null; }; -export const setStorageProfile = (profile: UserBaseEntity) => { + +export const setStorageProfile = (apiProfile: ApiUserBaseEntity) => { + const profile: UserBaseEntity = { + name: apiProfile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'], + email: apiProfile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'], + role: apiProfile['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'], + exp: apiProfile.exp, + perm: apiProfile.perm, + }; + Cookies.set('profile', JSON.stringify(profile), { domain: process.env.BASE_URL, sameSite: 'strict', @@ -18,6 +27,7 @@ export const setStorageProfile = (profile: UserBaseEntity) => { export const removeStorageProfile = () => Cookies.remove('profile'); export const getStorageAccessToken = () => Cookies.get('accessToken') || null; + export const setStorageAccessToken = (token: string) => { Cookies.set('accessToken', token, { domain: process.env.BASE_URL, diff --git a/src/shared/services/PlayersService.ts b/src/shared/services/PlayersService.ts index 63d68efa..9e7451ff 100644 --- a/src/shared/services/PlayersService.ts +++ b/src/shared/services/PlayersService.ts @@ -4,6 +4,8 @@ import { TGetPlayersResponse, TPostBanPlayersRequest, TPostBanPlayersResponse, + TPostBanPlayersOptions, + TPostPardonPlayersOptions, } from '@/shared/api/contracts'; class PlayersService { @@ -17,14 +19,20 @@ 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; } - 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/shared/ui/AccountNavigation/ui/AccountNavigation.tsx b/src/shared/ui/AccountNavigation/ui/AccountNavigation.tsx index 8bc80d82..1d93661c 100644 --- a/src/shared/ui/AccountNavigation/ui/AccountNavigation.tsx +++ b/src/shared/ui/AccountNavigation/ui/AccountNavigation.tsx @@ -11,7 +11,12 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/shared/ui/dropdown-menu'; -import { getStorageProfile, removeStorageProfile, removeStorageTokens, removeStorageRecloudIDAccessToken } from '@/shared/services'; +import { + getStorageProfile, + removeStorageProfile, + removeStorageRecloudIDAccessToken, + removeStorageTokens, +} from '@/shared/services'; import { AUTH_PAGES } from '@/shared/routes'; export const AccountNavigation = () => { @@ -36,7 +41,7 @@ export const AccountNavigation = () => {
-

{profile?.login}

+

{profile?.name}

{profile?.email}

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< (process.env.NEXT_PUBLIC_BACKEND_URL as string)?.slice(0, -7); +export const getApiBaseUrl = () => { + if (typeof window !== 'undefined') return window.location.origin; + return ''; +}; // Request type for installing a plugin export type PluginInstallRequest = { diff --git a/src/views/profile/ui/Profile.tsx b/src/views/profile/ui/Profile.tsx index be575eb2..58cc84d7 100644 --- a/src/views/profile/ui/Profile.tsx +++ b/src/views/profile/ui/Profile.tsx @@ -20,7 +20,11 @@ import { DASHBOARD_PAGES } from '@/shared/routes'; import { OsArchitectureEnum, OsTypeEnum } from '@/shared/enums'; import { useDeleteFilesWhitelist, useDeleteFolderWhitelist, useProfile } from '@/shared/hooks'; import { getStorageAccessToken, getStorageProfile } from '@/shared/services'; -import { FileListBaseEntity, FileListFolderBaseEntity } from '@/shared/api/contracts'; +import { + FileListBaseEntity, + FileListFolderBaseEntity, + GameLoaderType, +} from '@/shared/api/contracts'; import { Breadcrumbs } from '@/shared/ui/Breadcrumbs'; import { Button } from '@/shared/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'; @@ -58,7 +62,7 @@ export const ProfilePage = ({ params }: { params: { name: string } }) => { useEffect(() => { if (account && accessToken) { mutate({ - UserName: account.login, + UserName: account.name, ProfileName: decodeURIComponent(params.name), UserAccessToken: accessToken, UserUuid: 'uuid', @@ -74,6 +78,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 +153,11 @@ export const ProfilePage = ({ params }: { params: { name: string } }) => { Игроки - - Моды - + {!isVanilla && ( + + Моды + + )}
{
- -
- -
-
+ {!isVanilla && ( + +
+ +
+
+ )} ); 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 new file mode 100644 index 00000000..b77d6cd0 --- /dev/null +++ b/src/views/settings/ui/RolesPermissionsTab.tsx @@ -0,0 +1,1106 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from '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'; +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, + 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'; +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: '' }; + +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 [roleModalOpen, setRoleModalOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [roleToDelete, setRoleToDelete] = useState(null); + + const [editingPerm, setEditingPerm] = useState(null); + const [permForm, setPermForm] = useState>(emptyPerm); + const [permModalOpen, setPermModalOpen] = useState(false); + + 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 [deleteUserOpen, setDeleteUserOpen] = useState(false); + const [deleteUserTarget, setDeleteUserTarget] = useState(null); + + const [userModalOpen, setUserModalOpen] = useState(false); + const [newUser, setNewUser] = useState<{ login: string; password: string; email?: string }>({ + login: '', + 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 }) => { + 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], + ); + 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); + setRoleModalOpen(false); + } 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); + setPermModalOpen(false); + } 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 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); + 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, + }, + newUserRole?.trim() || undefined, + ); + setUserModalOpen(false); + setNewUser({ login: '', password: '', email: '' }); + setNewUserRole(''); + setUserErrors({}); + 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 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]); + + 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; + 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 ( +
+ + + Пользователи + Роли + Права + Матрица + + + +
+

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

+
+ + +
+
+
+ + + + ID + Логин/Email + Роли пользователя + Действия + + + + {users.map((u) => ( + + {u.id} + {u.login ?? u.email ?? '-'} + + {u.roles?.length ? ( +
+ {u.roles.map((r) => ( + + ))} +
+ ) : ( + '-' + )} +
+ + + + + + +
+
Действия
+
+ +
+
+
+
Роли
+
+ {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 && ( + + + Нет пользователей + + + )} +
+
+
+ + + + + Подтверждение удаления пользователя + +
+ Вы уверены, что хотите удалить пользователя « + {deleteUserTarget?.login ?? deleteUserTarget?.email ?? deleteUserTarget?.id}»? +
+ + + + +
+
+ + + + + Создать пользователя + +
+
+ + { + 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}
+ )} +
+
+ + +
+
+ + + + +
+
+
+ + +
+

Матрица прав

+ +
+
+ + + + Право + {rolesReversed.map((r) => ( + + {r.name} + + ))} + + + + {permsByGroup.map(([group, list]) => ( + + + +
{group}
+
Все из группы
+
+ {rolesReversed.map((r) => ( + + { + if (isAdminRole(r)) return; + toggleRoleGroup(r.id, list, !!v); + }} + /> + + ))} +
+ {list.map((p) => ( + + +
+
{p.name}
+ {isSystemPerm(p) && ( + + + + SYSTEM + + + + Системное право. Изменение запрещено. + + + )} +
+ {p.description && ( +
{p.description}
+ )} +
+ {rolesReversed.map((r) => ( + + { + if (isAdminRole(r)) return; + toggleRolePerm(r.id, p.id, !!v); + }} + /> + + ))} +
+ ))} +
+ ))} + {!perms.length && ( + + + Нет прав + + + )} +
+
+
+
+ + +

Роли

+
+
Управление ролями
+ +
+
+ {roles.map((r) => ( +
+
+
{r.name}
+ {r.description && ( +
{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}»? +
+ + + + +
+
+
+ + +
+

Права

+
+ + +
+
+ + {/* Groups */} +
+ {permsByGroup.map(([group, list]) => ( +
+
+
+ {group} + {list.length} +
+
+
+ {list.map((p) => ( +
+
+
+
{p.name}
+ {isSystemPerm(p) && ( + + + + SYSTEM + + + Системное право. Изменение запрещено. + + )} +
+ {p.description && ( +
{p.description}
+ )} +
+ + + + + + { + if (isSystemPerm(p)) return; + setEditingPerm(p); + setPermForm({ name: p.name, description: p.description ?? '' }); + setPermModalOpen(true); + }} + > + Редактировать + + { + if (!isSystemPerm(p)) deletePerm(p.id); + }} + > + Удалить + + + +
+ ))} + {!list.length && ( +
+ Нет прав в группе. +
+ )} +
+
+ ))} + {!perms.length &&
Прав пока нет.
} +
+ + {/* Create/Edit Permission Modal */} + + + + {editingPerm ? 'Редактировать право' : 'Создать право'} + +
+
+ + setPermForm({ ...permForm, name: e.target.value })} + /> +
+
+ + setPermForm({ ...permForm, description: e.target.value })} + /> +
+
+ + + + +
+
+
+
+
+ ); +}; diff --git a/src/views/settings/ui/Settings.tsx b/src/views/settings/ui/Settings.tsx index 45a2d4c9..83da8851 100644 --- a/src/views/settings/ui/Settings.tsx +++ b/src/views/settings/ui/Settings.tsx @@ -1,5 +1,10 @@ +'use client'; + import React from 'react'; +import { RolesPermissionsTab } from './RolesPermissionsTab'; +import { ApiKeysTab } from './ApiKeysTab'; + import { EditSettingsPlatformForm } from '@/features/edit-settings-platform-form'; import { Breadcrumbs } from '@/shared/ui/Breadcrumbs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'; @@ -15,7 +20,7 @@ export const SettingsPage = () => {

Настройки

-
+
{ Основные + + Роли и права + + Beta + + + {/**/} + {/* Api ключи*/} + {/**/} + + + + + +
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/client-hub/ui/ClientState.tsx b/src/widgets/client-hub/ui/ClientState.tsx index 526fac44..5bd45d46 100644 --- a/src/widgets/client-hub/ui/ClientState.tsx +++ b/src/widgets/client-hub/ui/ClientState.tsx @@ -22,10 +22,10 @@ export function ClientState({ state }: ClientStateProps) { <>
{EntityStateOption[`OPTION_${state}` as keyof typeof EntityStateOption]} diff --git a/src/widgets/client-hub/ui/DownloadClientHub.tsx b/src/widgets/client-hub/ui/DownloadClientHub.tsx index d9f0090f..eec4be59 100644 --- a/src/widgets/client-hub/ui/DownloadClientHub.tsx +++ b/src/widgets/client-hub/ui/DownloadClientHub.tsx @@ -28,7 +28,6 @@ import { } from '@/shared/ui/command'; import { Separator } from '@/shared/ui/separator'; import { Popover, PopoverContent, PopoverTrigger } from '@/shared/ui/popover'; -import { useGetJavaVersions } from '@/shared/hooks'; interface DownloadClientHubProps { profile?: ProfileExtendedBaseEntity; @@ -57,7 +56,8 @@ export function DownloadClientHub(props: DownloadClientHubProps) { logs, } = useConnectionHub(props); - const javaVersions = useGetJavaVersions(); + // const javaVersions = useGetJavaVersions(); + const javaVersions: { data?: JavaVersionBaseEntity[] } = { data: [] }; const form = useForm(); 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(); diff --git a/src/widgets/players-table/lib/columns.tsx b/src/widgets/players-table/lib/columns.tsx index 501b54a2..8b538d04 100644 --- a/src/widgets/players-table/lib/columns.tsx +++ b/src/widgets/players-table/lib/columns.tsx @@ -1,18 +1,38 @@ '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 { Ban as BanIcon, GavelIcon, MoreVertical, ShieldCheck, Trash, User, Monitor, Laptop, Smartphone, Tablet } 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 { useBanPlayer, usePardonPlayer, 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'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'; enum ColumnHeader { ICON = '', @@ -21,19 +41,372 @@ enum ColumnHeader { SESSION_END_DATE = 'Дата завершения сессии', UUID = 'UUID', BANNED = 'Заблокирован', + BANNED_HARDWARE = 'Заблокирован по железу', IP_ADDRESS = 'IP Адрес', ACTIONS = 'Действия', } 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)} + /> + ); +} +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)), + ); + + 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 ( + + + + + skin + {player.name} + + UUID: {player.uuid} + + {/* Tabs layout for player card */} +
+ + + Обзор + Текстуры + Авторизации + Сети/IP + Сервер + + +
+ +
+
+
+
Статус
+
+ {player.isBanned ? ( + + Заблокирован + + ) : ( + + Не заблокирован + + )} +
+ {player.isLauncherStarted && ( +
+ + Лаунчер запущен +
+ )} +
+
+
Сессия истекает
+
{sessionStr}
+
+
+
+ {addresses.length > 0 && ( +
+
IP адреса
+
{addresses.join(', ')}
+
+ )} +
+
+
+ + +
+
Текстуры
+
+ {player.textureSkinUrl && ( + skin + )} + {player.textureCloakUrl && ( + cloak + )} + {!player.textureSkinUrl && !player.textureCloakUrl && ( +
Нет текстур.
+ )} +
+
+
+ + +
+
Авторизации
+
+ {(player.authHistory || []).map((h, idx) => ( +
+
+ {getDeviceIcon(h.device)} +
+
+
+
+ {h.device || 'Неизвестное устройство'} +
+ {h.hwid && ( + + HWID: {h.hwid} + + )} +
+
+ {h.address || '-'} +
+
+ {timeAgo(h.date)} + {h.protocol} +
+
+
+ ))} + {(!player.authHistory || player.authHistory.length === 0) && ( +
Нет данных.
+ )} +
+
+
+ + + {addresses.length > 0 ? ( +
+ {addresses.map((a) => ( + + {a} + + ))} +
+ ) : ( +
Нет IP адресов.
+ )} +
+ + + {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); + 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 ( + <> + + + + + + setOpen(true)}> + Карточка игрока + + {isBanned ? ( + pardonUser(row.original.uuid)}> + Разбанить + + ) : ( + banUser(row.original.uuid)}> + Забанить + + )} + {isHwPermanent && ( + pardonUserHardware(row.original.uuid)}> + Разбанить по железу + + )} + {!isHwPermanent && ( + banUserHardware(row.original.uuid)}> + Забанить по железу + + )} + setConfirmOpen(true)}> + Удалить + + + + + + + + Подтверждение удаления + + Вы уверены, что хотите удалить игрока "{row.original.name}" (UUID:{' '} + {row.original.uuid})? Это действие необратимо. + + +
+ + +
+
+
+ + ); +} + export const useColumns = () => { 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]); }; @@ -42,64 +415,207 @@ export const useColumns = () => { await pardonPlayer.mutateAsync([data]); }; + const pardonUserHardware = async (data: string) => { + await pardonPlayerHardware.mutateAsync([data]); + }; + 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} + + {row.original.isLauncherStarted && ( +
+ + Лаунчер запущен +
+ )} +
+
+ 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 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}; + }, }), - columnsHelper.accessor('isBanned', { - size: 100, - 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', - 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,44 +637,11 @@ export const useColumns = () => { }), columnsHelper.display({ id: 'actions', - size: 100, + size: 64, header: ({ column }) => ( ), - cell: ({ row }) => { - return ( -
- {row.original.isBanned ? ( - - ) : ( - - )} - - -
- ); - }, + cell: ({ row }) => , }), ]; diff --git a/src/widgets/players-table/ui/PlayersTable.tsx b/src/widgets/players-table/ui/PlayersTable.tsx index ef57668b..4716906c 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, Filter, Fingerprint, Hash, SearchIcon, User as UserIcon, X } from 'lucide-react'; import { useColumns } from '../lib/columns'; @@ -21,11 +21,42 @@ 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'; +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'; export function PlayersTable() { const { ref, inView } = useInView(); const [search, setSearch] = useState(''); - const { data: players, status, error, fetchNextPage, refetch } = usePlayers(search); + 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: undefined as string | undefined, + findUuid: undefined as string | undefined, + findIp: undefined as string | undefined, + findHwid: undefined as string | undefined, + onlyBlocked: false, + onlyDeviceBlocked: false, + sortBy: undefined as 0 | 1 | 2 | undefined, + sortDesc: false, + take: 20, + }); + + const { + data: players, + isPending, + isError, + error, + fetchNextPage, + refetch, + } = usePlayers(debouncedFilters); const [sorting, setSorting] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); @@ -57,31 +88,267 @@ export function PlayersTable() { } }, [fetchNextPage, inView]); + // Auto-search when user stops typing (debounce) + useEffect(() => { + const handler = setTimeout(() => { + 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, uuid, ip, hwid, onlyBlocked, onlyDeviceBlocked, sortBy, sortDesc, take]); + const onSubmit = (event: React.FormEvent) => { event.preventDefault(); + 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(); }; return ( <> - {status === 'pending' ? ( - - ) : status === 'error' ? ( - Error: {error.message} - ) : ( -
-
- setSearch(e.target.value)} - /> - -
+ {/* Keep filters always mounted to preserve focus */} + {isError && Error: {error.message}} +
+ + + + Фильтры игроков + + + +
+
+
+ + 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 && (
@@ -122,7 +389,6 @@ export function PlayersTable() { )}
- )} ); }