Merge branch 'autorizayion'

This commit is contained in:
Kirill
2026-05-22 14:22:28 +05:00
49 changed files with 2282 additions and 348 deletions
@@ -0,0 +1 @@
12063
@@ -0,0 +1 @@
12189
@@ -0,0 +1 @@
12688
@@ -0,0 +1 @@
12844
@@ -0,0 +1 @@
12996
@@ -0,0 +1 @@
13143
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -17,7 +17,7 @@ export function MainLayout({ children }: PropsWithChildren) {
const year = new Date().getFullYear()
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', minWidth: '500px' }}>
<ScrollOnNavigate />
<ScrollToTop />
<AppHeader />
@@ -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',
+29 -2
View File
@@ -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;
}
@@ -42,7 +42,6 @@ export type AdminOrderDetailResponse = {
email: string
displayName: string | null
avatar?: string | null
avatarType?: string | null
avatarStyle?: string | null
}
items: Array<{
@@ -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
}
-1
View File
@@ -32,7 +32,6 @@ export async function updateAdminUser(
export type AdminAvatarResponse = {
avatar: string | null
avatarType: string | null
avatarStyle: string | null
}
-1
View File
@@ -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
@@ -16,6 +16,7 @@ export function OAuthButtons() {
'&:hover': {
borderColor: p.color,
bgcolor: `${p.color}14`,
borderWidth: '1px',
},
}}
>
@@ -53,18 +53,11 @@ export function OrderChat({ messages, isPending, onSend }: Props) {
const isAdminMsg = m.authorType === 'admin'
const adminAv = adminAvatarQuery.data
const avatarNode = isAdminMsg ? (
<UserAvatar
userId="admin"
avatarUrl={adminAv?.avatar}
avatarType={adminAv?.avatarType}
avatarStyle={adminAv?.avatarStyle}
size={24}
/>
<UserAvatar userId="admin" avatarUrl={adminAv?.avatar} avatarStyle={adminAv?.avatarStyle} size={24} />
) : currentUser ? (
<UserAvatar
userId={currentUser.id}
avatarUrl={currentUser.avatar}
avatarType={currentUser.avatarType}
avatarStyle={currentUser.avatarStyle}
size={24}
/>
@@ -175,7 +175,6 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
<UserAvatar
userId={currentUser.id}
avatarUrl={currentUser.avatar}
avatarType={currentUser.avatarType}
avatarStyle={currentUser.avatarStyle}
size={24}
/>
@@ -184,7 +183,6 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
<UserAvatar
userId={detail.user.id}
avatarUrl={detail.user.avatar}
avatarType={detail.user.avatarType}
avatarStyle={detail.user.avatarStyle}
size={24}
/>
@@ -22,7 +22,6 @@ function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
<UserAvatar
userId={rv.authorDisplay}
avatarUrl={rv.authorAvatar}
avatarType={rv.authorAvatarType}
avatarStyle={rv.authorAvatarStyle}
size={32}
/>
@@ -43,13 +43,7 @@ export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props)
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
{user ? (
<UserAvatar
userId={user.id}
avatarUrl={user.avatar}
avatarType={user.avatarType}
avatarStyle={user.avatarStyle}
size={28}
/>
<UserAvatar userId={user.id} avatarUrl={user.avatar} avatarStyle={user.avatarStyle} size={28} />
) : (
<PersonIcon sx={{ fontSize: 28 }} />
)}
@@ -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<string | null>(null)
const [previewStyle, setPreviewStyle] = useState<string>(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() {
<UserAvatar
userId={String(user.id)}
avatarUrl={hasUnsavedPreview ? previewSrc : user.avatar}
avatarType={hasUnsavedPreview ? 'generated' : user.avatarType}
avatarStyle={hasUnsavedPreview ? previewStyle : user.avatarStyle}
size={80}
sx={{
@@ -153,7 +142,7 @@ export function AdminSettingsPage() {
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'}
{hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
</Typography>
</Box>
{hasUnsavedPreview && (
@@ -161,7 +150,6 @@ export function AdminSettingsPage() {
<UserAvatar
userId={String(user.id)}
avatarUrl={user.avatar}
avatarType={user.avatarType}
avatarStyle={user.avatarStyle}
size={80}
sx={{ border: 2, borderColor: 'divider', opacity: 0.6 }}
@@ -209,7 +197,6 @@ export function AdminSettingsPage() {
profileSaveMut.mutate({
displayName: name.length ? name : null,
avatar: previewSrc,
avatarType: 'generated',
avatarStyle: previewStyle,
})
setPreviewSrc(null)
@@ -222,24 +209,6 @@ export function AdminSettingsPage() {
</Button>
</Stack>
)}
{hasOAuthAvatar && !hasUnsavedPreview && (
<Button
variant="outlined"
disabled={pendingProfile || profileSaveMut.isPending || useOAuth}
onClick={() => {
const raw = profileForm.getValues('displayName')
const name = raw.trim()
profileSaveMut.mutate({
displayName: name.length ? name : null,
avatarType: 'oauth',
})
}}
sx={{ mt: 0.5 }}
>
Использовать OAuth
</Button>
)}
</Box>
</Stack>
</Box>
@@ -192,13 +192,7 @@ export function AdminUsersPage() {
users.map((u) => (
<TableRow key={u.id} hover>
<TableCell>
<UserAvatar
userId={u.id}
avatarUrl={u.avatar}
avatarType={u.avatarType}
avatarStyle={u.avatarStyle}
size={28}
/>
<UserAvatar userId={u.id} avatarUrl={u.avatar} avatarStyle={u.avatarStyle} size={28} />
</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>{u.displayName ?? '—'}</TableCell>
@@ -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(
<QueryClientProvider client={qc}>
<MemoryRouter>
<AuthPage />
</MemoryRouter>
</QueryClientProvider>,
)
}
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()
})
})
+400 -46
View File
@@ -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<string | null>(null)
const [oauthError, setOauthError] = useState<string | null>(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<AuthResponse>('auth/login', { email, password })
tokenSet(data.token)
navigate('/', { replace: true })
},
})
const registerMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('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<AuthResponse>('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 (
<Box>
<Typography variant="h4" gutterBottom>
Вход / регистрация
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'start',
justifyContent: 'center',
minHeight: 'calc(100vh - 64px)',
px: 2,
background: `radial-gradient(circle at 50% 30%, ${alpha(theme.palette.primary.main, 0.05)} 0%, transparent 70%)`,
}}
>
<Box sx={{ width: '100%', maxWidth: 440 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<BearLogo sx={{ fontSize: 72 }} />
</Box>
{message && (
<Alert severity="success" sx={{ mb: 2 }}>
{message}
</Alert>
)}
{oauthError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>
{oauthError}
</Alert>
)}
{errMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{errMsg}
</Alert>
)}
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
Добро пожаловать <br />в Любимый Креатив
</Typography>
<Stack spacing={2} sx={{ maxWidth: 520 }}>
<Typography variant="h6">Email + код</Typography>
<TextField label="Email" {...register('email')} fullWidth />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="outlined" onClick={() => requestCode.mutate()} disabled={!email || requestCode.isPending}>
Отправить код
</Button>
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} />
<Button
variant="contained"
onClick={() => verifyCode.mutate()}
disabled={!email || code.length !== 6 || verifyCode.isPending}
>
Войти
</Button>
</Stack>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 3 }}>
Войдите или зарегистрируйтесь, чтобы продолжить
</Typography>
<Stack sx={{ maxWidth: 520 }}>
<Divider sx={{ my: 2 }}>или</Divider>
<Paper
sx={{
p: 4,
borderRadius: 3,
border: `1px solid ${theme.palette.divider}`,
}}
>
<Stack direction="row" spacing={1} sx={{ mb: 3 }}>
{[
{ label: 'Пароль', idx: 0 },
{ label: 'Код', idx: 1 },
].map(({ label, idx }) => (
<Button
key={idx}
variant={tab === idx ? 'contained' : 'outlined'}
size="small"
sx={{
borderRadius: '24px',
flex: 1,
textTransform: 'none',
'&:hover': { transform: 'none', borderWidth: '1px' },
}}
onClick={() => {
setTab(idx)
setMessage(null)
setOauthError(null)
}}
>
{label}
</Button>
))}
</Stack>
<OAuthButtons />
</Stack>
{(errMsg || oauthError) && (
<Alert
severity="error"
variant="outlined"
sx={{ mb: 2 }}
onClose={() => {
setOauthError(null)
}}
>
{errMsg || oauthError}
</Alert>
)}
{message && (
<Alert severity="success" variant="outlined" sx={{ mb: 2 }} onClose={() => setMessage(null)}>
{message}
</Alert>
)}
{tab === 0 && (
<Stack spacing={2}>
<Stack direction="row" sx={{ justifyContent: 'center' }} spacing={3}>
<Button
variant="text"
size="small"
sx={{
color: !isRegister ? 'primary.main' : 'text.secondary',
borderBottom: !isRegister ? 2 : 0,
borderColor: 'primary.main',
borderRadius: 0,
pb: 0.5,
textTransform: 'none',
}}
onClick={() => setIsRegister(false)}
>
Вход
</Button>
<Button
variant="text"
size="small"
sx={{
color: isRegister ? 'primary.main' : 'text.secondary',
borderBottom: isRegister ? 2 : 0,
borderColor: 'primary.main',
borderRadius: 0,
pb: 0.5,
textTransform: 'none',
}}
onClick={() => setIsRegister(true)}
>
Регистрация
</Button>
</Stack>
<TextField
label="Email"
{...register('email')}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Mail size={18} />
</InputAdornment>
),
},
}}
/>
{isRegister && (
<TextField
label="Имя (необязательно)"
{...register('displayName')}
fullWidth
helperText="Если не указать, будет использована часть email до @"
/>
)}
<TextField
label="Пароль"
type="password"
{...register('password')}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Lock size={18} />
</InputAdornment>
),
},
}}
/>
{isRegister && (
<TextField
label="Подтверждение пароля"
type="password"
{...register('passwordConfirm')}
fullWidth
error={Boolean(passwordError)}
helperText={passwordError}
/>
)}
{isRegister ? (
<Button
variant="contained"
size="large"
disabled={
!email ||
!password ||
password.length < 8 ||
(isRegister && password !== passwordConfirm) ||
registerMutation.isPending
}
onClick={() => registerMutation.mutate()}
>
Зарегистрироваться
</Button>
) : (
<Button
variant="contained"
size="large"
disabled={!email || !password || loginMutation.isPending}
onClick={() => loginMutation.mutate()}
>
Войти
</Button>
)}
{!isRegister && !showForgot && (
<Button
variant="text"
size="small"
sx={{ textTransform: 'none', alignSelf: 'center', color: 'text.secondary' }}
onClick={() => {
setShowForgot(true)
setForgotStep(0)
setForgotEmail(email)
setMessage(null)
}}
>
Забыли пароль?
</Button>
)}
{showForgot && (
<>
<TextField
label="Email"
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Mail size={18} />
</InputAdornment>
),
},
}}
/>
{forgotStep === 1 && (
<>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField
label="Код (6 цифр)"
inputMode="numeric"
value={code}
onChange={(e) => {
register('code').onChange(e)
}}
sx={{ flex: 1 }}
/>
<Button
variant="outlined"
onClick={() => forgotCode.mutate()}
disabled={!forgotEmail || forgotCode.isPending}
sx={{ whiteSpace: 'nowrap' }}
>
Отправить ещё раз
</Button>
</Stack>
<TextField label="Новый пароль" type="password" {...register('password')} fullWidth />
<Button
variant="contained"
disabled={
!code || code.length !== 6 || !password || password.length < 8 || resetPassword.isPending
}
onClick={() => resetPassword.mutate()}
>
Сменить пароль
</Button>
</>
)}
{forgotStep === 0 && (
<Button
variant="contained"
disabled={!forgotEmail || forgotCode.isPending}
onClick={() => forgotCode.mutate()}
>
Отправить код
</Button>
)}
<Button
variant="text"
size="small"
sx={{ textTransform: 'none', alignSelf: 'center' }}
onClick={() => {
setShowForgot(false)
setForgotStep(0)
setMessage(null)
}}
>
Назад к входу
</Button>
</>
)}
</Stack>
)}
{tab === 1 && (
<Stack spacing={2}>
<TextField
label="Email"
{...register('email')}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Mail size={18} />
</InputAdornment>
),
},
}}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button
variant="outlined"
onClick={() => requestCode.mutate()}
disabled={!email || requestCode.isPending}
sx={{ whiteSpace: 'nowrap' }}
>
Отправить код
</Button>
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} sx={{ flex: 1 }} />
<Button
variant="contained"
onClick={() => verifyCode.mutate()}
disabled={!email || code.length !== 6 || verifyCode.isPending}
sx={{ whiteSpace: 'nowrap' }}
>
Войти
</Button>
</Stack>
</Stack>
)}
<Box sx={{ mt: 3, mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ flex: 1, borderBottom: `1px solid ${theme.palette.divider}` }} />
<Typography variant="body2" color="text.secondary" sx={{ px: 1, whiteSpace: 'nowrap' }}>
или
</Typography>
<Box sx={{ flex: 1, borderBottom: `1px solid ${theme.palette.divider}` }} />
</Box>
<OAuthButtons />
</Paper>
</Box>
</Box>
)
}
@@ -181,7 +181,6 @@ export function MessagesPage() {
<UserAvatar
userId="admin"
avatarUrl={adminAv?.avatar}
avatarType={adminAv?.avatarType}
avatarStyle={adminAv?.avatarStyle}
size={24}
/>
@@ -189,7 +188,6 @@ export function MessagesPage() {
<UserAvatar
userId={currentUser.id}
avatarUrl={currentUser.avatar}
avatarType={currentUser.avatarType}
avatarStyle={currentUser.avatarStyle}
size={24}
/>
+210 -75
View File
@@ -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<string | null>(null)
const [previewStyle, setPreviewStyle] = useState<string>(DEFAULT_STYLE_ID)
const hasUnsavedPreview = previewSrc !== null
const [authMethods, setAuthMethods] = useState<AuthMethod[]>([])
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<string, string> = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' }
if (!user) {
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
}
@@ -79,11 +122,6 @@ export function SettingsPage() {
Текущая почта: <b>{user.email}</b>
</Typography>
{emailErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{emailErrorMsg}
</Alert>
)}
{profileErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{profileErrorMsg}
@@ -128,7 +166,6 @@ export function SettingsPage() {
<UserAvatar
userId={user.id}
avatarUrl={hasUnsavedPreview ? previewSrc : user.avatar}
avatarType={hasUnsavedPreview ? 'generated' : user.avatarType}
avatarStyle={hasUnsavedPreview ? previewStyle : user.avatarStyle}
size={80}
sx={{
@@ -137,7 +174,7 @@ export function SettingsPage() {
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'}
{hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
</Typography>
</Box>
{hasUnsavedPreview && (
@@ -145,7 +182,6 @@ export function SettingsPage() {
<UserAvatar
userId={user.id}
avatarUrl={user.avatar}
avatarType={user.avatarType}
avatarStyle={user.avatarStyle}
size={80}
sx={{ border: 2, borderColor: 'divider', opacity: 0.6 }}
@@ -191,7 +227,6 @@ export function SettingsPage() {
updateProfileFx({
displayName: user.displayName?.trim() || null,
avatar: previewSrc,
avatarType: 'generated',
avatarStyle: previewStyle,
})
setPreviewSrc(null)
@@ -204,56 +239,156 @@ export function SettingsPage() {
</Button>
</Stack>
)}
{hasOAuthAvatar && !hasUnsavedPreview && (
<Button
variant="outlined"
disabled={pendingProfile || useOAuth}
onClick={() => {
updateProfileFx({
displayName: user.displayName?.trim() || null,
avatarType: 'oauth',
})
}}
sx={{ mt: 0.5 }}
>
Использовать OAuth
</Button>
)}
</Box>
<Divider />
{!user.isAdmin && (
<>
<Divider />
<Box>
<Typography variant="h6" gutterBottom>
Методы входа
</Typography>
<Stack spacing={1}>
{authMethods.map((m) => (
<Stack key={m.type} direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Typography sx={{ minWidth: 120 }}>{METHOD_LABELS[m.type] || m.type}</Typography>
<Chip
label={m.active ? 'Привязан' : 'Не привязан'}
color={m.active ? 'success' : 'default'}
size="small"
/>
{m.active && m.type !== 'password' && (
<Button
size="small"
variant="outlined"
color="error"
disabled={linkedCount() <= 1}
onClick={() => unlinkMutation.mutate(m.type as 'vk' | 'yandex')}
>
Отвязать
</Button>
)}
{m.active && m.type === 'password' && (
<Button size="small" variant="outlined" onClick={() => setShowChangePassword(true)}>
Сменить пароль
</Button>
)}
{!m.active && m.type === 'password' && (
<Button size="small" variant="outlined" onClick={() => setShowSetPassword(true)}>
Установить пароль
</Button>
)}
{!m.active && m.type !== 'password' && (
<Button
size="small"
variant="outlined"
component="a"
href={`/api/auth/oauth/${m.type}/link?token=${localStorage.getItem('craftshop_auth_token') || ''}`}
>
Привязать
</Button>
)}
</Stack>
))}
</Stack>
<Box>
<Typography variant="h6" gutterBottom>
Смена почты
</Typography>
<Stack spacing={2}>
<TextField label="Новая почта" {...emailForm.register('newEmail')} />
<Button
variant="outlined"
disabled={!emailForm.watch('newEmail') || pendingEmailReq}
onClick={() => requestEmailChangeCodeFx(emailForm.getValues('newEmail').trim())}
>
Отправить код на новую почту
</Button>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField label="Код (6 цифр)" inputMode="numeric" {...emailForm.register('code')} />
<Button
variant="contained"
disabled={emailForm.watch('code').trim().length !== 6 || pendingEmailVerify}
onClick={() =>
verifyEmailChangeFx({
newEmail: emailForm.getValues('newEmail').trim(),
code: emailForm.getValues('code').trim(),
})
}
>
Подтвердить
</Button>
</Stack>
</Stack>
</Box>
{showSetPassword && (
<Stack spacing={2} sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<TextField label="Пароль" type="password" {...passwordForm.register('password')} fullWidth />
<TextField
label="Подтверждение пароля"
type="password"
{...passwordForm.register('passwordConfirm')}
fullWidth
error={
Boolean(passwordForm.watch('passwordConfirm')) &&
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
}
helperText={
passwordForm.watch('passwordConfirm') &&
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
? 'Пароли не совпадают'
: null
}
/>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
disabled={
!passwordForm.watch('password') ||
passwordForm.watch('password').length < 8 ||
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm') ||
setPasswordMutation.isPending
}
onClick={() => setPasswordMutation.mutate(passwordForm.getValues('password'))}
>
Сохранить
</Button>
<Button variant="text" onClick={() => setShowSetPassword(false)}>
Отмена
</Button>
</Stack>
</Stack>
)}
{showChangePassword && (
<Stack spacing={2} sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<TextField
label="Текущий пароль"
type="password"
{...changePasswordForm.register('oldPassword')}
fullWidth
/>
<TextField
label="Новый пароль"
type="password"
{...changePasswordForm.register('newPassword')}
fullWidth
/>
<TextField
label="Подтверждение пароля"
type="password"
{...changePasswordForm.register('confirmPassword')}
fullWidth
error={
Boolean(changePasswordForm.watch('confirmPassword')) &&
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword')
}
helperText={
changePasswordForm.watch('confirmPassword') &&
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword')
? 'Пароли не совпадают'
: null
}
/>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
disabled={
!changePasswordForm.watch('oldPassword') ||
!changePasswordForm.watch('newPassword') ||
changePasswordForm.watch('newPassword').length < 8 ||
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword') ||
changePasswordMutation.isPending
}
onClick={() =>
changePasswordMutation.mutate({
oldPassword: changePasswordForm.getValues('oldPassword'),
newPassword: changePasswordForm.getValues('newPassword'),
})
}
>
Сохранить
</Button>
<Button variant="text" onClick={() => setShowChangePassword(false)}>
Отмена
</Button>
</Stack>
</Stack>
)}
</Box>
</>
)}
</Stack>
</Box>
)
+34 -17
View File
@@ -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<string | null>()
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,
})
+2 -3
View File
@@ -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<Theme>
}
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 (
<Avatar src={src} sx={{ width: size, height: size, bgcolor: 'primary.main', ...sx }}>
@@ -104,7 +104,6 @@ export function ReviewsBlock() {
<UserAvatar
userId={r.authorDisplay}
avatarUrl={r.authorAvatar}
avatarType={r.authorAvatarType}
avatarStyle={r.authorAvatarStyle}
size={40}
/>
@@ -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<string, unknown>
const response = anyErr.response as Record<string, unknown> | undefined
const data = response?.data as Record<string, unknown> | undefined
const msg = data?.error
return typeof msg === 'string' ? msg : null
}
export function AuthPage() {
const theme = useTheme()
const [message, setMessage] = useState<string | null>(null)
const [oauthError, setOauthError] = useState<string | null>(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<AuthResponse>('auth/login', { email, password })
tokenSet(data.token)
navigate('/', { replace: true })
},
})
const registerMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('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<AuthResponse>('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 (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 'calc(100vh - 64px)',
px: 2,
background: `radial-gradient(circle at 50% 30%, ${alpha(theme.palette.primary.main, 0.05)} 0%, transparent 70%)`,
}}
>
<Box sx={{ width: '100%', maxWidth: 440 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<BearLogo sx={{ fontSize: 72 }} />
</Box>
<Typography variant="h5" fontWeight={700} textAlign="center" gutterBottom>
Добро пожаловать в Любимый Креатив
</Typography>
<Typography variant="body2" color="text.secondary" textAlign="center" sx={{ mb: 3 }}>
Войдите или зарегистрируйтесь, чтобы продолжить
</Typography>
<Paper
sx={{
p: 4,
borderRadius: 3,
border: `1px solid ${theme.palette.divider}`,
}}
>
<Stack direction="row" spacing={1} sx={{ mb: 3 }}>
{[
{ label: 'Пароль', idx: 0 },
{ label: 'Код', idx: 1 },
{ label: 'Другой способ', idx: 2 },
].map(({ label, idx }) => (
<Button
key={idx}
variant={tab === idx ? 'contained' : 'outlined'}
size="small"
sx={{ borderRadius: '24px', flex: 1, textTransform: 'none' }}
onClick={() => {
setTab(idx)
setMessage(null)
setOauthError(null)
}}
>
{label}
</Button>
))}
</Stack>
{(errMsg || oauthError) && (
<Alert
severity="error"
variant="outlined"
sx={{ mb: 2 }}
onClose={() => {
setOauthError(null)
}}
>
{errMsg || oauthError}
</Alert>
)}
{message && (
<Alert severity="success" variant="outlined" sx={{ mb: 2 }} onClose={() => setMessage(null)}>
{message}
</Alert>
)}
{tab === 0 && (
<Stack spacing={2}>
<Stack direction="row" justifyContent="center" spacing={3}>
<Button
variant="text"
size="small"
sx={{
color: !isRegister ? 'primary.main' : 'text.secondary',
borderBottom: !isRegister ? 2 : 0,
borderColor: 'primary.main',
borderRadius: 0,
pb: 0.5,
textTransform: 'none',
}}
onClick={() => setIsRegister(false)}
>
Вход
</Button>
<Button
variant="text"
size="small"
sx={{
color: isRegister ? 'primary.main' : 'text.secondary',
borderBottom: isRegister ? 2 : 0,
borderColor: 'primary.main',
borderRadius: 0,
pb: 0.5,
textTransform: 'none',
}}
onClick={() => setIsRegister(true)}
>
Регистрация
</Button>
</Stack>
<TextField
label="Email"
{...register('email')}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Mail size={18} />
</InputAdornment>
),
},
}}
/>
{isRegister && (
<TextField
label="Имя (необязательно)"
{...register('displayName')}
fullWidth
helperText="Если не указать, будет использована часть email до @"
/>
)}
<TextField
label="Пароль"
type="password"
{...register('password')}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Lock size={18} />
</InputAdornment>
),
},
}}
/>
{isRegister && (
<TextField
label="Подтверждение пароля"
type="password"
{...register('passwordConfirm')}
fullWidth
error={Boolean(passwordError)}
helperText={passwordError}
/>
)}
{isRegister ? (
<Button
variant="contained"
size="large"
disabled={
!email ||
!password ||
password.length < 8 ||
(isRegister && password !== passwordConfirm) ||
registerMutation.isPending
}
onClick={() => registerMutation.mutate()}
>
Зарегистрироваться
</Button>
) : (
<Button
variant="contained"
size="large"
disabled={!email || !password || loginMutation.isPending}
onClick={() => loginMutation.mutate()}
>
Войти
</Button>
)}
</Stack>
)}
{tab === 1 && (
<Stack spacing={2}>
<TextField
label="Email"
{...register('email')}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Mail size={18} />
</InputAdornment>
),
},
}}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button
variant="outlined"
onClick={() => requestCode.mutate()}
disabled={!email || requestCode.isPending}
sx={{ whiteSpace: 'nowrap' }}
>
Отправить код
</Button>
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} sx={{ flex: 1 }} />
<Button
variant="contained"
onClick={() => verifyCode.mutate()}
disabled={!email || code.length !== 6 || verifyCode.isPending}
sx={{ whiteSpace: 'nowrap' }}
>
Войти
</Button>
</Stack>
</Stack>
)}
{tab === 2 && (
<Stack spacing={2}>
<OAuthButtons />
</Stack>
)}
</Paper>
</Box>
</Box>
)
}
```
- [ ] **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"
```
@@ -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 на корневом `<Box>`: `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: __________________ │ │
│ │ Пароль: __________________ │ │
│ │ │ │
│ │ [────────── Войти ──────────] │ │
│ │ │ │
│ └─────────────────────────────────────┘ │
│ (воздух) │
└──────────────────────────────────────────┘
```
Детали:
- Корневой `<Box>`: `display: flex, alignItems: center, justifyContent: center, minHeight: calc(100vh - header)`
- BearLogo: `<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}><BearLogo sx={{ fontSize: 72 }} /></Box>`
- Заголовок: `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
<Stack direction="row" spacing={1} sx={{ mb: 3 }}>
<Button
variant={tab === 0 ? 'contained' : 'outlined'}
sx={{ borderRadius: '24px', flex: 1, textTransform: 'none' }}
onClick={() => setTab(0)}
>
Пароль
</Button>
<Button
variant={tab === 1 ? 'contained' : 'outlined'}
sx={{ borderRadius: '24px', flex: 1, textTransform: 'none' }}
onClick={() => setTab(1)}
>
Код
</Button>
<Button
variant={tab === 2 ? 'contained' : 'outlined'}
sx={{ borderRadius: '24px', flex: 1, textTransform: 'none' }}
onClick={() => setTab(2)}
>
Другой способ
</Button>
</Stack>
```
---
## 5. Под-переключатель Вход/Регистрация
Только на вкладке «Пароль»:
```tsx
<Stack direction="row" justifyContent="center" spacing={3} sx={{ mb: 2 }}>
<Button
variant="text"
size="small"
sx={{
color: !isRegister ? 'primary.main' : 'text.secondary',
borderBottom: !isRegister ? 2 : 0,
borderColor: 'primary.main',
borderRadius: 0,
pb: 0.5,
}}
onClick={() => setIsRegister(false)}
>
Вход
</Button>
<Button
variant="text"
size="small"
sx={{
color: isRegister ? 'primary.main' : 'text.secondary',
borderBottom: isRegister ? 2 : 0,
borderColor: 'primary.main',
borderRadius: 0,
pb: 0.5,
}}
onClick={() => setIsRegister(true)}
>
Регистрация
</Button>
</Stack>
```
---
## 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: `<Mail>` иконка (lucide-react)
- Пароль: `<Lock>` иконка
Иконки только если это не перегружает минималистичный стиль. Решение — использовать в полях 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
+35
View File
@@ -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",
+1
View File
@@ -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",
@@ -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;
-1
View File
@@ -82,7 +82,6 @@ model User {
lastName String?
gender String?
avatar String?
avatarType String?
avatarStyle String?
passwordHash String?
createdAt DateTime @default(now())
+3
View File
@@ -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: 'Не авторизован' })
+32
View File
@@ -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
}
+26
View File
@@ -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 }
}
@@ -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('последний метод')
})
})
@@ -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)
})
})
+1 -1
View File
@@ -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'
+3 -1
View File
@@ -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' } },
},
-11
View File
@@ -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,
}
})
-2
View File
@@ -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,
+4 -4
View File
@@ -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,
}))
+191 -43
View File
@@ -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
}
+112 -73
View File
@@ -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)