feat(client): redesign auth page with minimal style, BearLogo, pill buttons
This commit is contained in:
@@ -24,9 +24,9 @@ function renderPage() {
|
|||||||
describe('AuthPage', () => {
|
describe('AuthPage', () => {
|
||||||
it('renders three tabs', () => {
|
it('renders three tabs', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByRole('tab', { name: 'Пароль' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'Пароль' })).toBeTruthy()
|
||||||
expect(screen.getByRole('tab', { name: 'Код' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'Код' })).toBeTruthy()
|
||||||
expect(screen.getByRole('tab', { name: 'Другой способ' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'Другой способ' })).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows login form by default on tab 0', () => {
|
it('shows login form by default on tab 0', () => {
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { alpha, useTheme } from '@mui/material/styles'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
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 Stack from '@mui/material/Stack'
|
||||||
import Tab from '@mui/material/Tab'
|
|
||||||
import Tabs from '@mui/material/Tabs'
|
|
||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
|
import { Lock, Mail } from 'lucide-react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { OAuthButtons } from '@/features/auth-oauth'
|
import { OAuthButtons } from '@/features/auth-oauth'
|
||||||
import { apiClient } from '@/shared/api/client'
|
import { apiClient } from '@/shared/api/client'
|
||||||
import { $user, tokenSet } from '@/shared/model/auth'
|
import { $user, tokenSet } from '@/shared/model/auth'
|
||||||
|
import { BearLogo } from '@/shared/ui/BearLogo'
|
||||||
|
|
||||||
type AuthResponse = {
|
type AuthResponse = {
|
||||||
token: string
|
token: string
|
||||||
@@ -36,6 +39,7 @@ function getApiErrorMessage(err: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AuthPage() {
|
export function AuthPage() {
|
||||||
|
const theme = useTheme()
|
||||||
const [message, setMessage] = useState<string | null>(null)
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
const [oauthError, setOauthError] = useState<string | null>(null)
|
const [oauthError, setOauthError] = useState<string | null>(null)
|
||||||
const [tab, setTab] = useState(0)
|
const [tab, setTab] = useState(0)
|
||||||
@@ -112,121 +116,239 @@ export function AuthPage() {
|
|||||||
getApiErrorMessage(requestCode.error) ||
|
getApiErrorMessage(requestCode.error) ||
|
||||||
getApiErrorMessage(verifyCode.error)
|
getApiErrorMessage(verifyCode.error)
|
||||||
|
|
||||||
const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
|
const passwordError =
|
||||||
|
isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box
|
||||||
<Typography variant="h4" gutterBottom>
|
sx={{
|
||||||
Вход / регистрация
|
display: 'flex',
|
||||||
</Typography>
|
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>
|
||||||
|
|
||||||
{message && (
|
<Typography variant="h5" fontWeight={700} textAlign="center" gutterBottom>
|
||||||
<Alert severity="success" sx={{ mb: 2 }}>
|
Добро пожаловать в Любимый Креатив
|
||||||
{message}
|
</Typography>
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{oauthError && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>
|
|
||||||
{oauthError}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{errMsg && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
{errMsg}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
|
<Typography variant="body2" color="text.secondary" textAlign="center" sx={{ mb: 3 }}>
|
||||||
<Tab label="Пароль" />
|
Войдите или зарегистрируйтесь, чтобы продолжить
|
||||||
<Tab label="Код" />
|
</Typography>
|
||||||
<Tab label="Другой способ" />
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{tab === 0 && (
|
<Paper
|
||||||
<Stack spacing={2} sx={{ maxWidth: 520 }}>
|
sx={{
|
||||||
<Stack direction="row" spacing={1}>
|
p: 4,
|
||||||
<Button variant={!isRegister ? 'contained' : 'outlined'} onClick={() => setIsRegister(false)}>
|
borderRadius: 3,
|
||||||
Вход
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
</Button>
|
}}
|
||||||
<Button variant={isRegister ? 'contained' : 'outlined'} onClick={() => setIsRegister(true)}>
|
>
|
||||||
Регистрация
|
<Stack direction="row" spacing={1} sx={{ mb: 3 }}>
|
||||||
</Button>
|
{[
|
||||||
|
{ 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>
|
</Stack>
|
||||||
|
|
||||||
<TextField label="Email" {...register('email')} fullWidth />
|
{(errMsg || oauthError) && (
|
||||||
|
<Alert
|
||||||
{isRegister && (
|
severity="error"
|
||||||
<TextField
|
variant="outlined"
|
||||||
label="Имя (необязательно)"
|
sx={{ mb: 2 }}
|
||||||
{...register('displayName')}
|
onClose={() => {
|
||||||
fullWidth
|
setOauthError(null)
|
||||||
helperText="Если не указать, будет использована часть email до @"
|
}}
|
||||||
/>
|
>
|
||||||
|
{errMsg || oauthError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{message && (
|
||||||
|
<Alert severity="success" variant="outlined" sx={{ mb: 2 }} onClose={() => setMessage(null)}>
|
||||||
|
{message}
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TextField label="Пароль" type="password" {...register('password')} fullWidth />
|
{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>
|
||||||
|
|
||||||
{isRegister && (
|
<TextField
|
||||||
<TextField
|
label="Email"
|
||||||
label="Подтверждение пароля"
|
{...register('email')}
|
||||||
type="password"
|
fullWidth
|
||||||
{...register('passwordConfirm')}
|
slotProps={{
|
||||||
fullWidth
|
input: {
|
||||||
error={Boolean(passwordError)}
|
startAdornment: (
|
||||||
helperText={passwordError}
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isRegister ? (
|
{tab === 1 && (
|
||||||
<Button
|
<Stack spacing={2}>
|
||||||
variant="contained"
|
<TextField
|
||||||
disabled={
|
label="Email"
|
||||||
!email ||
|
{...register('email')}
|
||||||
!password ||
|
fullWidth
|
||||||
password.length < 8 ||
|
slotProps={{
|
||||||
(isRegister && password !== passwordConfirm) ||
|
input: {
|
||||||
registerMutation.isPending
|
startAdornment: (
|
||||||
}
|
<InputAdornment position="start">
|
||||||
onClick={() => registerMutation.mutate()}
|
<Mail size={18} />
|
||||||
>
|
</InputAdornment>
|
||||||
Зарегистрироваться
|
),
|
||||||
</Button>
|
},
|
||||||
) : (
|
}}
|
||||||
<Button
|
/>
|
||||||
variant="contained"
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||||
disabled={!email || !password || loginMutation.isPending}
|
<Button
|
||||||
onClick={() => loginMutation.mutate()}
|
variant="outlined"
|
||||||
>
|
onClick={() => requestCode.mutate()}
|
||||||
Войти
|
disabled={!email || requestCode.isPending}
|
||||||
</Button>
|
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>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 1 && (
|
{tab === 2 && (
|
||||||
<Stack spacing={2} sx={{ maxWidth: 520 }}>
|
<Stack spacing={2}>
|
||||||
<TextField label="Email" {...register('email')} fullWidth />
|
<OAuthButtons />
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
</Stack>
|
||||||
<Button variant="outlined" onClick={() => requestCode.mutate()} disabled={!email || requestCode.isPending}>
|
)}
|
||||||
Отправить код
|
</Paper>
|
||||||
</Button>
|
</Box>
|
||||||
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} />
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => verifyCode.mutate()}
|
|
||||||
disabled={!email || code.length !== 6 || verifyCode.isPending}
|
|
||||||
>
|
|
||||||
Войти
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 2 && (
|
|
||||||
<Stack sx={{ maxWidth: 520 }}>
|
|
||||||
<OAuthButtons />
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user