) => {
32 | e.preventDefault();
33 |
34 | let reader = new FileReader();
35 | let file = e.target && e.target.files && e.target.files[0];
36 |
37 | if (file) {
38 | reader.readAsDataURL(file);
39 | helpers.setValue(file);
40 | }
41 | };
42 |
43 | return (
44 |
45 |
50 |
54 | {label}{" "}
55 | {optional && (
56 | (Optional)
57 | )}
58 |
59 |
60 |
61 |
77 |
78 | {field.value && (
79 | {
81 | helpers.setValue("");
82 | }}
83 | >
84 |
85 |
86 | )}
87 |
88 |
89 | {meta.touched && meta.error &&
}
90 |
91 | );
92 | };
93 |
94 | export default FileInput;
95 |
--------------------------------------------------------------------------------
/client/src/pages/EmailNotVerified.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import { useDispatch } from "react-redux";
3 | import axiosInstance from "../axiosInstance";
4 | import { logoutUser } from "../redux/features/authSlice";
5 | import { addToast } from "../redux/features/toastSlice";
6 | import { ERROR, SUCCESS } from "../types/constants";
7 |
8 | const EmailNotVerified = () => {
9 | const dispatch = useDispatch();
10 |
11 | const resendEmail = useCallback(() => {
12 | axiosInstance
13 | .post(`/email/resend-verify`)
14 | .then((response) => {
15 | const { message } = response.data;
16 |
17 | dispatch(addToast({ kind: SUCCESS, msg: message }));
18 | })
19 | .catch((error) => {
20 | if (error.response) {
21 | const response = error.response;
22 | const { message } = response.data;
23 |
24 | switch (response.status) {
25 | case 500:
26 | dispatch(addToast({ kind: ERROR, msg: message }));
27 | break;
28 | default:
29 | dispatch(
30 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
31 | );
32 | break;
33 | }
34 | } else if (error.request) {
35 | dispatch(
36 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
37 | );
38 | } else {
39 | dispatch(addToast({ kind: ERROR, msg: `Error: ${error.message}` }));
40 | }
41 | });
42 | }, []);
43 |
44 | return (
45 |
46 |
52 |
53 | Verify your Email
54 |
55 |
56 | To use Workflow, click the verification link your email. This helps
57 | keep your account secure.
58 |
59 |
60 | No email in your inbox or spam folder?{" "}
61 |
62 | Let’s resend it.
63 |
64 |
65 |
66 | Wrong address?{" "}
67 | {
70 | dispatch(logoutUser());
71 | }}
72 | >
73 | Log out
74 | {" "}
75 | to sign in with a different email. If you mistyped your email when
76 | signing up, create a new account.
77 |
78 |
79 |
80 | );
81 | };
82 |
83 | export default EmailNotVerified;
84 |
--------------------------------------------------------------------------------
/client/src/components/Sidebar/SpaceList/SpaceList.tsx:
--------------------------------------------------------------------------------
1 | import { HiOutlineRefresh } from "react-icons/hi";
2 | import { useQuery, useQueryClient } from "react-query";
3 | import { useDispatch } from "react-redux";
4 | import axiosInstance from "../../../axiosInstance";
5 | import spaces from "../../../data/spaces";
6 | import { showModal } from "../../../redux/features/modalSlice";
7 | import { SpaceObj } from "../../../types";
8 | import { CREATE_SPACE_MODAL } from "../../../types/constants";
9 | import Loader from "../../Loader/Loader";
10 | import UtilityBtn from "../../UtilityBtn/UtilityBtn";
11 | import SpaceItem from "./SpaceItem";
12 |
13 | const SpaceList = () => {
14 | const dispatch = useDispatch();
15 | const queryClient = useQueryClient();
16 |
17 | const getSpaces = async () => {
18 | const response = await axiosInstance.get(`/spaces`);
19 | const { data } = response.data;
20 |
21 | return data;
22 | };
23 |
24 | const { data, isLoading, error } = useQuery<
25 | SpaceObj[] | undefined,
26 | any,
27 | SpaceObj[],
28 | string[]
29 | >(["getSpaces"], getSpaces);
30 |
31 | if (error) {
32 | return (
33 |
39 |
40 | Unable to get data.
41 | {
45 | queryClient.invalidateQueries(["getSpaces"]);
46 | }}
47 | >
48 | Retry
49 |
50 |
51 |
52 | );
53 | }
54 |
55 | if (isLoading) {
56 | return (
57 |
63 |
64 |
65 | );
66 | }
67 |
68 | return (
69 |
70 | {data && data.length > 0 ? (
71 | data.map((space) => {
72 | return ;
73 | })
74 | ) : (
75 |
76 | Start a
77 | {
80 | dispatch(
81 | showModal({
82 | modalType: CREATE_SPACE_MODAL,
83 | })
84 | );
85 | }}
86 | className="ml-1 underline text-violet-500 decoration-dashed outline-violet-500 underline-offset-4"
87 | >
88 | new space
89 |
90 |
91 | )}
92 |
93 | );
94 | };
95 |
96 | export default SpaceList;
97 |
--------------------------------------------------------------------------------
/server/routes/space.route.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import { authMiddleware } from "../middlewares/auth";
3 | import * as spaceController from "../controllers/space.controller";
4 | import { multerUploadSingle } from "../middlewares/multerUploadSingle";
5 |
6 | const spaceRouter = express.Router();
7 |
8 | // Protected(Auth) GET /spaces -> get all spaces (sidebar)
9 | spaceRouter.get("/", authMiddleware, spaceController.getSpaces);
10 |
11 | // Protected(Auth) POST /spaces -> create new space
12 | spaceRouter.post("/", authMiddleware, spaceController.createSpace);
13 |
14 | // Protected(Auth) GET /spaces/mine -> gets all the spaces in which current user is either an admin/normal member (for board creation dropdown)
15 | spaceRouter.get("/mine", authMiddleware, spaceController.getSpacesMine);
16 |
17 | // Protected(Auth) GET /spaces/:id/info -> get space info
18 | spaceRouter.get("/:id/info", authMiddleware, spaceController.getSpaceInfo);
19 | // Protected(Auth) GET /spaces/:id/boards -> get all space boards according to user role
20 | spaceRouter.get("/:id/boards", authMiddleware, spaceController.getSpaceBoards);
21 | // Protected(Auth) GET /spaces/:id/members -> get all space members according to user role
22 | spaceRouter.get(
23 | "/:id/members",
24 | authMiddleware,
25 | spaceController.getAllSpaceMembers
26 | );
27 | // Protected(Auth) PUT /spaces/:id/members -> add a member to space
28 | spaceRouter.put("/:id/members", authMiddleware, spaceController.addAMember);
29 | // Protected(Auth) PUT /spaces/:id/members/bulk -> add one or more members to space
30 | spaceRouter.put(
31 | "/:id/members/bulk",
32 | authMiddleware,
33 | spaceController.addSpaceMembers
34 | );
35 | // Protected(Auth) PUT /spaces/:id/members/:memberId -> update member role in space
36 | spaceRouter.put(
37 | "/:id/members/:memberId",
38 | authMiddleware,
39 | spaceController.updateMemberRole
40 | );
41 | // Protected(Auth) DELETE /spaces/:id/members/:memberId -> remove the member from this space and remove him from all his boards in this space
42 | spaceRouter.delete(
43 | "/:id/members/:memberId",
44 | authMiddleware,
45 | spaceController.removeMember
46 | );
47 | // Protected(Auth) DELETE /spaces/:id/members -> leave from space
48 | spaceRouter.delete(
49 | "/:id/members",
50 | authMiddleware,
51 | spaceController.leaveFromSpace
52 | );
53 |
54 | // Protected(Auth) GET /spaces/:id/settings -> get space settings
55 | spaceRouter.get(
56 | "/:id/settings",
57 | authMiddleware,
58 | spaceController.getSpaceSettings
59 | );
60 | // Protected(Auth) PUT /spaces/:id/settings -> update space settings
61 | spaceRouter.put(
62 | "/:id/settings",
63 | authMiddleware,
64 | function (req, res, next) {
65 | multerUploadSingle(req, res, next, "icon");
66 | },
67 | spaceController.updateSpaceSettings
68 | );
69 | // Protected(Auth) DELETE /spaces/:id -> delete space
70 | spaceRouter.delete("/:id", authMiddleware, spaceController.deleteSpace);
71 |
72 | export default spaceRouter;
73 |
--------------------------------------------------------------------------------
/client/src/components/BoardLists/ListDummy.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | DraggableProvided,
4 | DraggableRubric,
5 | DraggableStateSnapshot,
6 | } from "react-beautiful-dnd";
7 | import { HiOutlineDotsHorizontal, HiOutlinePlus } from "react-icons/hi";
8 | import { CardObj, ListObj } from "../../types";
9 | import { BOARD_ROLES } from "../../types/constants";
10 | import CardDummy from "./CardDummy";
11 | import ListName from "./ListName";
12 |
13 | interface Props {
14 | provided: DraggableProvided;
15 | snapshot: DraggableStateSnapshot;
16 | list: ListObj;
17 | spaceId: string;
18 | boardId: string;
19 | cards: CardObj[];
20 | myRole:
21 | | typeof BOARD_ROLES.ADMIN
22 | | typeof BOARD_ROLES.NORMAL
23 | | typeof BOARD_ROLES.OBSERVER;
24 | }
25 |
26 | const ListDummy = ({
27 | provided,
28 | snapshot,
29 | list,
30 | cards,
31 | myRole,
32 | boardId,
33 | spaceId,
34 | }: Props) => {
35 | return (
36 |
47 |
51 | <>
52 | {[BOARD_ROLES.ADMIN, BOARD_ROLES.NORMAL].includes(myRole) ? (
53 |
59 | ) : (
60 |
61 | {list.name.length > 34
62 | ? list.name.slice(0, 34) + "..."
63 | : list.name}
64 |
65 | )}
66 | >
67 | {[BOARD_ROLES.ADMIN, BOARD_ROLES.NORMAL].includes(myRole) && (
68 |
69 |
70 |
71 | )}
72 |
73 |
74 |
75 |
82 | {cards.map((c, index) => (
83 |
84 | ))}
85 |
86 |
87 |
88 | {[BOARD_ROLES.ADMIN, BOARD_ROLES.NORMAL].includes(myRole) && (
89 |
90 |
91 | Add a card
92 |
93 | )}
94 |
95 | );
96 | };
97 |
98 | export default ListDummy;
99 |
--------------------------------------------------------------------------------
/server/routes/board.route.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import { authMiddleware } from "../middlewares/auth";
3 | import * as boardController from "../controllers/board.controller";
4 |
5 | const boardRouter = express.Router();
6 |
7 | // Protected(Auth) GET /recentBoards -> get recently visited boards
8 | boardRouter.get(
9 | "/recentBoards",
10 | authMiddleware,
11 | boardController.getRecentBoards
12 | );
13 | // Protected(Auth) POST /boards -> create new board
14 | boardRouter.post("/", authMiddleware, boardController.createBoard);
15 | // Protected(Auth) GET /boards/:id -> get board info
16 | boardRouter.get("/:id", authMiddleware, boardController.getBoard);
17 | // Protected(Auth) PUT /boards/:id/visibility -> update board visibility
18 | boardRouter.put(
19 | "/:id/visibility",
20 | authMiddleware,
21 | boardController.changeBoardVisibility
22 | );
23 | // Protected(Auth) PUT /boards/:id/name -> update board name
24 | boardRouter.put("/:id/name", authMiddleware, boardController.updateBoardName);
25 | // Protected(Auth) PUT /boards/:id/description -> update board description
26 | boardRouter.put(
27 | "/:id/description",
28 | authMiddleware,
29 | boardController.updateBoardDesc
30 | );
31 | // Protected(Auth) PUT /boards/:id/background -> update board background
32 | boardRouter.put(
33 | "/:id/background",
34 | authMiddleware,
35 | boardController.updateBoardBackground
36 | );
37 | // Protected(Auth) PUT /boards/:id/members/bulk -> add one or more members to board
38 | boardRouter.put(
39 | "/:id/members/bulk",
40 | authMiddleware,
41 | boardController.addBoardMembers
42 | );
43 | // Protected(Auth) PUT /boards/:id/members/join -> join as board member
44 | boardRouter.put("/:id/members/join", authMiddleware, boardController.joinBoard);
45 | // Protected(Auth) PUT /boards/:id/members/:memberId -> update board member role
46 | boardRouter.put(
47 | "/:id/members/:memberId",
48 | authMiddleware,
49 | boardController.updateMemberRole
50 | );
51 | // Protected(Auth) DELETE /boards/:id/members/:memberId -> remove board member
52 | boardRouter.delete(
53 | "/:id/members/:memberId",
54 | authMiddleware,
55 | boardController.removeMember
56 | );
57 | // Protected(Auth) DELETE /boards/:id/members -> remove board member
58 | boardRouter.delete(
59 | "/:id/members",
60 | authMiddleware,
61 | boardController.leaveFromBoard
62 | );
63 |
64 | // Protected(Auth) GET /boards/:id/labels -> get all board labels
65 | boardRouter.get("/:id/labels", authMiddleware, boardController.getAllLabels);
66 | // Protected(Auth) PUT /boards/:id/labels -> create new label
67 | boardRouter.post("/:id/labels", authMiddleware, boardController.createNewLabel);
68 | // Protected(Auth) PUT /boards/:id/labels -> update label
69 | boardRouter.put("/:id/labels", authMiddleware, boardController.updateLabel);
70 | // Protected(Auth) DELETE /boards/:id/labels -> remove label
71 | boardRouter.delete("/:id/labels", authMiddleware, boardController.removeLabel);
72 |
73 | // Protected(Auth) DELETE /boards/:id -> delete board
74 | boardRouter.delete("/:id", authMiddleware, boardController.deleteBoard);
75 |
76 | export default boardRouter;
77 |
--------------------------------------------------------------------------------
/client/src/components/FormikComponents/RemoteSelect.tsx:
--------------------------------------------------------------------------------
1 | import { useField } from "formik";
2 | import React, { useEffect } from "react";
3 | import { HiOutlineRefresh } from "react-icons/hi";
4 | import { useQueryClient } from "react-query";
5 | import { Option } from "../../types";
6 | import UtilityBtn from "../UtilityBtn/UtilityBtn";
7 | import ErrorBox from "./ErrorBox";
8 |
9 | interface Props {
10 | label: string;
11 | id: string;
12 | name: string;
13 | isLoading: boolean;
14 | isFetching: boolean;
15 | queryKey: string[];
16 | error: any;
17 | options: Option[];
18 | selected?: string;
19 | inline?: boolean;
20 | classes?: string;
21 | }
22 |
23 | const RemoteSelect = ({
24 | label,
25 | id,
26 | name,
27 | queryKey,
28 | isFetching,
29 | isLoading,
30 | error,
31 | options = [],
32 | selected,
33 | classes,
34 | inline,
35 | ...props
36 | }: Props) => {
37 | const queryClient = useQueryClient();
38 |
39 | // props -> every props except label and options -> { name: 'value', id: 'value' }
40 | const [field, meta, helpers] = useField(name);
41 |
42 | useEffect(() => {
43 | if (options.length > 0 && !field.value) {
44 | const exists = selected
45 | ? options.find((o) => o.value === selected)
46 | : undefined;
47 |
48 | // if selected is given and it is also found in the given options
49 | if (exists) {
50 | helpers.setValue(exists.value);
51 | } else {
52 | helpers.setValue(options[0].value);
53 | }
54 | }
55 | }, [options]);
56 |
57 | return (
58 |
59 |
64 |
68 | {label}
69 |
70 |
71 |
81 | {options.map((option) => (
82 |
83 | {option.label}
84 |
85 | ))}
86 |
87 |
88 | {
94 | queryClient.invalidateQueries(queryKey);
95 | }}
96 | />
97 |
98 |
99 | {error ? (
100 |
101 | ) : (
102 | meta.touched &&
103 | meta.error &&
104 | !isFetching &&
105 | )}
106 |
107 | );
108 | };
109 |
110 | export default RemoteSelect;
111 |
--------------------------------------------------------------------------------
/client/src/components/Header/ProfileCard.tsx:
--------------------------------------------------------------------------------
1 | import { HiOutlineLogout } from "react-icons/hi";
2 | import React, { useState } from "react";
3 | import { useDispatch, useSelector } from "react-redux";
4 | import { Link } from "react-router-dom";
5 | import { CgProfile } from "react-icons/cg";
6 | import useClose from "../../hooks/useClose";
7 | import { logoutUser } from "../../redux/features/authSlice";
8 | import { chopChars } from "../../utils/helpers";
9 | import { RootState } from "../../redux/app";
10 | import Profile from "../Profile/Profile";
11 |
12 | const ProfileCard = () => {
13 | const [show, setShow] = useState(false);
14 | const ref = useClose(() => setShow(false));
15 |
16 | const { user } = useSelector((state: RootState) => state.auth);
17 |
18 | const dispatch = useDispatch();
19 |
20 | const handleLogout = () => {
21 | dispatch(logoutUser());
22 | };
23 |
24 | return (
25 |
26 | {user ? (
27 |
setShow(!show)}
29 | src={user.profile}
30 | alt={`${user.username} profile`}
31 | classes="cursor-pointer"
32 | />
33 | ) : (
34 | setShow(!show)}
36 | src={undefined}
37 | classes="cursor-pointer"
38 | />
39 | )}
40 |
41 | {show && (
42 |
48 |
49 |
50 | {user ? (
51 |
52 | ) : (
53 |
54 | )}
55 |
56 |
57 |
58 | {user ? chopChars(24, user.username) : "Unknown"}
59 |
60 |
61 | {user ? chopChars(24, user.email) : "unknown"}
62 |
63 |
64 |
65 |
{
68 | setShow(false);
69 | }}
70 | className="flex items-center justify-between w-full p-3 text-gray-600 hover:bg-gray-200 "
71 | >
72 |
Profile
73 |
74 |
75 |
76 |
77 |
{
79 | handleLogout();
80 | setShow(false);
81 | }}
82 | className="flex items-center justify-between w-full p-3 text-gray-600 rounded-md rounded-t-none hover:bg-gray-200 "
83 | >
84 | Log Out
85 |
86 |
87 |
88 |
89 |
90 | )}
91 |
92 | );
93 | };
94 |
95 | export default ProfileCard;
96 |
--------------------------------------------------------------------------------
/server/routes/card.route.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import * as cardController from "../controllers/card.controller";
3 | import { authMiddleware } from "../middlewares/auth";
4 |
5 | const cardRouter = express.Router();
6 |
7 | // Protected(Auth) GET /cards/all -> get all cards
8 | cardRouter.get("/all", authMiddleware, cardController.getAllCards);
9 | // POST /cards -> create a new card
10 | cardRouter.post("/", authMiddleware, cardController.createCard);
11 | // Protected(Auth) GET /cards/:id -> get card
12 | cardRouter.get("/:id", authMiddleware, cardController.getCard);
13 | // Protected(Auth) DELETE /cards/:id -> delete card
14 | cardRouter.delete("/:id", authMiddleware, cardController.deleteCard);
15 | // Protected(Auth) PUT /cards/:id/dnd -> dnd card
16 | cardRouter.put("/:id/dnd", authMiddleware, cardController.dndCard);
17 | // Protected(Auth) PUT /cards/:id/name -> update card name
18 | cardRouter.put("/:id/name", authMiddleware, cardController.updateCardName);
19 | // Protected(Auth) PUT /cards/:id/description -> update card description
20 | cardRouter.put(
21 | "/:id/description",
22 | authMiddleware,
23 | cardController.updateCardDescription
24 | );
25 | // Protected(Auth) PUT /cards/:id/dueDate -> add/update card dueDate
26 | cardRouter.put("/:id/dueDate", authMiddleware, cardController.updateDueDate);
27 | // Protected(Auth) DELETE /cards/:id/dueDate -> remove card dueDate
28 | cardRouter.delete("/:id/dueDate", authMiddleware, cardController.removeDueDate);
29 | // Protected(Auth) PUT /cards/:id/isComplete -> toggle card isComplete
30 | cardRouter.put(
31 | "/:id/isComplete",
32 | authMiddleware,
33 | cardController.toggleIsComplete
34 | );
35 |
36 | // Protected(Auth) PUT /cards/:id/cover -> add/update card cover
37 | cardRouter.put("/:id/cover", authMiddleware, cardController.updateCardCover);
38 | // Protected(Auth) DELETE /cards/:id/cover -> remove card cover
39 | cardRouter.delete("/:id/cover", authMiddleware, cardController.removeCardCover);
40 |
41 | // Protected(Auth) PUT /cards/:id/members -> add card member
42 | cardRouter.put("/:id/members", authMiddleware, cardController.addAMember);
43 | // Protected(Auth) DELETE /cards/:id/members -> remove from card
44 | cardRouter.delete(
45 | "/:id/members",
46 | authMiddleware,
47 | cardController.removeCardMember
48 | );
49 |
50 | // Protected(Auth) POST /cards/:id/comments -> add comment
51 | cardRouter.post("/:id/comments", authMiddleware, cardController.createComment);
52 | // Protected(Auth) PUT /cards/:id/comments -> update comment
53 | cardRouter.put("/:id/comments", authMiddleware, cardController.updateComment);
54 | // Protected(Auth) DELETE /cards/:id/comments -> delete comment
55 | cardRouter.delete(
56 | "/:id/comments",
57 | authMiddleware,
58 | cardController.deleteComment
59 | );
60 |
61 | // Protected(Auth) GET /cards/:id/labels -> get labels
62 | cardRouter.get("/:id/labels", authMiddleware, cardController.getCardLabels);
63 | // Protected(Auth) PUT /cards/:id/labels -> add a label to card
64 | cardRouter.put("/:id/labels", authMiddleware, cardController.addCardLabel);
65 | // Protected(Auth) DELETE /cards/:id/labels -> remove label from card
66 | cardRouter.delete(
67 | "/:id/labels",
68 | authMiddleware,
69 | cardController.removeCardLabel
70 | );
71 | // Protected(Auth) POST /cards/:id/labels -> create new label card
72 | cardRouter.post("/:id/labels", authMiddleware, cardController.createLabel);
73 |
74 | export default cardRouter;
75 |
--------------------------------------------------------------------------------
/client/src/axiosInstance.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { BASE_URL } from "./config";
3 | import { store } from "./redux/app";
4 | import { logoutUser, setAccessToken } from "./redux/features/authSlice";
5 |
6 | const axiosInstance = axios.create();
7 |
8 | // axios defaults
9 | axiosInstance.defaults.baseURL = BASE_URL;
10 |
11 | // interceptors
12 | // Request interceptor
13 | axiosInstance.interceptors.request.use(
14 | (config: any) => {
15 | // bottom line is required, if you are using react-query or something similar
16 | if (config.headers["Authorization"]) {
17 | config.headers["Authorization"] = null;
18 | }
19 | config.headers["Authorization"] =
20 | "Bearer " + store.getState().auth.accessToken;
21 | return config;
22 | },
23 | (error) => {
24 | Promise.reject(error);
25 | }
26 | );
27 |
28 | // for multiple requests
29 | let isRefreshing = false;
30 | let failedQueue: any[] = [];
31 |
32 | const processQueue = (error: any, token = null) => {
33 | failedQueue.forEach((prom) => {
34 | if (error) {
35 | prom.reject(error);
36 | } else {
37 | prom.resolve(token);
38 | }
39 | });
40 |
41 | failedQueue = [];
42 | };
43 |
44 | axiosInstance.interceptors.response.use(
45 | function (response) {
46 | return response;
47 | },
48 | function (error) {
49 | const originalRequest = error.config;
50 |
51 | // if refresh also fails with 401
52 | if (
53 | error.response.status === 401 &&
54 | originalRequest.url.includes("refresh")
55 | ) {
56 | store.dispatch(logoutUser());
57 | }
58 |
59 | // if retried request failed with 401 status
60 | if (error.response.status === 401 && originalRequest._retry) {
61 | // doesn't stops here, but also shows all the toast below due to Promise reject at the bottom
62 | return store.dispatch(logoutUser());
63 | }
64 |
65 | if (
66 | error.response.status === 401 &&
67 | !originalRequest.url.includes("login") &&
68 | !originalRequest._retry
69 | ) {
70 | // if refreshing logic is happening, then push subsequent req to the queue
71 | if (isRefreshing) {
72 | return new Promise(function (resolve, reject) {
73 | failedQueue.push({ resolve, reject });
74 | })
75 | .then(() => {
76 | return axiosInstance(originalRequest);
77 | })
78 | .catch((err) => {
79 | return Promise.reject(err);
80 | });
81 | }
82 |
83 | originalRequest._retry = true;
84 | isRefreshing = true;
85 |
86 | return new Promise(function (resolve, reject) {
87 | axiosInstance
88 | .post(`${BASE_URL}/auth/refresh`, {
89 | refreshToken: store.getState().auth.refreshToken,
90 | })
91 | .then((response) => {
92 | // get the accessToken
93 | const { accessToken } = response.data.data;
94 |
95 | store.dispatch(setAccessToken(accessToken));
96 |
97 | processQueue(null, accessToken);
98 | resolve(axiosInstance(originalRequest));
99 | })
100 | .catch((error) => {
101 | processQueue(error, null);
102 | reject(error);
103 | })
104 | .finally(() => {
105 | isRefreshing = false;
106 | });
107 | });
108 | }
109 |
110 | return Promise.reject(error);
111 | }
112 | );
113 |
114 | export default axiosInstance;
115 |
--------------------------------------------------------------------------------
/client/src/components/ModalComponents/RemoveMemberSpaceConfirmationModal.tsx:
--------------------------------------------------------------------------------
1 | import { AxiosError } from "axios";
2 | import React, { useCallback } from "react";
3 | import { useQueryClient } from "react-query";
4 | import { useDispatch } from "react-redux";
5 | import { useNavigate } from "react-router-dom";
6 | import axiosInstance from "../../axiosInstance";
7 | import { hideModal } from "../../redux/features/modalSlice";
8 | import { addToast } from "../../redux/features/toastSlice";
9 | import { ERROR, SUCCESS } from "../../types/constants";
10 |
11 | interface Props {
12 | spaceId: string;
13 | memberId: string;
14 | }
15 |
16 | const RemoveMemberSpaceConfirmationModal = ({ spaceId, memberId }: Props) => {
17 | const dispatch = useDispatch();
18 |
19 | const queryClient = useQueryClient();
20 |
21 | const navigate = useNavigate();
22 |
23 | const removeMember = useCallback((spaceId, memberId) => {
24 | dispatch(hideModal());
25 |
26 | axiosInstance
27 | .delete(`/spaces/${spaceId}/members/${memberId}`)
28 | .then((response) => {
29 | const { message } = response.data;
30 |
31 | dispatch(
32 | addToast({
33 | kind: SUCCESS,
34 | msg: message,
35 | })
36 | );
37 |
38 | queryClient.invalidateQueries(["getSpaceMembers", spaceId]);
39 | })
40 | .catch((error: AxiosError) => {
41 | if (error.response) {
42 | const response = error.response;
43 | const { message } = response.data;
44 |
45 | switch (response.status) {
46 | case 404:
47 | dispatch(addToast({ kind: ERROR, msg: message }));
48 | queryClient.invalidateQueries(["getSpaces"]);
49 | queryClient.invalidateQueries(["getFavorites"]);
50 | queryClient.invalidateQueries(["getRecentBoards"]);
51 | queryClient.invalidateQueries(["getAllMyCards"]);
52 | // redirect them to home page
53 | navigate("/", { replace: true });
54 | break;
55 | case 400:
56 | case 403:
57 | case 500:
58 | dispatch(addToast({ kind: ERROR, msg: message }));
59 | break;
60 | default:
61 | dispatch(
62 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
63 | );
64 | break;
65 | }
66 | } else if (error.request) {
67 | dispatch(
68 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
69 | );
70 | } else {
71 | dispatch(addToast({ kind: ERROR, msg: `Error: ${error.message}` }));
72 | }
73 | });
74 | }, []);
75 |
76 | return (
77 |
83 |
84 | The member will be removed from the space as well as from all the
85 | board(s) which he/she is a part of. But if he/she is the only{" "}
86 | admin of any board, then you will fill that space after
87 | removing him/her.
88 |
89 |
90 | dispatch(hideModal())}
92 | className="font-medium text-slate-600 mr-4"
93 | >
94 | Cancel
95 |
96 | removeMember(spaceId, memberId)}
98 | className="btn-danger"
99 | >
100 | Remove
101 |
102 |
103 |
104 | );
105 | };
106 |
107 | export default RemoveMemberSpaceConfirmationModal;
108 |
--------------------------------------------------------------------------------
/client/src/components/JoinBtn/JoinBtn.tsx:
--------------------------------------------------------------------------------
1 | import { AxiosError } from "axios";
2 | import React, { useCallback } from "react";
3 | import { useQueryClient } from "react-query";
4 | import { useDispatch } from "react-redux";
5 | import axiosInstance from "../../axiosInstance";
6 | import { addToast } from "../../redux/features/toastSlice";
7 | import { ERROR, SUCCESS } from "../../types/constants";
8 |
9 | interface Props {
10 | boardId: string;
11 | spaceId: string;
12 | }
13 |
14 | const JoinBtn = ({ boardId, spaceId }: Props) => {
15 | const dispatch = useDispatch();
16 |
17 | const queryClient = useQueryClient();
18 |
19 | const handleJoin = useCallback(
20 | (boardId, spaceId) => {
21 | axiosInstance
22 | .put(`/boards/${boardId}/members/join`)
23 | .then((response) => {
24 | const { message } = response.data;
25 |
26 | dispatch(
27 | addToast({
28 | kind: SUCCESS,
29 | msg: message,
30 | })
31 | );
32 |
33 | queryClient.invalidateQueries(["getBoard", boardId]);
34 | queryClient.invalidateQueries(["getSpaces"]);
35 | queryClient.invalidateQueries(["getFavorites"]);
36 |
37 | queryClient.invalidateQueries(["getSpaceBoards", spaceId]);
38 | queryClient.invalidateQueries(["getSpaceSettings", spaceId]);
39 | queryClient.invalidateQueries(["getSpaceMembers", spaceId]);
40 | })
41 | .catch((error: AxiosError) => {
42 | if (error.response) {
43 | const response = error.response;
44 | const { message } = response.data;
45 |
46 | switch (response.status) {
47 | case 403:
48 | dispatch(addToast({ kind: ERROR, msg: message }));
49 |
50 | queryClient.invalidateQueries(["getBoard", boardId]);
51 | queryClient.invalidateQueries(["getSpaces"]);
52 | queryClient.invalidateQueries(["getFavorites"]);
53 | break;
54 | case 404:
55 | dispatch(addToast({ kind: ERROR, msg: message }));
56 |
57 | queryClient.invalidateQueries(["getBoard", boardId]);
58 | queryClient.invalidateQueries(["getSpaces"]);
59 | queryClient.invalidateQueries(["getFavorites"]);
60 |
61 | queryClient.invalidateQueries(["getRecentBoards"]);
62 | queryClient.invalidateQueries(["getAllMyCards"]);
63 |
64 | queryClient.invalidateQueries(["getSpaceInfo", spaceId]);
65 | queryClient.invalidateQueries(["getSpaceBoards", spaceId]);
66 | queryClient.invalidateQueries(["getSpaceMembers", spaceId]);
67 | queryClient.invalidateQueries(["getSpaceSettings", spaceId]);
68 | break;
69 | case 400:
70 | case 500:
71 | dispatch(addToast({ kind: ERROR, msg: message }));
72 | break;
73 | default:
74 | dispatch(
75 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
76 | );
77 | break;
78 | }
79 | } else if (error.request) {
80 | dispatch(
81 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
82 | );
83 | } else {
84 | dispatch(addToast({ kind: ERROR, msg: `Error: ${error.message}` }));
85 | }
86 | });
87 | },
88 | [spaceId, boardId]
89 | );
90 |
91 | return (
92 | handleJoin(boardId, spaceId)}
94 | className="bg-slate-200 rounded px-3 py-1.5 flex items-center"
95 | >
96 | Join
97 |
98 | );
99 | };
100 |
101 | export default JoinBtn;
102 |
--------------------------------------------------------------------------------
/client/src/components/ModalComponents/LeaveSpaceConfirmationModal.tsx:
--------------------------------------------------------------------------------
1 | import { AxiosError } from "axios";
2 | import React, { useCallback } from "react";
3 | import { useQueryClient } from "react-query";
4 | import { useDispatch } from "react-redux";
5 | import { useNavigate } from "react-router-dom";
6 | import axiosInstance from "../../axiosInstance";
7 | import { hideModal } from "../../redux/features/modalSlice";
8 | import { addToast } from "../../redux/features/toastSlice";
9 | import { ERROR, SUCCESS } from "../../types/constants";
10 |
11 | interface Props {
12 | spaceId: string;
13 | }
14 |
15 | const LeaveSpaceConfirmationModal = ({ spaceId }: Props) => {
16 | const dispatch = useDispatch();
17 |
18 | const queryClient = useQueryClient();
19 |
20 | const navigate = useNavigate();
21 |
22 | const leaveFromSpace = useCallback((spaceId) => {
23 | dispatch(hideModal());
24 |
25 | axiosInstance
26 | .delete(`/spaces/${spaceId}/members`)
27 | .then((response) => {
28 | const { message } = response.data;
29 |
30 | dispatch(
31 | addToast({
32 | kind: SUCCESS,
33 | msg: message,
34 | })
35 | );
36 |
37 | queryClient.invalidateQueries(["getSpaces"]);
38 | queryClient.invalidateQueries(["getFavorites"]);
39 | queryClient.invalidateQueries(["getSpaceInfo", spaceId]);
40 | queryClient.invalidateQueries(["getSpaceMembers", spaceId]);
41 |
42 | queryClient.invalidateQueries(["getRecentBoards"]);
43 | queryClient.invalidateQueries(["getAllMyCards"]);
44 |
45 | navigate("/", { replace: true });
46 | })
47 | .catch((error: AxiosError) => {
48 | if (error.response) {
49 | const response = error.response;
50 | const { message } = response.data;
51 |
52 | switch (response.status) {
53 | case 404:
54 | dispatch(addToast({ kind: ERROR, msg: message }));
55 | queryClient.invalidateQueries(["getSpaces"]);
56 | queryClient.invalidateQueries(["getFavorites"]);
57 | queryClient.invalidateQueries(["getRecentBoards"]);
58 | queryClient.invalidateQueries(["getAllMyCards"]);
59 | // redirect them to home page
60 | navigate("/", { replace: true });
61 | break;
62 | case 400:
63 | case 403:
64 | case 500:
65 | dispatch(addToast({ kind: ERROR, msg: message }));
66 | break;
67 | default:
68 | dispatch(
69 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
70 | );
71 | break;
72 | }
73 | } else if (error.request) {
74 | dispatch(
75 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
76 | );
77 | } else {
78 | dispatch(addToast({ kind: ERROR, msg: `Error: ${error.message}` }));
79 | }
80 | });
81 | }, []);
82 |
83 | return (
84 |
90 |
91 | If you are a part of one or more boards, even if you leave, you will be
92 | retained as a Guest in this space till you manually
93 | leave from all those boards.
94 |
95 |
96 | dispatch(hideModal())}
98 | className="font-medium text-slate-600 mr-4"
99 | >
100 | Cancel
101 |
102 | leaveFromSpace(spaceId)} className="btn-danger">
103 | Leave
104 |
105 |
106 |
107 | );
108 | };
109 |
110 | export default LeaveSpaceConfirmationModal;
111 |
--------------------------------------------------------------------------------
/client/src/components/ModalComponents/RemoveMemberBoardConfirmationModal.tsx:
--------------------------------------------------------------------------------
1 | import { AxiosError } from "axios";
2 | import React, { useCallback } from "react";
3 | import { useQueryClient } from "react-query";
4 | import { useDispatch } from "react-redux";
5 | import { useNavigate } from "react-router-dom";
6 | import axiosInstance from "../../axiosInstance";
7 | import { hideModal } from "../../redux/features/modalSlice";
8 | import { addToast } from "../../redux/features/toastSlice";
9 | import { ERROR, SUCCESS } from "../../types/constants";
10 |
11 | interface Props {
12 | spaceId: string;
13 | boardId: string;
14 | memberId: string;
15 | }
16 |
17 | const RemoveMemberBoardConfirmationModal = ({
18 | spaceId,
19 | boardId,
20 | memberId,
21 | }: Props) => {
22 | const dispatch = useDispatch();
23 |
24 | const queryClient = useQueryClient();
25 |
26 | const navigate = useNavigate();
27 |
28 | const removeMember = useCallback((boardId, memberId, spaceId) => {
29 | dispatch(hideModal());
30 |
31 | axiosInstance
32 | .delete(`/boards/${boardId}/members/${memberId}`)
33 | .then((response) => {
34 | const { message } = response.data;
35 |
36 | dispatch(
37 | addToast({
38 | kind: SUCCESS,
39 | msg: message,
40 | })
41 | );
42 |
43 | queryClient.invalidateQueries(["getBoard", boardId]);
44 | })
45 | .catch((error: AxiosError) => {
46 | if (error.response) {
47 | const response = error.response;
48 | const { message } = response.data;
49 |
50 | switch (response.status) {
51 | case 403:
52 | dispatch(addToast({ kind: ERROR, msg: message }));
53 |
54 | queryClient.invalidateQueries(["getBoard", boardId]);
55 | queryClient.invalidateQueries(["getSpaces"]);
56 | queryClient.invalidateQueries(["getFavorites"]);
57 | break;
58 | case 404:
59 | dispatch(addToast({ kind: ERROR, msg: message }));
60 |
61 | queryClient.invalidateQueries(["getBoard", boardId]);
62 | queryClient.invalidateQueries(["getSpaces"]);
63 | queryClient.invalidateQueries(["getFavorites"]);
64 |
65 | queryClient.invalidateQueries(["getRecentBoards"]);
66 | queryClient.invalidateQueries(["getAllMyCards"]);
67 |
68 | queryClient.invalidateQueries(["getSpaceInfo", spaceId]);
69 | queryClient.invalidateQueries(["getSpaceBoards", spaceId]);
70 | queryClient.invalidateQueries(["getSpaceMembers", spaceId]);
71 | queryClient.invalidateQueries(["getSpaceSettings", spaceId]);
72 | break;
73 | case 400:
74 | case 500:
75 | dispatch(addToast({ kind: ERROR, msg: message }));
76 | break;
77 | default:
78 | dispatch(
79 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
80 | );
81 | break;
82 | }
83 | } else if (error.request) {
84 | dispatch(
85 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
86 | );
87 | } else {
88 | dispatch(addToast({ kind: ERROR, msg: `Error: ${error.message}` }));
89 | }
90 | });
91 | }, []);
92 |
93 | return (
94 |
100 |
101 | The member will be removed from the board as well as from all the
102 | card(s) which he/she is a part of.
103 |
104 |
105 | dispatch(hideModal())}
107 | className="font-medium text-slate-600 mr-4"
108 | >
109 | Cancel
110 |
111 | removeMember(boardId, memberId, spaceId)}
113 | className="btn-danger"
114 | >
115 | Remove
116 |
117 |
118 |
119 | );
120 | };
121 |
122 | export default RemoveMemberBoardConfirmationModal;
123 |
--------------------------------------------------------------------------------
/client/src/pages/ForgotPassword.tsx:
--------------------------------------------------------------------------------
1 | import { AxiosError } from "axios";
2 | import { Form, Formik } from "formik";
3 | import React, { useCallback, useState } from "react";
4 | import { useDispatch } from "react-redux";
5 | import { Link, useNavigate } from "react-router-dom";
6 | import * as Yup from "yup";
7 | import axiosInstance from "../axiosInstance";
8 | import Input from "../components/FormikComponents/Input";
9 | import SubmitBtn from "../components/FormikComponents/SubmitBtn";
10 | import { addToast } from "../redux/features/toastSlice";
11 | import { ERROR } from "../types/constants";
12 |
13 | interface EmailObj {
14 | email: string;
15 | }
16 |
17 | const ForgotPassword = () => {
18 | const navigate = useNavigate();
19 |
20 | const dispatch = useDispatch();
21 |
22 | const [isMsgScreen, setIsMsgScreen] = useState(false);
23 | const [isSubmitting, setIsSubmitting] = useState(false);
24 |
25 | const initialValues: EmailObj = {
26 | email: "",
27 | };
28 |
29 | const validationSchema = Yup.object({
30 | email: Yup.string().email("Invalid Email").required("Email is required"),
31 | });
32 |
33 | const handleSubmit = useCallback((emailObj: EmailObj) => {
34 | setIsSubmitting(true);
35 |
36 | axiosInstance
37 | .post(`/accounts/forgot-password`, emailObj, {
38 | headers: {
39 | ContentType: "application/json",
40 | },
41 | })
42 | .then((response) => {
43 | setIsSubmitting(false);
44 |
45 | // show message screen
46 | setIsMsgScreen(true);
47 | })
48 | .catch((error: AxiosError) => {
49 | setIsSubmitting(false);
50 |
51 | if (error.response) {
52 | const response = error.response;
53 | const { message } = response.data;
54 |
55 | switch (response.status) {
56 | case 400:
57 | case 500:
58 | dispatch(addToast({ kind: ERROR, msg: message }));
59 | break;
60 | default:
61 | dispatch(
62 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
63 | );
64 | break;
65 | }
66 | } else if (error.request) {
67 | dispatch(
68 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
69 | );
70 | } else {
71 | dispatch(addToast({ kind: ERROR, msg: `Error: ${error.message}` }));
72 | }
73 | });
74 | }, []);
75 |
76 | return !isMsgScreen ? (
77 | handleSubmit(values)}
81 | >
82 |
114 |
115 | ) : (
116 |
122 |
123 | If an account exists for the email address, you will get an email with
124 | instructions on resetting your password. If it doesn't arrive, be sure
125 | to check your spam folder.
126 |
127 |
128 | Back to Log in
129 |
130 |
131 | );
132 | };
133 |
134 | export default ForgotPassword;
135 |
--------------------------------------------------------------------------------
/client/src/pages/auth/Login.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState } from "react";
2 | import { Form, Formik } from "formik";
3 | import * as Yup from "yup";
4 | import Input from "../../components/FormikComponents/Input";
5 | import ErrorBox from "../../components/FormikComponents/ErrorBox";
6 | import SubmitBtn from "../../components/FormikComponents/SubmitBtn";
7 | import { Link } from "react-router-dom";
8 | import { useDispatch } from "react-redux";
9 | import { AxiosError } from "axios";
10 | import { loginUser } from "../../redux/features/authSlice";
11 | import GoogleAuthBtn from "../../components/GoogleAuth/GoogleAuthBtn";
12 | import axiosInstance from "../../axiosInstance";
13 |
14 | interface UserObj {
15 | email: string;
16 | password: string;
17 | }
18 |
19 | const Login = () => {
20 | const dispatch = useDispatch();
21 | const [isSubmitting, setIsSubmitting] = useState(false);
22 |
23 | const initalValues: UserObj = {
24 | email: "",
25 | password: "",
26 | };
27 | const [commonError, setCommonError] = useState("");
28 |
29 | const validationSchema = Yup.object({
30 | email: Yup.string().email("Invalid Email").required("Email is required"),
31 | password: Yup.string().required("Password is required"),
32 | });
33 |
34 | const handleSubmit = useCallback((user: UserObj) => {
35 | setIsSubmitting(true);
36 |
37 | axiosInstance
38 | .post(`/auth/login`, user, {
39 | headers: {
40 | ContentType: "application/json",
41 | },
42 | })
43 | .then((response) => {
44 | const { data } = response.data;
45 |
46 | setCommonError("");
47 |
48 | setIsSubmitting(false);
49 |
50 | dispatch(
51 | loginUser({
52 | accessToken: data.accessToken,
53 | refreshToken: data.refreshToken,
54 | })
55 | );
56 | })
57 | .catch((error: AxiosError) => {
58 | setIsSubmitting(false);
59 |
60 | if (error.response) {
61 | const response = error.response;
62 | const { message } = response.data;
63 |
64 | switch (response.status) {
65 | // bad request or invalid format or unauthorized
66 | case 400:
67 | case 401:
68 | case 500:
69 | setCommonError(message);
70 | break;
71 | default:
72 | setCommonError("Oops, something went wrong");
73 | break;
74 | }
75 | } else if (error.request) {
76 | setCommonError("Oops, something went wrong");
77 | } else {
78 | setCommonError(`Error: ${error.message}`);
79 | }
80 | });
81 | }, []);
82 |
83 | return (
84 | handleSubmit(values)}
88 | >
89 |
131 |
132 | );
133 | };
134 |
135 | export default Login;
136 |
--------------------------------------------------------------------------------
/client/src/utils/helpers.ts:
--------------------------------------------------------------------------------
1 | import TimeAgo from "javascript-time-ago";
2 | import jwtDecode, { JwtPayload } from "jwt-decode";
3 | import en from "javascript-time-ago/locale/en.json";
4 | import { DUE_DATE_STATUSES } from "../types/constants";
5 | import { isBefore } from "date-fns";
6 |
7 | export const checkTokens = (): boolean => {
8 | try {
9 | const refreshToken = localStorage.getItem("refreshToken");
10 | const accessToken = localStorage.getItem("accessToken");
11 |
12 | if (!refreshToken && !accessToken) {
13 | return false;
14 | }
15 |
16 | // first check, if you have a valid access_token
17 | if (accessToken) {
18 | // accessToken may be invalid, or expired, or no refreshToken or refreshToken present or refreshToken may be invalid
19 | try {
20 | // decode the token
21 | // invalid or malformed token will throw error
22 | const atoken = jwtDecode(accessToken as string);
23 | let exp = null;
24 |
25 | if (atoken && atoken?.exp) {
26 | exp = atoken.exp;
27 | }
28 |
29 | // if no exp date or expired exp date
30 | if (!exp || exp < new Date().getTime() / 1000) {
31 | // invalid accessToken
32 | // now check for refreshToken
33 | if (refreshToken) {
34 | const rtoken = jwtDecode(refreshToken as string);
35 | let exp = null;
36 |
37 | if (rtoken && rtoken?.exp) {
38 | exp = rtoken.exp;
39 | }
40 |
41 | // if no exp date or expired exp date
42 | if (!exp || exp < new Date().getTime() / 1000) {
43 | return false;
44 | }
45 | } else {
46 | return false;
47 | }
48 | }
49 | } catch {
50 | // invalid accessToken
51 | // now check for refreshToken
52 | if (refreshToken) {
53 | const rtoken = jwtDecode(refreshToken as string);
54 | let exp = null;
55 |
56 | if (rtoken && rtoken?.exp) {
57 | exp = rtoken.exp;
58 | }
59 |
60 | // if no exp date or expired exp date
61 | if (!exp || exp < new Date().getTime() / 1000) {
62 | return false;
63 | }
64 | } else {
65 | return false;
66 | }
67 | }
68 | } else {
69 | // we have refreshToken
70 | // check if refreshToken exists or not
71 | const rtoken = jwtDecode(refreshToken as string);
72 | let exp = null;
73 |
74 | if (rtoken && rtoken?.exp) {
75 | exp = rtoken.exp;
76 | }
77 |
78 | // if no exp date or expired exp date
79 | if (!exp || exp < new Date().getTime() / 1000) {
80 | return false;
81 | }
82 | }
83 |
84 | // valid token
85 | return true;
86 | } catch (e) {
87 | return false;
88 | }
89 | };
90 |
91 | export const getTokens = () => {
92 | // check if the user has a valid or a access_token refresh_token
93 | if (checkTokens()) {
94 | return {
95 | accessToken: localStorage.getItem("accessToken"),
96 | refreshToken: localStorage.getItem("refreshToken"),
97 | };
98 | }
99 |
100 | removeTokens();
101 | return {
102 | accessToken: null,
103 | refreshToken: null,
104 | };
105 | };
106 |
107 | export const saveTokens = (accessToken: string, refreshToken: string): void => {
108 | localStorage.setItem("accessToken", accessToken);
109 | localStorage.setItem("refreshToken", refreshToken);
110 | };
111 |
112 | // fn to save new access token
113 | export const saveAccessTokens = (accessToken: string): void => {
114 | localStorage.setItem("accessToken", accessToken);
115 | };
116 |
117 | // fn to remove tokens
118 | export const removeTokens = (): void => {
119 | localStorage.removeItem("accessToken");
120 | localStorage.removeItem("refreshToken");
121 | };
122 |
123 | // slice chars
124 | export const chopChars = (maxLength: number, text: string) => {
125 | return text.length > maxLength ? text.slice(0, maxLength) + "..." : text;
126 | };
127 |
128 | TimeAgo.addDefaultLocale(en);
129 |
130 | export const getDate = (date: string) => {
131 | const timeAgo = new TimeAgo("en-US");
132 |
133 | return timeAgo.format(new Date(date));
134 | };
135 |
136 | // get status
137 | export const getStatus = (date: string, isComplete: boolean) => {
138 | if (isComplete) {
139 | return DUE_DATE_STATUSES.COMPLETE;
140 | }
141 |
142 | if (date && isBefore(new Date(date), new Date())) {
143 | return DUE_DATE_STATUSES.OVERDUE;
144 | }
145 |
146 | return null;
147 | };
148 |
--------------------------------------------------------------------------------
/client/src/components/ModalComponents/DeleteSpaceConfirmationModal.tsx:
--------------------------------------------------------------------------------
1 | import { AxiosError } from "axios";
2 | import React, { useCallback } from "react";
3 | import { useQueryClient } from "react-query";
4 | import { useDispatch } from "react-redux";
5 | import { useNavigate } from "react-router-dom";
6 | import axiosInstance from "../../axiosInstance";
7 | import { hideModal } from "../../redux/features/modalSlice";
8 | import { addToast } from "../../redux/features/toastSlice";
9 | import { ERROR, SUCCESS } from "../../types/constants";
10 |
11 | interface Props {
12 | spaceId: string;
13 | memberId: string;
14 | }
15 |
16 | const DeleteSpaceConfirmationModal = ({ spaceId, memberId }: Props) => {
17 | const dispatch = useDispatch();
18 |
19 | const queryClient = useQueryClient();
20 |
21 | const navigate = useNavigate();
22 |
23 | const deleteSpace = useCallback((spaceId) => {
24 | axiosInstance
25 | .delete(`/spaces/${spaceId}`)
26 | .then((response) => {
27 | dispatch(hideModal());
28 |
29 | queryClient.invalidateQueries(["getFavorites"]);
30 | queryClient.invalidateQueries(["getSpaces"]);
31 |
32 | queryClient.removeQueries(["getSpaceInfo", spaceId]);
33 | queryClient.removeQueries(["getSpaceBoards", spaceId]);
34 | queryClient.invalidateQueries(["getRecentBoards"]);
35 | queryClient.invalidateQueries(["getAllMyCards"]);
36 | queryClient.removeQueries(["getSpaceSettings", spaceId]);
37 | queryClient.removeQueries(["getSpaceMembers", spaceId]);
38 |
39 | // redirect them to space boards page
40 | navigate(`/`, { replace: true });
41 | })
42 | .catch((error: AxiosError) => {
43 | dispatch(hideModal());
44 |
45 | if (error.response) {
46 | const response = error.response;
47 | const { message } = response.data;
48 |
49 | switch (response.status) {
50 | case 403:
51 | dispatch(addToast({ kind: ERROR, msg: message }));
52 |
53 | queryClient.invalidateQueries(["getSpaceInfo", spaceId]);
54 | queryClient.invalidateQueries(["getSpaceBoards", spaceId]);
55 | queryClient.invalidateQueries(["getSpaceSettings", spaceId]);
56 | queryClient.invalidateQueries(["getSpaceMembers", spaceId]);
57 | queryClient.invalidateQueries(["getSpaces"]);
58 | queryClient.invalidateQueries(["getFavorites"]);
59 | break;
60 | case 404:
61 | dispatch(addToast({ kind: ERROR, msg: message }));
62 |
63 | queryClient.invalidateQueries(["getSpaceInfo", spaceId]);
64 | queryClient.invalidateQueries(["getSpaces"]);
65 | queryClient.invalidateQueries(["getFavorites"]);
66 |
67 | queryClient.invalidateQueries(["getRecentBoards"]);
68 | queryClient.invalidateQueries(["getAllMyCards"]);
69 |
70 | queryClient.invalidateQueries(["getSpaceBoards", spaceId]);
71 | queryClient.invalidateQueries(["getSpaceSettings", spaceId]);
72 | queryClient.invalidateQueries(["getSpaceMembers", spaceId]);
73 | break;
74 | case 400:
75 | queryClient.invalidateQueries(["getSpaceInfo", spaceId]);
76 | dispatch(addToast({ kind: ERROR, msg: message }));
77 | break;
78 | case 500:
79 | dispatch(addToast({ kind: ERROR, msg: message }));
80 | break;
81 | default:
82 | dispatch(
83 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
84 | );
85 | break;
86 | }
87 | } else if (error.request) {
88 | dispatch(
89 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
90 | );
91 | } else {
92 | dispatch(addToast({ kind: ERROR, msg: `Error: ${error.message}` }));
93 | }
94 | });
95 | }, []);
96 |
97 | return (
98 |
104 |
This is permanent and can't be undone.
105 |
106 | dispatch(hideModal())}
108 | className="font-medium text-slate-600 mr-4"
109 | >
110 | Cancel
111 |
112 | deleteSpace(spaceId)} className="btn-danger">
113 | Delete
114 |
115 |
116 |
117 | );
118 | };
119 |
120 | export default DeleteSpaceConfirmationModal;
121 |
--------------------------------------------------------------------------------
/client/src/components/ModalComponents/DeleteBoardConfirmationModal.tsx:
--------------------------------------------------------------------------------
1 | import { AxiosError } from "axios";
2 | import React, { useCallback } from "react";
3 | import { useQueryClient } from "react-query";
4 | import { useDispatch } from "react-redux";
5 | import { useLocation, useNavigate } from "react-router-dom";
6 | import axiosInstance from "../../axiosInstance";
7 | import { hideModal } from "../../redux/features/modalSlice";
8 | import { addToast } from "../../redux/features/toastSlice";
9 | import { ERROR, SUCCESS } from "../../types/constants";
10 |
11 | interface Props {
12 | boardId: string;
13 | spaceId: string;
14 | }
15 |
16 | const DeleteBoardConfirmationModal = ({ boardId, spaceId }: Props) => {
17 | const dispatch = useDispatch();
18 |
19 | const queryClient = useQueryClient();
20 |
21 | const navigate = useNavigate();
22 |
23 | const { pathname } = useLocation();
24 |
25 | const deleteBoard = useCallback((boardId) => {
26 | axiosInstance
27 | .delete(`/boards/${boardId}`)
28 | .then((response) => {
29 | dispatch(hideModal());
30 |
31 | queryClient.invalidateQueries(["getRecentBoards"]);
32 | queryClient.invalidateQueries(["getAllMyCards"]);
33 |
34 | queryClient.removeQueries(["getBoard", boardId]);
35 | queryClient.refetchQueries(["getLists", boardId]);
36 | queryClient.invalidateQueries(["getFavorites"]);
37 | queryClient.invalidateQueries(["getSpaces"]);
38 | queryClient.invalidateQueries(["getSpaceBoards", spaceId]);
39 |
40 | // redirect them to space boards page
41 | if (pathname === `/b/${boardId}`) {
42 | navigate(`/s/${spaceId}/boards`, { replace: true });
43 | }
44 | })
45 | .catch((error: AxiosError) => {
46 | dispatch(hideModal());
47 |
48 | if (error.response) {
49 | const response = error.response;
50 | const { message } = response.data;
51 |
52 | switch (response.status) {
53 | case 403:
54 | dispatch(addToast({ kind: ERROR, msg: message }));
55 |
56 | queryClient.invalidateQueries(["getBoard", boardId]);
57 | queryClient.invalidateQueries(["getLists", boardId]);
58 | queryClient.invalidateQueries(["getSpaces"]);
59 | queryClient.invalidateQueries(["getFavorites"]);
60 | break;
61 | case 404:
62 | dispatch(addToast({ kind: ERROR, msg: message }));
63 |
64 | queryClient.invalidateQueries(["getBoard", boardId]);
65 | queryClient.invalidateQueries(["getLists", boardId]);
66 | queryClient.invalidateQueries(["getSpaces"]);
67 | queryClient.invalidateQueries(["getFavorites"]);
68 |
69 | queryClient.invalidateQueries(["getRecentBoards"]);
70 | queryClient.invalidateQueries(["getAllMyCards"]);
71 |
72 | queryClient.invalidateQueries(["getSpaceBoards", spaceId]);
73 | queryClient.invalidateQueries(["getSpaceSettings", spaceId]);
74 | queryClient.invalidateQueries(["getSpaceMembers", spaceId]);
75 | break;
76 | case 400:
77 | queryClient.invalidateQueries(["getBoard", boardId]);
78 | dispatch(addToast({ kind: ERROR, msg: message }));
79 | break;
80 | case 500:
81 | dispatch(addToast({ kind: ERROR, msg: message }));
82 | break;
83 | default:
84 | dispatch(
85 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
86 | );
87 | break;
88 | }
89 | } else if (error.request) {
90 | dispatch(
91 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
92 | );
93 | } else {
94 | dispatch(addToast({ kind: ERROR, msg: `Error: ${error.message}` }));
95 | }
96 | });
97 | }, [spaceId]);
98 |
99 | return (
100 |
106 |
107 | All lists & cards will be deleted. There is no undo.
108 |
109 |
110 | dispatch(hideModal())}
112 | className="font-medium text-slate-600 mr-4"
113 | >
114 | Cancel
115 |
116 | deleteBoard(boardId)} className="btn-danger">
117 | Delete
118 |
119 |
120 |
121 | );
122 | };
123 |
124 | export default DeleteBoardConfirmationModal;
125 |
--------------------------------------------------------------------------------
/client/src/components/ModalComponents/LeaveBoardConfirmationModal.tsx:
--------------------------------------------------------------------------------
1 | import { AxiosError } from "axios";
2 | import React, { useCallback } from "react";
3 | import { useQueryClient } from "react-query";
4 | import { useDispatch } from "react-redux";
5 | import { useNavigate } from "react-router-dom";
6 | import axiosInstance from "../../axiosInstance";
7 | import { hideModal } from "../../redux/features/modalSlice";
8 | import { addToast } from "../../redux/features/toastSlice";
9 | import { ERROR, SUCCESS } from "../../types/constants";
10 |
11 | interface Props {
12 | spaceId: string;
13 | boardId: string;
14 | }
15 |
16 | const LeaveBoardConfirmationModal = ({ spaceId, boardId }: Props) => {
17 | const dispatch = useDispatch();
18 |
19 | const queryClient = useQueryClient();
20 |
21 | const navigate = useNavigate();
22 |
23 | const leaveFromSpace = useCallback((boardId, spaceId) => {
24 | dispatch(hideModal());
25 |
26 | axiosInstance
27 | .delete(`/boards/${boardId}/members`)
28 | .then((response) => {
29 | const { message, data } = response.data;
30 |
31 | dispatch(
32 | addToast({
33 | kind: SUCCESS,
34 | msg: message,
35 | })
36 | );
37 |
38 | queryClient.invalidateQueries(["getBoard", boardId]);
39 | queryClient.invalidateQueries(["getSpaces"]);
40 | queryClient.invalidateQueries(["getFavorites"]);
41 |
42 | queryClient.invalidateQueries(["getSpaceInfo", spaceId]);
43 | queryClient.invalidateQueries(["getSpaceBoards", spaceId]);
44 | queryClient.invalidateQueries(["getSpaceMembers", spaceId]);
45 | queryClient.invalidateQueries(["getSpaceSettings", spaceId]);
46 |
47 | queryClient.invalidateQueries(["getRecentBoards"]);
48 | queryClient.invalidateQueries(["getAllMyCards"]);
49 |
50 | if (!data.isSpacePart) {
51 | navigate(`/`, { replace: true });
52 | }
53 | })
54 | .catch((error: AxiosError) => {
55 | if (error.response) {
56 | const response = error.response;
57 | const { message } = response.data;
58 |
59 | switch (response.status) {
60 | case 403:
61 | dispatch(addToast({ kind: ERROR, msg: message }));
62 |
63 | queryClient.invalidateQueries(["getBoard", boardId]);
64 | queryClient.invalidateQueries(["getSpaces"]);
65 | queryClient.invalidateQueries(["getFavorites"]);
66 | break;
67 | case 404:
68 | dispatch(addToast({ kind: ERROR, msg: message }));
69 |
70 | queryClient.invalidateQueries(["getBoard", boardId]);
71 | queryClient.invalidateQueries(["getSpaces"]);
72 | queryClient.invalidateQueries(["getFavorites"]);
73 |
74 | queryClient.invalidateQueries(["getRecentBoards"]);
75 | queryClient.invalidateQueries(["getAllMyCards"]);
76 |
77 | queryClient.invalidateQueries(["getSpaceInfo", spaceId]);
78 | queryClient.invalidateQueries(["getSpaceBoards", spaceId]);
79 | queryClient.invalidateQueries(["getSpaceMembers", spaceId]);
80 | queryClient.invalidateQueries(["getSpaceSettings", spaceId]);
81 | break;
82 | case 400:
83 | case 500:
84 | dispatch(addToast({ kind: ERROR, msg: message }));
85 | break;
86 | default:
87 | dispatch(
88 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
89 | );
90 | break;
91 | }
92 | } else if (error.request) {
93 | dispatch(
94 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
95 | );
96 | } else {
97 | dispatch(addToast({ kind: ERROR, msg: `Error: ${error.message}` }));
98 | }
99 | });
100 | }, []);
101 |
102 | return (
103 |
109 |
110 | You will be removed from all the cards on this board.
111 |
112 |
113 | dispatch(hideModal())}
115 | className="font-medium text-slate-600 mr-4"
116 | >
117 | Cancel
118 |
119 | leaveFromSpace(boardId, spaceId)}
121 | className="btn-danger"
122 | >
123 | Leave
124 |
125 |
126 |
127 | );
128 | };
129 |
130 | export default LeaveBoardConfirmationModal;
131 |
--------------------------------------------------------------------------------
/client/src/components/BoardMenu/ChangeBgMenu.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState } from "react";
2 | import BoardBackground from "../FormikComponents/BoardBackground";
3 | import * as Yup from "yup";
4 | import { Form, Formik } from "formik";
5 | import SubmitBtn from "../FormikComponents/SubmitBtn";
6 | import axiosInstance from "../../axiosInstance";
7 | import { useQueryClient } from "react-query";
8 | import { useDispatch } from "react-redux";
9 | import { addToast } from "../../redux/features/toastSlice";
10 | import { ERROR, SUCCESS } from "../../types/constants";
11 | import { AxiosError } from "axios";
12 |
13 | interface BoardBGObj {
14 | bgImg: string;
15 | color: string;
16 | }
17 |
18 | interface Props {
19 | spaceId: string;
20 | boardId: string;
21 | }
22 |
23 | const ChangeBgMenu = ({ spaceId, boardId }: Props) => {
24 | const dispatch = useDispatch();
25 | const queryClient = useQueryClient();
26 |
27 | const initialValues: BoardBGObj = {
28 | bgImg: "",
29 | color: "",
30 | };
31 | const validationSchema = Yup.object({
32 | bgImg: Yup.string(),
33 | color: Yup.string()
34 | .matches(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, "Invalid color hex code")
35 | .required("Color is required"),
36 | });
37 |
38 | const [isSubmitting, setIsSubmitting] = useState(false);
39 |
40 | const handleSubmit = useCallback(
41 | (boardBg: BoardBGObj) => {
42 | setIsSubmitting(true);
43 |
44 | axiosInstance
45 | .put(
46 | `/boards/${boardId}/background`,
47 | {
48 | ...boardBg,
49 | },
50 | {
51 | headers: {
52 | ContentType: "application/json",
53 | },
54 | }
55 | )
56 | .then((response) => {
57 | const { message } = response.data;
58 |
59 | setIsSubmitting(false);
60 |
61 | queryClient.invalidateQueries(["getRecentBoards"]);
62 |
63 | queryClient.invalidateQueries(["getBoard", boardId]);
64 | queryClient.invalidateQueries(["getSpaces"]);
65 | queryClient.invalidateQueries(["getFavorites"]);
66 | })
67 | .catch((error: AxiosError) => {
68 | setIsSubmitting(false);
69 |
70 | if (error.response) {
71 | const response = error.response;
72 | const { message } = response.data;
73 |
74 | switch (response.status) {
75 | case 403:
76 | dispatch(addToast({ kind: ERROR, msg: message }));
77 |
78 | queryClient.invalidateQueries(["getBoard", boardId]);
79 | queryClient.invalidateQueries(["getSpaces"]);
80 | queryClient.invalidateQueries(["getFavorites"]);
81 | break;
82 | case 404:
83 | dispatch(addToast({ kind: ERROR, msg: message }));
84 |
85 | queryClient.invalidateQueries(["getBoard", boardId]);
86 | queryClient.invalidateQueries(["getSpaces"]);
87 | queryClient.invalidateQueries(["getFavorites"]);
88 |
89 | queryClient.invalidateQueries(["getRecentBoards"]);
90 | queryClient.invalidateQueries(["getAllMyCards"]);
91 |
92 | queryClient.invalidateQueries(["getSpaceInfo", spaceId]);
93 | queryClient.invalidateQueries(["getSpaceBoards", spaceId]);
94 | queryClient.invalidateQueries(["getSpaceMembers", spaceId]);
95 | queryClient.invalidateQueries(["getSpaceSettings", spaceId]);
96 | break;
97 | case 400:
98 | case 500:
99 | dispatch(addToast({ kind: ERROR, msg: message }));
100 | break;
101 | default:
102 | dispatch(
103 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
104 | );
105 | break;
106 | }
107 | } else if (error.request) {
108 | dispatch(
109 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
110 | );
111 | } else {
112 | dispatch(addToast({ kind: ERROR, msg: `Error: ${error.message}` }));
113 | }
114 | });
115 | },
116 | [boardId, spaceId]
117 | );
118 |
119 | return (
120 | handleSubmit(values)}
124 | >
125 |
130 |
131 | );
132 | };
133 |
134 | export default ChangeBgMenu;
135 |
--------------------------------------------------------------------------------
/client/src/components/BoardLists/ListName.tsx:
--------------------------------------------------------------------------------
1 | import { AxiosError } from "axios";
2 | import debounce from "debounce-promise";
3 | import React, { useCallback, useState } from "react";
4 | import { useQueryClient } from "react-query";
5 | import { useDispatch } from "react-redux";
6 | import { useNavigate } from "react-router-dom";
7 | import axiosInstance from "../../axiosInstance";
8 | import { addToast } from "../../redux/features/toastSlice";
9 | import { BoardObj, FavoriteObj, SpaceObj } from "../../types";
10 | import { ERROR } from "../../types/constants";
11 |
12 | interface Props {
13 | listId: string;
14 | boardId: string;
15 | spaceId: string;
16 | initialValue: string;
17 | }
18 |
19 | const ListName = ({ boardId, spaceId, listId, initialValue }: Props) => {
20 | const dispatch = useDispatch();
21 |
22 | const queryClient = useQueryClient();
23 |
24 | const navigate = useNavigate();
25 |
26 | const [name, setName] = useState(initialValue);
27 | const [lastVal, setLastVal] = useState(initialValue);
28 |
29 | const updateName = debounce(
30 | (newName, boardId) =>
31 | axiosInstance
32 | .put(
33 | `/lists/${listId}/name`,
34 | {
35 | name: newName,
36 | },
37 | {
38 | headers: {
39 | ContentType: "application/json",
40 | },
41 | }
42 | )
43 | .then((response) => {
44 | queryClient.setQueryData(["getLists", boardId], (oldValue: any) => {
45 | return {
46 | ...oldValue,
47 | lists: oldValue.lists.map((l: any) => {
48 | if (l._id === listId) {
49 | return {
50 | ...l,
51 | name: newName,
52 | };
53 | }
54 | return l;
55 | }),
56 | };
57 | });
58 | })
59 | .catch((error: AxiosError) => {
60 | if (error.response) {
61 | const response = error.response;
62 | const { message } = response.data;
63 |
64 | switch (response.status) {
65 | case 403:
66 | dispatch(addToast({ kind: ERROR, msg: message }));
67 |
68 | queryClient.invalidateQueries(["getBoard", boardId]);
69 | queryClient.invalidateQueries(["getLists", boardId]);
70 | queryClient.invalidateQueries(["getSpaces"]);
71 | queryClient.invalidateQueries(["getFavorites"]);
72 | break;
73 | case 404:
74 | dispatch(addToast({ kind: ERROR, msg: message }));
75 |
76 | queryClient.invalidateQueries(["getBoard", boardId]);
77 | queryClient.invalidateQueries(["getLists", boardId]);
78 | queryClient.invalidateQueries(["getSpaces"]);
79 | queryClient.invalidateQueries(["getFavorites"]);
80 |
81 | queryClient.invalidateQueries(["getRecentBoards"]);
82 | queryClient.invalidateQueries(["getAllMyCards"]);
83 |
84 | queryClient.invalidateQueries(["getSpaceBoards", spaceId]);
85 | queryClient.invalidateQueries(["getSpaceSettings", spaceId]);
86 | queryClient.invalidateQueries(["getSpaceMembers", spaceId]);
87 | break;
88 | case 400:
89 | case 500:
90 | dispatch(addToast({ kind: ERROR, msg: message }));
91 | break;
92 | default:
93 | dispatch(
94 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
95 | );
96 | break;
97 | }
98 | } else if (error.request) {
99 | dispatch(
100 | addToast({ kind: ERROR, msg: "Oops, something went wrong" })
101 | );
102 | } else {
103 | dispatch(addToast({ kind: ERROR, msg: `Error: ${error.message}` }));
104 | }
105 | }),
106 | 500
107 | );
108 |
109 | const handleChange = (e: React.ChangeEvent) => {
110 | const value = e.target.value;
111 | setName(e.target.value);
112 |
113 | if (value !== "") {
114 | setLastVal(e.target.value);
115 | updateName(e.target.value.trim(), boardId);
116 | }
117 | };
118 |
119 | const handleBlur = () => {
120 | if (name === "") {
121 | setName(lastVal);
122 | }
123 | };
124 |
125 | return (
126 | handleChange(e)}
129 | value={name}
130 | onBlur={handleBlur}
131 | />
132 | );
133 | };
134 |
135 | export default ListName;
136 |
--------------------------------------------------------------------------------
/client/src/pages/auth/Register.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState } from "react";
2 | import * as Yup from "yup";
3 | import { useDispatch } from "react-redux";
4 | import { Link } from "react-router-dom";
5 | import SubmitBtn from "../../components/FormikComponents/SubmitBtn";
6 | import ErrorBox from "../../components/FormikComponents/ErrorBox";
7 | import Input from "../../components/FormikComponents/Input";
8 | import { Form, Formik } from "formik";
9 | import { AxiosError } from "axios";
10 | import { loginUser, setEmailVerified } from "../../redux/features/authSlice";
11 | import GoogleAuthBtn from "../../components/GoogleAuth/GoogleAuthBtn";
12 | import axiosInstance from "../../axiosInstance";
13 |
14 | interface UserObj {
15 | username: string;
16 | email: string;
17 | password: string;
18 | }
19 |
20 | const Register = () => {
21 | const dispatch = useDispatch();
22 |
23 | const [isSubmitting, setIsSubmitting] = useState(false);
24 |
25 | const initialValues: UserObj = {
26 | username: "",
27 | email: "",
28 | password: "",
29 | };
30 | const [commonError, setCommonError] = useState("");
31 |
32 | const validationSchema = Yup.object({
33 | username: Yup.string()
34 | .min(2, "Username should be at least 2 chars long")
35 | .matches(
36 | /^[A-Za-z0-9_-]*$/,
37 | "Username must only contain letters, numbers, underscores and dashes"
38 | )
39 | .required("Username is required"),
40 | email: Yup.string().email("Invalid Email").required("Email is required"),
41 | password: Yup.string()
42 | .min(8, "Password should be min 8 chars long")
43 | .matches(/\d/, "Password must contain at least one number")
44 | .matches(/[a-zA-Z]/, "Password must contain at least one letter")
45 | .required("Password is required"),
46 | });
47 |
48 | // register user
49 | const handleSubmit = useCallback((user: UserObj) => {
50 | setIsSubmitting(true);
51 |
52 | axiosInstance
53 | .post(`/auth/register`, user, {
54 | headers: {
55 | ContentType: "application/json",
56 | },
57 | })
58 | .then((response) => {
59 | const { data } = response.data;
60 |
61 | setCommonError("");
62 |
63 | setIsSubmitting(false);
64 |
65 | dispatch(
66 | loginUser({
67 | accessToken: data.accessToken,
68 | refreshToken: data.refreshToken,
69 | })
70 | );
71 | })
72 | .catch((error: AxiosError) => {
73 | setIsSubmitting(false);
74 |
75 | if (error.response) {
76 | const response = error.response;
77 | const { message } = response.data;
78 |
79 | switch (response.status) {
80 | // bad request or invalid format or unauthorized
81 | case 400:
82 | case 409:
83 | case 500:
84 | setCommonError(message);
85 | break;
86 | default:
87 | setCommonError("Oops, something went wrong");
88 | break;
89 | }
90 | } else if (error.request) {
91 | setCommonError("Oops, something went wrong");
92 | } else {
93 | setCommonError(`Error: ${error.message}`);
94 | }
95 | });
96 | }, []);
97 |
98 | return (
99 | handleSubmit(values)}
103 | >
104 |
140 |
141 | );
142 | };
143 |
144 | export default Register;
145 |
--------------------------------------------------------------------------------
/client/src/components/BoardMenu/BoardMenu.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import { MdChevronLeft, MdClose } from "react-icons/md";
3 | import { useDispatch } from "react-redux";
4 | import { showModal } from "../../redux/features/modalSlice";
5 | import { BOARD_ROLES, CONFIRM_DELETE_BOARD_MODAL } from "../../types/constants";
6 | import AboutMenu from "./AboutMenu";
7 | import ChangeBgMenu from "./ChangeBgMenu";
8 | import LabelMenu from "./LabelMenu";
9 |
10 | interface Props {
11 | description: string;
12 | spaceId: string;
13 | boardId: string;
14 | myRole:
15 | | typeof BOARD_ROLES.ADMIN
16 | | typeof BOARD_ROLES.NORMAL
17 | | typeof BOARD_ROLES.OBSERVER;
18 | setShowMenu: React.Dispatch>;
19 | }
20 |
21 | const BoardMenu = ({
22 | description,
23 | spaceId,
24 | boardId,
25 | setShowMenu,
26 | myRole,
27 | }: Props) => {
28 | const dispatch = useDispatch();
29 |
30 | const [showOption, setShowOption] = useState(false);
31 | const [currentOption, setCurrentOption] = useState(null);
32 |
33 | const [component, setCurrentComponent] = useState(null);
34 |
35 | useEffect(() => {
36 | switch (currentOption) {
37 | case "About":
38 | setShowOption(true);
39 | setCurrentComponent(
40 |
46 | );
47 | break;
48 | case "ChangeBG":
49 | setShowOption(true);
50 | setCurrentComponent(
51 |
52 | );
53 | break;
54 | case "Labels":
55 | setShowOption(true);
56 | setCurrentComponent(
57 |
58 | );
59 | break;
60 | default:
61 | setCurrentComponent(null);
62 | }
63 | }, [currentOption]);
64 |
65 | return (
66 |
67 |
68 | {showOption ? (
69 | {
72 | setCurrentOption(null);
73 | setShowOption(false);
74 | }}
75 | type="button"
76 | role="close-dropdown-options"
77 | >
78 |
79 |
80 | ) : (
81 |
82 | )}
83 | Menu
84 | {
86 | setCurrentOption(null);
87 | setShowOption(false);
88 | setShowMenu(false);
89 | }}
90 | type="button"
91 | role="close-dropdown-options"
92 | className="flex-1 flex justify-end"
93 | >
94 |
95 |
96 |
97 |
98 | {!showOption ? (
99 |
100 |
101 | setCurrentOption("About")}
103 | className="w-full px-3 py-3 text-left hover:bg-slate-200"
104 | >
105 | About this board
106 |
107 |
108 | {[BOARD_ROLES.ADMIN, BOARD_ROLES.NORMAL].includes(myRole) && (
109 |
110 | setCurrentOption("ChangeBG")}
112 | className="w-full px-3 py-3 text-left hover:bg-slate-200"
113 | >
114 | Change background
115 |
116 |
117 | )}
118 |
119 | setCurrentOption("Labels")}
121 | className="w-full px-3 py-3 text-left hover:bg-slate-200"
122 | >
123 | Labels
124 |
125 |
126 |
127 | {myRole === BOARD_ROLES.ADMIN && (
128 |
129 |
132 | dispatch(
133 | showModal({
134 | modalType: CONFIRM_DELETE_BOARD_MODAL,
135 | modalProps: {
136 | boardId,
137 | spaceId,
138 | },
139 | modalTitle: "Delete board?",
140 | })
141 | )
142 | }
143 | >
144 | Delete board
145 |
146 |
147 | )}
148 |
149 | ) : (
150 |
{component && component}
151 | )}
152 |
153 | );
154 | };
155 |
156 | export default BoardMenu;
157 |
--------------------------------------------------------------------------------