diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
index 29c1ff5..b7e0e73 100644
--- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
+++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
@@ -15,7 +15,7 @@ import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
-import { Bell, Image, LayoutGrid, ListOrdered, MessageSquare, Store, Users } from 'lucide-react'
+import { Bell, Image, LayoutGrid, ListOrdered, MessageSquare, Settings, Store, Users } from 'lucide-react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
import { AdminCategoriesPage } from '@/pages/admin-categories'
@@ -23,6 +23,7 @@ import { AdminGalleryPage } from '@/pages/admin-gallery'
import { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminProductsPage } from '@/pages/admin-products'
import { AdminReviewsPage } from '@/pages/admin-reviews'
+import { AdminSettingsPage } from '@/pages/admin-settings'
import { AdminUsersPage } from '@/pages/admin-users'
import { $user } from '@/shared/model/auth'
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
@@ -63,6 +64,7 @@ export function AdminLayoutPage() {
{ to: '/admin/reviews', label: 'Отзывы', icon: },
{ to: '/admin/users', label: 'Пользователи', icon: },
{ to: '/admin/notifications', label: 'Уведомления', icon: },
+ { to: '/admin/settings', label: 'Настройки', icon: },
],
[],
)
@@ -192,6 +194,7 @@ export function AdminLayoutPage() {
} />
} />
} />
+ } />
} />
diff --git a/client/src/pages/admin-settings/index.ts b/client/src/pages/admin-settings/index.ts
new file mode 100644
index 0000000..e5be1b6
--- /dev/null
+++ b/client/src/pages/admin-settings/index.ts
@@ -0,0 +1 @@
+export { AdminSettingsPage } from './ui/AdminSettingsPage'
diff --git a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx
new file mode 100644
index 0000000..fff9454
--- /dev/null
+++ b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx
@@ -0,0 +1,247 @@
+import { useState } from 'react'
+import Alert from '@mui/material/Alert'
+import Box from '@mui/material/Box'
+import Button from '@mui/material/Button'
+import Divider from '@mui/material/Divider'
+import FormControl from '@mui/material/FormControl'
+import InputLabel from '@mui/material/InputLabel'
+import MenuItem from '@mui/material/MenuItem'
+import Select from '@mui/material/Select'
+import Stack from '@mui/material/Stack'
+import TextField from '@mui/material/TextField'
+import Typography from '@mui/material/Typography'
+import { createAvatar } from '@dicebear/core'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useUnit } from 'effector-react'
+import { useForm } from 'react-hook-form'
+import { apiClient } from '@/shared/api/client'
+import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles'
+import { $user, UpdateProfileParams, updateProfileFx } from '@/shared/model/auth'
+import { UserAvatar } from '@/shared/ui/UserAvatar'
+
+function getApiErrorMessage(error: unknown): string | null {
+ const e = error as { response?: { data?: { error?: string } } }
+ const msg = e?.response?.data?.error
+ return msg ? String(msg) : null
+}
+
+export function AdminSettingsPage() {
+ const user = useUnit($user)
+ const qc = useQueryClient()
+ const pendingProfile = useUnit(updateProfileFx.pending)
+
+ const {
+ data: profile,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: ['admin', 'profile'],
+ queryFn: async () => {
+ const { data } = await apiClient.get<{
+ id: string
+ email: string
+ displayName: string | null
+ avatar: string | null
+ avatarType: string | null
+ avatarStyle: string | null
+ }>('admin/profile')
+ return data
+ },
+ })
+
+ const profileForm = useForm<{ displayName: string }>({
+ defaultValues: { displayName: profile?.displayName ?? '' },
+ values: { displayName: profile?.displayName ?? '' },
+ mode: 'onChange',
+ })
+
+ const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated')
+ const useOAuth = user?.avatarType === 'oauth'
+ const useGenerated = user?.avatarType === 'generated'
+
+ const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID)
+ const [previewSrc, setPreviewSrc] = useState(null)
+ const [previewStyle, setPreviewStyle] = useState(DEFAULT_STYLE_ID)
+
+ const hasUnsavedPreview = previewSrc !== null
+
+ const profileSaveMut = useMutation({
+ mutationFn: (params: {
+ displayName: string | null
+ avatar?: string | null
+ avatarType?: string | null
+ avatarStyle?: string | null
+ }) => apiClient.patch('admin/profile', params),
+ onSuccess: () => {
+ const name = profileForm.getValues('displayName').trim()
+ const p: UpdateProfileParams = { displayName: name.length ? name : null }
+ if (hasUnsavedPreview) {
+ p.avatar = previewSrc
+ p.avatarType = 'generated'
+ p.avatarStyle = previewStyle
+ }
+ updateProfileFx(p)
+ void qc.invalidateQueries({ queryKey: ['admin', 'profile'] })
+ },
+ })
+
+ const profileErrorMsg = getApiErrorMessage(profileSaveMut.error)
+
+ if (isLoading) return Загрузка настроек…
+ if (isError) return Не удалось загрузить настройки.
+ if (!user) return Нужно войти.
+
+ return (
+
+
+ Настройки
+
+
+ Текущая почта: {String(user.email)}
+
+
+ {profileErrorMsg && (
+
+ {profileErrorMsg}
+
+ )}
+
+
+
+
+ Профиль
+
+
+
+
+
+
+
+
+
+
+
+ Аватар
+
+
+
+
+
+
+ {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'}
+
+
+ {hasUnsavedPreview && (
+
+
+
+ Текущий
+
+
+ )}
+
+
+
+
+ Стиль
+
+
+
+
+
+ {hasUnsavedPreview && (
+
+
+
+
+ )}
+
+ {hasOAuthAvatar && !hasUnsavedPreview && (
+
+ )}
+
+
+
+ )
+}
diff --git a/server/src/routes/api.js b/server/src/routes/api.js
index 32612d6..66a71c3 100644
--- a/server/src/routes/api.js
+++ b/server/src/routes/api.js
@@ -1,5 +1,6 @@
import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js'
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
+import { registerAdminProfileRoutes } from './api/admin-profile.js'
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
import { registerAdminOrderRoutes } from './api/admin-orders.js'
@@ -26,4 +27,5 @@ export async function registerApiRoutes(fastify) {
await registerAdminReviewRoutes(fastify)
await registerAdminUserRoutes(fastify)
await registerAdminNotificationRoutes(fastify)
+ await registerAdminProfileRoutes(fastify)
}
diff --git a/server/src/routes/api/admin-profile.js b/server/src/routes/api/admin-profile.js
new file mode 100644
index 0000000..ab7f5c2
--- /dev/null
+++ b/server/src/routes/api/admin-profile.js
@@ -0,0 +1,64 @@
+import { prisma } from '../../lib/prisma.js'
+
+export async function registerAdminProfileRoutes(fastify) {
+ fastify.get('/api/admin/profile', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
+ const userId = request.user.sub
+ const user = await prisma.user.findUnique({ where: { id: userId } })
+ if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
+ return {
+ id: user.id,
+ email: user.email,
+ displayName: user.displayName,
+ avatar: user.avatar,
+ avatarType: user.avatarType,
+ avatarStyle: user.avatarStyle,
+ }
+ })
+
+ fastify.patch('/api/admin/profile', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
+ const userId = request.user.sub
+ const nameRaw = request.body?.displayName
+ const displayName = nameRaw === null || nameRaw === undefined ? undefined : String(nameRaw).trim()
+ const avatarRaw = request.body?.avatar
+ const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim()
+ const avatarTypeRaw = request.body?.avatarType
+ const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim()
+ const avatarStyleRaw = request.body?.avatarStyle
+ const avatarStyle =
+ avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim()
+
+ if (displayName !== undefined && displayName !== null && displayName.length > 40)
+ return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
+ if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') {
+ return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' })
+ }
+ if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' })
+ if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) {
+ return reply.code(400).send({ error: 'Стиль аватара слишком длинный' })
+ }
+
+ const data = {}
+ if (displayName !== undefined) {
+ data.displayName = displayName && displayName.length ? displayName : null
+ }
+ if (avatarType !== undefined) {
+ data.avatarType = avatarType === '' ? null : avatarType
+ }
+ if (avatar !== undefined) {
+ data.avatar = avatar === '' ? null : avatar
+ }
+ if (avatarStyle !== undefined) {
+ data.avatarStyle = avatarStyle === '' ? null : avatarStyle
+ }
+
+ const updated = await prisma.user.update({ where: { id: userId }, data })
+ return {
+ id: updated.id,
+ email: updated.email,
+ displayName: updated.displayName,
+ avatar: updated.avatar,
+ avatarType: updated.avatarType,
+ avatarStyle: updated.avatarStyle,
+ }
+ })
+}