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

View File

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

View File

@@ -1,21 +1,33 @@
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useLocation } from 'react-router-dom' 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 { tokenStore } from '@shared/api/tokenStore'
import { AUTH_QUERY_KEY } from '@features/auth'
import { ROUTES } from '@shared/config/routes' 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() { export function useLoginForm() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [verificationCode, setVerificationCode] = useState('')
const [codeSent, setCodeSent] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? ROUTES.WALLET const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? ROUTES.WALLET
const mutation = useMutation({ const startMutation = useMutation({
mutationFn: login, mutationFn: loginStart,
onSuccess: () => setCodeSent(true),
})
const completeMutation = useMutation({
mutationFn: loginComplete,
onSuccess: ({ access_token }) => { onSuccess: ({ access_token }) => {
tokenStore.set(access_token) tokenStore.set(access_token)
queryClient.setQueryData(AUTH_QUERY_KEY, 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 { return {
email, setEmail, email, setEmail,
password, setPassword, password, setPassword,
isLoading: mutation.isPending, verificationCode, setVerificationCode,
error: mutation.isError ? 'Неверный email или пароль' : null, codeSent,
handleSubmit: (e: React.FormEvent) => { isLoadingCode: startMutation.isPending,
e.preventDefault() isLoadingSubmit: completeMutation.isPending,
mutation.mutate({ email, password }) error,
}, handleRequestCode,
handleSubmit,
} }
} }

View File

@@ -2,9 +2,9 @@
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: 24px; border-radius: 24px;
padding: 48px 40px; padding: 32px;
width: 100%; width: 100%;
max-width: 480px; max-width: 600px;
} }
.logo { .logo {
@@ -26,10 +26,48 @@
line-height: 1.3; line-height: 1.3;
} }
.fields { .twoCol {
display: grid;
grid-template-columns: 1fr;
gap: 20px 24px;
align-items: start;
}
.leftCol {
display: flex; display: flex;
flex-direction: column; 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 { .forgot {
@@ -37,7 +75,7 @@
text-align: right; text-align: right;
font-size: 13px; font-size: 13px;
color: var(--interactive); color: var(--interactive);
margin: 8px 0 16px; margin-bottom: 16px;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
transition: color 0.2s; transition: color 0.2s;
@@ -47,17 +85,11 @@
color: var(--highlight); color: var(--highlight);
} }
.actions {
display: flex;
flex-direction: column;
gap: 0;
}
.divider { .divider {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
margin: 24px 0; margin: 16px 0;
} }
.divider::before, .divider::before,
@@ -73,14 +105,13 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
@media (max-width: 520px) { @media (max-width: 560px) {
.card { .card {
padding: 32px 20px; padding: 32px 20px;
min-height: 100vh;
min-width: 100%;
border-radius: 0; 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' import { useLoginForm } from '../model/useLoginForm'
export function LoginForm() { 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() const navigate = useNavigate()
return ( return (
@@ -18,33 +28,57 @@ export function LoginForm() {
</div> </div>
<h1 className={styles.title}>Войти в кошелёк ЭКСА</h1> <h1 className={styles.title}>Войти в кошелёк ЭКСА</h1>
<div className={styles.fields}> <div className={styles.twoCol}>
<FormField <div className={styles.leftCol}>
label="Адрес электронной почты" <FormField
type="email" label="Адрес электронной почты"
value={email} type="email"
onChange={setEmail} value={email}
placeholder="example@mail.ru" onChange={setEmail}
required placeholder="example@mail.ru"
/> required
<FormField />
label="Пароль" <FormField
type="password" label="Пароль"
value={password} type="password"
onChange={setPassword} value={password}
placeholder="••••••••" onChange={setPassword}
required 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> </div>
{error && <p className={styles.error}>{error}</p>} {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}> <div className={styles.footer}>
<PrimaryButton label="Войти" disabled={isLoading} /> <a className={styles.forgot}>Забыли пароль?</a>
<div className={styles.divider}><span>или</span></div> <div className={styles.divider}><span>или</span></div>
<Button variant="outline" onClick={() => navigate(ROUTES.REGISTER)}> <Button variant="outline" type="button" onClick={() => navigate(ROUTES.REGISTER)}>
Создать новый кошелёк Создать новый кошелёк
</Button> </Button>
</div> </div>