Add settings endpoints and registration status check: implement new API endpoints for settings management, including registration status retrieval, and update the registration command handler to enforce registration rules based on site settings.

This commit is contained in:
Leonid Pershin
2026-07-25 07:48:17 +03:00
parent 1bdc3323ab
commit c53848477f
33 changed files with 1289 additions and 17 deletions
@@ -0,0 +1,62 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { toast } from '@/shared/ui/toast-store'
import { getSiteSettings, updateSiteSettings } from './api'
export function SettingsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [registrationEnabled, setRegistrationEnabled] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'settings'],
queryFn: getSiteSettings,
})
useEffect(() => {
if (data) setRegistrationEnabled(data.registrationEnabled)
}, [data])
const save = useMutation({
mutationFn: () => updateSiteSettings({ registrationEnabled }),
onSuccess: () => {
toast.success(t('settings.saved'))
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
},
onError: (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
})
return (
<div className="flex flex-col gap-4">
<h2 className="crt-glow text-xl font-semibold">{t('admin.settings.title')}</h2>
<Card>
<CardHeader>
<CardTitle>{t('admin.settings.registration')}</CardTitle>
<CardDescription>{t('admin.settings.registrationHint')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={registrationEnabled}
disabled={isLoading}
onChange={(e) => setRegistrationEnabled(e.target.checked)}
/>
{t('admin.settings.registrationLabel')}
</label>
<div>
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,10 @@
import { apiRequest } from '@/shared/api/client'
import type { SiteSettings } from '@/shared/api/types'
export function getSiteSettings() {
return apiRequest<SiteSettings>('/admin/settings')
}
export function updateSiteSettings(body: SiteSettings) {
return apiRequest<void>('/admin/settings', { method: 'PUT', body })
}
+6 -1
View File
@@ -1,7 +1,12 @@
import { apiRequest, setAccessToken } from '@/shared/api/client'
import type { AuthResponse, CurrentUser } from '@/shared/api/types'
import type { AuthResponse, CurrentUser, RegistrationStatus } from '@/shared/api/types'
import { useAuthStore } from './store'
/** Публично: включена ли открытая регистрация (для страниц входа/регистрации). */
export function fetchRegistrationStatus() {
return apiRequest<RegistrationStatus>('/auth/registration')
}
export function login(userName: string, password: string) {
return apiRequest<AuthResponse>('/auth/login', { method: 'POST', body: { userName, password } })
}
+21
View File
@@ -20,6 +20,7 @@ import { Route as AdminChannelsRouteImport } from './routes/admin/channels'
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
import { Route as AdminMediaRouteImport } from './routes/admin/media'
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
import { Route as AdminSettingsRouteImport } from './routes/admin/settings'
import { Route as AdminShowsRouteImport } from './routes/admin/shows'
import { Route as AdminUsersRouteImport } from './routes/admin/users'
import { Route as AdminChannelsIndexRouteImport } from './routes/admin/channels.index'
@@ -82,6 +83,11 @@ const AdminRolesRoute = AdminRolesRouteImport.update({
path: '/roles',
getParentRoute: () => AdminRoute,
} as any)
const AdminSettingsRoute = AdminSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AdminRoute,
} as any)
const AdminShowsRoute = AdminShowsRouteImport.update({
id: '/shows',
path: '/shows',
@@ -124,6 +130,7 @@ export interface FileRoutesByFullPath {
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/settings': typeof AdminSettingsRoute
'/admin/shows': typeof AdminShowsRouteWithChildren
'/admin/users': typeof AdminUsersRoute
'/admin/': typeof AdminIndexRoute
@@ -141,6 +148,7 @@ export interface FileRoutesByTo {
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/settings': typeof AdminSettingsRoute
'/admin/users': typeof AdminUsersRoute
'/admin': typeof AdminIndexRoute
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
@@ -160,6 +168,7 @@ export interface FileRoutesById {
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/settings': typeof AdminSettingsRoute
'/admin/shows': typeof AdminShowsRouteWithChildren
'/admin/users': typeof AdminUsersRoute
'/admin/': typeof AdminIndexRoute
@@ -181,6 +190,7 @@ export interface FileRouteTypes {
| '/admin/maintenance'
| '/admin/media'
| '/admin/roles'
| '/admin/settings'
| '/admin/shows'
| '/admin/users'
| '/admin/'
@@ -198,6 +208,7 @@ export interface FileRouteTypes {
| '/admin/maintenance'
| '/admin/media'
| '/admin/roles'
| '/admin/settings'
| '/admin/users'
| '/admin'
| '/admin/channels/$channelId'
@@ -216,6 +227,7 @@ export interface FileRouteTypes {
| '/admin/maintenance'
| '/admin/media'
| '/admin/roles'
| '/admin/settings'
| '/admin/shows'
| '/admin/users'
| '/admin/'
@@ -313,6 +325,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminRolesRouteImport
parentRoute: typeof AdminRoute
}
'/admin/settings': {
id: '/admin/settings'
path: '/settings'
fullPath: '/admin/settings'
preLoaderRoute: typeof AdminSettingsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/shows': {
id: '/admin/shows'
path: '/shows'
@@ -391,6 +410,7 @@ interface AdminRouteChildren {
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
AdminMediaRoute: typeof AdminMediaRoute
AdminRolesRoute: typeof AdminRolesRoute
AdminSettingsRoute: typeof AdminSettingsRoute
AdminShowsRoute: typeof AdminShowsRouteWithChildren
AdminUsersRoute: typeof AdminUsersRoute
AdminIndexRoute: typeof AdminIndexRoute
@@ -401,6 +421,7 @@ const AdminRouteChildren: AdminRouteChildren = {
AdminMaintenanceRoute: AdminMaintenanceRoute,
AdminMediaRoute: AdminMediaRoute,
AdminRolesRoute: AdminRolesRoute,
AdminSettingsRoute: AdminSettingsRoute,
AdminShowsRoute: AdminShowsRouteWithChildren,
AdminUsersRoute: AdminUsersRoute,
AdminIndexRoute: AdminIndexRoute,
+7
View File
@@ -57,6 +57,13 @@ function AdminLayout() {
>
{t('admin.maintenance.title')}
</Link>
<Link
to="/admin/settings"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
>
{t('admin.settings.title')}
</Link>
</nav>
<Outlet />
</div>
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { SettingsPanel } from '@/features/admin/settings/SettingsPanel'
export const Route = createFileRoute('/admin/settings')({ component: SettingsPanel })
+15 -6
View File
@@ -1,6 +1,8 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { LoginForm } from '@/features/auth/LoginForm'
import { fetchRegistrationStatus } from '@/features/auth/api'
import { useRequireGuest } from '@/features/auth/guards'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
@@ -11,6 +13,11 @@ function LoginPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const { data: registration } = useQuery({
queryKey: ['auth', 'registration'],
queryFn: fetchRegistrationStatus,
})
return (
<div className="mx-auto max-w-sm py-8">
<Card>
@@ -20,12 +27,14 @@ function LoginPage() {
</CardHeader>
<CardContent>
<LoginForm onSuccess={() => void navigate({ to: '/dashboard' })} />
<p className="mt-4 text-center text-sm text-muted-foreground">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-primary hover:underline">
{t('nav.register')}
</Link>
</p>
{registration?.enabled && (
<p className="mt-4 text-center text-sm text-muted-foreground">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-primary hover:underline">
{t('nav.register')}
</Link>
</p>
)}
</CardContent>
</Card>
</div>
+30 -8
View File
@@ -1,6 +1,8 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { RegisterForm } from '@/features/auth/RegisterForm'
import { fetchRegistrationStatus } from '@/features/auth/api'
import { useRequireGuest } from '@/features/auth/guards'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
@@ -11,21 +13,41 @@ function RegisterPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const { data: registration, isLoading } = useQuery({
queryKey: ['auth', 'registration'],
queryFn: fetchRegistrationStatus,
})
const disabled = !isLoading && registration?.enabled === false
return (
<div className="mx-auto max-w-sm py-8">
<Card>
<CardHeader>
<CardTitle>{t('auth.registerTitle')}</CardTitle>
<CardDescription>{t('auth.registerSubtitle')}</CardDescription>
<CardDescription>
{disabled ? t('auth.registrationClosed') : t('auth.registerSubtitle')}
</CardDescription>
</CardHeader>
<CardContent>
<RegisterForm onSuccess={() => void navigate({ to: '/dashboard' })} />
<p className="mt-4 text-center text-sm text-muted-foreground">
{t('auth.haveAccount')}{' '}
<Link to="/login" className="text-primary hover:underline">
{t('nav.login')}
</Link>
</p>
{disabled ? (
<p className="text-center text-sm text-muted-foreground">
{t('auth.registrationClosedHint')}{' '}
<Link to="/login" className="text-primary hover:underline">
{t('nav.login')}
</Link>
</p>
) : (
<>
<RegisterForm onSuccess={() => void navigate({ to: '/dashboard' })} />
<p className="mt-4 text-center text-sm text-muted-foreground">
{t('auth.haveAccount')}{' '}
<Link to="/login" className="text-primary hover:underline">
{t('nav.login')}
</Link>
</p>
</>
)}
</CardContent>
</Card>
</div>
+4
View File
@@ -16,6 +16,10 @@ export type AuthResponse = {
user: CurrentUser
}
export type RegistrationStatus = { enabled: boolean }
export type SiteSettings = { registrationEnabled: boolean }
export type RoleDto = {
id: string
name: string
+18
View File
@@ -47,6 +47,8 @@ const resources = {
submitRegister: 'Зарегистрироваться',
noAccount: 'Нет аккаунта?',
haveAccount: 'Уже есть аккаунт?',
registrationClosed: 'Регистрация закрыта',
registrationClosedHint: 'Открытая регистрация отключена. Учётную запись может завести администратор.',
invalidCredentials: 'Неверное имя пользователя или пароль',
userNameTaken: 'Это имя пользователя уже занято',
blocked: 'Аккаунт заблокирован администратором',
@@ -229,6 +231,13 @@ const resources = {
confirmDeleteShows: 'Удалить ВСЕ шоу безвозвратно?',
doneCount: 'Удалено: {{count}}',
},
settings: {
title: 'Настройки',
registration: 'Регистрация',
registrationHint:
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
registrationLabel: 'Разрешить регистрацию на сайте',
},
},
},
},
@@ -277,6 +286,8 @@ const resources = {
submitRegister: 'Sign up',
noAccount: "Don't have an account?",
haveAccount: 'Already have an account?',
registrationClosed: 'Registration is closed',
registrationClosedHint: 'Public registration is disabled. An administrator can create an account for you.',
invalidCredentials: 'Invalid username or password',
userNameTaken: 'This username is already taken',
blocked: 'Account blocked by an administrator',
@@ -459,6 +470,13 @@ const resources = {
confirmDeleteShows: 'Permanently delete ALL shows?',
doneCount: 'Deleted: {{count}}',
},
settings: {
title: 'Settings',
registration: 'Registration',
registrationHint:
'When off, new users cannot sign up themselves — only an administrator can create accounts.',
registrationLabel: 'Allow public registration',
},
},
},
},