fix: authorization / registration
This commit is contained in:
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
13
src/features/auth/hooks/useAuth.ts
Normal file
13
src/features/auth/hooks/useAuth.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': token,
|
||||
'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),
|
||||
}
|
||||
|
||||
17
src/shared/api/tokenStore.ts
Normal file
17
src/shared/api/tokenStore.ts
Normal 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
|
||||
}
|
||||
36
src/widgets/login-form/model/useLoginForm.ts
Normal file
36
src/widgets/login-form/model/useLoginForm.ts
Normal 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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)}>
|
||||
Создать новый кошелёк
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user