Files
shop-server/client/src/pages/auth/ui/AuthPage.tsx
T

354 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
Добро пожаловать в Любимый Креатив
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', 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" 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>
)}
</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>
)
}