diff --git a/src/Pages/login-page/login-page.tsx b/src/Pages/login-page/login-page.tsx index 923233c..96cd2f6 100644 --- a/src/Pages/login-page/login-page.tsx +++ b/src/Pages/login-page/login-page.tsx @@ -1,71 +1,91 @@ import { Helmet } from 'react-helmet-async'; +import { FormEvent, useState } from 'react'; +import { Layout } from '../../components/layout.tsx'; +import { LoginInfo } from '../../dataTypes/user.ts'; +import { store } from '../../store/store.ts'; +import { login } from '../../store/actions.ts'; export function LoginPage(): React.JSX.Element { + const [loginInfo, setLoginInfo] = useState({ + email: '', + password: '', + }); + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + store.dispatch(login(loginInfo)); + }; + + const validateEmail = (email: string) => { + const re = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/; + return re.test(String(email).toLowerCase()); + }; + const isValid = () => + loginInfo.email && + validateEmail(loginInfo.email) && + loginInfo.password && + loginInfo.password.length > 3 && + loginInfo.password.match(/[a-zA-z]/g) && + loginInfo.password.match(/[0-9]/g); return (
-
-
-
-
- - 6 cities logo - -
-
-
-
- -
- - 6 cities - login - -
-
-

Sign in

-
-
- - + +
+ + 6 cities - login + +
+
+

Sign in

+ +
+ + + setLoginInfo({ ...loginInfo, email: event.target.value }) + } + required + /> +
+
+ + + setLoginInfo({ + ...loginInfo, + password: event.target.value, + }) + } + required + /> +
+ + +
+
+ -
- - -
- - -
-
- -
-
-
+
+
+
+
); } diff --git a/src/api/api.ts b/src/api/api.ts index 50ee062..e5b5d6d 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,13 +1,48 @@ -import axios, {AxiosInstance} from 'axios'; +import axios, { + AxiosError, + AxiosInstance, + InternalAxiosRequestConfig, +} from 'axios'; +import { getToken } from '../utils/token-utils.ts'; +import { store } from '../store/store.ts'; +import { AuthorizationStatus } from '../dataTypes/enums/authorization-status.ts'; +import { setAuthorizationStatus } from '../store/actions.ts'; const BACKEND_URL = 'https://14.design.htmlacademy.pro/six-cities'; const REQUEST_TIMEOUT = 5000; +type DetailMessageType = { + errorType: string; + message: string; +}; + export const createAPI = (): AxiosInstance => { const api = axios.create({ baseURL: BACKEND_URL, timeout: REQUEST_TIMEOUT, }); + api.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = getToken(); + + if (token && config.headers) { + config.headers['x-token'] = token; + } + + return config; + }); + + api.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (error.response && error.response.status === 401) { + store.dispatch( + setAuthorizationStatus(AuthorizationStatus.Unauthorized), + ); + } + throw error; + }, + ); + return api; }; diff --git a/src/components/app.tsx b/src/components/app.tsx index 95ce034..f875bc1 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -4,7 +4,10 @@ import { LoginPage } from '../pages/login-page/login-page.tsx'; import { FavoritesPage } from '../pages/favorites-page/favorites-page.tsx'; import { OfferPage } from '../pages/offer-page/offer-page.tsx'; import { NotFoundPage } from '../pages/not-found-page/not-found-page.tsx'; -import { AuthorizationWrapper } from './authorization-wrapper.tsx'; +import { + AuthorizationWrapperForAuthorizedOnly, + AuthorizationWrapperForUnauthorizedOnly, +} from './authorization-wrapper.tsx'; import { AppRoutes } from '../dataTypes/enums/app-routes.ts'; import { HelmetProvider } from 'react-helmet-async'; import { Provider } from 'react-redux'; @@ -17,13 +20,24 @@ export function App(): React.JSX.Element { } /> - } /> + + + + } + /> + - + } /> } /> diff --git a/src/components/authorization-wrapper.tsx b/src/components/authorization-wrapper.tsx index b7370a5..17e36d2 100644 --- a/src/components/authorization-wrapper.tsx +++ b/src/components/authorization-wrapper.tsx @@ -1,13 +1,29 @@ import { Navigate } from 'react-router-dom'; +import { useAppSelector } from '../store/store.ts'; +import { AuthorizationStatus } from '../dataTypes/enums/authorization-status.ts'; +import { AppRoutes } from '../dataTypes/enums/app-routes.ts'; interface AuthorizationWrapperProps { - isAuthorized: boolean; children: React.JSX.Element; + fallbackUrl: AppRoutes; } -export function AuthorizationWrapper({ - isAuthorized, +export function AuthorizationWrapperForAuthorizedOnly({ children, + fallbackUrl, }: AuthorizationWrapperProps): React.JSX.Element { - return isAuthorized ? children : ; + const isAuthorized = + useAppSelector((state) => state.authorizationStatus) === + AuthorizationStatus.Authorized; + return isAuthorized ? children : ; +} + +export function AuthorizationWrapperForUnauthorizedOnly({ + children, + fallbackUrl, +}: AuthorizationWrapperProps): React.JSX.Element { + const isUnauthorized = + useAppSelector((state) => state.authorizationStatus) === + AuthorizationStatus.Unauthorized; + return isUnauthorized ? children : ; } diff --git a/src/components/layout.tsx b/src/components/layout.tsx index 5c492ea..e8b2377 100644 --- a/src/components/layout.tsx +++ b/src/components/layout.tsx @@ -1,15 +1,26 @@ import { AppRoutes } from '../dataTypes/enums/app-routes.ts'; import { Link } from 'react-router-dom'; +import { store, useAppSelector } from '../store/store.ts'; +import { AuthorizationStatus } from '../dataTypes/enums/authorization-status.ts'; +import { logout } from '../store/actions.ts'; interface LayoutProps { children: React.JSX.Element; showFooter?: boolean; + dontShowUserInfo?: boolean; } export function Layout({ children, showFooter, + dontShowUserInfo, }: LayoutProps): React.JSX.Element { + const isAuthorized = + useAppSelector((state) => state.authorizationStatus) === + AuthorizationStatus.Authorized; + const handleLogout = () => { + store.dispatch(logout()); + }; return ( <>
@@ -26,27 +37,44 @@ export function Layout({ /> - + {dontShowUserInfo || + (isAuthorized ? ( + + ) : ( + + ))}
diff --git a/src/dataTypes/enums/api-routes.ts b/src/dataTypes/enums/api-routes.ts index e4f8343..4586df3 100644 --- a/src/dataTypes/enums/api-routes.ts +++ b/src/dataTypes/enums/api-routes.ts @@ -1,3 +1,5 @@ export enum ApiRoutes { Offers = '/offers', + Login = '/login', + Logout = '/logout', } diff --git a/src/dataTypes/user.ts b/src/dataTypes/user.ts index 3b221f9..dcf0ceb 100644 --- a/src/dataTypes/user.ts +++ b/src/dataTypes/user.ts @@ -3,3 +3,16 @@ avatarUrl: string; isPro: boolean; }; + +export type LoginInfo = { + email: string; + password: string; +}; + +export type AuthInfo = { + name: string; + avatarUrl: string; + isPro: boolean; + email: string; + token: string; +}; diff --git a/src/index.tsx b/src/index.tsx index 456f533..650a13b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,13 +2,14 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { App } from './components/app.tsx'; import { store } from './store/store.ts'; -import { fetchOffers } from './store/actions.ts'; +import { checkAuthorization, fetchOffers } from './store/actions.ts'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement, ); store.dispatch(fetchOffers()); +store.dispatch(checkAuthorization()); root.render( diff --git a/src/store/actions.ts b/src/store/actions.ts index d2e3b1b..541c743 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -7,6 +7,9 @@ import { AppDispatch, State } from '../dataTypes/store-types.ts'; import { ApiRoutes } from '../dataTypes/enums/api-routes.ts'; import { DetailedOffer } from '../dataTypes/detailed-offer.ts'; import { Nullable } from 'vitest'; +import { AuthorizationStatus } from '../dataTypes/enums/authorization-status.ts'; +import { AuthInfo, LoginInfo } from '../dataTypes/user.ts'; +import { saveToken } from '../utils/token-utils.ts'; export const changeCity = createAction('offers/changeCity'); @@ -16,6 +19,10 @@ export const setCurrentOffer = createAction>( 'offers/setCurrentOffer', ); +export const setAuthorizationStatus = createAction( + 'auth/setAuthorizationStatus', +); + export const setSorting = createAction('offers/setSorting'); export const setNearbyOffers = createAction('offers/setNearbyOffers'); @@ -58,3 +65,47 @@ export const fetchNearbyOffers = createAsyncThunk< const { data } = await api.get(`${ApiRoutes.Offers}/${id}/nearby`); dispatch(setNearbyOffers(data)); }); + +export const login = createAsyncThunk< + void, + LoginInfo, + { + dispatch: AppDispatch; + state: State; + extra: AxiosInstance; + } +>('auth/login', async (loginInfo, { dispatch, extra: api }) => { + const response = await api.post(ApiRoutes.Login, loginInfo); + if (response.status === 200 || response.status === 201) { + dispatch(setAuthorizationStatus(AuthorizationStatus.Authorized)); + saveToken(response.data.token); + } else { + throw response; + } +}); + +export const checkAuthorization = createAsyncThunk< + void, + undefined, + { + dispatch: AppDispatch; + state: State; + extra: AxiosInstance; + } +>('auth/checkAuthorization', async (_arg, { dispatch, extra: api }) => { + await api.get(ApiRoutes.Login); + dispatch(setAuthorizationStatus(AuthorizationStatus.Authorized)); +}); + +export const logout = createAsyncThunk< + void, + undefined, + { + dispatch: AppDispatch; + state: State; + extra: AxiosInstance; + } +>('auth/logout', async (_arg, { dispatch, extra: api }) => { + await api.delete(ApiRoutes.Logout); + dispatch(setAuthorizationStatus(AuthorizationStatus.Unauthorized)); +}); diff --git a/src/store/store.ts b/src/store/store.ts index 1cf2dc5..3d060f4 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,6 +1,7 @@ import { configureStore, createReducer } from '@reduxjs/toolkit'; import { changeCity, + setAuthorizationStatus, setCurrentOffer, setNearbyOffers, setOffers, @@ -15,6 +16,7 @@ import { City } from '../dataTypes/city.ts'; import { SortOffers } from '../dataTypes/sort-offers.ts'; import { Nullable } from 'vitest'; import { DetailedOffer } from '../dataTypes/detailed-offer.ts'; +import { AuthorizationStatus } from '../dataTypes/enums/authorization-status.ts'; type InitialState = { city: City; @@ -22,6 +24,7 @@ type InitialState = { sorting: SortOffers; currentOffer: Nullable; nearbyOffers: Offer[]; + authorizationStatus: AuthorizationStatus; }; const initialState: InitialState = { @@ -30,6 +33,7 @@ const initialState: InitialState = { sorting: (offers: Offer[]) => offers, currentOffer: null, nearbyOffers: [], + authorizationStatus: AuthorizationStatus.Unknown, }; export const api = createAPI(); @@ -50,6 +54,9 @@ const reducer = createReducer(initialState, (builder) => { }) .addCase(setNearbyOffers, (state, action) => { state.nearbyOffers = action.payload; + }) + .addCase(setAuthorizationStatus, (state, action) => { + state.authorizationStatus = action.payload; }); }); diff --git a/src/utils/token-utils.ts b/src/utils/token-utils.ts new file mode 100644 index 0000000..e7355f7 --- /dev/null +++ b/src/utils/token-utils.ts @@ -0,0 +1,16 @@ +const AUTH_TOKEN_KEY_NAME = 'six-cities-token'; + +export type Token = string; + +export const getToken = (): Token => { + const token = localStorage.getItem(AUTH_TOKEN_KEY_NAME); + return token ?? ''; +}; + +export const saveToken = (token: Token): void => { + localStorage.setItem(AUTH_TOKEN_KEY_NAME, token); +}; + +export const dropToken = (): void => { + localStorage.removeItem(AUTH_TOKEN_KEY_NAME); +};