feat(client): auth page with 3 tabs (password/code/oauth)

This commit is contained in:
Kirill
2026-05-22 12:11:36 +05:00
parent be65f2330e
commit afc763c522
+135 -38
View File
@@ -2,8 +2,9 @@ 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 Stack from '@mui/material/Stack'
import Tab from '@mui/material/Tab'
import Tabs from '@mui/material/Tabs'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation } from '@tanstack/react-query'
@@ -21,7 +22,6 @@ type AuthResponse = {
email: string
displayName?: string | null
avatar?: string | null
avatarType?: string | null
avatarStyle?: string | null
}
}
@@ -38,19 +38,28 @@ function getApiErrorMessage(err: unknown): string | null {
export function AuthPage() {
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: '', 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,6 +71,26 @@ 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 })
@@ -73,12 +102,18 @@ export function AuthPage() {
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 errMsg =
getApiErrorMessage(loginMutation.error) ||
getApiErrorMessage(registerMutation.error) ||
getApiErrorMessage(requestCode.error) ||
getApiErrorMessage(verifyCode.error)
const passwordError =
isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
return (
<Box>
@@ -86,45 +121,107 @@ export function AuthPage() {
Вход / регистрация
</Typography>
{message && (
<Alert severity="success" sx={{ mb: 2 }}>
{message}
</Alert>
)}
{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>
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>{oauthError}</Alert>
)}
{errMsg && <Alert severity="error" sx={{ mb: 2 }}>{errMsg}</Alert>}
<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>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
<Tab label="Пароль" />
<Tab label="Код" />
<Tab label="Другой способ" />
</Tabs>
{tab === 0 && (
<Stack spacing={2} sx={{ maxWidth: 520 }}>
<Stack direction="row" spacing={1}>
<Button variant={!isRegister ? 'contained' : 'outlined'} onClick={() => setIsRegister(false)}>
Вход
</Button>
<Button variant={isRegister ? 'contained' : 'outlined'} onClick={() => setIsRegister(true)}>
Регистрация
</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 sx={{ maxWidth: 520 }}>
<Divider sx={{ my: 2 }}>или</Divider>
{tab === 1 && (
<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 />
</Stack>
{tab === 2 && (
<Stack sx={{ maxWidth: 520 }}>
<OAuthButtons />
</Stack>
)}
</Box>
)
}