feat(client): auth page with 3 tabs (password/code/oauth)
This commit is contained in:
@@ -2,8 +2,9 @@ import { useEffect, useState } from 'react'
|
|||||||
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 Divider from '@mui/material/Divider'
|
|
||||||
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'
|
||||||
@@ -21,7 +22,6 @@ type AuthResponse = {
|
|||||||
email: string
|
email: string
|
||||||
displayName?: string | null
|
displayName?: string | null
|
||||||
avatar?: string | null
|
avatar?: string | null
|
||||||
avatarType?: string | null
|
|
||||||
avatarStyle?: string | null
|
avatarStyle?: string | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,19 +38,28 @@ function getApiErrorMessage(err: unknown): string | null {
|
|||||||
export function AuthPage() {
|
export function AuthPage() {
|
||||||
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 [isRegister, setIsRegister] = useState(false)
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useUnit($user)
|
const user = useUnit($user)
|
||||||
|
|
||||||
const { register, watch } = useForm<{
|
const { register, watch } = useForm<{
|
||||||
email: string
|
email: string
|
||||||
|
password: string
|
||||||
|
passwordConfirm: string
|
||||||
|
displayName: string
|
||||||
code: string
|
code: string
|
||||||
}>({
|
}>({
|
||||||
defaultValues: { email: '', code: '' },
|
defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '', code: '' },
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
})
|
})
|
||||||
|
|
||||||
const email = watch('email')
|
const email = watch('email')
|
||||||
|
const password = watch('password')
|
||||||
|
const passwordConfirm = watch('passwordConfirm')
|
||||||
const code = watch('code')
|
const code = watch('code')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) navigate('/', { replace: true })
|
if (user) navigate('/', { replace: true })
|
||||||
}, [navigate, user])
|
}, [navigate, user])
|
||||||
@@ -62,6 +71,26 @@ export function AuthPage() {
|
|||||||
setSearchParams({}, { replace: true })
|
setSearchParams({}, { replace: true })
|
||||||
}, [searchParams, setSearchParams])
|
}, [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({
|
const requestCode = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await apiClient.post('auth/request-code', { email })
|
await apiClient.post('auth/request-code', { email })
|
||||||
@@ -73,12 +102,18 @@ export function AuthPage() {
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const { data } = await apiClient.post<AuthResponse>('auth/verify-code', { email, code })
|
const { data } = await apiClient.post<AuthResponse>('auth/verify-code', { email, code })
|
||||||
tokenSet(data.token)
|
tokenSet(data.token)
|
||||||
setMessage(`Вход выполнен: ${data.user.email}`)
|
|
||||||
navigate('/', { replace: true })
|
navigate('/', { replace: true })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errMsg = getApiErrorMessage(requestCode.error || verifyCode.error)
|
const errMsg =
|
||||||
|
getApiErrorMessage(loginMutation.error) ||
|
||||||
|
getApiErrorMessage(registerMutation.error) ||
|
||||||
|
getApiErrorMessage(requestCode.error) ||
|
||||||
|
getApiErrorMessage(verifyCode.error)
|
||||||
|
|
||||||
|
const passwordError =
|
||||||
|
isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -86,45 +121,107 @@ export function AuthPage() {
|
|||||||
Вход / регистрация
|
Вход / регистрация
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{message && (
|
{message && <Alert severity="success" sx={{ mb: 2 }}>{message}</Alert>}
|
||||||
<Alert severity="success" sx={{ mb: 2 }}>
|
|
||||||
{message}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{oauthError && (
|
{oauthError && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>{oauthError}</Alert>
|
||||||
{oauthError}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{errMsg && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
{errMsg}
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
{errMsg && <Alert severity="error" sx={{ mb: 2 }}>{errMsg}</Alert>}
|
||||||
|
|
||||||
<Stack spacing={2} sx={{ maxWidth: 520 }}>
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
|
||||||
<Typography variant="h6">Email + код</Typography>
|
<Tab label="Пароль" />
|
||||||
<TextField label="Email" {...register('email')} fullWidth />
|
<Tab label="Код" />
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
<Tab label="Другой способ" />
|
||||||
<Button variant="outlined" onClick={() => requestCode.mutate()} disabled={!email || requestCode.isPending}>
|
</Tabs>
|
||||||
Отправить код
|
|
||||||
</Button>
|
{tab === 0 && (
|
||||||
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} />
|
<Stack spacing={2} sx={{ maxWidth: 520 }}>
|
||||||
<Button
|
<Stack direction="row" spacing={1}>
|
||||||
variant="contained"
|
<Button variant={!isRegister ? 'contained' : 'outlined'} onClick={() => setIsRegister(false)}>
|
||||||
onClick={() => verifyCode.mutate()}
|
Вход
|
||||||
disabled={!email || code.length !== 6 || verifyCode.isPending}
|
</Button>
|
||||||
>
|
<Button variant={isRegister ? 'contained' : 'outlined'} onClick={() => setIsRegister(true)}>
|
||||||
Войти
|
Регистрация
|
||||||
</Button>
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TextField label="Email" {...register('email')} fullWidth />
|
||||||
|
|
||||||
|
{isRegister && (
|
||||||
|
<TextField
|
||||||
|
label="Имя (необязательно)"
|
||||||
|
{...register('displayName')}
|
||||||
|
fullWidth
|
||||||
|
helperText="Если не указать, будет использована часть email до @"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField label="Пароль" type="password" {...register('password')} fullWidth />
|
||||||
|
|
||||||
|
{isRegister && (
|
||||||
|
<TextField
|
||||||
|
label="Подтверждение пароля"
|
||||||
|
type="password"
|
||||||
|
{...register('passwordConfirm')}
|
||||||
|
fullWidth
|
||||||
|
error={Boolean(passwordError)}
|
||||||
|
helperText={passwordError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRegister ? (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={
|
||||||
|
!email ||
|
||||||
|
!password ||
|
||||||
|
password.length < 8 ||
|
||||||
|
(isRegister && password !== passwordConfirm) ||
|
||||||
|
registerMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => registerMutation.mutate()}
|
||||||
|
>
|
||||||
|
Зарегистрироваться
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={!email || !password || loginMutation.isPending}
|
||||||
|
onClick={() => loginMutation.mutate()}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
)}
|
||||||
|
|
||||||
<Stack sx={{ maxWidth: 520 }}>
|
{tab === 1 && (
|
||||||
<Divider sx={{ my: 2 }}>или</Divider>
|
<Stack spacing={2} sx={{ maxWidth: 520 }}>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<OAuthButtons />
|
{tab === 2 && (
|
||||||
</Stack>
|
<Stack sx={{ maxWidth: 520 }}>
|
||||||
|
<OAuthButtons />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user