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 1 commit
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
19 changes: 19 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ const nextConfig = {
swcMinify: true,
poweredByHeader: false,
output: 'standalone',
async redirects() {
return [
{
source: '/auth/register/code',
destination: '/auth/register',
permanent: true,
},
{
source: '/auth/recoveryPassword/code',
destination: '/auth/recoveryPassword',
permanent: true,
},
{
source: '/auth/recoveryPassword/new',
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
33 changes: 20 additions & 13 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,
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') || '';
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;
34 changes: 29 additions & 5 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 { login, register } from './auth.api';

const initialState: AuthState = {
isLogin: false,
token: '',
user: null,
};
Expand All @@ -11,12 +11,36 @@ const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
logout: (state) => {
logout: () => {
window.localStorage.removeItem('token');
state.isLogin = false;
state.token = '';
return initialState;
},
},
extraReducers: (builder) => {
builder
.addMatcher(register.matchFulfilled, (state, { payload: { token, user } }) => {
window.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 } }) => {
window.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
2 changes: 1 addition & 1 deletion src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,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
43 changes: 43 additions & 0 deletions src/components/Code/Code.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { FC, PropsWithChildren, useEffect } from 'react';
import { Box, TextField, Typography, TextFieldProps } from '@mui/material';
import { useTimer } from '@/hooks/useTimer';
import { LoadingButton } from '../UI/LoadingButton';

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

export const Code: FC<PropsWithChildren<CodeProps & TextFieldProps>> = ({
leendrew marked this conversation as resolved.
Show resolved Hide resolved
children,
onTimerRestart = () => undefined,
...rest
}) => {
const { mins, secs, start, restart, isOver } = useTimer({ expiredTime: 300 });

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

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

return (
<>
<TextField {...(rest as TextFieldProps)} />
{!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/Code/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Code } from './Code';
23 changes: 13 additions & 10 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { AppBar, Toolbar, Typography, Button, IconButton } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import NextLink from 'next/link';
import { AppBar, Toolbar, Typography } from '@mui/material';
import Nav from './Nav';

export default function Header() {
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>
);
Expand Down
37 changes: 37 additions & 0 deletions src/components/Nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextPage } from 'next';
import NextLink from 'next/link';
import { Button, Stack } from '@mui/material';
import { useTypedSelector, useTypedDispatch } from '@/app/store';
import { authActions } from '@/app/auth/auth.slice';

const Nav: NextPage = () => {
const dispatch = useTypedDispatch();
const logout = () => dispatch(authActions.logout());
const isLoggedIn = useTypedSelector((state) => !!state.auth.user);

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>
);
};

export default Nav;
38 changes: 38 additions & 0 deletions src/components/UI/LoadingButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { FC, PropsWithChildren } from 'react';
import { Stack, Button, CircularProgress, SxProps, Box, ButtonProps } from '@mui/material';

interface LoadingButtonProps {
children: React.ReactNode;
type?: 'button' | 'submit' | 'reset';
loading?: boolean;
sx?: SxProps;
}

export const LoadingButton: FC<PropsWithChildren<LoadingButtonProps & ButtonProps>> = ({
children,
type = 'button',
loading = false,
sx = {},
...rest
}) => {
return (
<Button
sx={{
color: loading ? 'grey.500' : '',
backgroundColor: loading ? 'grey500' : '',
appearance: loading ? 'none' : 'auto',
cursor: loading ? 'not-allowed' : 'pointer',
...sx,
}}
disabled={loading}
type={type}
{...rest}
>
<Stack direction="row" alignItems="center" spacing={1}>
{loading && <CircularProgress color="inherit" size={16} />}
{loading && <Box>Pending</Box>}
{!loading && <Box>{children}</Box>}
</Stack>
</Button>
);
};
11 changes: 11 additions & 0 deletions src/contexts/Root.context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { FC, PropsWithChildren } from 'react';
import { RegisterProvider } from './auth/Register.context';
import { RecoveryPasswordProvider } from './auth/RecoveryPassword.context';

export const RootContext: FC<PropsWithChildren> = ({ children }) => (
<>
<RegisterProvider>
<RecoveryPasswordProvider>{children}</RecoveryPasswordProvider>
</RegisterProvider>
</>
);
Loading