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

[FEAT] Auth #7

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ const nextConfig = {
swcMinify: true,
poweredByHeader: false,
output: 'standalone',
async redirects() {
return [
{
source: '/auth/register/:path*',
destination: '/auth/register',
permanent: true,
},
{
source: '/auth/recoveryPassword/:path*',
destination: '/auth/recoveryPassword',
permanent: true,
},
];
},
};

module.exports = nextConfig;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "frontend",
"name": "ucrm-web-client",
"version": "0.1.0",
"private": true,
"scripts": {
Expand Down
37 changes: 22 additions & 15 deletions src/app/auth/auth.api.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { HYDRATE } from 'next-redux-wrapper';
import { HOST_URL } from '../constants';
import {
GetVerificationCodePayload,
import type {
GetVerifyCodePayload,
RegisterPayload,
RegisterResponse,
LoginPayload,
LoginResponse,
GetRecoveryCodePayload,
RecoveryPasswordPayload,
} from './auth.types';
import { HTTP_METHODS as HTTP } from '../constants';
import { HTTP_METHODS as HTTP, AUTH_API_URL } from '../constants';

export const authApi = createApi({
baseQuery: fetchBaseQuery({
baseUrl: HOST_URL + '/users',
baseUrl: AUTH_API_URL,
prepareHeaders: (headers) => {
const token = window.localStorage.getItem('token') || '';
const token = localStorage.getItem('token') || '';
headers.set('Authorization', token);

return headers;
},
credentials: 'include',
credentials: 'same-origin',
}),
reducerPath: 'api/auth',
extractRehydrationInfo(action, { reducerPath }) {
Expand All @@ -28,23 +28,30 @@ export const authApi = createApi({
}
},
endpoints: (builder) => ({
getVerificationCode: builder.mutation<GetVerificationCodePayload, void>({
query: (data) => ({ url: '/verificationCode', method: HTTP.POST, body: data }),
getVerifyCode: builder.mutation<void, GetVerifyCodePayload>({
query: (data) => ({ url: '/sendVerifyCode', method: HTTP.POST, body: data }),
}),
register: builder.mutation<RegisterPayload, RegisterResponse>({
register: builder.mutation<RegisterResponse, RegisterPayload>({
query: (data) => ({ url: '/signUp', method: HTTP.POST, body: data }),
}),
login: builder.mutation<LoginPayload, LoginResponse>({
login: builder.mutation<LoginResponse, LoginPayload>({
query: (data) => ({ url: '/signIn', method: HTTP.POST, body: data }),
}),
getRecoveryCode: builder.mutation<void, GetRecoveryCodePayload>({
query: (data) => ({ url: '/sendRecoveryCode', method: HTTP.POST, body: data }),
}),
recoveryPassword: builder.mutation<void, RecoveryPasswordPayload>({
query: (data) => ({ url: '/recoveryPassword', method: HTTP.POST, body: data }),
}),
}),
});

export const {
useGetVerificationCodeMutation,
useGetVerifyCodeMutation,
useRegisterMutation,
useLoginMutation,
useGetRecoveryCodeMutation,
useRecoveryPasswordMutation,
endpoints: { getVerifyCode, register, login, getRecoveryCode, recoveryPassword },
util: { getRunningOperationPromises },
} = authApi;

export const { getVerificationCode, register, login } = authApi.endpoints;
45 changes: 37 additions & 8 deletions src/app/auth/auth.slice.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { AuthState } from './auth.types';
import { createSlice } from '@reduxjs/toolkit';
import { login, register } from './auth.api';
import type { AuthState } from './auth.types';

const initialState: AuthState = {
isLogin: false,
token: '',
user: null,
};
Expand All @@ -11,12 +11,41 @@ const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
logout: (state) => {
window.localStorage.removeItem('token');
state.isLogin = false;
state.token = '';
logout: () => {
localStorage.removeItem('token');
return initialState;
},
takeTokenFromLocalStorage: (state) => {
if (!state.token) {
state.token = localStorage.getItem('token') || '';
}
},
},
extraReducers: (builder) => {
builder
.addMatcher(register.matchFulfilled, (state, { payload: { token, user } }) => {
localStorage.setItem('token', token);

state.token = token;
state.user = {
id: user.id,
email: user.email,
avatarUrl: user.avatar_url,
createdAt: user.created_at,
};
})
.addMatcher(login.matchFulfilled, (state, { payload: { token, user } }) => {
localStorage.setItem('token', token);

state.token = token;
state.user = {
id: user.id,
email: user.email,
avatarUrl: user.avatar_url,
createdAt: user.created_at,
};
});
},
});

export const { actions: authAction, reducer: authReducer } = authSlice;
export const { actions: authActions, reducer: authReducer } = authSlice;
17 changes: 12 additions & 5 deletions src/app/auth/auth.types.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
export type Email = string;
export type Token = string;

export type GetVerificationCodePayload = {
type CodePayload = {
email: Email;
};

export type GetVerifyCodePayload = CodePayload;

type AuthPayload = {
email: Email;
password: string;
};

export type RegisterPayload = AuthPayload & {
verificationCode: number;
type CodeDto = {
code: number;
};

export type RegisterPayload = AuthPayload & CodeDto;

export type UserDto = {
id: string;
created_at: string;
created_at: Date;
email: Email;
avatar_url: string;
};
Expand All @@ -38,7 +42,10 @@ export type LoginPayload = AuthPayload;
export type LoginResponse = AuthResponse;

export type AuthState = {
isLogin: boolean;
token: Token;
user: User | null;
};

export type GetRecoveryCodePayload = CodePayload;

export type RecoveryPasswordPayload = AuthPayload & CodeDto;
1 change: 1 addition & 0 deletions src/app/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const HOST_URL = process.env.host || 'http://localhost:8081/api/v1';
export const AUTH_API_URL = HOST_URL + '/users';

export enum HTTP_METHODS {
GET = 'GET',
Expand Down
5 changes: 3 additions & 2 deletions src/app/store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { configureStore } from '@reduxjs/toolkit';
import { createWrapper } from 'next-redux-wrapper';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import { authReducer } from './auth/auth.slice';
import { authApi } from './auth/auth.api';

Expand All @@ -14,7 +15,7 @@ const makeStore = () =>
});

type AppStore = ReturnType<typeof makeStore>;
type RootState = ReturnType<AppStore['getState']>;
export type RootState = ReturnType<AppStore['getState']>;
type AppDispatch = AppStore['dispatch'];

export const useTypedDispatch = () => useDispatch<AppDispatch>();
Expand Down
44 changes: 44 additions & 0 deletions src/components/CodeInput/CodeInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { FC, PropsWithChildren, useEffect } from 'react';
import { Box, TextField, Typography } from '@mui/material';
import type { TextFieldProps } from '@mui/material';
import { useTimer } from '@/hooks/useTimer';
import { LoadingButton } from '../UI/LoadingButton';

interface CodeInputProps {
onTimerRestart?: () => void;
}

export const CodeInput: FC<PropsWithChildren<CodeInputProps & TextFieldProps>> = ({
children,
onTimerRestart = () => undefined,
...rest
}) => {
const { mins, secs, start, restart, isOver } = useTimer({ expiredTime: 300 });

const restartTimer = () => {
onTimerRestart();
restart();
};

useEffect(() => {
start();
}, [start]);

return (
<>
<TextField {...rest} />
{!isOver && (
<Box>
<Typography component="span">
You may resend in {mins.toString().padStart(2, '0')}:{secs.toString().padStart(2, '0')}
</Typography>
</Box>
)}
{isOver && (
<LoadingButton variant="text" onClick={restartTimer}>
Resend
</LoadingButton>
)}
</>
);
};
1 change: 1 addition & 0 deletions src/components/CodeInput/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CodeInput } from './CodeInput';
5 changes: 3 additions & 2 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { FC } from 'react';
import { Box, Link, Typography } from '@mui/material';

export default function Footer() {
export const Footer: FC = () => {
return (
<Box
sx={{
Expand All @@ -17,4 +18,4 @@ export default function Footer() {
</Link>
</Box>
);
}
};
28 changes: 16 additions & 12 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import { AppBar, Toolbar, Typography, Button, IconButton } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import type { FC } from 'react';
import NextLink from 'next/link';
import { AppBar, Toolbar, Typography } from '@mui/material';
import { Nav } from './Nav';

export default function Header() {
export const Header: FC = () => {
return (
<AppBar position="static">
<Toolbar>
<IconButton sx={{ mr: 2 }} size="large" edge="start" color="inherit" aria-label="menu">
<MenuIcon />
</IconButton>
<Typography sx={{ flexGrow: 1 }} variant="h6" component="div">
UCRM
</Typography>
<Button color="inherit">Login</Button>
<Button color="inherit">Register</Button>
<NextLink href="/">
<Typography
sx={{ marginRight: 'auto', ':hover': { cursor: 'pointer' } }}
variant="h6"
component="a"
>
UCRM
</Typography>
</NextLink>
<Nav />
</Toolbar>
</AppBar>
);
}
};
9 changes: 5 additions & 4 deletions src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import Header from './Header';
import Footer from './Footer';
import type { FC, PropsWithChildren } from 'react';
import { Header } from './Header';
import { Footer } from './Footer';
import { Box } from '@mui/material';

interface LayoutProps {
children: React.ReactNode;
}

export default function Layout({ children }: LayoutProps) {
export const Layout: FC<PropsWithChildren<LayoutProps>> = ({ children }) => {
return (
<>
<Header />
Expand All @@ -16,4 +17,4 @@ export default function Layout({ children }: LayoutProps) {
<Footer />
</>
);
}
};
35 changes: 35 additions & 0 deletions src/components/Nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { FC } from 'react';
import NextLink from 'next/link';
import { Button, Stack } from '@mui/material';
import { useTypedSelector, useTypedDispatch } from '@/app/store';
import { authActions } from '@/app/auth/auth.slice';

export const Nav: FC = () => {
const dispatch = useTypedDispatch();
const logout = () => dispatch(authActions.logout());
const isLoggedIn = useTypedSelector((state) => !!state.auth.token);

return (
<Stack direction="row" spacing={2}>
{isLoggedIn && (
<Button color="inherit" variant="outlined" onClick={logout}>
Logout
</Button>
)}
{!isLoggedIn && (
<>
<NextLink href="/auth/login">
<Button color="inherit" variant="text">
Login
</Button>
</NextLink>
<NextLink href="/auth/register">
<Button color="inherit" variant="outlined">
Register
</Button>
</NextLink>
</>
)}
</Stack>
);
};
Loading