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)