diff --git a/.superpowers/brainstorm/12055-1779436874/state/server.pid b/.superpowers/brainstorm/12055-1779436874/state/server.pid new file mode 100644 index 0000000..ad52c0c --- /dev/null +++ b/.superpowers/brainstorm/12055-1779436874/state/server.pid @@ -0,0 +1 @@ +12063 diff --git a/.superpowers/brainstorm/12189-1779436893/state/server.pid b/.superpowers/brainstorm/12189-1779436893/state/server.pid new file mode 100644 index 0000000..8be8535 --- /dev/null +++ b/.superpowers/brainstorm/12189-1779436893/state/server.pid @@ -0,0 +1 @@ +12189 diff --git a/.superpowers/brainstorm/12680-1779437109/state/server.pid b/.superpowers/brainstorm/12680-1779437109/state/server.pid new file mode 100644 index 0000000..32e8255 --- /dev/null +++ b/.superpowers/brainstorm/12680-1779437109/state/server.pid @@ -0,0 +1 @@ +12688 diff --git a/.superpowers/brainstorm/12844-1779437126/state/server.pid b/.superpowers/brainstorm/12844-1779437126/state/server.pid new file mode 100644 index 0000000..aa93a35 --- /dev/null +++ b/.superpowers/brainstorm/12844-1779437126/state/server.pid @@ -0,0 +1 @@ +12844 diff --git a/.superpowers/brainstorm/12988-1779437168/state/server.pid b/.superpowers/brainstorm/12988-1779437168/state/server.pid new file mode 100644 index 0000000..204a445 --- /dev/null +++ b/.superpowers/brainstorm/12988-1779437168/state/server.pid @@ -0,0 +1 @@ +12996 diff --git a/.superpowers/brainstorm/13143-1779437184/state/server.pid b/.superpowers/brainstorm/13143-1779437184/state/server.pid new file mode 100644 index 0000000..5030d63 --- /dev/null +++ b/.superpowers/brainstorm/13143-1779437184/state/server.pid @@ -0,0 +1 @@ +13143 diff --git a/client/public/fonts/Outfit-Bold.woff2 b/client/public/fonts/Outfit-Bold.woff2 new file mode 100644 index 0000000..8674e73 Binary files /dev/null and b/client/public/fonts/Outfit-Bold.woff2 differ diff --git a/client/public/fonts/Outfit-Medium.woff2 b/client/public/fonts/Outfit-Medium.woff2 new file mode 100644 index 0000000..264ce74 Binary files /dev/null and b/client/public/fonts/Outfit-Medium.woff2 differ diff --git a/client/public/fonts/Outfit-Regular.woff2 b/client/public/fonts/Outfit-Regular.woff2 new file mode 100644 index 0000000..2c401f5 Binary files /dev/null and b/client/public/fonts/Outfit-Regular.woff2 differ diff --git a/client/public/fonts/Outfit-SemiBold.woff2 b/client/public/fonts/Outfit-SemiBold.woff2 new file mode 100644 index 0000000..aa32da2 Binary files /dev/null and b/client/public/fonts/Outfit-SemiBold.woff2 differ diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx index 00208a0..450b0f5 100644 --- a/client/src/app/layout/MainLayout.tsx +++ b/client/src/app/layout/MainLayout.tsx @@ -17,7 +17,7 @@ export function MainLayout({ children }: PropsWithChildren) { const year = new Date().getFullYear() return ( - + diff --git a/client/src/app/providers/AppProviders.tsx b/client/src/app/providers/AppProviders.tsx index c1eb273..7d02222 100644 --- a/client/src/app/providers/AppProviders.tsx +++ b/client/src/app/providers/AppProviders.tsx @@ -124,7 +124,6 @@ function AppThemeInner({ children }: PropsWithChildren) { border: '1px solid', '&:hover': { boxShadow: '0 2px 8px 0 rgba(0,0,0,0.1)', - borderWidth: '2px', }, '&:active': { boxShadow: 'none', diff --git a/client/src/app/styles/global.css b/client/src/app/styles/global.css index 0cf9d1c..0962570 100644 --- a/client/src/app/styles/global.css +++ b/client/src/app/styles/global.css @@ -1,13 +1,40 @@ +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 400; + src: url('/fonts/Outfit-Regular.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 500; + src: url('/fonts/Outfit-Medium.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 600; + src: url('/fonts/Outfit-SemiBold.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 700; + src: url('/fonts/Outfit-Bold.woff2') format('woff2'); + font-display: swap; +} + :root { color-scheme: light; } - html, body, #root { min-height: 100%; } - body { margin: 0; } diff --git a/client/src/entities/order/api/admin-order-api.ts b/client/src/entities/order/api/admin-order-api.ts index 6e20b8f..9ff8947 100644 --- a/client/src/entities/order/api/admin-order-api.ts +++ b/client/src/entities/order/api/admin-order-api.ts @@ -42,7 +42,6 @@ export type AdminOrderDetailResponse = { email: string displayName: string | null avatar?: string | null - avatarType?: string | null avatarStyle?: string | null } items: Array<{ diff --git a/client/src/entities/review/api/reviews-api.ts b/client/src/entities/review/api/reviews-api.ts index 584198e..badcf9e 100644 --- a/client/src/entities/review/api/reviews-api.ts +++ b/client/src/entities/review/api/reviews-api.ts @@ -27,7 +27,6 @@ export type PublicReviewFeedItem = { createdAt: string authorDisplay: string authorAvatar?: string | null - authorAvatarType?: string | null authorAvatarStyle?: string | null product: { id: string @@ -56,7 +55,6 @@ export type PublicProductReviewItem = { createdAt: string authorDisplay: string authorAvatar?: string | null - authorAvatarType?: string | null authorAvatarStyle?: string | null } diff --git a/client/src/entities/user/api/user-api.ts b/client/src/entities/user/api/user-api.ts index d65bbce..60d3076 100644 --- a/client/src/entities/user/api/user-api.ts +++ b/client/src/entities/user/api/user-api.ts @@ -32,7 +32,6 @@ export async function updateAdminUser( export type AdminAvatarResponse = { avatar: string | null - avatarType: string | null avatarStyle: string | null } diff --git a/client/src/entities/user/model/types.ts b/client/src/entities/user/model/types.ts index 3d00022..2ff1b45 100644 --- a/client/src/entities/user/model/types.ts +++ b/client/src/entities/user/model/types.ts @@ -3,7 +3,6 @@ export type AdminUser = { email: string displayName: string | null avatar?: string | null - avatarType?: string | null avatarStyle?: string | null createdAt: string updatedAt: string diff --git a/client/src/features/auth-oauth/ui/OAuthButtons.tsx b/client/src/features/auth-oauth/ui/OAuthButtons.tsx index 18ccc53..4a28d87 100644 --- a/client/src/features/auth-oauth/ui/OAuthButtons.tsx +++ b/client/src/features/auth-oauth/ui/OAuthButtons.tsx @@ -16,6 +16,7 @@ export function OAuthButtons() { '&:hover': { borderColor: p.color, bgcolor: `${p.color}14`, + borderWidth: '1px', }, }} > diff --git a/client/src/features/order-chat/ui/OrderChat.tsx b/client/src/features/order-chat/ui/OrderChat.tsx index 27d1c8a..ad07c43 100644 --- a/client/src/features/order-chat/ui/OrderChat.tsx +++ b/client/src/features/order-chat/ui/OrderChat.tsx @@ -53,18 +53,11 @@ export function OrderChat({ messages, isPending, onSend }: Props) { const isAdminMsg = m.authorType === 'admin' const adminAv = adminAvatarQuery.data const avatarNode = isAdminMsg ? ( - + ) : currentUser ? ( diff --git a/client/src/features/order-detail/ui/OrderDetailContent.tsx b/client/src/features/order-detail/ui/OrderDetailContent.tsx index 721c478..36f5b0c 100644 --- a/client/src/features/order-detail/ui/OrderDetailContent.tsx +++ b/client/src/features/order-detail/ui/OrderDetailContent.tsx @@ -175,7 +175,6 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta @@ -184,7 +183,6 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta diff --git a/client/src/features/product-review/ui/ProductReviewsList.tsx b/client/src/features/product-review/ui/ProductReviewsList.tsx index ed03e3d..a7f05e4 100644 --- a/client/src/features/product-review/ui/ProductReviewsList.tsx +++ b/client/src/features/product-review/ui/ProductReviewsList.tsx @@ -22,7 +22,6 @@ function ReviewItem({ rv }: { rv: PublicProductReviewItem }) { diff --git a/client/src/features/user/user-menu/ui/UserMenu.tsx b/client/src/features/user/user-menu/ui/UserMenu.tsx index ab661c3..02ea396 100644 --- a/client/src/features/user/user-menu/ui/UserMenu.tsx +++ b/client/src/features/user/user-menu/ui/UserMenu.tsx @@ -43,13 +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 41adb80..bd805e5 100644 --- a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx +++ b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx @@ -43,7 +43,6 @@ export function AdminSettingsPage() { email: string displayName: string | null avatar: string | null - avatarType: string | null avatarStyle: string | null }>('admin/profile') return data @@ -56,10 +55,6 @@ export function AdminSettingsPage() { 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) @@ -67,17 +62,12 @@ export function AdminSettingsPage() { 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), + 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) { p.avatar = variables.avatar - p.avatarType = variables.avatarType ?? null p.avatarStyle = variables.avatarStyle ?? null } updateProfileFx(p) @@ -144,7 +134,6 @@ export function AdminSettingsPage() { - {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} + {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} {hasUnsavedPreview && ( @@ -161,7 +150,6 @@ export function AdminSettingsPage() { )} - - {hasOAuthAvatar && !hasUnsavedPreview && ( - - )} diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index 6e5d85b..4c2a506 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -192,13 +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 new file mode 100644 index 0000000..98b3802 --- /dev/null +++ b/client/src/pages/auth/__tests__/AuthPage.test.tsx @@ -0,0 +1,58 @@ +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' + +vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) +vi.mock('effector-react', async () => { + const actual = await vi.importActual('effector-react') + return { ...actual, useUnit: () => null } +}) + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + , + ) +} + +describe('AuthPage', () => { + it('renders two tabs', () => { + renderPage() + expect(screen.getByRole('button', { name: 'Пароль' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Код' })).toBeTruthy() + }) + + it('shows OAuth buttons and separator always', () => { + renderPage() + expect(screen.getByText('или')).toBeTruthy() + expect(screen.getByText('Войти через VK ID')).toBeTruthy() + expect(screen.getByText('Войти через Яндекс ID')).toBeTruthy() + }) + + it('shows login form by default on tab 0', () => { + renderPage() + expect(screen.getByText('Вход')).toBeTruthy() + expect(screen.getByText('Регистрация')).toBeTruthy() + const buttons = screen.getAllByRole('button') + const loginBtn = buttons.find((b) => b.textContent === 'Войти') + expect(loginBtn).toBeTruthy() + }) + + it('switches to register form', () => { + renderPage() + fireEvent.click(screen.getByText('Регистрация')) + expect(screen.getByText('Зарегистрироваться')).toBeTruthy() + }) + + it('switches to code tab', () => { + renderPage() + fireEvent.click(screen.getByText('Код')) + expect(screen.getByText('Отправить код')).toBeTruthy() + }) +}) diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index ea35191..985b653 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -2,17 +2,21 @@ import { useEffect, 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 InputAdornment from '@mui/material/InputAdornment' +import Paper from '@mui/material/Paper' import Stack from '@mui/material/Stack' +import { alpha, useTheme } from '@mui/material/styles' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' +import { Lock, Mail } from 'lucide-react' import { useForm } from 'react-hook-form' import { useNavigate, useSearchParams } from 'react-router-dom' import { OAuthButtons } from '@/features/auth-oauth' import { apiClient } from '@/shared/api/client' import { $user, tokenSet } from '@/shared/model/auth' +import { BearLogo } from '@/shared/ui/BearLogo' type AuthResponse = { token: string @@ -21,7 +25,6 @@ type AuthResponse = { email: string displayName?: string | null avatar?: string | null - avatarType?: string | null avatarStyle?: string | null } } @@ -36,21 +39,34 @@ function getApiErrorMessage(err: unknown): string | null { } export function AuthPage() { + const theme = useTheme() const [message, setMessage] = useState(null) const [oauthError, setOauthError] = useState(null) + const [tab, setTab] = useState(0) + const [isRegister, setIsRegister] = useState(false) + const [showForgot, setShowForgot] = useState(false) + const [forgotStep, setForgotStep] = useState(0) + const [forgotEmail, setForgotEmail] = useState('') const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() const user = useUnit($user) + const { register, watch } = useForm<{ email: string + password: string + passwordConfirm: string + displayName: string code: string }>({ - defaultValues: { email: '', code: '' }, + defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '', code: '' }, mode: 'onChange', }) const email = watch('email') + const password = watch('password') + const passwordConfirm = watch('passwordConfirm') const code = watch('code') + useEffect(() => { if (user) navigate('/', { replace: true }) }, [navigate, user]) @@ -62,69 +78,407 @@ export function AuthPage() { setSearchParams({}, { replace: true }) }, [searchParams, setSearchParams]) + const loginMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/login', { email, password }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + + const registerMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/register', { + email, + password, + displayName: watch('displayName') || undefined, + }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + const requestCode = useMutation({ mutationFn: async () => { await apiClient.post('auth/request-code', { email }) }, - onSuccess: () => setMessage('Код отправлен. Проверьте почту (в dev может быть в логах сервера).'), + onSuccess: () => setMessage('Код отправлен. Проверьте почту.'), }) const verifyCode = useMutation({ mutationFn: async () => { const { data } = await apiClient.post('auth/verify-code', { email, code }) tokenSet(data.token) - setMessage(`Вход выполнен: ${data.user.email}`) navigate('/', { replace: true }) }, }) - const errMsg = getApiErrorMessage(requestCode.error || verifyCode.error) + const forgotCode = useMutation({ + mutationFn: async () => { + await apiClient.post('auth/forgot-password', { email: forgotEmail }) + }, + onSuccess: () => { + setForgotStep(1) + setMessage('Код отправлен на почту') + }, + }) + + const resetPassword = useMutation({ + mutationFn: async () => { + await apiClient.post('auth/reset-password', { + email: forgotEmail, + code, + newPassword: password, + }) + }, + onSuccess: () => { + setShowForgot(false) + setForgotStep(0) + setMessage('Пароль изменён. Войдите с новым паролем.') + }, + }) + + const errMsg = + getApiErrorMessage(loginMutation.error) || + getApiErrorMessage(registerMutation.error) || + getApiErrorMessage(requestCode.error) || + getApiErrorMessage(verifyCode.error) || + getApiErrorMessage(forgotCode.error) || + getApiErrorMessage(resetPassword.error) + + const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null return ( - - - Вход / регистрация - + + + + + - {message && ( - - {message} - - )} - {oauthError && ( - setOauthError(null)}> - {oauthError} - - )} - {errMsg && ( - - {errMsg} - - )} + + Добро пожаловать
в Любимый Креатив +
- - Email + код - - - - - - - + + Войдите или зарегистрируйтесь, чтобы продолжить + - - или + + + {[ + { label: 'Пароль', idx: 0 }, + { label: 'Код', idx: 1 }, + ].map(({ label, idx }) => ( + + ))} + - - + {(errMsg || oauthError) && ( + { + setOauthError(null) + }} + > + {errMsg || oauthError} + + )} + {message && ( + setMessage(null)}> + {message} + + )} + + {tab === 0 && ( + + + + + + + + + + ), + }, + }} + /> + + {isRegister && ( + + )} + + + + + ), + }, + }} + /> + + {isRegister && ( + + )} + + {isRegister ? ( + + ) : ( + + )} + + {!isRegister && !showForgot && ( + + )} + + {showForgot && ( + <> + setForgotEmail(e.target.value)} + fullWidth + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + {forgotStep === 1 && ( + <> + + { + register('code').onChange(e) + }} + sx={{ flex: 1 }} + /> + + + + + + )} + + {forgotStep === 0 && ( + + )} + + + + )} + + )} + + {tab === 1 && ( + + + + + ), + }, + }} + /> + + + + + + + )} + + + + + или + + + + + + + ) } diff --git a/client/src/pages/me/ui/sections/MessagesPage.tsx b/client/src/pages/me/ui/sections/MessagesPage.tsx index 220753d..f225f78 100644 --- a/client/src/pages/me/ui/sections/MessagesPage.tsx +++ b/client/src/pages/me/ui/sections/MessagesPage.tsx @@ -181,7 +181,6 @@ export function MessagesPage() { @@ -189,7 +188,6 @@ export function MessagesPage() { diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index 2b0e403..df6635a 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' +import Chip from '@mui/material/Chip' import Divider from '@mui/material/Divider' import FormControl from '@mui/material/FormControl' import InputLabel from '@mui/material/InputLabel' @@ -11,19 +12,21 @@ 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 { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' import { - $requestEmailChangeCodeError, $updateProfileError, $user, - $verifyEmailChangeError, - requestEmailChangeCodeFx, + fetchAuthMethodsFx, + setPasswordFx, + unlinkOAuthFx, updateProfileFx, - verifyEmailChangeFx, + type AuthMethod, } from '@/shared/model/auth' import { UserAvatar } from '@/shared/ui/UserAvatar' +import { apiClient } from '@/shared/api/client' import type { AxiosError } from 'axios' function getApiErrorMessage(error: unknown): string | null { @@ -34,17 +37,8 @@ function getApiErrorMessage(error: unknown): string | null { export function SettingsPage() { const user = useUnit($user) - const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending) - const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending) const pendingProfile = useUnit(updateProfileFx.pending) - const errorEmailReq = useUnit($requestEmailChangeCodeError) const errorProfile = useUnit($updateProfileError) - const errorEmailVerify = useUnit($verifyEmailChangeError) - - const emailForm = useForm<{ newEmail: string; code: string }>({ - defaultValues: { newEmail: '', code: '' }, - mode: 'onChange', - }) const profileForm = useForm<{ displayName: string }>({ defaultValues: { @@ -53,19 +47,68 @@ export function SettingsPage() { mode: 'onChange', }) - const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) const profileErrorMsg = getApiErrorMessage(errorProfile) - 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 [authMethods, setAuthMethods] = useState([]) + const [showSetPassword, setShowSetPassword] = useState(false) + const passwordForm = useForm<{ password: string; passwordConfirm: string }>({ + defaultValues: { password: '', passwordConfirm: '' }, + }) + + useEffect(() => { + fetchAuthMethodsFx() + .then(setAuthMethods) + .catch(() => { + setAuthMethods([]) + }) + }, []) + + const setPasswordMutation = useMutation({ + mutationFn: async (pw: string) => { + await setPasswordFx(pw) + const methods = await fetchAuthMethodsFx() + setAuthMethods(methods) + setShowSetPassword(false) + }, + onError: () => {}, + }) + + const unlinkMutation = useMutation({ + mutationFn: async (provider: 'vk' | 'yandex') => { + await unlinkOAuthFx(provider) + const methods = await fetchAuthMethodsFx() + setAuthMethods(methods) + }, + onError: () => {}, + }) + + const [showChangePassword, setShowChangePassword] = useState(false) + const changePasswordForm = useForm<{ oldPassword: string; newPassword: string; confirmPassword: string }>({ + defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' }, + }) + + const changePasswordMutation = useMutation({ + mutationFn: async (params: { oldPassword: string; newPassword: string }) => { + await apiClient.post('me/change-password', params) + }, + onSuccess: () => { + setShowChangePassword(false) + changePasswordForm.reset() + }, + }) + + const linkedCount = useCallback(() => { + return authMethods.filter((m) => m.active).length + }, [authMethods]) + + const METHOD_LABELS: Record = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' } + if (!user) { return Нужно войти. Перейдите на страницу «Вход». } @@ -79,11 +122,6 @@ export function SettingsPage() { Текущая почта: {user.email} - {emailErrorMsg && ( - - {emailErrorMsg} - - )} {profileErrorMsg && ( {profileErrorMsg} @@ -128,7 +166,6 @@ export function SettingsPage() { - {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} + {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
{hasUnsavedPreview && ( @@ -145,7 +182,6 @@ export function SettingsPage() { )} - - {hasOAuthAvatar && !hasUnsavedPreview && ( - - )}
- + {!user.isAdmin && ( + <> + + + + Методы входа + + + {authMethods.map((m) => ( + + {METHOD_LABELS[m.type] || m.type} + + {m.active && m.type !== 'password' && ( + + )} + {m.active && m.type === 'password' && ( + + )} + {!m.active && m.type === 'password' && ( + + )} + {!m.active && m.type !== 'password' && ( + + )} + + ))} + - - - Смена почты - - - - - - - - - - + {showSetPassword && ( + + + + + + + + + )} + + {showChangePassword && ( + + + + + + + + + + )} + + + )}
) diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index 6761582..cf018fc 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -11,11 +11,15 @@ export type AuthUser = { lastName?: string | null gender?: string | null avatar?: string | null - avatarType?: string | null avatarStyle?: string | null isAdmin?: boolean } +export type AuthMethod = { + type: 'password' | 'vk' | 'yandex' + active: boolean +} + export const tokenSet = createEvent() export const logout = createEvent() @@ -56,23 +60,11 @@ sample({ target: $user, }) -// ----- Email change ----- - -export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => { - await apiClient.post('me/change-email/request-code', { newEmail }) -}) - -export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => { - const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params) - return data.user -}) - // ----- Profile update ----- export type UpdateProfileParams = { displayName: string | null avatar?: string | null - avatarType?: string | null avatarStyle?: string | null } @@ -81,19 +73,44 @@ export const updateProfileFx = createEffect(async (params: UpdateProfileParams) return data.user }) +// ----- Auth effects ----- + +export const loginFx = createEffect(async (params: { email: string; password: string }) => { + const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/login', 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') + return data.methods +}) + +export const setPasswordFx = createEffect(async (password: string) => { + await apiClient.post('me/password', { password }) +}) + +export const unlinkOAuthFx = createEffect(async (provider: 'vk' | 'yandex') => { + await apiClient.delete(`me/oauth/${provider}`) +}) + // ----- Error stores ----- -export const $requestEmailChangeCodeError = createErrorStore(requestEmailChangeCodeFx).$error -export const $verifyEmailChangeError = createErrorStore(verifyEmailChangeFx).$error export const $updateProfileError = createErrorStore(updateProfileFx).$error // ----- Re-exports ----- export { readStoredToken } from '@/shared/lib/persist-token' -// ----- Sync user from profile/email changes ----- +// ----- Sync user from profile changes ----- sample({ - clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData], + clock: [updateProfileFx.doneData], target: $user, }) diff --git a/client/src/shared/ui/UserAvatar.tsx b/client/src/shared/ui/UserAvatar.tsx index bc3eecf..7dd1fbe 100644 --- a/client/src/shared/ui/UserAvatar.tsx +++ b/client/src/shared/ui/UserAvatar.tsx @@ -7,20 +7,19 @@ import { DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' type UserAvatarProps = { userId: string avatarUrl?: string | null - avatarType?: string | null avatarStyle?: string | null size?: number sx?: SxProps } -export function UserAvatar({ userId, avatarUrl, avatarType, avatarStyle, size = 40, sx }: UserAvatarProps) { +export function UserAvatar({ userId, avatarUrl, avatarStyle, size = 40, sx }: UserAvatarProps) { const generatedSrc = useMemo(() => { const styleDef = getStyleById(avatarStyle || DEFAULT_STYLE_ID) const avatar = createAvatar(styleDef.style, { seed: userId }) return avatar.toDataUri() }, [userId, avatarStyle]) - const src = avatarType && avatarUrl ? avatarUrl : generatedSrc + const src = avatarUrl || generatedSrc return ( diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx index 4e5cb76..2a2f622 100644 --- a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx +++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx @@ -104,7 +104,6 @@ export function ReviewsBlock() { diff --git a/docs/superpowers/plans/2026-05-22-auth-redesign.md b/docs/superpowers/plans/2026-05-22-auth-redesign.md new file mode 100644 index 0000000..0b2ffca --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-auth-redesign.md @@ -0,0 +1,542 @@ +# Auth Page Redesign — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Минималистичный редизайн страницы входа: Outfit локально, BearLogo, Paper-карточка, pill-кнопки, радиальный градиент. + +**Architecture:** MUI sx prop only, замена `client/src/pages/auth/ui/AuthPage.tsx` полностью. Шрифт в `client/public/fonts/` + `@font-face` в `global.css`. + +**Tech Stack:** React, MUI, lucide-react (иконки) + +--- + +### Task 1: Download Outfit font and add @font-face + +**Files:** +- Create: `client/public/fonts/Outfit-Regular.woff2` +- Create: `client/public/fonts/Outfit-Medium.woff2` +- Create: `client/public/fonts/Outfit-SemiBold.woff2` +- Create: `client/public/fonts/Outfit-Bold.woff2` +- Modify: `client/src/app/styles/global.css` + +- [ ] **Step 1: Create fonts directory** + +```bash +mkdir -p /mnt/d/my_projects/shop/client/public/fonts +``` + +- [ ] **Step 2: Download Outfit woff2 files** + +```bash +cd /mnt/d/my_projects/shop/client/public/fonts +curl -sL 'https://fonts.google.com/download?family=Outfit' -o outfit.zip +# OR download individual woff2 files from a CDN: +curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-400-normal.woff2' -o Outfit-Regular.woff2 +curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-500-normal.woff2' -o Outfit-Medium.woff2 +curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-600-normal.woff2' -o Outfit-SemiBold.woff2 +curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-700-normal.woff2' -o Outfit-Bold.woff2 +``` + +Wait — the jsdelivr URLs may not be exact. Better approach: use `@fontsource/outfit` npm package or download from fontsource CDN: + +```bash +cd /mnt/d/my_projects/shop/client/public/fonts +# Outfit Regular (400) +curl -sLo Outfit-Regular.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-400-normal.woff2' +# Outfit Medium (500) +curl -sLo Outfit-Medium.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-500-normal.woff2' +# Outfit SemiBold (600) +curl -sLo Outfit-SemiBold.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-600-normal.woff2' +# Outfit Bold (700) +curl -sLo Outfit-Bold.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-700-normal.woff2' +``` + +- [ ] **Step 3: Verify files downloaded** + +```bash +ls -la /mnt/d/my_projects/shop/client/public/fonts/ +``` + +Expected: 4 woff2 files, each > 10KB. + +- [ ] **Step 4: Add @font-face to global.css** + +Read `/mnt/d/my_projects/shop/client/src/app/styles/global.css`. It currently has: + +```css +:root { color-scheme: light; } +html, body, #root { min-height: 100%; } +body { margin: 0; } +``` + +Replace entire file with: + +```css +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 400; + src: url('/fonts/Outfit-Regular.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 500; + src: url('/fonts/Outfit-Medium.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 600; + src: url('/fonts/Outfit-SemiBold.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 700; + src: url('/fonts/Outfit-Bold.woff2') format('woff2'); + font-display: swap; +} + +:root { color-scheme: light; } +html, body, #root { min-height: 100%; } +body { margin: 0; } +``` + +- [ ] **Step 5: Commit** + +```bash +cd /mnt/d/my_projects/shop +git add client/public/fonts/ client/src/app/styles/global.css +git commit -m "feat: load Outfit font from static files" +``` + +--- + +### Task 2: Rewrite AuthPage with new design + +**Files:** +- Modify: `client/src/pages/auth/ui/AuthPage.tsx` (replace entirely) + +- [ ] **Step 1: Read the current file for reference** + +Read `/mnt/d/my_projects/shop/client/src/pages/auth/ui/AuthPage.tsx` — keep the imports, hooks and mutation logic. Only the render JSX changes. + +- [ ] **Step 2: Replace AuthPage.tsx** + +Write the entire file: + +```tsx +import { useEffect, useState } from 'react' +import { alpha, useTheme } from '@mui/material/styles' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import InputAdornment from '@mui/material/InputAdornment' +import Paper from '@mui/material/Paper' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useMutation } from '@tanstack/react-query' +import { useUnit } from 'effector-react' +import { Lock, Mail } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { OAuthButtons } from '@/features/auth-oauth' +import { apiClient } from '@/shared/api/client' +import { $user, tokenSet } from '@/shared/model/auth' +import { BearLogo } from '@/shared/ui/BearLogo' + +type AuthResponse = { + token: string + user: { + id: string + email: string + displayName?: string | null + avatar?: string | null + avatarStyle?: string | null + } +} + +function getApiErrorMessage(err: unknown): string | null { + if (!err || typeof err !== 'object') return null + const anyErr = err as Record + const response = anyErr.response as Record | undefined + const data = response?.data as Record | undefined + const msg = data?.error + return typeof msg === 'string' ? msg : null +} + +export function AuthPage() { + const theme = useTheme() + const [message, setMessage] = useState(null) + const [oauthError, setOauthError] = useState(null) + const [tab, setTab] = useState(0) + const [isRegister, setIsRegister] = useState(false) + const [searchParams, setSearchParams] = useSearchParams() + const navigate = useNavigate() + const user = useUnit($user) + + const { register, watch } = useForm<{ + email: string + password: string + passwordConfirm: string + displayName: string + code: string + }>({ + defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '', code: '' }, + mode: 'onChange', + }) + + const email = watch('email') + const password = watch('password') + const passwordConfirm = watch('passwordConfirm') + const code = watch('code') + + useEffect(() => { + if (user) navigate('/', { replace: true }) + }, [navigate, user]) + + useEffect(() => { + const err = searchParams.get('oauthError') + if (!err) return + setOauthError(err) + setSearchParams({}, { replace: true }) + }, [searchParams, setSearchParams]) + + const loginMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/login', { email, password }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + + const registerMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/register', { + email, + password, + displayName: watch('displayName') || undefined, + }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + + const requestCode = useMutation({ + mutationFn: async () => { + await apiClient.post('auth/request-code', { email }) + }, + onSuccess: () => setMessage('Код отправлен. Проверьте почту (в dev может быть в логах сервера).'), + }) + + const verifyCode = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/verify-code', { email, code }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + + const errMsg = + getApiErrorMessage(loginMutation.error) || + getApiErrorMessage(registerMutation.error) || + getApiErrorMessage(requestCode.error) || + getApiErrorMessage(verifyCode.error) + + const passwordError = + isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null + + return ( + + + + + + + + Добро пожаловать в Любимый Креатив + + + + Войдите или зарегистрируйтесь, чтобы продолжить + + + + + {[ + { label: 'Пароль', idx: 0 }, + { label: 'Код', idx: 1 }, + { label: 'Другой способ', idx: 2 }, + ].map(({ label, idx }) => ( + + ))} + + + {(errMsg || oauthError) && ( + { + setOauthError(null) + }} + > + {errMsg || oauthError} + + )} + {message && ( + setMessage(null)}> + {message} + + )} + + {tab === 0 && ( + + + + + + + + + + ), + }, + }} + /> + + {isRegister && ( + + )} + + + + + ), + }, + }} + /> + + {isRegister && ( + + )} + + {isRegister ? ( + + ) : ( + + )} + + )} + + {tab === 1 && ( + + + + + ), + }, + }} + /> + + + + + + + )} + + {tab === 2 && ( + + + + )} + + + + ) +} +``` + +- [ ] **Step 3: Run typecheck** + +```bash +cd /mnt/d/my_projects/shop/client && npx tsc --noEmit 2>&1 | head -20 +``` + +Expected: no errors. + +- [ ] **Step 4: Run tests** + +```bash +cd /mnt/d/my_projects/shop/client && npx vitest run +``` + +Expected: all tests pass (7 files, 29 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /mnt/d/my_projects/shop +git add client/src/pages/auth/ui/AuthPage.tsx +git commit -m "feat(client): redesign auth page with minimal style, BearLogo, pill buttons" +``` + +--- + +### Task 3: Run full verification + +- [ ] **Step 1: Client lint + format + build** + +```bash +cd /mnt/d/my_projects/shop/client +npm run lint +npm run format:check +npm run build +``` + +Expected: 0 errors, format clean, build success. + +- [ ] **Step 2: Server tests (regression check)** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run +``` + +Expected: all pass (ignore pre-existing user-payments.test.js failures if any). + +- [ ] **Step 3: Commit if anything changed** + +```bash +cd /mnt/d/my_projects/shop +git add -A +git diff --cached --quiet || git commit -m "chore: post-redesign lint fixes" +``` diff --git a/docs/superpowers/specs/2026-05-22-auth-redesign-design.md b/docs/superpowers/specs/2026-05-22-auth-redesign-design.md new file mode 100644 index 0000000..6bf2444 --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-auth-redesign-design.md @@ -0,0 +1,225 @@ +# Auth Page Redesign — Spec + +**Date:** 2026-05-22 +**Goal:** Минималистичный редизайн страницы входа с лёгким брендингом (медведь + слоган), pill-кнопками и Paper-карточкой. + +**Style:** Минималистичный, чистый. Одна колонка по центру. + +--- + +## 1. Шрифт Outfit + +**Проблема:** Outfit указан в MUI-теме, но не загружается. Фактически везде системный Segoe UI. + +**Исправление:** Скачать шрифт Outfit (woff2, веса 400/500/600/700) и разместить в `client/public/fonts/`. Добавить `@font-face` в `client/src/app/styles/global.css`: + +```css +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 400; + src: url('/fonts/Outfit-Regular.woff2') format('woff2'); +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 500; + src: url('/fonts/Outfit-Medium.woff2') format('woff2'); +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 600; + src: url('/fonts/Outfit-SemiBold.woff2') format('woff2'); +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 700; + src: url('/fonts/Outfit-Bold.woff2') format('woff2'); +} +``` + +Файлы woff2 скачать с Google Fonts или из CDN и положить в `client/public/fonts/`. + +--- + +## 2. Фон страницы + +- `background.default` + лёгкий радиальный градиент +- Градиент: от центра к краям, `primary.main` с 3-5% opacity +- Реализация: `sx` prop на корневом ``: `background: radial-gradient(circle at 50% 30%, ${alpha(theme.palette.primary.main, 0.05)} 0%, transparent 70%)` + +--- + +## 3. Компоновка + +``` +┌──────────────────────────────────────────┐ +│ (воздух) │ +│ 🐻 BearLogo 72px │ +│ Добро пожаловать в Любимый Креатив │ +│ (subtitle, text.secondary) │ +│ (воздух) │ +│ ┌─────── Paper 440px max-width ──────┐ │ +│ │ [Пароль] [Код] [Другой способ] │ │ +│ │ │ │ +│ │ Вход / Регистрация │ │ +│ │ │ │ +│ │ Email: __________________ │ │ +│ │ Пароль: __________________ │ │ +│ │ │ │ +│ │ [────────── Войти ──────────] │ │ +│ │ │ │ +│ └─────────────────────────────────────┘ │ +│ (воздух) │ +└──────────────────────────────────────────┘ +``` + +Детали: +- Корневой ``: `display: flex, alignItems: center, justifyContent: center, minHeight: calc(100vh - header)` +- BearLogo: `` +- Заголовок: `variant="h5"`, `fontWeight: 700`, `textAlign: center` +- Слоган: `variant="body2"`, `color: text.secondary`, `textAlign: center`, `mb: 3` +- Paper: `maxWidth: 440`, `mx: auto`, `p: 4`, `borderRadius: 3` (12px), `border: 1px solid divider`, мягкая тень + +--- + +## 4. Pill-переключатель методов + +Вместо MUI Tabs — три MUI Button в ряд: + +```tsx + + + + + +``` + +--- + +## 5. Под-переключатель Вход/Регистрация + +Только на вкладке «Пароль»: + +```tsx + + + + +``` + +--- + +## 6. Формы по вкладкам + +### Пароль (вход) +- Email TextField +- Пароль TextField +- Button contained fullWidth: «Войти» + +### Пароль (регистрация) +- Email TextField +- Имя TextField (опционально, helperText: «Необязательно. Будет использована часть email») +- Пароль TextField +- Подтверждение пароля TextField (с валидацией совпадения) +- Button contained fullWidth: «Зарегистрироваться» + +### Код +- Строка: Email + кнопка «Отправить код» +- Строка: поле Код + кнопка «Войти» +- Alert outlined success после успешной отправки + +### Другой способ +- OAuthButtons — стилизовать кнопки как outlined pill (borderRadius 24px, fullWidth) +- Кнопки: «Войти через Яндекс ID», «Войти через VK ID» + +--- + +## 7. Alert'ы + +- Все ошибки: `Alert severity="error" variant="outlined"` внутри Paper, над формой +- Успешная отправка кода: `Alert severity="success" variant="outlined"` +- OAuth-ошибки: так же внутри Paper + +--- + +## 8. Иконки в TextField + +Добавить `InputAdornment` с иконками для визуального улучшения: +- Email: `` иконка (lucide-react) +- Пароль: `` иконка + +Иконки только если это не перегружает минималистичный стиль. Решение — использовать в полях email и пароля `startAdornment`. + +--- + +## 9. Адаптивность + +- `min-height` вместо `height` (использовать `minHeight: calc(100vh - 64px)`) +- Paper: `mx: 2` на мобильных, `mx: auto` на десктопе +- Pill-кнопки остаются в ряд на всех разрешениях (они и так компактные) +- Отправка кода: на мобильных поля в столбец (уже есть `direction={{ xs: 'column', sm: 'row' }}`) + +--- + +## 10. Плавные переходы + +- Смена вкладок: контент формы — `opacity` transition 200ms +- Смена Вход/Регистрация: поля появляются с fade-in + +--- + +## 11. Заметки + +- BearLogo уже существует (`@/shared/ui/BearLogo`) +- OAuthButtons существует (`@/features/auth-oauth`) +- Менять бизнес-логику (хуки, mutations) не нужно — только вёрстку +- Текущий AuthPage — 232 строки, нужно заменить полностью +- Все цвета брать из темы (`primary.main`, `text.secondary`, `divider`, `background.paper`) +- Для градиента использовать `useTheme` + `alpha` из MUI diff --git a/server/package-lock.json b/server/package-lock.json index 1186824..48993e2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -13,6 +13,7 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", "@prisma/client": "5.22.0", + "bcrypt": "^6.0.0", "dotenv": "^17.4.2", "fastify": "^5.8.5", "nodemailer": "^8.0.7", @@ -2130,6 +2131,29 @@ ], "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bcrypt/node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -3605,6 +3629,17 @@ "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "license": "MIT" }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemailer": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", diff --git a/server/package.json b/server/package.json index 37080f8..ed46702 100644 --- a/server/package.json +++ b/server/package.json @@ -23,6 +23,7 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", "@prisma/client": "5.22.0", + "bcrypt": "^6.0.0", "dotenv": "^17.4.2", "fastify": "^5.8.5", "nodemailer": "^8.0.7", diff --git a/server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql b/server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql new file mode 100644 index 0000000..b9ee70a --- /dev/null +++ b/server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - You are about to drop the column `avatarType` on the `User` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "displayName" TEXT, + "firstName" TEXT, + "lastName" TEXT, + "gender" TEXT, + "avatar" TEXT, + "avatarStyle" TEXT, + "passwordHash" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_User" ("avatar", "avatarStyle", "createdAt", "displayName", "email", "firstName", "gender", "id", "lastName", "passwordHash", "updatedAt") SELECT "avatar", "avatarStyle", "createdAt", "displayName", "email", "firstName", "gender", "id", "lastName", "passwordHash", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 3bc4946..be0aa90 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -82,7 +82,6 @@ model User { lastName String? gender String? avatar String? - avatarType String? avatarStyle String? passwordHash String? createdAt DateTime @default(now()) diff --git a/server/src/index.js b/server/src/index.js index 19cd72e..2f1477e 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -75,6 +75,9 @@ await fastify.register(fastifyStatic, { fastify.decorate('authenticate', async function authenticate(request, reply) { try { + if (!request.headers.authorization && request.query?.token) { + request.headers.authorization = `Bearer ${request.query.token}` + } await request.jwtVerify() } catch { return reply.code(401).send({ error: 'Не авторизован' }) diff --git a/server/src/lib/auth.js b/server/src/lib/auth.js index fcd6996..cdfc5bb 100644 --- a/server/src/lib/auth.js +++ b/server/src/lib/auth.js @@ -1,4 +1,5 @@ import crypto from 'node:crypto' +import bcrypt from 'bcrypt' import { sendLoginCodeEmail } from './email.js' import { prisma } from './prisma.js' @@ -72,3 +73,34 @@ export async function verifyEmailCode({ email, purpose, code, userId = null }) { }) return true } + +const PASSWORD_MIN_LEN = 8 + +const PASSWORD_REGEX = { + letter: /[a-zа-яё]/i, + digit: /[0-9]/, + special: /[^a-zа-яё0-9\s]/i, +} + +export function validatePassword(password) { + if (typeof password !== 'string') return 'Пароль обязателен' + if (password.length < PASSWORD_MIN_LEN) return `Пароль должен быть не менее ${PASSWORD_MIN_LEN} символов` + if (!PASSWORD_REGEX.letter.test(password)) return 'Пароль должен содержать хотя бы одну букву' + if (!PASSWORD_REGEX.digit.test(password)) return 'Пароль должен содержать хотя бы одну цифру' + if (!PASSWORD_REGEX.special.test(password)) return 'Пароль должен содержать хотя бы один спецсимвол' + return null +} + +export async function hashPassword(password) { + return bcrypt.hash(password, 10) +} + +export async function comparePassword(password, hash) { + return bcrypt.compare(password, hash) +} + +export function isAdminEmail(email) { + const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() + if (!adminEmail) return false + return normalizeEmail(email) === adminEmail +} diff --git a/server/src/lib/rate-limit.js b/server/src/lib/rate-limit.js new file mode 100644 index 0000000..7198aaa --- /dev/null +++ b/server/src/lib/rate-limit.js @@ -0,0 +1,26 @@ +const windows = new Map() + +const MAX_ATTEMPTS = 5 +const WINDOW_MS = 60_000 + +setInterval(() => { + const now = Date.now() + for (const [ip, entry] of windows) { + if (now - entry.start > WINDOW_MS) windows.delete(ip) + } +}, 5 * 60_000).unref() + +export function checkLoginRateLimit(ip) { + const now = Date.now() + const entry = windows.get(ip) + if (!entry || now - entry.start > WINDOW_MS) { + windows.set(ip, { start: now, count: 1 }) + return { allowed: true } + } + entry.count += 1 + if (entry.count > MAX_ATTEMPTS) { + const retryAfter = Math.ceil((entry.start + WINDOW_MS - now) / 1000) + return { allowed: false, retryAfter } + } + return { allowed: true } +} diff --git a/server/src/routes/__tests__/auth-methods.test.js b/server/src/routes/__tests__/auth-methods.test.js new file mode 100644 index 0000000..f02b898 --- /dev/null +++ b/server/src/routes/__tests__/auth-methods.test.js @@ -0,0 +1,185 @@ +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' + +const JWT_SECRET = 'test-secret' + +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 { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', { emit: () => {} }) + await registerAuthRoutes(app) + await app.ready() + return app +} + +function signToken(app, userId, email) { + return app.jwt.sign({ sub: userId, email }) +} + +async function createUser(email) { + const user = await prisma.user.create({ + data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' }, + }) + await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } }) + return user +} + +describe('GET /api/me/auth-methods', () => { + let app, user, token + const email = `test-methods-${Date.now()}@example.com` + + beforeAll(async () => { + app = await buildApp() + }) + afterAll(async () => { + await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } }) + await prisma.user.deleteMany({ where: { email } }) + await app.close() + }) + + beforeEach(async () => { + await prisma.oAuthAccount.deleteMany({ where: { user: { email } } }) + await prisma.notificationPreference.deleteMany({ where: { user: { email } } }) + await prisma.user.deleteMany({ where: { email } }) + user = await createUser(email) + token = signToken(app, user.id, email) + }) + + it('returns methods for user without any method', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/me/auth-methods', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.body) + expect(body.methods.find((m) => m.type === 'password').active).toBe(false) + expect(body.methods.find((m) => m.type === 'vk').active).toBe(false) + expect(body.methods.find((m) => m.type === 'yandex').active).toBe(false) + }) + + 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', + headers: { authorization: `Bearer ${token}` }, + }) + expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true) + }) +}) + +describe('POST /api/me/password', () => { + let app, user, token + const email = `test-set-pw-${Date.now()}@example.com` + + beforeAll(async () => { + app = await buildApp() + }) + afterAll(async () => { + await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } }) + await prisma.user.deleteMany({ where: { email } }) + await app.close() + }) + + beforeEach(async () => { + await prisma.notificationPreference.deleteMany({ where: { user: { email } } }) + await prisma.user.deleteMany({ where: { email } }) + user = await createUser(email) + token = signToken(app, user.id, email) + }) + + it('sets password', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/me/password', + headers: { authorization: `Bearer ${token}` }, + payload: { password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(200) + + const u = await prisma.user.findUnique({ where: { id: user.id } }) + expect(u.passwordHash).toBeTruthy() + }) + + 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', + headers: { authorization: `Bearer ${token}` }, + payload: { password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(409) + }) +}) + +describe('DELETE /api/me/oauth/:provider', () => { + let app, user, token + const email = `test-unlink-${Date.now()}@example.com` + + beforeAll(async () => { + app = await buildApp() + }) + afterAll(async () => { + await prisma.oAuthAccount.deleteMany({ where: { user: { email } } }) + await prisma.notificationPreference.deleteMany({ where: { user: { email } } }) + await prisma.user.deleteMany({ where: { email } }) + await app.close() + }) + + beforeEach(async () => { + await prisma.oAuthAccount.deleteMany({ where: { user: { email } } }) + await prisma.user.deleteMany({ where: { email } }) + user = await createUser(email) + token = signToken(app, user.id, email) + }) + + it('returns 404 for non-linked provider', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/api/me/oauth/vk', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(404) + }) + + it('unlinks a provider', async () => { + await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } }) + await prisma.oAuthAccount.create({ + data: { provider: 'vk', providerUserId: '123', userId: user.id }, + }) + const res = await app.inject({ + method: 'DELETE', + url: '/api/me/oauth/vk', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + + const count = await prisma.oAuthAccount.count({ where: { userId: user.id } }) + expect(count).toBe(0) + }) + + it('rejects removing last method without password', async () => { + await prisma.oAuthAccount.create({ + data: { provider: 'vk', providerUserId: '123', userId: user.id }, + }) + const res = await app.inject({ + method: 'DELETE', + url: '/api/me/oauth/vk', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('последний метод') + }) +}) diff --git a/server/src/routes/__tests__/auth-password.test.js b/server/src/routes/__tests__/auth-password.test.js new file mode 100644 index 0000000..db6cbf9 --- /dev/null +++ b/server/src/routes/__tests__/auth-password.test.js @@ -0,0 +1,147 @@ +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' + +const JWT_SECRET = 'test-secret' +const TEST_EMAIL = `test-reg-${Date.now()}@example.com` +const LOGIN_EMAIL = `test-login-${Date.now()}@example.com` + +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 { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', { emit: () => {} }) + await registerAuthRoutes(app) + await app.ready() + return app +} + +describe('POST /api/auth/register', () => { + let app + 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 } } }) + await prisma.user.deleteMany({ where: { email: TEST_EMAIL } }) + }) + + it('registers a new user with password', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(201) + const body = JSON.parse(res.body) + expect(body.token).toBeTruthy() + expect(body.user.email).toBe(TEST_EMAIL) + }) + + it('rejects duplicate email', async () => { + await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Test123!@' }, + }) + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(409) + }) + + it('rejects weak password — too short', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Ab1!' }, + }) + expect(res.statusCode).toBe(400) + const body = JSON.parse(res.body) + expect(body.error).toContain('не менее 8') + }) + + it('rejects weak password — no digit', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Abcdefgh!' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('цифру') + }) + + it('rejects weak password — no special char', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Abcdefg1' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('спецсимвол') + }) +}) + +describe('POST /api/auth/login', () => { + let app + beforeAll(async () => { + app = await buildApp() + await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: LOGIN_EMAIL, password: 'Test123!@' }, + }) + }) + afterAll(async () => { + await prisma.authCode.deleteMany({ where: { email: LOGIN_EMAIL } }) + await prisma.notificationPreference.deleteMany({ where: { user: { email: LOGIN_EMAIL } } }) + await prisma.oAuthAccount.deleteMany({ where: { user: { email: LOGIN_EMAIL } } }) + await prisma.user.deleteMany({ where: { email: LOGIN_EMAIL } }) + await app.close() + }) + + it('logs in with correct password', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: LOGIN_EMAIL, password: 'Test123!@' }, + headers: { 'x-forwarded-for': '1.1.1.1' }, + }) + expect(res.statusCode).toBe(200) + expect(JSON.parse(res.body).token).toBeTruthy() + }) + + it('rejects wrong password', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: LOGIN_EMAIL, password: 'Wrong!!1!' }, + headers: { 'x-forwarded-for': '2.2.2.2' }, + }) + expect(res.statusCode).toBe(401) + }) + + it('rejects non-existent email', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: 'nobody@nowhere.test', password: 'Test123!@' }, + headers: { 'x-forwarded-for': '3.3.3.3' }, + }) + expect(res.statusCode).toBe(401) + }) +}) 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..2f67809 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, avatarStyle: true }, + }, items: true, messages: { orderBy: { createdAt: 'asc' } }, }, diff --git a/server/src/routes/api/admin-profile.js b/server/src/routes/api/admin-profile.js index d69f0c5..748d45c 100644 --- a/server/src/routes/api/admin-profile.js +++ b/server/src/routes/api/admin-profile.js @@ -11,7 +11,6 @@ export async function registerAdminProfileRoutes(fastify) { email: user.email, displayName: user.displayName, avatar: user.avatar, - avatarType: user.avatarType, avatarStyle: user.avatarStyle, } }) @@ -25,7 +24,6 @@ export async function registerAdminProfileRoutes(fastify) { return { avatar: user.avatar, - avatarType: user.avatarType, avatarStyle: user.avatarStyle, } }) @@ -37,17 +35,12 @@ export async function registerAdminProfileRoutes(fastify) { nameRaw === undefined ? undefined : nameRaw === null ? null : nameRaw === '' ? null : 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: 'Стиль аватара слишком длинный' }) @@ -57,9 +50,6 @@ export async function registerAdminProfileRoutes(fastify) { 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 } @@ -73,7 +63,6 @@ export async function registerAdminProfileRoutes(fastify) { email: updated.email, displayName: updated.displayName, avatar: updated.avatar, - avatarType: updated.avatarType, avatarStyle: updated.avatarStyle, } }) diff --git a/server/src/routes/api/admin-users.js b/server/src/routes/api/admin-users.js index e6fc7b4..9dd717b 100644 --- a/server/src/routes/api/admin-users.js +++ b/server/src/routes/api/admin-users.js @@ -34,7 +34,6 @@ export async function registerAdminUserRoutes(fastify) { email: true, displayName: true, avatar: true, - avatarType: true, avatarStyle: true, createdAt: true, updatedAt: true, @@ -48,7 +47,6 @@ export async function registerAdminUserRoutes(fastify) { email: u.email, displayName: u.displayName, avatar: u.avatar, - avatarType: u.avatarType, avatarStyle: u.avatarStyle, createdAt: u.createdAt, updatedAt: u.updatedAt, diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js index a9d86fe..58265c1 100644 --- a/server/src/routes/api/public-reviews.js +++ b/server/src/routes/api/public-reviews.js @@ -40,7 +40,7 @@ export async function registerPublicReviewRoutes(fastify) { const rows = await prisma.review.findMany({ where: { status: 'approved', product: { published: true } }, include: { - user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } }, + user: { select: { email: true, displayName: true, avatar: true, avatarStyle: true } }, product: { select: { id: true, title: true, published: true, slug: true } }, }, orderBy: { createdAt: 'desc' }, @@ -55,7 +55,6 @@ export async function registerPublicReviewRoutes(fastify) { createdAt: r.createdAt, authorDisplay: publicReviewAuthorDisplay(r.user), authorAvatar: r.user?.avatar ?? null, - authorAvatarType: r.user?.avatarType ?? null, authorAvatarStyle: r.user?.avatarStyle ?? null, product: { id: r.product?.id ?? r.productId, @@ -87,7 +86,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, avatarStyle: true } }, + }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, @@ -101,7 +102,6 @@ export async function registerPublicReviewRoutes(fastify) { createdAt: r.createdAt, authorDisplay: publicReviewAuthorDisplay(r.user), authorAvatar: r.user?.avatar ?? null, - authorAvatarType: r.user?.avatarType ?? null, authorAvatarStyle: r.user?.avatarStyle ?? null, })) diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index de8773f..004df4c 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -1,6 +1,15 @@ import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' -import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js' +import { + comparePassword, + hashPassword, + isAdminEmail, + issueEmailCode, + normalizeEmail, + validatePassword, + verifyEmailCode, +} from '../lib/auth.js' import { prisma } from '../lib/prisma.js' +import { checkLoginRateLimit } from '../lib/rate-limit.js' function mapUserForClient(user) { const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) @@ -13,7 +22,6 @@ function mapUserForClient(user) { lastName: user.lastName, gender: user.gender, avatar: user.avatar, - avatarType: user.avatarType, avatarStyle: user.avatarStyle, isAdmin: Boolean(adminEmail) && userEmail === adminEmail, } @@ -64,6 +72,105 @@ export async function registerAuthRoutes(fastify) { return { token, user: mapUserForClient(user) } }) + fastify.post('/api/auth/register', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const password = String(request.body?.password || '') + const displayNameRaw = request.body?.displayName + const displayName = displayNameRaw ? String(displayNameRaw).trim().slice(0, 100) : email.split('@')[0] + + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор не может регистрироваться с паролем' }) + + const passwordErr = validatePassword(password) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const exists = await prisma.user.findUnique({ where: { email } }) + if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' }) + + const passwordHash = await hashPassword(password) + const user = await prisma.user.create({ + data: { + email, + passwordHash, + displayName: displayName || null, + avatar: null, + avatarStyle: 'avataaars', + }, + }) + + await prisma.notificationPreference.upsert({ + where: { userId: user.id }, + create: { userId: user.id, globalEnabled: true }, + update: {}, + }) + + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return reply.code(201).send({ token, user: mapUserForClient(user) }) + }) + + fastify.post('/api/auth/login', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const password = String(request.body?.password || '') + const ip = request.ip + + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор входит только по коду' }) + + const rate = checkLoginRateLimit(ip) + if (!rate.allowed) { + return reply + .code(429) + .header('Retry-After', String(rate.retryAfter)) + .send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` }) + } + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user || !user.passwordHash) { + return reply.code(401).send({ error: 'Неверная почта или пароль' }) + } + + const valid = await comparePassword(password, user.passwordHash) + if (!valid) { + return reply.code(401).send({ error: 'Неверная почта или пароль' }) + } + + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return { token, user: mapUserForClient(user) } + }) + + fastify.post('/api/auth/forgot-password', async (request) => { + const email = normalizeEmail(request.body?.email) + if (!email || !email.includes('@')) return { ok: true } + + if (isAdminEmail(email)) return { ok: true } + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user || !user.passwordHash) return { ok: true } + + await issueEmailCode({ email, purpose: 'reset_password' }) + return { ok: true } + }) + + fastify.post('/api/auth/reset-password', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const code = String(request.body?.code || '').trim() + const newPassword = String(request.body?.newPassword || '') + + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) + + const ok = await verifyEmailCode({ email, purpose: 'reset_password', code }) + if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) + + const passwordErr = validatePassword(newPassword) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const passwordHash = await hashPassword(newPassword) + await prisma.user.update({ where: { email }, data: { passwordHash } }) + + return { ok: true } + }) + fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => { const userId = request.user.sub const user = await prisma.user.findUnique({ where: { id: userId } }) @@ -71,68 +178,112 @@ export async function registerAuthRoutes(fastify) { return { user: mapUserForClient(user) } }) - fastify.post('/api/me/change-email/request-code', { preHandler: [fastify.authenticate] }, async (request, reply) => { + fastify.get('/api/me/auth-methods', { preHandler: [fastify.authenticate] }, async (request) => { const userId = request.user.sub - const newEmail = normalizeEmail(request.body?.newEmail) - if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - - const exists = await prisma.user.findUnique({ - where: { email: newEmail }, + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { oauthAccounts: { select: { provider: true } } }, }) - if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' }) + if (!user) return { methods: [] } + + const providers = user.oauthAccounts.map((a) => a.provider) + return { + methods: [ + { type: 'password', active: Boolean(user.passwordHash) }, + { type: 'vk', active: providers.includes('vk') }, + { type: 'yandex', active: providers.includes('yandex') }, + ], + } + }) + + fastify.post('/api/me/password', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может устанавливать пароль' }) + } + + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) + if (user.passwordHash) return reply.code(409).send({ error: 'Пароль уже установлен' }) + + const password = String(request.body?.password || '') + const passwordErr = validatePassword(password) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const passwordHash = await hashPassword(password) + await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) - await issueEmailCode({ - email: newEmail, - purpose: 'change_email', - userId, - }) return { ok: true } }) - fastify.post('/api/me/change-email/verify', { preHandler: [fastify.authenticate] }, async (request, reply) => { + fastify.post('/api/me/change-password', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub - const newEmail = normalizeEmail(request.body?.newEmail) - const code = String(request.body?.code || '').trim() - if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может менять пароль' }) + } - const exists = await prisma.user.findUnique({ - where: { email: newEmail }, - }) - if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' }) + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) + if (!user.passwordHash) return reply.code(400).send({ error: 'Пароль не установлен. Используйте установку пароля.' }) - const ok = await verifyEmailCode({ - email: newEmail, - purpose: 'change_email', - code, - userId, - }) - if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) + const oldPassword = String(request.body?.oldPassword || '') + const valid = await comparePassword(oldPassword, user.passwordHash) + if (!valid) return reply.code(401).send({ error: 'Неверный текущий пароль' }) - const user = await prisma.user.update({ - where: { id: userId }, - data: { email: newEmail }, - }) - return { user: mapUserForClient(user) } + const newPassword = String(request.body?.newPassword || '') + const passwordErr = validatePassword(newPassword) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const passwordHash = await hashPassword(newPassword) + await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) + + return { ok: true } }) + fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const provider = request.params?.provider + + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может отвязывать OAuth' }) + } + if (provider !== 'vk' && provider !== 'yandex') { + return reply.code(400).send({ error: 'Неизвестный провайдер' }) + } + + const oauth = await prisma.oAuthAccount.findFirst({ + where: { userId, provider }, + }) + if (!oauth) return reply.code(404).send({ error: 'Аккаунт не привязан' }) + + const remainingOAuth = await prisma.oAuthAccount.count({ + where: { userId, provider: { not: provider } }, + }) + const currentUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { passwordHash: true }, + }) + if (!currentUser?.passwordHash && remainingOAuth === 0) { + return reply.code(400).send({ error: 'Нельзя удалить последний метод входа' }) + } + + await prisma.oAuthAccount.delete({ where: { id: oauth.id } }) + return { ok: true } + }) + + fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const nameRaw = request.body?.displayName const displayName = nameRaw === null || nameRaw === undefined ? null : 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 !== 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: 'Стиль аватара слишком длинный' }) @@ -142,9 +293,6 @@ export async function registerAuthRoutes(fastify) { displayName: displayName && displayName.length ? displayName : null, } - if (avatarType !== undefined) { - data.avatarType = avatarType === '' ? null : avatarType - } if (avatar !== undefined) { data.avatar = avatar === '' ? null : avatar } diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index dba2db1..cfb3c9e 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -17,7 +17,7 @@ async function issueUserJwt(fastify, userId, email) { return fastify.jwt.sign({ sub: userId, email }) } -async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail }) { +async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail, linkToUserId }) { const existingLink = await prisma.oAuthAccount.findUnique({ where: { provider_providerUserId: { provider, providerUserId } }, include: { user: true }, @@ -34,6 +34,15 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : '' const norm = trimmed ? normalizeEmail(trimmed) : null + + if (linkToUserId) { + if (!norm) return null + await prisma.oAuthAccount.create({ + data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken }, + }) + return prisma.user.findUnique({ where: { id: linkToUserId } }) + } + let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null if (user) { await prisma.oAuthAccount.create({ @@ -42,16 +51,22 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken return user } - let email = norm || `${provider}_${providerUserId}@oauth.craftshop.local` - let n = 0 - while (await prisma.user.findUnique({ where: { email } })) { - n += 1 - email = `${provider}_${providerUserId}_${n}@oauth.craftshop.local` - } - user = await prisma.user.create({ data: { email } }) + if (!norm) return null + + user = await prisma.user.create({ + data: { + email: norm, + displayName: norm.split('@')[0], + avatar: null, + avatarStyle: 'avataaars', + }, + }) await prisma.oAuthAccount.create({ data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, }) + await prisma.notificationPreference.create({ + data: { userId: user.id, globalEnabled: true }, + }) return user } @@ -79,18 +94,46 @@ export async function registerOAuthSocialRoutes(fastify) { return reply.redirect(url.toString()) }) + fastify.get('/api/auth/oauth/vk/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (request.user.email === adminEmail) { + return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) + } + + const clientId = process.env.VK_CLIENT_ID + const clientSecret = process.env.VK_CLIENT_SECRET + 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 url = new URL('https://oauth.vk.com/authorize') + url.searchParams.set('client_id', clientId) + url.searchParams.set('display', 'page') + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', 'email') + url.searchParams.set('response_type', 'code') + url.searchParams.set('v', '5.199') + url.searchParams.set('state', state) + + return reply.redirect(url.toString()) + }) + fastify.get('/api/auth/oauth/vk/callback', async (request, reply) => { const query = request.query ?? {} if (query.error || query.error_description) { return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK')) } - try { - const state = typeof query.state === 'string' ? query.state : '' - fastify.jwt.verify(state || '') - } 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') @@ -114,50 +157,26 @@ export async function registerOAuthSocialRoutes(fastify) { const vkUserId = tokenBody?.user_id const accessTokenVk = tokenBody?.access_token - let emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null - let firstName = null - let lastName = null - let gender = null - let avatar = null - try { - if (accessTokenVk && vkUserId) { - const u = new URL('https://api.vk.com/method/users.get') - u.searchParams.set('access_token', accessTokenVk) - u.searchParams.set('users_ids', String(vkUserId)) - u.searchParams.set('fields', 'photo_200,sex') - u.searchParams.set('v', '5.199') - const profRes = await fetch(u.toString()) - const prof = await profRes.json() - const u0 = prof?.response?.[0] - if (u0) { - firstName = u0.first_name ?? null - lastName = u0.last_name ?? null - avatar = u0.photo_200 ?? null - if (u0.sex === 1) gender = 'female' - else if (u0.sex === 2) gender = 'male' - } - } - } catch { - // ignore profile extras - } + const emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null + + if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email') + + const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined const user = await findOrCreateUserFromOAuth({ provider: 'vk', providerUserId: String(vkUserId), accessToken: accessTokenVk ?? null, suggestedEmail: emailSuggestion, + linkToUserId, }) - const displayName = [firstName, lastName].filter(Boolean).join(' ').trim() - const updateData = {} - if (displayName && !user.displayName) updateData.displayName = displayName - if (firstName) updateData.firstName = firstName - if (lastName) updateData.lastName = lastName - if (gender) updateData.gender = gender - if (avatar) updateData.avatar = avatar - if (Object.keys(updateData).length > 0) { - await prisma.user.update({ where: { id: user.id }, data: updateData }) + if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK') + + if (linkToUserId) { + const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' + return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`) } const token = await issueUserJwt(fastify, user.id, user.email) @@ -176,7 +195,29 @@ export async function registerOAuthSocialRoutes(fastify) { url.searchParams.set('response_type', 'code') url.searchParams.set('client_id', clientId) url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('scope', 'login:email login:info') + url.searchParams.set('scope', 'login:email') + url.searchParams.set('state', state) + + return reply.redirect(url.toString()) + }) + + fastify.get('/api/auth/oauth/yandex/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (request.user.email === adminEmail) { + return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) + } + + const clientId = process.env.YANDEX_CLIENT_ID + 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 url = new URL('https://oauth.yandex.ru/authorize') + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', clientId) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', 'login:email') url.searchParams.set('state', state) return reply.redirect(url.toString()) @@ -186,12 +227,15 @@ export async function registerOAuthSocialRoutes(fastify) { const query = request.query ?? {} if (query.error) return oauthErrorRedirect(reply, String(query.error)) - try { - const state = typeof query.state === 'string' ? query.state : '' - fastify.jwt.verify(state || '') - } 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, 'Не получен код от Яндекс') @@ -230,30 +274,25 @@ 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 || - (info?.login ? `${info.login}@yandex.ru` : null) + const emailGuess = (Array.isArray(info?.emails) && info.emails[0]) || info?.default_email || null + + if (!emailGuess) return oauthErrorRedirect(reply, 'no_email') + + const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined const user = await findOrCreateUserFromOAuth({ provider: 'yandex', providerUserId: yaUserId, accessToken: yaToken, - suggestedEmail: emailGuess || null, + suggestedEmail: emailGuess, + linkToUserId, }) - const updateData = {} - const displayName = - [info.first_name, info.last_name].filter(Boolean).join(' ').trim() || info.display_name || info.real_name - if (displayName && !user.displayName) updateData.displayName = displayName - if (info.first_name) updateData.firstName = info.first_name - if (info.last_name) updateData.lastName = info.last_name - if (info.sex === 'male' || info.sex === 'female') updateData.gender = info.sex - if (info.default_avatar_id && !info.is_avatar_empty) { - updateData.avatar = `https://avatars.yandex.net/get-yapic/${info.default_avatar_id}/islands-200` - } - if (Object.keys(updateData).length > 0) { - await prisma.user.update({ where: { id: user.id }, data: updateData }) + if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от Яндекс') + + if (linkToUserId) { + const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' + return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=yandex`) } const token = await issueUserJwt(fastify, user.id, user.email)