fix: authorization / registration

This commit is contained in:
2026-05-10 19:24:32 +03:00
parent 574e27a379
commit 8b0e787fc6
11 changed files with 140 additions and 39 deletions

View File

@@ -3,12 +3,10 @@ import { useIsAuthenticated } from '@features/auth'
import { ROUTES } from '@shared/config/routes'
export function ProtectedRoute() {
const isAuthenticated = useIsAuthenticated()
const { isAuthenticated, isLoading } = useIsAuthenticated()
const location = useLocation()
if (!isAuthenticated) {
return <Navigate to={ROUTES.LOGIN} state={{ from: location }} replace />
}
if (isLoading) return null
if (!isAuthenticated) return <Navigate to={ROUTES.LOGIN} state={{ from: location }} replace />
return <Outlet />
}

View File

@@ -12,12 +12,25 @@ export interface RegistrationCompletePayload {
code: string
}
export function registrationStart(payload: RegistrationStartPayload): Promise<string> {
return api.post<string>('/auth/registration/start', payload)
export interface LoginPayload {
email: string
password: string
}
export function registrationComplete(payload: RegistrationCompletePayload): Promise<string> {
return api.post<string>('/auth/registration/complete', payload)
export interface AuthResponse {
access_token: string
}
export function registrationStart(payload: RegistrationStartPayload): Promise<void> {
return api.post<void>('/auth/registration/start', payload)
}
export function registrationComplete(payload: RegistrationCompletePayload): Promise<AuthResponse> {
return api.post<AuthResponse>('/auth/registration/complete', payload)
}
export function login(payload: LoginPayload): Promise<AuthResponse> {
return api.post<AuthResponse>('/auth/login', payload)
}
export async function logout(): Promise<void> {

View File

@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query'
import { refreshAccessToken } from '@shared/api/tokenStore'
export const AUTH_QUERY_KEY = ['auth']
export function useAuth() {
return useQuery({
queryKey: AUTH_QUERY_KEY,
queryFn: refreshAccessToken,
retry: false,
staleTime: Infinity,
})
}

View File

@@ -1,3 +1,6 @@
export function useIsAuthenticated(): boolean {
return localStorage.getItem('isAuthenticated') === 'true'
import { useAuth } from './useAuth'
export function useIsAuthenticated(): { isAuthenticated: boolean; isLoading: boolean } {
const { data, isLoading, isError } = useAuth()
return { isAuthenticated: !!data && !isError, isLoading }
}

View File

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

View File

@@ -1,28 +1,37 @@
import { API_URL } from '@shared/config/env'
import { getCsrfToken } from './csrf'
import { tokenStore, refreshAccessToken } from './tokenStore'
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = await getCsrfToken()
async function doRequest<T>(path: string, options: RequestInit, allowRetry: boolean): Promise<T> {
const csrf = await getCsrfToken()
const bearer = tokenStore.get()
const res = await fetch(`${API_URL}${path}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token,
...options.headers,
},
})
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
...options.headers,
}
const res = await fetch(`${API_URL}${path}`, { ...options, credentials: 'include', headers })
if (res.status === 401 && allowRetry) {
try {
await refreshAccessToken()
return doRequest<T>(path, options, false)
} catch {
tokenStore.clear()
throw new Error('Unauthorized')
}
}
const data = await res.json()
if (!res.ok) throw data
return data as T
}
export const api = {
get: <T>(path: string) => request<T>(path),
get: <T>(path: string) => doRequest<T>(path, {}, true),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
doRequest<T>(path, { method: 'POST', body: JSON.stringify(body) }, true),
}

View File

@@ -0,0 +1,17 @@
let accessToken: string | null = null
export const tokenStore = {
get: () => accessToken,
set: (token: string) => { accessToken = token },
clear: () => { accessToken = null },
}
const REFRESH_URL = 'https://app.auth.elcsa.ru/v1/jwt/refresh'
export async function refreshAccessToken(): Promise<string> {
const res = await fetch(REFRESH_URL, { method: 'POST', credentials: 'include' })
if (!res.ok) throw new Error('Unauthorized')
const data = await res.json()
tokenStore.set(data.access_token)
return data.access_token as string
}

View File

@@ -0,0 +1,36 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useLocation } from 'react-router-dom'
import { login } from '@features/auth'
import { tokenStore } from '@shared/api/tokenStore'
import { AUTH_QUERY_KEY } from '@features/auth'
import { ROUTES } from '@shared/config/routes'
export function useLoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
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,
onSuccess: ({ access_token }) => {
tokenStore.set(access_token)
queryClient.setQueryData(AUTH_QUERY_KEY, access_token)
navigate(from, { replace: true })
},
})
return {
email, setEmail,
password, setPassword,
isLoading: mutation.isPending,
error: mutation.isError ? 'Неверный email или пароль' : null,
handleSubmit: (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({ email, password })
},
}
}

View File

@@ -1,4 +1,3 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FormField } from '@shared/ui'
import { PrimaryButton } from '@shared/ui'
@@ -6,14 +5,14 @@ import { Button } from '@shared/ui'
import { ROUTES } from '@shared/config/routes'
import logo from '@shared/assets/logo-full-white.png'
import styles from './LoginForm.module.css'
import { useLoginForm } from '../model/useLoginForm'
export function LoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const { email, setEmail, password, setPassword, isLoading, error, handleSubmit } = useLoginForm()
const navigate = useNavigate()
return (
<form className={styles.card}>
<form className={styles.card} onSubmit={handleSubmit}>
<div className={styles.logo}>
<img src={logo} alt="ЭКСА" />
</div>
@@ -38,10 +37,12 @@ export function LoginForm() {
/>
</div>
{error && <p className={styles.error}>{error}</p>}
<a className={styles.forgot}>Забыли пароль?</a>
<div className={styles.actions}>
<PrimaryButton label="Войти" />
<PrimaryButton label="Войти" disabled={isLoading} />
<div className={styles.divider}><span>или</span></div>
<Button variant="outline" onClick={() => navigate(ROUTES.REGISTER)}>
Создать новый кошелёк

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { registrationStart, registrationComplete } from '@features/auth'
import { registrationStart, registrationComplete, AUTH_QUERY_KEY } from '@features/auth'
import { tokenStore } from '@shared/api/tokenStore'
import { ROUTES } from '@shared/config/routes'
import type { ApiErrorResponse } from '@shared/api/types'
@@ -12,6 +13,7 @@ function extractErrorMessage(error: unknown): string {
export function useRegisterForm() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
@@ -26,8 +28,9 @@ export function useRegisterForm() {
const completeMutation = useMutation({
mutationFn: registrationComplete,
onSuccess: () => {
localStorage.setItem('isAuthenticated', 'true')
onSuccess: ({ access_token }) => {
tokenStore.set(access_token)
queryClient.setQueryData(AUTH_QUERY_KEY, access_token)
navigate(ROUTES.WALLET)
},
})

View File

@@ -3,9 +3,11 @@ import styles from './WalletHeader.module.css'
import { Link } from 'react-router-dom'
import { ROUTES } from '@shared/config/routes'
import { useEffect, useRef, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { logout } from '@features/auth/api/registrationApi'
import { AUTH_QUERY_KEY } from '@features/auth'
import { tokenStore } from '@shared/api/tokenStore'
import { Notification } from '@shared/ui'
const TICKERS = [
@@ -19,10 +21,15 @@ export function WalletHeader() {
const [error, setError] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const queryClient = useQueryClient()
const { mutate: logoutMutate } = useMutation({
mutationFn: logout,
onSuccess: () => navigate(ROUTES.HOME),
onSuccess: () => {
tokenStore.clear()
queryClient.setQueryData(AUTH_QUERY_KEY, null)
navigate(ROUTES.HOME)
},
onError: () => setError(true),
})