Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/jc 726 authorization implementation #11

Open
wants to merge 36 commits into
base: feature/JC-702-initialize-anime-app
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
97f9bb3
Install packages for forms JC-726
TebyakinaEkaterina Aug 22, 2024
1867987
Add registration form JC-726
TebyakinaEkaterina Aug 22, 2024
4f39880
Add registration page JC-726
TebyakinaEkaterina Aug 22, 2024
219e8c6
Setup routes for registration page JC-726
TebyakinaEkaterina Aug 22, 2024
2e6dbe4
Add authorization service JC-726
TebyakinaEkaterina Aug 22, 2024
8552ce4
Add local storage service JC-726
TebyakinaEkaterina Aug 22, 2024
4d524a3
Add user service JC-726
TebyakinaEkaterina Aug 22, 2024
d6e2b31
Add dtos for authorization JC-726
TebyakinaEkaterina Aug 22, 2024
c150beb
Add models for authorization JC-726
TebyakinaEkaterina Aug 22, 2024
24646a1
Add mappers for authorization JC-726
TebyakinaEkaterina Aug 22, 2024
f674a29
Add api key variable in config JC-726
TebyakinaEkaterina Aug 22, 2024
fb12e2c
Add interceptors JC-726
TebyakinaEkaterina Aug 22, 2024
edb7893
Add user slice JC-726
TebyakinaEkaterina Aug 22, 2024
fceb695
Add registration components JC-726
TebyakinaEkaterina Aug 22, 2024
8ca6344
Add login components JC-726
TebyakinaEkaterina Aug 22, 2024
e2c1536
Setup routes for login page Jc-726
TebyakinaEkaterina Aug 22, 2024
009e660
Add authorization menu JC-726
TebyakinaEkaterina Aug 22, 2024
2cfd356
Add display server error for login form JC-726
TebyakinaEkaterina Aug 22, 2024
3b29277
Implement server errors handling JC-726
TebyakinaEkaterina Aug 23, 2024
bc0c742
Fix access token refreshing JC-726
TebyakinaEkaterina Aug 23, 2024
1f17128
Add comment to get user method JC-726
TebyakinaEkaterina Aug 23, 2024
2140a60
Fix JSDoc comments JC-726
TebyakinaEkaterina Aug 23, 2024
5af180d
Implement a universal function for handling errors from the server JC…
TebyakinaEkaterina Aug 23, 2024
b19db30
Fix required error display JC-726
TebyakinaEkaterina Aug 23, 2024
5f58fb7
Fix JJSDoc comment JC-726
TebyakinaEkaterina Aug 23, 2024
e83b708
Add refreshToken as header useEffect dependence JC-726
TebyakinaEkaterina Aug 26, 2024
ba77cf5
Fix an endless cycle of token update requests JC-726
TebyakinaEkaterina Aug 26, 2024
e11b760
Move the interceptors to separate files JC-726
TebyakinaEkaterina Aug 26, 2024
760a7c5
Fix refresh tokens interceptor JC-679
TebyakinaEkaterina Aug 26, 2024
2149e6f
Update JSDoc comment JC-726
TebyakinaEkaterina Sep 11, 2024
f40ebb3
Remove redundant annotation of type JC-726
TebyakinaEkaterina Sep 11, 2024
faa2dff
Remove redundant annotation of type JC-726
TebyakinaEkaterina Sep 11, 2024
773c72e
Change the approach to converting a value to boolean JC-726
TebyakinaEkaterina Sep 11, 2024
8792b91
Extend eslint config so this rule is ignored in *.mapper.ts files JC-726
TebyakinaEkaterina Sep 11, 2024
cffdc1b
Fix types annotations JC-726
TebyakinaEkaterina Sep 11, 2024
861791c
Implement a service for urls JC-726
TebyakinaEkaterina Sep 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/react/.env
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
VITE_APP_API_BASE_URL=https://api.camp-js.saritasa.rocks/api/v1/
VITE_APP_API_KEY=4d114755-959f-4864-b484-b1752f9ae529
1 change: 0 additions & 1 deletion apps/react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export const App: FC = () => (
<RootRouter />
</Suspense>
</StyledEngineProvider>

</div>
</BrowserRouter>
</Provider>
Expand Down
1 change: 1 addition & 0 deletions apps/react/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
*/
export const CONFIG = {
apiUrl: import.meta.env.VITE_APP_API_BASE_URL ?? '',
apiKey: import.meta.env.VITE_APP_API_KEY ?? '',
};
13 changes: 10 additions & 3 deletions apps/react/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import axios from 'axios';

import { CONFIG } from './config';
import { apiKeyHeaderInterceptor } from './interceptors/apiKeyHeaderInterceptor';
import { authorizationHeaderInterceptor } from './interceptors/authorizationTokenInterceptor';
import { refreshTokenInterceptor } from './interceptors/refreshTokenInterceptor';

/**
* Http const with base url.
*/
/** Http const with base url. */
export const http = axios.create({
baseURL: CONFIG.apiUrl,
});

http.interceptors.request.use(apiKeyHeaderInterceptor, error => Promise.reject(error));

http.interceptors.request.use(authorizationHeaderInterceptor, error => Promise.reject(error));

http.interceptors.response.use(response => response, refreshTokenInterceptor);
13 changes: 13 additions & 0 deletions apps/react/src/api/interceptors/apiKeyHeaderInterceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { InternalAxiosRequestConfig } from 'axios';

import { CONFIG } from '../config';

/**
* Sets the Api-Key header to request.
* @param config - Request config.
* @returns Request config with Api-Key header.
*/
export function apiKeyHeaderInterceptor(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig {
config.headers.set('Api-Key', CONFIG.apiKey);
return config;
}
16 changes: 16 additions & 0 deletions apps/react/src/api/interceptors/authorizationTokenInterceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { InternalAxiosRequestConfig } from 'axios';

import { LocalStorageService } from '../services/localStorageService';

/**
* Sets the Authorization header to request.
* @param config - Request config.
* @returns Request config with Authorization header.
*/
export function authorizationHeaderInterceptor(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig {
const accessToken = LocalStorageService.getAccessToken();
if (accessToken) {
config.headers.set('Authorization', `Bearer ${accessToken}`);
}
return config;
}
46 changes: 46 additions & 0 deletions apps/react/src/api/interceptors/refreshTokenInterceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import axios from 'axios';
import { ServerErrorStatus } from '@js-camp/core/models/server-error-status';

import { http } from '..';
import { LocalStorageService } from '../services/localStorageService';
import { AuthorizationService } from '../services/authorizationService';
import { UrlConfigService } from '../services/urlConfigService';

/**
* Refresh access token and sets it as the Authorization header to request.
* @param error - Request error.
* @returns Request config with new Authorization header.
*/
export async function refreshTokenInterceptor(error: unknown) {

if (!axios.isAxiosError(error)) {
return Promise.reject(error);
}

const originalRequest = error.config;

if (!originalRequest || originalRequest.url === UrlConfigService.authorizationUrls.refreshToken) {
return Promise.reject(error);
}

if (error.response?.status === ServerErrorStatus.Unauthorized) {
const refreshToken = LocalStorageService.getRefreshToken();

if (refreshToken) {
try {
const tokens = await AuthorizationService.refreshAccessToken(refreshToken);
LocalStorageService.saveTokens(tokens);
http.defaults.headers.common.Authorization = `Bearer ${tokens.access}`;
originalRequest.headers.Authorization = `Bearer ${tokens.access}`;
return http(originalRequest);
} catch (refreshError) {
console.error(refreshError);
LocalStorageService.removeTokens();
return Promise.reject(refreshError);
}
}
}

LocalStorageService.removeTokens();
return Promise.reject(error);
}
55 changes: 55 additions & 0 deletions apps/react/src/api/services/authorizationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { RegistrationData } from '@js-camp/core/models/registration-data';
import { RegistrationDataMapper } from '@js-camp/core/mappers/registration-data.mapper';
import { AuthorizationTokens } from '@js-camp/core/models/authorization-tokens';
import { AuthorizationTokensDto } from '@js-camp/core/dtos/authorization-tokens.dto';
import { AuthorizationTokensMapper } from '@js-camp/core/mappers/authorization-tokens.mapper';
import { LoginData } from '@js-camp/core/models/login-data';
import { LoginDataMapper } from '@js-camp/core/mappers/login-data.mapper';

import { http } from '..';

import { UrlConfigService } from './urlConfigService';

export namespace AuthorizationService {

/**
* Login the user and receives authorization tokens.
* @param loginData - User's data for login.
* @returns - Authorization tokens.
*/
export async function login(loginData: LoginData): Promise<AuthorizationTokens> {
const { data } = await http.post<AuthorizationTokensDto>(
UrlConfigService.authorizationUrls.login,
LoginDataMapper.toDto(loginData),
);
return AuthorizationTokensMapper.fromDto(data);
}

/**
* Registers the user and receives authorization tokens.
* @param registrationData - User's data for registration.
* @returns - Authorization tokens.
*/
export async function register(registrationData: RegistrationData): Promise<AuthorizationTokens> {
const { data } = await http.post<AuthorizationTokensDto>(
UrlConfigService.authorizationUrls.register,
RegistrationDataMapper.toDto(registrationData),
);
return AuthorizationTokensMapper.fromDto(data);
}

/**
* Refresh access token with refresh token.
* @param refreshToken - Token for refreshing access token.
* @returns - Authorization tokens.
*/
export async function refreshAccessToken(refreshToken: string): Promise<AuthorizationTokens> {
const { data } = await http.post<AuthorizationTokensDto>(
UrlConfigService.authorizationUrls.refreshToken,
{
refresh: refreshToken,
},
);
return AuthorizationTokensMapper.fromDto(data);
}
}
37 changes: 37 additions & 0 deletions apps/react/src/api/services/localStorageService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { AuthorizationTokens } from '@js-camp/core/models/authorization-tokens';

export namespace LocalStorageService {

/**
* Save tokens to local storage.
* @param tokens - Access and refresh tokens.
*/
export function saveTokens(tokens: AuthorizationTokens): void {
localStorage.setItem('accessToken', tokens.access);
localStorage.setItem('refreshToken', tokens.refresh);
}

/**
* Remove tokens from local storage.
*/
export function removeTokens(): void {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}

/**
* Get access token from local storage.
* @returns Access token.
*/
export function getAccessToken(): string | null {
return localStorage.getItem('accessToken');
}

/**
* Get refresh token from local storage.
* @returns Refresh token.
*/
export function getRefreshToken(): string | null {
return localStorage.getItem('refreshToken');
}
}
15 changes: 15 additions & 0 deletions apps/react/src/api/services/urlConfigService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@

export namespace UrlConfigService {

/** Object with parts of the authorization url requests. */
export const authorizationUrls = {
login: 'auth/login/',
register: 'auth/register/',
refreshToken: 'auth/token/refresh/',
};

/** Object with parts of the user url requests. */
export const userUrls = {
profile: 'users/profile/',
};
}
30 changes: 30 additions & 0 deletions apps/react/src/api/services/userService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { User } from '@js-camp/core/models/user';
import { UserDto } from '@js-camp/core/dtos/user.dto';
import { UserMapper } from '@js-camp/core/mappers/user.mapper';

import { http } from '..';

import { UrlConfigService } from './urlConfigService';

export namespace UserService {

const errorObject = {
avatar: null,
};

/**
* Get the current user based on the stored token.
* @returns Current user.
*/
export async function getCurrentUser(): Promise<User> {
const { data } = await http.get<UserDto>(UrlConfigService.userUrls.profile);

// When the server fails to retrieve a user, it does not send an error,
// but an object with an avatar field equal to null.
// Therefore, we will throw an error when receiving such data.
if (JSON.stringify(data) === JSON.stringify(errorObject)) {
throw new Error('Failed to get current user');
}
return UserMapper.fromDto(data);
}
}
12 changes: 12 additions & 0 deletions apps/react/src/components/Progress/Progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { FC, memo } from 'react';
import { Box, CircularProgress } from '@mui/material';

/** Component to show the loading process. */
const ProgressComponent: FC = () => (
<Box sx={{ display: 'flex', justifyContent: 'center', padding: 2 }}>
<CircularProgress />
</Box>
);

/** Memoized progress component. */
export const Progress = memo(ProgressComponent);
28 changes: 28 additions & 0 deletions apps/react/src/components/header/Header.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,31 @@
flex-direction: row;
gap: var(--space-m);
}

.authorizationMenu {
display: flex;
flex-direction: row;
gap: var(--space-s);
}

.link {
font-size: var(--font-size-base);
text-decoration: none;
color: var(--primary-font-color);
}

.link:hover {
text-decoration: underline;
}

.logoutButton {
font-size: var(--font-size-base);
background-color: var(--primary-background-color);
border: none;
padding: 0;
cursor: pointer;
}

.logoutButton:hover {
text-decoration: underline;
}
46 changes: 43 additions & 3 deletions apps/react/src/components/header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,44 @@
import { FC, memo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { FC, memo, useEffect } from 'react';
import { Link, NavLink, useLocation } from 'react-router-dom';
import AppBar from '@mui/material/AppBar';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import { ANIME_PATH } from '@js-camp/react/features/anime/routes';
import { GENRES_PATH } from '@js-camp/react/features/genres/routes';
import { STUDIOS_PATH } from '@js-camp/react/features/studios/routes';
import { useAppDispatch, useAppSelector } from '@js-camp/react/store/store';
import { selectCurrentUser, selectIsCurrentUserLoading } from '@js-camp/react/store/user/selectors';
import { REGISTER_PATH } from '@js-camp/react/features/registration/routes';
import { fetchUser } from '@js-camp/react/store/user/dispatchers';
import { LOGIN_PATH } from '@js-camp/react/features/login/routes';
import { logout } from '@js-camp/react/store/user/slice';
import { LocalStorageService } from '@js-camp/react/api/services/localStorageService';

import styles from './Header.module.css';

/** Header component. */
const HeaderComponent: FC = () => {

const user = useAppSelector(selectCurrentUser);
const isLoading = useAppSelector(selectIsCurrentUserLoading);
const refreshToken = LocalStorageService.getRefreshToken();

const dispatch = useAppDispatch();

useEffect(() => {
if (!isLoading) {
dispatch(fetchUser());
}
}, [refreshToken]);

const location = useLocation();

const isCurrentPath = (path: string) => location.pathname.startsWith(path);

const handleLogoutButtonClick = () => {
dispatch(logout());
};

return (
<AppBar
position="static"
Expand Down Expand Up @@ -47,7 +70,24 @@ const HeaderComponent: FC = () => {
/>
</Stack>
</nav>
<span>Authorization menu</span>
<span className={styles.authorizationMenu}>
{ user ?
<>
<span>{`Hi, ${user.firstName} ${user.lastName}!`}</span>
<button
type='button'
className={styles.logoutButton}
onClick={handleLogoutButtonClick}
>
Logout
</button>
</> :
<>
<NavLink to={LOGIN_PATH} className={styles.link}>Login</NavLink>
<NavLink to={REGISTER_PATH} className={styles.link}>Register</NavLink>
</>
}
</span>
</AppBar>
);
};
Expand Down
Loading
Loading