diff --git a/client/src/features/user/user-menu/ui/UserMenu.tsx b/client/src/features/user/user-menu/ui/UserMenu.tsx index 44b7828..02ea396 100644 --- a/client/src/features/user/user-menu/ui/UserMenu.tsx +++ b/client/src/features/user/user-menu/ui/UserMenu.tsx @@ -43,12 +43,7 @@ export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props) anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} > {user ? ( - + ) : ( )} diff --git a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx index dd765fd..5bc98fc 100644 --- a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx +++ b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx @@ -63,11 +63,8 @@ export function AdminSettingsPage() { const hasUnsavedPreview = previewSrc !== null const profileSaveMut = useMutation({ - mutationFn: (params: { - displayName: string | null - avatar?: string | null - avatarStyle?: string | null - }) => apiClient.patch('admin/profile', params), + mutationFn: (params: { displayName: string | null; avatar?: string | null; avatarStyle?: string | null }) => + apiClient.patch('admin/profile', params), onSuccess: (_data, variables) => { const p: UpdateProfileParams = { displayName: variables.displayName ?? null } if (variables.avatar !== undefined) { diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index cabc230..4c2a506 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -192,12 +192,7 @@ export function AdminUsersPage() { users.map((u) => ( - + {u.email} {u.displayName ?? '—'} diff --git a/client/src/pages/auth/__tests__/AuthPage.test.tsx b/client/src/pages/auth/__tests__/AuthPage.test.tsx index e068d9c..05e2f32 100644 --- a/client/src/pages/auth/__tests__/AuthPage.test.tsx +++ b/client/src/pages/auth/__tests__/AuthPage.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, fireEvent } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, fireEvent } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { describe, expect, it, vi } from 'vitest' import { AuthPage } from '../ui/AuthPage' diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 3f7e3b4..99182b7 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -112,8 +112,7 @@ export function AuthPage() { getApiErrorMessage(requestCode.error) || getApiErrorMessage(verifyCode.error) - const passwordError = - isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null + const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null return ( @@ -121,11 +120,21 @@ export function AuthPage() { Вход / регистрация - {message && {message}} - {oauthError && ( - setOauthError(null)}>{oauthError} + {message && ( + + {message} + + )} + {oauthError && ( + setOauthError(null)}> + {oauthError} + + )} + {errMsg && ( + + {errMsg} + )} - {errMsg && {errMsg}} setTab(v)} sx={{ mb: 3 }}> @@ -198,11 +207,7 @@ export function AuthPage() { - diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index 95522a9..9c88dc1 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -12,9 +12,9 @@ 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 } from '@tanstack/react-query' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' -import { useMutation } from '@tanstack/react-query' import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' import { $requestEmailChangeCodeError, @@ -62,8 +62,6 @@ export function SettingsPage() { const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) const profileErrorMsg = getApiErrorMessage(errorProfile) - const hasAvatar = Boolean(user?.avatar) - const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID) const [previewSrc, setPreviewSrc] = useState(null) const [previewStyle, setPreviewStyle] = useState(DEFAULT_STYLE_ID) @@ -77,9 +75,11 @@ export function SettingsPage() { }) useEffect(() => { - fetchAuthMethodsFx().then(setAuthMethods).catch(() => { - setAuthMethods([]) - }) + fetchAuthMethodsFx() + .then(setAuthMethods) + .catch(() => { + setAuthMethods([]) + }) }, []) const setPasswordMutation = useMutation({ @@ -277,12 +277,7 @@ export function SettingsPage() { )} {!m.active && m.type !== 'password' && ( - )} diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index 0e4e96d..36cf4d6 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -92,13 +92,11 @@ export const loginFx = createEffect(async (params: { email: string; password: st return data.user }) -export const registerFx = createEffect( - async (params: { email: string; password: string; displayName?: string }) => { - const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/register', params) - tokenSet(data.token) - return data.user - }, -) +export const registerFx = createEffect(async (params: { email: string; password: string; displayName?: string }) => { + const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/register', params) + tokenSet(data.token) + return data.user +}) export const fetchAuthMethodsFx = createEffect(async () => { const { data } = await apiClient.get<{ methods: AuthMethod[] }>('me/auth-methods') diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 037559b..378bf4e 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ diff --git a/server/src/routes/__tests__/auth-methods.test.js b/server/src/routes/__tests__/auth-methods.test.js index c0fa825..f02b898 100644 --- a/server/src/routes/__tests__/auth-methods.test.js +++ b/server/src/routes/__tests__/auth-methods.test.js @@ -1,5 +1,5 @@ -import Fastify from 'fastify' import jwt from '@fastify/jwt' +import Fastify from 'fastify' import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest' import { prisma } from '../../lib/prisma.js' import { registerAuthRoutes } from '../auth.js' @@ -10,7 +10,9 @@ async function buildApp() { const app = Fastify({ logger: false }) await app.register(jwt, { secret: JWT_SECRET }) app.decorate('authenticate', async function (request, reply) { - try { await request.jwtVerify() } catch { + try { + await request.jwtVerify() + } catch { return reply.code(401).send({ error: 'Unauthorized' }) } }) @@ -36,7 +38,9 @@ describe('GET /api/me/auth-methods', () => { let app, user, token const email = `test-methods-${Date.now()}@example.com` - beforeAll(async () => { app = await buildApp() }) + beforeAll(async () => { + app = await buildApp() + }) afterAll(async () => { await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } }) await prisma.user.deleteMany({ where: { email } }) @@ -53,7 +57,8 @@ describe('GET /api/me/auth-methods', () => { it('returns methods for user without any method', async () => { const res = await app.inject({ - method: 'GET', url: '/api/me/auth-methods', + method: 'GET', + url: '/api/me/auth-methods', headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(200) @@ -66,7 +71,8 @@ describe('GET /api/me/auth-methods', () => { it('returns password as active after setting it', async () => { await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } }) const res = await app.inject({ - method: 'GET', url: '/api/me/auth-methods', + method: 'GET', + url: '/api/me/auth-methods', headers: { authorization: `Bearer ${token}` }, }) expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true) @@ -77,7 +83,9 @@ describe('POST /api/me/password', () => { let app, user, token const email = `test-set-pw-${Date.now()}@example.com` - beforeAll(async () => { app = await buildApp() }) + beforeAll(async () => { + app = await buildApp() + }) afterAll(async () => { await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } }) await prisma.user.deleteMany({ where: { email } }) @@ -93,7 +101,8 @@ describe('POST /api/me/password', () => { it('sets password', async () => { const res = await app.inject({ - method: 'POST', url: '/api/me/password', + method: 'POST', + url: '/api/me/password', headers: { authorization: `Bearer ${token}` }, payload: { password: 'Test123!@' }, }) @@ -106,7 +115,8 @@ describe('POST /api/me/password', () => { it('rejects if password already set', async () => { await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'existing' } }) const res = await app.inject({ - method: 'POST', url: '/api/me/password', + method: 'POST', + url: '/api/me/password', headers: { authorization: `Bearer ${token}` }, payload: { password: 'Test123!@' }, }) @@ -118,7 +128,9 @@ describe('DELETE /api/me/oauth/:provider', () => { let app, user, token const email = `test-unlink-${Date.now()}@example.com` - beforeAll(async () => { app = await buildApp() }) + beforeAll(async () => { + app = await buildApp() + }) afterAll(async () => { await prisma.oAuthAccount.deleteMany({ where: { user: { email } } }) await prisma.notificationPreference.deleteMany({ where: { user: { email } } }) @@ -135,7 +147,8 @@ describe('DELETE /api/me/oauth/:provider', () => { it('returns 404 for non-linked provider', async () => { const res = await app.inject({ - method: 'DELETE', url: '/api/me/oauth/vk', + method: 'DELETE', + url: '/api/me/oauth/vk', headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(404) @@ -147,7 +160,8 @@ describe('DELETE /api/me/oauth/:provider', () => { data: { provider: 'vk', providerUserId: '123', userId: user.id }, }) const res = await app.inject({ - method: 'DELETE', url: '/api/me/oauth/vk', + method: 'DELETE', + url: '/api/me/oauth/vk', headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(200) @@ -161,7 +175,8 @@ describe('DELETE /api/me/oauth/:provider', () => { data: { provider: 'vk', providerUserId: '123', userId: user.id }, }) const res = await app.inject({ - method: 'DELETE', url: '/api/me/oauth/vk', + method: 'DELETE', + url: '/api/me/oauth/vk', headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(400) diff --git a/server/src/routes/__tests__/auth-password.test.js b/server/src/routes/__tests__/auth-password.test.js index c281c90..db6cbf9 100644 --- a/server/src/routes/__tests__/auth-password.test.js +++ b/server/src/routes/__tests__/auth-password.test.js @@ -1,5 +1,5 @@ -import Fastify from 'fastify' import jwt from '@fastify/jwt' +import Fastify from 'fastify' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' import { prisma } from '../../lib/prisma.js' import { registerAuthRoutes } from '../auth.js' @@ -26,8 +26,12 @@ async function buildApp() { describe('POST /api/auth/register', () => { let app - beforeAll(async () => { app = await buildApp() }) - afterAll(async () => { await app.close() }) + beforeAll(async () => { + app = await buildApp() + }) + afterAll(async () => { + await app.close() + }) afterEach(async () => { await prisma.authCode.deleteMany({ where: { email: TEST_EMAIL } }) await prisma.notificationPreference.deleteMany({ where: { user: { email: TEST_EMAIL } } }) @@ -48,11 +52,13 @@ describe('POST /api/auth/register', () => { it('rejects duplicate email', async () => { await app.inject({ - method: 'POST', url: '/api/auth/register', + method: 'POST', + url: '/api/auth/register', payload: { email: TEST_EMAIL, password: 'Test123!@' }, }) const res = await app.inject({ - method: 'POST', url: '/api/auth/register', + method: 'POST', + url: '/api/auth/register', payload: { email: TEST_EMAIL, password: 'Test123!@' }, }) expect(res.statusCode).toBe(409) @@ -60,7 +66,8 @@ describe('POST /api/auth/register', () => { it('rejects weak password — too short', async () => { const res = await app.inject({ - method: 'POST', url: '/api/auth/register', + method: 'POST', + url: '/api/auth/register', payload: { email: TEST_EMAIL, password: 'Ab1!' }, }) expect(res.statusCode).toBe(400) @@ -70,7 +77,8 @@ describe('POST /api/auth/register', () => { it('rejects weak password — no digit', async () => { const res = await app.inject({ - method: 'POST', url: '/api/auth/register', + method: 'POST', + url: '/api/auth/register', payload: { email: TEST_EMAIL, password: 'Abcdefgh!' }, }) expect(res.statusCode).toBe(400) @@ -79,7 +87,8 @@ describe('POST /api/auth/register', () => { it('rejects weak password — no special char', async () => { const res = await app.inject({ - method: 'POST', url: '/api/auth/register', + method: 'POST', + url: '/api/auth/register', payload: { email: TEST_EMAIL, password: 'Abcdefg1' }, }) expect(res.statusCode).toBe(400) @@ -92,7 +101,8 @@ describe('POST /api/auth/login', () => { beforeAll(async () => { app = await buildApp() await app.inject({ - method: 'POST', url: '/api/auth/register', + method: 'POST', + url: '/api/auth/register', payload: { email: LOGIN_EMAIL, password: 'Test123!@' }, }) }) @@ -106,7 +116,8 @@ describe('POST /api/auth/login', () => { it('logs in with correct password', async () => { const res = await app.inject({ - method: 'POST', url: '/api/auth/login', + method: 'POST', + url: '/api/auth/login', payload: { email: LOGIN_EMAIL, password: 'Test123!@' }, headers: { 'x-forwarded-for': '1.1.1.1' }, }) @@ -116,7 +127,8 @@ describe('POST /api/auth/login', () => { it('rejects wrong password', async () => { const res = await app.inject({ - method: 'POST', url: '/api/auth/login', + method: 'POST', + url: '/api/auth/login', payload: { email: LOGIN_EMAIL, password: 'Wrong!!1!' }, headers: { 'x-forwarded-for': '2.2.2.2' }, }) @@ -125,7 +137,8 @@ describe('POST /api/auth/login', () => { it('rejects non-existent email', async () => { const res = await app.inject({ - method: 'POST', url: '/api/auth/login', + method: 'POST', + url: '/api/auth/login', payload: { email: 'nobody@nowhere.test', password: 'Test123!@' }, headers: { 'x-forwarded-for': '3.3.3.3' }, }) diff --git a/server/src/routes/api.js b/server/src/routes/api.js index 66a71c3..82554d9 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -1,10 +1,10 @@ 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' import { registerAdminProductRoutes } from './api/admin-products.js' +import { registerAdminProfileRoutes } from './api/admin-profile.js' import { registerAdminReviewRoutes } from './api/admin-reviews.js' import { registerAdminUserRoutes } from './api/admin-users.js' import { registerCatalogSliderRoutes } from './api/catalog-slider.js' diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js index eef31db..e30210f 100644 --- a/server/src/routes/api/admin-orders.js +++ b/server/src/routes/api/admin-orders.js @@ -73,7 +73,9 @@ export async function registerAdminOrderRoutes(fastify) { const order = await prisma.order.findUnique({ where: { id }, include: { - user: { select: { id: true, email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } }, + user: { + select: { id: true, email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true }, + }, items: true, messages: { orderBy: { createdAt: 'asc' } }, }, diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js index a9d86fe..048c844 100644 --- a/server/src/routes/api/public-reviews.js +++ b/server/src/routes/api/public-reviews.js @@ -87,7 +87,9 @@ export async function registerPublicReviewRoutes(fastify) { const total = await prisma.review.count({ where }) const rawItems = await prisma.review.findMany({ where, - include: { user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } } }, + include: { + user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } }, + }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 9fae41b..cfb3c9e 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -105,10 +105,7 @@ export async function registerOAuthSocialRoutes(fastify) { if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' }) const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` - const state = fastify.jwt.sign( - { oauth: 'vk', action: 'link', userId: request.user.sub }, - { expiresIn: '15m' }, - ) + const state = fastify.jwt.sign({ oauth: 'vk', action: 'link', userId: request.user.sub }, { expiresIn: '15m' }) const url = new URL('https://oauth.vk.com/authorize') url.searchParams.set('client_id', clientId) @@ -128,13 +125,15 @@ export async function registerOAuthSocialRoutes(fastify) { return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK')) } - let statePayload = null - try { - const raw = typeof query.state === 'string' ? query.state : '' - statePayload = fastify.jwt.verify(raw || '') - } catch { - return oauthErrorRedirect(reply, 'Недействительный state OAuth') - } + const statePayload = (() => { + try { + const raw = typeof query.state === 'string' ? query.state : '' + return fastify.jwt.verify(raw || '') + } catch { + return null + } + })() + if (!statePayload) return oauthErrorRedirect(reply, 'Недействительный state OAuth') const code = typeof query.code === 'string' ? query.code.trim() : '' if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK') @@ -212,10 +211,7 @@ export async function registerOAuthSocialRoutes(fastify) { if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен' }) const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback` - const state = fastify.jwt.sign( - { oauth: 'yandex', action: 'link', userId: request.user.sub }, - { expiresIn: '15m' }, - ) + const state = fastify.jwt.sign({ oauth: 'yandex', action: 'link', userId: request.user.sub }, { expiresIn: '15m' }) const url = new URL('https://oauth.yandex.ru/authorize') url.searchParams.set('response_type', 'code') @@ -231,13 +227,15 @@ export async function registerOAuthSocialRoutes(fastify) { const query = request.query ?? {} if (query.error) return oauthErrorRedirect(reply, String(query.error)) - let statePayload = null - try { - const raw = typeof query.state === 'string' ? query.state : '' - statePayload = fastify.jwt.verify(raw || '') - } catch { - return oauthErrorRedirect(reply, 'Недействительный state OAuth') - } + const statePayload = (() => { + try { + const raw = typeof query.state === 'string' ? query.state : '' + return fastify.jwt.verify(raw || '') + } catch { + return null + } + })() + if (!statePayload) return oauthErrorRedirect(reply, 'Недействительный state OAuth') const code = typeof query.code === 'string' ? query.code.trim() : '' if (!code) return oauthErrorRedirect(reply, 'Не получен код от Яндекс') @@ -276,10 +274,7 @@ export async function registerOAuthSocialRoutes(fastify) { const yaUserId = String(info?.id || '') if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex') - const emailGuess = - (Array.isArray(info?.emails) && info.emails[0]) || - info?.default_email || - null + const emailGuess = (Array.isArray(info?.emails) && info.emails[0]) || info?.default_email || null if (!emailGuess) return oauthErrorRedirect(reply, 'no_email')