Merge branch 'autorizayion'
This commit is contained in:
@@ -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.
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ export async function updateAdminUser(
|
||||
|
||||
export type AdminAvatarResponse = {
|
||||
avatar: string | null
|
||||
avatarType: string | null
|
||||
avatarStyle: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
Generated
+35
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -82,7 +82,6 @@ model User {
|
||||
lastName String?
|
||||
gender String?
|
||||
avatar String?
|
||||
avatarType String?
|
||||
avatarStyle String?
|
||||
passwordHash String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -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: 'Не авторизован' })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user