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:
@@ -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 })
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user