diff --git a/.github/workflows/docker-package-publish.yml b/.github/workflows/docker-package-publish.yml index ad751c72..8e10ac38 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', 'develop' ] + branches: [ 'master', 'develop', 'feature/*' ] # Publish semver tags as releases. tags: [ 'v*.*', 'v*.*.*', 'v*.*.*-beta*' ] pull_request: - branches: [ 'master', 'develop' ] + branches: [ 'master', 'develop', 'feature/*' ] env: # Use docker.io for Docker Hub if empty @@ -21,25 +21,43 @@ env: jobs: build: - runs-on: ubuntu-latest + name: Build (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-22.04 + - platform: linux/arm64 + runner: ubuntu-22.04-arm permissions: contents: read packages: write - # This is used to complete the identity challenge - # with sigstore/fulcio when running outside of PRs. id-token: write + outputs: + image-name: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - # Install the cosign tool except on PR - # https://github.com/sigstore/cosign-installer - - name: Install cosign - if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1 - with: - cosign-release: 'v2.1.1' + # Ensure repository/image name is lowercase for Docker registry + - name: Set lowercase image name + id: image + run: | + image="${IMAGE_NAME}" + echo "lower=${image,,}" >> "$GITHUB_OUTPUT" + echo "IMAGE_NAME_LC=${image,,}" >> "$GITHUB_ENV" + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + + - name: Prepare platform name + id: platform + run: | + platform=${{ matrix.platform }} + echo "name=${platform//\//-}" >> $GITHUB_OUTPUT # Set up BuildKit Docker container builder to be able to build # multi-platform images and export cache @@ -63,17 +81,118 @@ jobs: id: meta uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: ${{ env.REGISTRY }}/${{ steps.image.outputs.lower }} + flavor: | + latest=false + tags: | + type=raw,value=${{ github.sha }}-${{ steps.platform.outputs.name }} # Build and push Docker image with Buildx (don't push on PR) # https://github.com/docker/build-push-action - - name: Build and push Docker image - id: build-and-push + - name: Build and push Docker image by digest + id: build uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 with: context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} + platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + outputs: type=image,name=${{ env.REGISTRY }}/${{ steps.image.outputs.lower }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + cache-from: type=gha,scope=build-${{ steps.platform.outputs.name }} + cache-to: type=gha,mode=max,scope=build-${{ steps.platform.outputs.name }} + provenance: false + sbom: false + + - name: Export digest + if: github.event_name != 'pull_request' + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: digests-${{ steps.platform.outputs.name }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Create multi-arch manifest + runs-on: ubuntu-22.04 + needs: build + if: github.event_name != 'pull_request' + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + pattern: digests-* + path: /tmp/digests + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + - name: Log into registry ${{ env.REGISTRY }} + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Ensure repository/image name is lowercase for Docker registry + - name: Set lowercase image name + id: image + run: | + image="${IMAGE_NAME}" + echo "lower=${image,,}" >> "$GITHUB_OUTPUT" + echo "IMAGE_NAME_LC=${image,,}" >> "$GITHUB_ENV" + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ steps.image.outputs.lower }} + + - name: Create manifest list and push + id: create-manifest + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ steps.image.outputs.lower }}@sha256:%s ' *) + + # Get the digest of the manifest + manifest_digest=$(docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ steps.image.outputs.lower }}:${{ steps.meta.outputs.version }} --format '{{json .Manifest}}' | jq -r '.digest // .Digest // empty') + if [ -z "$manifest_digest" ]; then + manifest_digest=$(docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ steps.image.outputs.lower }}:${{ steps.meta.outputs.version }} --format '{{.Digest}}') + fi + echo "digest=${manifest_digest}" >> $GITHUB_OUTPUT + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ steps.image.outputs.lower }}:${{ steps.meta.outputs.version }} + + # Install cosign (before using it) + - name: Install cosign + uses: sigstore/cosign-installer@v3.7.0 + with: + cosign-release: 'v2.2.4' + + # Sign the pushed image by digest (skip on PRs) + - name: Sign image with cosign + if: github.event_name != 'pull_request' + env: + DIGEST: ${{ steps.create-manifest.outputs.digest }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/package-lock.json b/package-lock.json index dd122069..7f5faf83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gml-frontend", - "version": "25.2", + "version": "25.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gml-frontend", - "version": "25.2", + "version": "25.3", "dependencies": { "@faker-js/faker": "9.0.0", "@hookform/resolvers": "3.9.0", diff --git a/package.json b/package.json index 74a62457..01a32ccd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gml-frontend", - "version": "25.2", + "version": "25.3", "private": true, "scripts": { "dev": "next dev --experimental-https", diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 0f53eb07..5bec5367 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -1,10 +1,8 @@ import Image from 'next/image'; -import Link from 'next/link'; import classes from './styles.module.css'; import { LoginPluginScriptViewer, SignInForm } from '@/features/auth-credentials-form'; -import { AUTH_PAGES } from '@/shared/routes'; import logo from '@/assets/logos/logo.svg'; export default function Page() { @@ -22,12 +20,12 @@ export default function Page() { -
- Нет аккаунта?{' '} - - Регистрация - -
+ {/*
*/} + {/* Нет аккаунта?{' '}*/} + {/* */} + {/* Регистрация*/} + {/* */} + {/*
*/}
diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 41b57520..9144aa00 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -1,38 +1,7 @@ -import Image from 'next/image'; -import Link from 'next/link'; +import { redirect } from 'next/navigation'; -import classes from './styles.module.css'; - -import { SignUpForm } from '@/features/auth-credentials-form'; import { AUTH_PAGES } from '@/shared/routes'; -import logo from '@/assets/logos/logo.svg'; export default function Page() { - return ( - <> -
-
-
-
- Gml Frontend -

Регистрация

-

- Введите свой адрес электронной почты ниже, чтобы создать свою учетную запись -

-
- -
- Уже есть аккаунт?{' '} - - Войти - -
-
-
-
- Gml Frontend -
-
- - ); + redirect(AUTH_PAGES.SIGN_IN); } diff --git a/src/app/mnt/page.tsx b/src/app/mnt/page.tsx index 6bced766..64e17fc7 100644 --- a/src/app/mnt/page.tsx +++ b/src/app/mnt/page.tsx @@ -75,7 +75,7 @@ export default function MntPage() {

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

diff --git a/src/app/page.tsx b/src/app/page.tsx index ef98e3c9..9a262fc4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,142 +1,113 @@ -import Link from 'next/link'; -import { CodeIcon, DesktopIcon, LockClosedIcon } from '@radix-ui/react-icons'; -import { - AtomIcon, - CloudUploadIcon, - GamepadIcon, - GlobeLockIcon, - MessageSquareHeartIcon, - PencilIcon, - ServerIcon, - SettingsIcon, - ShieldIcon, -} from 'lucide-react'; +import Image from 'next/image'; import React from 'react'; +import Link from 'next/link'; -import { cn } from '@/shared/lib/utils'; +import { config } from '@/core/configs'; +import logo from '@/assets/logos/logo.svg'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'; import { AUTH_PAGES } from '@/shared/routes'; +import { cn } from '@/shared/lib/utils'; import { buttonVariants } from '@/shared/ui/button'; -import WelcomeNavbar from '@/app/auth/welcome/welcomeNavbar'; -import { Card, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'; - -const cardData = [ - { - icon: , - title: 'Кроссплатформенность', - description: - 'Поддержка различных операционных систем для легкости развертывания и использования.', - }, - { - icon: , - title: 'Инфраструктура для Minecraft', - description: - 'Полный набор инструментов для создания и управления игровыми клиентами и профилями.', - }, - { - icon: , - title: 'Безопасность и защита', - description: - 'Встроенные механизмы безопасности и базовая защита для обеспечения безопасности проекта.', - }, - { - icon: , - title: 'Гибкость и расширяемость', - description: - 'Легкая адаптация под различные требования и возможность расширять функциональность.', - }, - { - icon: , - title: 'Установка на любую ОС', - description: - 'Простота и гибкость установки на Windows, Linux или macOS (сборка из исходного кода) с использованием Docker и Pterodactyl для удобства.', - }, - { - icon: , - title: 'Собственная авторизация', - description: - 'Возможность использования собственной, так и предустановленной системы авторизации.', - }, - { - icon: , - title: 'Современные технологии', - description: - 'Использование C#, .NET 8, Docker и других технологий для обеспечения высокой производительности.', - }, - { - icon: , - title: 'Поддержка серверов', - description: 'Удобная настройка и управление игровыми серверами с полной поддержкой Docker.', - }, - { - icon: , - title: 'Редактирование лаунчера', - description: - 'Легкость в редактировании стилей, цветов и компонентов лаунчера для индивидуальных нужд.', - }, - { - icon: , - title: 'Публикация лаунчера', - description: 'Простота публикации лаунчера через панель для различных операционных систем.', - }, - { - icon: , - title: 'Интеграция с популярными сервисами', - description: - 'Возможность подключения к сторонним API, таким как Discord, и другими платформами.', - }, - { - icon: , - title: 'Обновления и поддержка', - description: - 'Регулярные обновления с улучшениями и исправлениями, а также доступ к документации .', - }, -]; export default function Home() { return ( <> -
- - -
-
-

Спасибо за выбор Gml

-

- Добро пожаловать в панель управления! Мы рады приветствовать вас в панели управления. - Здесь вы можете настроить, управлять и следить за всеми аспектами вашего проекта. -

- -
+
+
+ + Gml Frontend + {config.name} + + + + Добро пожаловать + + Здесь вы можете настроить, управлять и следить за всеми аспектами вашего проекта. + + + - Войти - - - Регистрация + Продолжить -
-
- -
- {cardData.map((card) => ( -
- - - {card.icon} - {card.title} - {card.description} - - -
- ))} -
+ +
+
+ {/*
*/} + {/* Gml Frontend*/} + {/*

Добро пожаловать!

*/} + + {/*
*/} + {/* */} + {/* */} + {/* Панель управления вашим игровым проектом*/} + {/* */} + {/* Добро пожаловать в панель управления! Мы рады приветствовать вас в панели*/} + {/* управления. Здесь вы можете настроить, управлять и следить за всеми аспектами вашего*/} + {/* проекта.*/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* Войти*/} + {/* */} + {/* */} + {/* */} + + {/*
*/} + {/* {config.name} {config.version}*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/* */} + + {/*
*/} + {/*
*/} + {/*

Спасибо за выбор Gml

*/} + {/*

*/} + {/* Добро пожаловать в панель управления! Мы рады приветствовать вас в панели управления.*/} + {/* Здесь вы можете настроить, управлять и следить за всеми аспектами вашего проекта.*/} + {/*

*/} + + {/*
*/} + {/* */} + {/* Войти*/} + {/* */} + {/* /!**!/*/} + {/* /!* Регистрация*!/*/} + {/* /!**!/*/} + {/*
*/} + {/*
*/} + + {/* /!*
*!/*/} + {/* /!* {cardData.map((card) => (*!/*/} + {/* /!*
*!/*/} + {/* /!* *!/*/} + {/* /!* *!/*/} + {/* /!* {card.icon}*!/*/} + {/* /!* {card.title}*!/*/} + {/* /!* {card.description}*!/*/} + {/* /!* *!/*/} + {/* /!* *!/*/} + {/* /!*
*!/*/} + {/* /!* ))}*!/*/} + {/* /!*
*!/*/} + {/*
*/} + {/*
*/} ); } diff --git a/src/features/applications/api/index.ts b/src/features/applications/api/index.ts new file mode 100644 index 00000000..d337e568 --- /dev/null +++ b/src/features/applications/api/index.ts @@ -0,0 +1 @@ +export * from './useApplications'; diff --git a/src/features/applications/api/useApplications.ts b/src/features/applications/api/useApplications.ts new file mode 100644 index 00000000..be8fac7f --- /dev/null +++ b/src/features/applications/api/useApplications.ts @@ -0,0 +1,55 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { applicationsApi } from '@/shared/api/applications'; +import { ExternalApplicationCreateDto } from '@/shared/api/contracts'; + +// Query key for applications +const APPLICATIONS_QUERY_KEY = ['applications']; + +/** + * Hook to fetch all applications + */ +export const useApplications = () => { + return useQuery({ + queryKey: APPLICATIONS_QUERY_KEY, + queryFn: () => applicationsApi.getApplications(), + }); +}; + +/** + * Hook to create a new application + */ +export const useCreateApplication = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: ExternalApplicationCreateDto) => applicationsApi.createApplication(data), + onSuccess: (data) => { + toast.success('Приложение успешно создано'); + queryClient.invalidateQueries({ queryKey: APPLICATIONS_QUERY_KEY }); + return data; + }, + onError: (error: any) => { + toast.error(error?.message || 'Ошибка при создании приложения'); + }, + }); +}; + +/** + * Hook to delete an application + */ +export const useDeleteApplication = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => applicationsApi.deleteApplication(id), + onSuccess: () => { + toast.success('Приложение успешно удалено'); + queryClient.invalidateQueries({ queryKey: APPLICATIONS_QUERY_KEY }); + }, + onError: (error: any) => { + toast.error(error?.message || 'Ошибка при удалении приложения'); + }, + }); +}; diff --git a/src/features/applications/index.ts b/src/features/applications/index.ts new file mode 100644 index 00000000..308f5ae1 --- /dev/null +++ b/src/features/applications/index.ts @@ -0,0 +1 @@ +export * from './api'; \ No newline at end of file diff --git a/src/features/authentication-azuriom-form/ui/AuthenticationFormAzuriom.tsx b/src/features/authentication-azuriom-form/ui/AuthenticationFormAzuriom.tsx index de80b768..d71d96b2 100644 --- a/src/features/authentication-azuriom-form/ui/AuthenticationFormAzuriom.tsx +++ b/src/features/authentication-azuriom-form/ui/AuthenticationFormAzuriom.tsx @@ -66,7 +66,7 @@ export function AuthenticationFormAzuriom({ className, onOpenChange, ...props }: в Azuriom есть проблема, которая не позволит заходить другим людям на ваши сервера!
- + Прочитайте эту статью , чтобы узнать подробности. diff --git a/src/middleware.ts b/src/middleware.ts index 8eb6605a..e0da2f83 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -12,7 +12,7 @@ const protectedRoutes = [ DASHBOARD_PAGES.PROFILES, DASHBOARD_PAGES.PROFILE, ]; -const publicRoutes = ['/', AUTH_PAGES.HOME, AUTH_PAGES.SIGN_IN, AUTH_PAGES.SIGN_UP]; +const publicRoutes = ['/', AUTH_PAGES.HOME, AUTH_PAGES.SIGN_IN]; export async function middleware(request: NextRequest) { const { diff --git a/src/shared/api/applications.ts b/src/shared/api/applications.ts new file mode 100644 index 00000000..e1b72bf8 --- /dev/null +++ b/src/shared/api/applications.ts @@ -0,0 +1,34 @@ +import { $api } from '@/services/api.service'; +import { + ExternalApplicationCreateDto, + ExternalApplicationListDto, + ExternalApplicationReadDto, + TGetApplicationsResponse, + TPostApplicationResponse, + TDeleteApplicationResponse +} from '@/shared/api/contracts'; + +export const applicationsApi = { + /** + * Get all applications for the current user + */ + async getApplications(): Promise { + const { data } = await $api.get('/applications'); + return data.data; + }, + + /** + * Create a new external application + */ + async createApplication(payload: ExternalApplicationCreateDto): Promise { + const { data } = await $api.post('/applications', payload); + return data.data; + }, + + /** + * Delete an external application + */ + async deleteApplication(id: string): Promise { + await $api.delete(`/applications/${id}`); + } +}; \ No newline at end of file diff --git a/src/shared/api/contracts/applications/requests.ts b/src/shared/api/contracts/applications/requests.ts new file mode 100644 index 00000000..e2569fe4 --- /dev/null +++ b/src/shared/api/contracts/applications/requests.ts @@ -0,0 +1,22 @@ +import { + ExternalApplicationListDto, + ExternalApplicationReadDto, + ExternalApplicationCreateDto +} from './schemas'; + +import { ResponseBaseEntity } from '@/shared/api/schemas'; + +// GET /api/v1/applications +export type TGetApplicationsResponse = ResponseBaseEntity & { + data: ExternalApplicationListDto[]; +}; + +// POST /api/v1/applications +export type TPostApplicationRequest = ExternalApplicationCreateDto; + +export type TPostApplicationResponse = ResponseBaseEntity & { + data: ExternalApplicationReadDto; +}; + +// DELETE /api/v1/applications/{id} +export type TDeleteApplicationResponse = ResponseBaseEntity; diff --git a/src/shared/api/contracts/applications/schemas.ts b/src/shared/api/contracts/applications/schemas.ts new file mode 100644 index 00000000..bdc0c56f --- /dev/null +++ b/src/shared/api/contracts/applications/schemas.ts @@ -0,0 +1,25 @@ +export interface PermissionDto { + id: number; + name: string; + description?: string; +} + +export interface ExternalApplicationBaseEntity { + id: string; + name: string; + createdAtUtc: string; +} + +export interface ExternalApplicationListDto extends ExternalApplicationBaseEntity { + permissions: PermissionDto[]; +} + +export interface ExternalApplicationReadDto extends ExternalApplicationBaseEntity { + token: string; + permissions: PermissionDto[]; +} + +export interface ExternalApplicationCreateDto { + name: string; + permissionIds: number[]; +} diff --git a/src/shared/api/contracts/index.ts b/src/shared/api/contracts/index.ts index 2dae0c3e..88872663 100644 --- a/src/shared/api/contracts/index.ts +++ b/src/shared/api/contracts/index.ts @@ -25,3 +25,7 @@ export * from '@/shared/api/contracts/gameservers/zod'; // Контракты для уведомлений export * from '@/shared/api/contracts/notification/schemas'; export * from '@/shared/api/contracts/notification/requests'; + +// Applications +export * from '@/shared/api/contracts/applications/schemas'; +export * from '@/shared/api/contracts/applications/requests'; diff --git a/src/shared/api/contracts/user/UserBaseEntitry.ts b/src/shared/api/contracts/user/UserBaseEntitry.ts index 10501ada..2e752324 100644 --- a/src/shared/api/contracts/user/UserBaseEntitry.ts +++ b/src/shared/api/contracts/user/UserBaseEntitry.ts @@ -31,6 +31,8 @@ export interface PlayerBaseEntity { expiredDate: string; textureSkinUrl: string; textureCloakUrl: string; + externalTextureSkinUrl: string; + externalTextureCloakUrl: string; textureSkinGuid: string; textureCloakGuid: string; fullSkinUrl?: any; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index ec75bfff..0b06c8ce 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -7,7 +7,7 @@ export const HREF_GET_WEBMCR_RELOADED_AUTH_PHP = export const HREF_GET_WORDPRESS_AUTH_PHP = 'https://github.com/GamerVII-NET/Gml.Modules.Auth/tree/master/WordPress'; export const HREF_DOCUMENTATION_CUSTOM_ENDPOINT = - 'https://gml-launcher.github.io/Gml.Docs/integrations-auth-custom.html'; + 'https://wiki.recloud.tech/docs/gml-launcher/backend/authorization/custom'; export const HREF_DISCORD = 'https://discord.com/invite/b5xgqfWgNt'; export const HREF_RECLOUD_PRO = 'https://market.recloud.tech/#:~:text=Gml%20Лаунчер%20%7C-,Тариф%20Pro,-(месяц)'; diff --git a/src/shared/routes/index.ts b/src/shared/routes/index.ts index 4d4406bb..355a1447 100644 --- a/src/shared/routes/index.ts +++ b/src/shared/routes/index.ts @@ -1,7 +1,7 @@ export const AUTH_PAGES = { HOME: '/auth', SIGN_IN: '/auth/signin', - SIGN_UP: '/auth/signup', + // SIGN_UP: '/auth/signup', }; export const DASHBOARD_PAGES = { diff --git a/src/views/settings/ui/ApplicationsTab.tsx b/src/views/settings/ui/ApplicationsTab.tsx new file mode 100644 index 00000000..8efa718d --- /dev/null +++ b/src/views/settings/ui/ApplicationsTab.tsx @@ -0,0 +1,403 @@ +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import { MoreVertical, Plus, Trash, Copy, CheckCircle } from 'lucide-react'; + +import { + useApplications, + useCreateApplication, + useDeleteApplication, +} from '@/features/applications'; +import { ExternalApplicationCreateDto, ExternalApplicationReadDto } from '@/shared/api/contracts'; +import { rbacApi, PermissionDto } from '@/shared/api/rbac'; +import { Button } from '@/shared/ui/button'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/shared/ui/table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/shared/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/shared/ui/dialog'; +import { Label } from '@/shared/ui/label'; +import { Input } from '@/shared/ui/input'; +import { Icons } from '@/shared/ui/icons'; +import { MultiSelect } from '@/shared/ui/multi-select'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/shared/ui/alert-dialog'; + +export const ApplicationsTab: React.FC = () => { + // Fetch applications data + const { data: applications = [], isLoading: isLoadingApplications, error } = useApplications(); + + // Create application mutation + const { mutateAsync: createApplication, isPending: isCreating } = useCreateApplication(); + + // Delete application mutation + const { mutateAsync: deleteApplication, isPending: isDeleting } = useDeleteApplication(); + + // State for permissions + const [permissions, setPermissions] = useState([]); + const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); + + const [createModalOpen, setCreateModalOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [successDialogOpen, setSuccessDialogOpen] = useState(false); + const [createdApplication, setCreatedApplication] = useState( + null, + ); + const [applicationToDelete, setApplicationToDelete] = useState<{ + id: string; + name: string; + } | null>(null); + const [newApplication, setNewApplication] = useState({ + name: '', + permissionIds: [], + }); + + // Fetch permissions when component mounts + useEffect(() => { + const fetchPermissions = async () => { + setIsLoadingPermissions(true); + try { + const perms = await rbacApi.getPermissions(); + setPermissions(perms); + } catch (error) { + console.error('Failed to fetch permissions:', error); + } finally { + setIsLoadingPermissions(false); + } + }; + + fetchPermissions(); + }, []); + + // Group permissions by their prefix (before the dot in the permission name) + const permsByGroup = useMemo(() => { + const map = new Map(); + for (const p of permissions) { + const name = p.name ?? ''; + const group = name.includes('.') ? name.split('.')[0] : 'other'; + const list = map.get(group) ?? []; + list.push(p); + map.set(group, list); + } + // sort groups alphabetically, and items within group by name + return Array.from(map.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([g, list]) => [g, list.sort((x, y) => (x.name ?? '').localeCompare(y.name ?? ''))]) as [ + string, + PermissionDto[], + ][]; + }, [permissions]); + + // Convert permissions to format expected by MultiSelect with groups + const permissionOptions = useMemo(() => { + return permsByGroup.flatMap(([group, perms]) => [ + // Group header - using a custom component would be better, but we'll use a special label format + { + label: `▼ ${group.toUpperCase()} - Группа прав`, + value: `group-${group}`, + icon: undefined, + }, + // Individual permissions with descriptions and indentation + ...perms.map((perm) => ({ + label: perm.description ? ` • ${perm.name} - ${perm.description}` : ` • ${perm.name}`, + value: perm.id.toString(), + icon: undefined, + })), + ]); + }, [permsByGroup]); + + // Handle permission selection changes + const handlePermissionChange = (selectedValues: string[]) => { + // Filter out group headers (values starting with "group-") + const filteredValues = selectedValues.filter((val) => !val.startsWith('group-')); + + // Convert string IDs back to numbers + const permissionIds = filteredValues.map((val) => parseInt(val, 10)).filter((id) => !isNaN(id)); // Filter out any NaN values + + setNewApplication((prev) => ({ ...prev, permissionIds })); + }; + + const handleCreateApplication = async () => { + const createdApp = await createApplication(newApplication); + setCreatedApplication(createdApp); + setNewApplication({ name: '', permissionIds: [] }); + setCreateModalOpen(false); + setSuccessDialogOpen(true); + }; + + const handleDeleteApplication = async () => { + if (!applicationToDelete) return; + + await deleteApplication(applicationToDelete.id); + setApplicationToDelete(null); + setDeleteDialogOpen(false); + }; + + return ( +
+
+

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

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

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

+
+ + + +
+
+
+ ); +}; diff --git a/src/views/settings/ui/Settings.tsx b/src/views/settings/ui/Settings.tsx index 83da8851..9f8f7e86 100644 --- a/src/views/settings/ui/Settings.tsx +++ b/src/views/settings/ui/Settings.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { RolesPermissionsTab } from './RolesPermissionsTab'; +import { ApplicationsTab } from './ApplicationsTab'; import { ApiKeysTab } from './ApiKeysTab'; import { EditSettingsPlatformForm } from '@/features/edit-settings-platform-form'; @@ -31,10 +32,21 @@ export const SettingsPage = () => { Основные - + Роли и права + + + Приложения - Beta + + Beta + {/**/} @@ -47,6 +59,9 @@ export const SettingsPage = () => { + + + diff --git a/src/widgets/players-table/lib/columns.tsx b/src/widgets/players-table/lib/columns.tsx index 8b538d04..031cdc96 100644 --- a/src/widgets/players-table/lib/columns.tsx +++ b/src/widgets/players-table/lib/columns.tsx @@ -3,7 +3,18 @@ import React, { useMemo, useState } from 'react'; import { createColumnHelper } from '@tanstack/table-core'; import { format } from 'date-fns'; -import { Ban as BanIcon, GavelIcon, MoreVertical, ShieldCheck, Trash, User, Monitor, Laptop, Smartphone, Tablet } from 'lucide-react'; +import { + Ban as BanIcon, + GavelIcon, + Laptop, + Monitor, + MoreVertical, + ShieldCheck, + Smartphone, + Tablet, + Trash, + User, +} from 'lucide-react'; import { DataTableColumnHeader } from '@/entities/Table'; import { PlayerBaseEntity } from '@/shared/api/contracts'; @@ -129,144 +140,153 @@ function PlayerDetailsDialog({ {/* Tabs layout for player card */}
- - Обзор - Текстуры - Авторизации - Сети/IP - Сервер - + + Обзор + Текстуры + Авторизации + Сети/IP + Сервер + -
- -
-
-
-
Статус
-
- {player.isBanned ? ( - - Заблокирован - - ) : ( - - Не заблокирован - +
+ +
+
+
+
Статус
+
+ {player.isBanned ? ( + + Заблокирован + + ) : ( + + Не заблокирован + + )} +
+ {player.isLauncherStarted && ( +
+ + + Лаунчер запущен + +
+ )} +
+
+
Сессия истекает
+
{sessionStr}
+
+
+
+ {addresses.length > 0 && ( +
+
IP адреса
+
{addresses.join(', ')}
+
)}
- {player.isLauncherStarted && ( -
- - Лаунчер запущен -
- )}
+
+ +
-
Сессия истекает
-
{sessionStr}
+
Текстуры
+
+ {player.externalTextureSkinUrl && ( + skin + )} + {player.externalTextureCloakUrl && ( + cloak + )} + {!player.externalTextureSkinUrl && !player.externalTextureCloakUrl && ( +
Нет текстур.
+ )} +
-
-
- {addresses.length > 0 && ( -
-
IP адреса
-
{addresses.join(', ')}
+ + + +
+
Авторизации
+
+ {(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) && ( +
Нет данных.
+ )}
- )} -
-
- +
+ - -
-
Текстуры
-
- {player.textureSkinUrl && ( - skin - )} - {player.textureCloakUrl && ( - cloak - )} - {!player.textureSkinUrl && !player.textureCloakUrl && ( -
Нет текстур.
+ + {addresses.length > 0 ? ( +
+ {addresses.map((a) => ( + + {a} + + ))} +
+ ) : ( +
Нет IP адресов.
)} -
-
-
+ - -
-
Авторизации
-
- {(player.authHistory || []).map((h, idx) => ( -
-
- {getDeviceIcon(h.device)} -
-
-
-
- {h.device || 'Неизвестное устройство'} -
- {h.hwid && ( - - HWID: {h.hwid} - - )} -
-
- {h.address || '-'} -
-
- {timeAgo(h.date)} - {h.protocol} -
-
+ + {Array.isArray((player as any).serverJoinHistory) ? ( +
+ {(player as any).serverJoinHistory?.length || 0} записей
- ))} - {(!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} записей -
- ) : ( -
Нет данных.
- )} -
-
- +