fix: authorization / registration

This commit is contained in:
2026-05-10 19:33:48 +03:00
parent 8b0e787fc6
commit a89f215fcb
5 changed files with 157 additions and 56 deletions

View File

@@ -12,9 +12,14 @@ export interface RegistrationCompletePayload {
code: string
}
export interface LoginPayload {
export interface LoginStartPayload {
email: string
}
export interface LoginCompletePayload {
email: string
password: string
code: string
}
export interface AuthResponse {
@@ -29,8 +34,12 @@ export function registrationComplete(payload: RegistrationCompletePayload): Prom
return api.post<AuthResponse>('/auth/registration/complete', payload)
}
export function login(payload: LoginPayload): Promise<AuthResponse> {
return api.post<AuthResponse>('/auth/login', payload)
export function loginStart(payload: LoginStartPayload): Promise<void> {
return api.post<void>('/auth/login/start', payload)
}
export function loginComplete(payload: LoginCompletePayload): Promise<AuthResponse> {
return api.post<AuthResponse>('/auth/login/complete', payload)
}
export async function logout(): Promise<void> {

View File

@@ -1,4 +1,4 @@
export { registrationStart, registrationComplete, login } from './api/registrationApi'
export type { RegistrationStartPayload, RegistrationCompletePayload, LoginPayload, AuthResponse } from './api/registrationApi'
export { registrationStart, registrationComplete, loginStart, loginComplete } from './api/registrationApi'
export type { RegistrationStartPayload, RegistrationCompletePayload, LoginStartPayload, LoginCompletePayload, AuthResponse } from './api/registrationApi'
export { useIsAuthenticated } from './hooks/useIsAuthenticated'
export { useAuth, AUTH_QUERY_KEY } from './hooks/useAuth'

View File

@@ -1,21 +1,33 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useLocation } from 'react-router-dom'
import { login } from '@features/auth'
import { loginStart, loginComplete, AUTH_QUERY_KEY } from '@features/auth'
import { tokenStore } from '@shared/api/tokenStore'
import { AUTH_QUERY_KEY } from '@features/auth'
import { ROUTES } from '@shared/config/routes'
import type { ApiErrorResponse } from '@shared/api/types'
function extractErrorMessage(error: unknown): string {
const apiError = error as ApiErrorResponse
return apiError?.detail?.[0]?.msg ?? 'Произошла ошибка'
}
export function useLoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [verificationCode, setVerificationCode] = useState('')
const [codeSent, setCodeSent] = useState(false)
const navigate = useNavigate()
const location = useLocation()
const queryClient = useQueryClient()
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? ROUTES.WALLET
const mutation = useMutation({
mutationFn: login,
const startMutation = useMutation({
mutationFn: loginStart,
onSuccess: () => setCodeSent(true),
})
const completeMutation = useMutation({
mutationFn: loginComplete,
onSuccess: ({ access_token }) => {
tokenStore.set(access_token)
queryClient.setQueryData(AUTH_QUERY_KEY, access_token)
@@ -23,14 +35,29 @@ export function useLoginForm() {
},
})
const handleRequestCode = () => {
if (!email) return
startMutation.mutate({ email })
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
completeMutation.mutate({ email, password, code: verificationCode })
}
const error =
(startMutation.isError ? extractErrorMessage(startMutation.error) : null) ??
(completeMutation.isError ? extractErrorMessage(completeMutation.error) : null)
return {
email, setEmail,
password, setPassword,
isLoading: mutation.isPending,
error: mutation.isError ? 'Неверный email или пароль' : null,
handleSubmit: (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({ email, password })
},
verificationCode, setVerificationCode,
codeSent,
isLoadingCode: startMutation.isPending,
isLoadingSubmit: completeMutation.isPending,
error,
handleRequestCode,
handleSubmit,
}
}

View File

@@ -2,9 +2,9 @@
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 48px 40px;
padding: 32px;
width: 100%;
max-width: 480px;
max-width: 600px;
}
.logo {
@@ -26,10 +26,48 @@
line-height: 1.3;
}
.fields {
.twoCol {
display: grid;
grid-template-columns: 1fr;
gap: 20px 24px;
align-items: start;
}
.leftCol {
display: flex;
flex-direction: column;
gap: 12px;
gap: 20px;
}
.rightCol {
display: flex;
flex-direction: column;
gap: 8px;
}
.codeHint {
font-size: 12px;
color: var(--text-secondary);
text-decoration: underline;
cursor: pointer;
}
.error {
color: #ff5a5a;
font-size: 13px;
margin-top: 12px;
text-align: center;
}
.submitWrapper {
margin-top: 28px;
}
.footer {
display: flex;
flex-direction: column;
gap: 0;
margin-top: 16px;
}
.forgot {
@@ -37,7 +75,7 @@
text-align: right;
font-size: 13px;
color: var(--interactive);
margin: 8px 0 16px;
margin-bottom: 16px;
cursor: pointer;
text-decoration: none;
transition: color 0.2s;
@@ -47,17 +85,11 @@
color: var(--highlight);
}
.actions {
display: flex;
flex-direction: column;
gap: 0;
}
.divider {
display: flex;
align-items: center;
gap: 16px;
margin: 24px 0;
margin: 16px 0;
}
.divider::before,
@@ -73,14 +105,13 @@
color: var(--text-secondary);
}
@media (max-width: 520px) {
@media (max-width: 560px) {
.card {
padding: 32px 20px;
min-height: 100vh;
min-width: 100%;
border-radius: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.twoCol {
grid-template-columns: 1fr;
}
}

View File

@@ -8,7 +8,17 @@ import styles from './LoginForm.module.css'
import { useLoginForm } from '../model/useLoginForm'
export function LoginForm() {
const { email, setEmail, password, setPassword, isLoading, error, handleSubmit } = useLoginForm()
const {
email, setEmail,
password, setPassword,
verificationCode, setVerificationCode,
codeSent,
isLoadingCode,
isLoadingSubmit,
error,
handleRequestCode,
handleSubmit,
} = useLoginForm()
const navigate = useNavigate()
return (
@@ -18,33 +28,57 @@ export function LoginForm() {
</div>
<h1 className={styles.title}>Войти в кошелёк ЭКСА</h1>
<div className={styles.fields}>
<FormField
label="Адрес электронной почты"
type="email"
value={email}
onChange={setEmail}
placeholder="example@mail.ru"
required
/>
<FormField
label="Пароль"
type="password"
value={password}
onChange={setPassword}
placeholder="••••••••"
required
/>
<div className={styles.twoCol}>
<div className={styles.leftCol}>
<FormField
label="Адрес электронной почты"
type="email"
value={email}
onChange={setEmail}
placeholder="example@mail.ru"
required
/>
<FormField
label="Пароль"
type="password"
value={password}
onChange={setPassword}
placeholder="••••••••"
required
/>
</div>
<div className={styles.rightCol}>
<Button
variant="ghost"
type="button"
onClick={handleRequestCode}
disabled={codeSent || isLoadingCode}
>
{isLoadingCode ? 'Отправка...' : codeSent ? 'Код отправлен' : 'Получить код на email'}
</Button>
<span className={styles.codeHint}>Код не пришёл</span>
<FormField
label="Ввести код"
type="text"
value={verificationCode}
onChange={setVerificationCode}
placeholder="000 000"
required
/>
</div>
</div>
{error && <p className={styles.error}>{error}</p>}
<a className={styles.forgot}>Забыли пароль?</a>
<div className={styles.submitWrapper}>
<PrimaryButton label={isLoadingSubmit ? 'Вход...' : 'Войти'} disabled={isLoadingSubmit} />
</div>
<div className={styles.actions}>
<PrimaryButton label="Войти" disabled={isLoading} />
<div className={styles.footer}>
<a className={styles.forgot}>Забыли пароль?</a>
<div className={styles.divider}><span>или</span></div>
<Button variant="outline" onClick={() => navigate(ROUTES.REGISTER)}>
<Button variant="outline" type="button" onClick={() => navigate(ROUTES.REGISTER)}>
Создать новый кошелёк
</Button>
</div>