fix: authorization / registration
This commit is contained in:
@@ -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> {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,7 +28,8 @@ export function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
<h1 className={styles.title}>Войти в кошелёк ЭКСА</h1>
|
<h1 className={styles.title}>Войти в кошелёк ЭКСА</h1>
|
||||||
|
|
||||||
<div className={styles.fields}>
|
<div className={styles.twoCol}>
|
||||||
|
<div className={styles.leftCol}>
|
||||||
<FormField
|
<FormField
|
||||||
label="Адрес электронной почты"
|
label="Адрес электронной почты"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -37,14 +48,37 @@ export function LoginForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>}
|
{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>
|
||||||
|
|||||||
Reference in New Issue
Block a user