├── client ├── .dockerignore ├── src │ ├── vite-env.d.ts │ ├── favicon.png │ ├── assets │ │ ├── astronaut.png │ │ └── icons │ │ │ └── admin.png │ ├── components │ │ ├── Options │ │ │ ├── OptionsHR.tsx │ │ │ ├── OptionsItem.tsx │ │ │ └── Options.tsx │ │ ├── HorizontalSeparator │ │ │ └── HorizontalSeparator.tsx │ │ ├── FormikComponents │ │ │ ├── ErrorBox.tsx │ │ │ ├── SubmitBtn.tsx │ │ │ ├── NextBtn.tsx │ │ │ ├── ColorLabel.tsx │ │ │ ├── TextArea.tsx │ │ │ ├── Input.tsx │ │ │ ├── Select.tsx │ │ │ ├── SelectDropDownAsync.tsx │ │ │ ├── FileInput.tsx │ │ │ └── RemoteSelect.tsx │ │ ├── CustomReactToolTip │ │ │ └── CustomReactToolTip.tsx │ │ ├── Logo │ │ │ └── Logo.tsx │ │ ├── Icon │ │ │ └── Icon.tsx │ │ ├── Error │ │ │ └── Error.tsx │ │ ├── layouts │ │ │ ├── AuthLayout.tsx │ │ │ ├── MainLayout.tsx │ │ │ └── DefaultLayout.tsx │ │ ├── Skeletons │ │ │ └── CardDetailSkeleton.tsx │ │ ├── CustomOption │ │ │ └── CustomOption.tsx │ │ ├── CardDetail │ │ │ └── DueDateStatus.tsx │ │ ├── Loader │ │ │ └── Loader.tsx │ │ ├── Sidebar │ │ │ ├── SidebarLink.tsx │ │ │ ├── SpaceList │ │ │ │ ├── BoardList.tsx │ │ │ │ └── SpaceList.tsx │ │ │ └── FavoritesList │ │ │ │ └── FavoritesList.tsx │ │ ├── Header │ │ │ ├── Header.tsx │ │ │ └── ProfileCard.tsx │ │ ├── Profile │ │ │ └── Profile.tsx │ │ ├── CustomNavLink │ │ │ └── CustomNavLink.tsx │ │ ├── ErrorCard │ │ │ └── ErrorCard.tsx │ │ ├── ErrorBoardLists │ │ │ └── ErrorBoardLists.tsx │ │ ├── BoardMembers │ │ │ └── BoardMembers.tsx │ │ ├── Toasts │ │ │ └── Toasts.tsx │ │ ├── UtilityBtn │ │ │ └── UtilityBtn.tsx │ │ ├── RecentBoards │ │ │ ├── RecentBoard.tsx │ │ │ └── RecentBoards.tsx │ │ ├── GoogleAuth │ │ │ └── GoogleAuthBtn.tsx │ │ ├── MyCards │ │ │ └── MyCards.tsx │ │ ├── BoardLists │ │ │ ├── ListDummy.tsx │ │ │ └── ListName.tsx │ │ ├── ModalComponents │ │ │ ├── RemoveMemberSpaceConfirmationModal.tsx │ │ │ ├── LeaveSpaceConfirmationModal.tsx │ │ │ ├── RemoveMemberBoardConfirmationModal.tsx │ │ │ ├── DeleteSpaceConfirmationModal.tsx │ │ │ ├── DeleteBoardConfirmationModal.tsx │ │ │ └── LeaveBoardConfirmationModal.tsx │ │ ├── JoinBtn │ │ │ └── JoinBtn.tsx │ │ └── BoardMenu │ │ │ ├── ChangeBgMenu.tsx │ │ │ └── BoardMenu.tsx │ ├── config.ts │ ├── main.tsx │ ├── hooks │ │ ├── useEnter.ts │ │ ├── useEscClose.ts │ │ └── useClose.ts │ ├── redux │ │ ├── features │ │ │ ├── sidebarSlice.ts │ │ │ ├── spaceMenu.ts │ │ │ ├── sidebarMenu.ts │ │ │ ├── toastSlice.ts │ │ │ ├── modalSlice.ts │ │ │ └── authSlice.ts │ │ └── app │ │ │ └── index.ts │ ├── PrivateRoute.tsx │ ├── pages │ │ ├── Home.tsx │ │ ├── Error404.tsx │ │ ├── EmailVerify.tsx │ │ ├── spaces │ │ │ └── SpaceBoards.tsx │ │ ├── EmailNotVerified.tsx │ │ ├── ForgotPassword.tsx │ │ └── auth │ │ │ ├── Login.tsx │ │ │ └── Register.tsx │ ├── utils │ │ ├── lexorank.ts │ │ └── helpers.ts │ ├── types │ │ └── constants.ts │ ├── App.tsx │ └── axiosInstance.ts ├── postcss.config.js ├── Dockerfile ├── vite.config.ts ├── index.html ├── tailwind.config.js ├── tsconfig.json └── package.json ├── server ├── .dockerignore ├── types │ ├── index.ts │ └── constants.ts ├── public │ └── profiles │ │ └── default.jpg ├── Dockerfile ├── tsconfig.json ├── utils │ ├── db.ts │ ├── uniqueUsernameGen.ts │ ├── multerConfig.ts │ ├── file.ts │ ├── helpers.ts │ ├── token.ts │ └── lexorank.ts ├── models │ ├── refreshTokens.model.ts │ ├── recentBoards.model.ts │ ├── favorite.model.ts │ ├── comment.model.ts │ ├── forgotPassword.model.ts │ ├── emailVerification.model..ts │ ├── label.model.ts │ ├── list.model.ts │ ├── space.model.ts │ ├── card.model.ts │ ├── user.model.ts │ └── board.model.ts ├── routes │ ├── email.route.ts │ ├── auth.route.ts │ ├── account.route.ts │ ├── favorite.route.ts │ ├── index.ts │ ├── list.route.ts │ ├── user.route.ts │ ├── space.route.ts │ ├── board.route.ts │ └── card.route.ts ├── app.ts ├── config.ts ├── middlewares │ ├── multerUploadSingle.ts │ └── auth.ts └── package.json ├── docker-compose.yml └── .gitignore /client/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /server/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface UserTokenObj { 2 | _id: string; 3 | } -------------------------------------------------------------------------------- /client/src/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-coding-pie/workflow/HEAD/client/src/favicon.png -------------------------------------------------------------------------------- /client/src/assets/astronaut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-coding-pie/workflow/HEAD/client/src/assets/astronaut.png -------------------------------------------------------------------------------- /client/src/assets/icons/admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-coding-pie/workflow/HEAD/client/src/assets/icons/admin.png -------------------------------------------------------------------------------- /server/public/profiles/default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-coding-pie/workflow/HEAD/server/public/profiles/default.jpg -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:17-alpine 2 | 3 | WORKDIR /client 4 | 5 | COPY package.json . 6 | 7 | RUN yarn install 8 | 9 | COPY . . 10 | 11 | EXPOSE 3000 12 | 13 | CMD ["yarn", "dev"] -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:17-alpine 2 | 3 | WORKDIR /server 4 | 5 | COPY package.json . 6 | 7 | RUN yarn install 8 | 9 | COPY . . 10 | 11 | EXPOSE 8000 12 | 13 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /client/src/components/Options/OptionsHR.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const OptionsHR = () => { 4 | return ( 5 |
6 | ) 7 | } 8 | 9 | export default OptionsHR 10 | -------------------------------------------------------------------------------- /client/src/config.ts: -------------------------------------------------------------------------------- 1 | export const BASE_URL = "http://localhost:8000/api/v1"; 2 | export const UNSPLASH_URL = "https://api.unsplash.com"; 3 | 4 | // FORGOT PASSWORD TOKEN LENGTH 5 | export const FORGOT_PASSWORD_TOKEN_LENGTH = 124; 6 | -------------------------------------------------------------------------------- /client/src/components/HorizontalSeparator/HorizontalSeparator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const HorizontalSeparator = () => { 4 | return
|
; 5 | }; 6 | 7 | export default HorizontalSeparator; 8 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | host: true 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /client/src/components/FormikComponents/ErrorBox.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | msg: string; 5 | } 6 | 7 | const ErrorBox = ({ msg }: Props) => { 8 | return

{msg}

; 9 | }; 10 | 11 | export default ErrorBox; 12 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": ["es6"], 6 | "allowJs": true, 7 | "outDir": "dist", 8 | "rootDir": "./", 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "esModuleInterop": true 12 | }, 13 | "exclude": ["./node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /client/src/components/CustomReactToolTip/CustomReactToolTip.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactTooltip, { Place } from "react-tooltip"; 3 | 4 | interface Props { 5 | id: string; 6 | place?: Place; 7 | } 8 | 9 | const CustomReactToolTip = ({ id, place }: Props) => { 10 | return ; 11 | }; 12 | 13 | export default CustomReactToolTip; 14 | -------------------------------------------------------------------------------- /server/utils/db.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | // connect to db 4 | export const connectDB = async () => { 5 | try { 6 | const connection = await mongoose.connect(process.env.MONGO_URI!); 7 | 8 | console.log(`MongoDB Connected: ${connection.connection.host}`); 9 | } catch (err: any) { 10 | console.log(`Error: ${err.message}`); 11 | 12 | process.exit(1); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Workflow 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /server/utils/uniqueUsernameGen.ts: -------------------------------------------------------------------------------- 1 | import { 2 | adjectives, 3 | names, 4 | starWars, 5 | colors, 6 | uniqueNamesGenerator, 7 | } from "unique-names-generator"; 8 | 9 | export const generateUsername = () => { 10 | const username = uniqueNamesGenerator({ 11 | dictionaries: [adjectives, names, starWars, colors], 12 | separator: "_", 13 | length: 2, 14 | style: "lowerCase", 15 | }); 16 | 17 | return username; 18 | }; 19 | -------------------------------------------------------------------------------- /server/models/refreshTokens.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const refreshTokenSchema = new mongoose.Schema({ 4 | userId: { 5 | type: mongoose.Schema.Types.ObjectId, 6 | ref: "User", 7 | required: true, 8 | unique: true, 9 | }, 10 | refreshToken: { 11 | type: String, 12 | required: true, 13 | }, 14 | }); 15 | 16 | const RefreshToken = mongoose.model("RefreshToken", refreshTokenSchema); 17 | 18 | export default RefreshToken; 19 | -------------------------------------------------------------------------------- /client/src/components/Logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MdOutlineTimeline } from "react-icons/md"; 3 | import { Link } from "react-router-dom"; 4 | 5 | const Logo = () => { 6 | return ( 7 | 8 |
9 | 10 |
11 |

workflow

12 | 13 | ); 14 | }; 15 | 16 | export default Logo; 17 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./src/**/*.{js,jsx,ts,tsx}"], 3 | theme: { 4 | extend: { 5 | colors: { 6 | primary: "var(--primary)", 7 | primary_light: "var(--primary-light)", 8 | secondary: "var(--secondary)", 9 | }, 10 | fontFamily: { 11 | ubuntu: "Ubuntu, sans-serif", 12 | }, 13 | transitionProperty: { 14 | width: "width", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | -------------------------------------------------------------------------------- /client/src/components/Icon/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | src: string; 5 | alt: string; 6 | classes?: string; 7 | size?: number; 8 | } 9 | 10 | const Icon = ({ src, alt, classes, size = 48 }: Props) => { 11 | return ( 12 | {alt} 21 | ); 22 | }; 23 | 24 | export default Icon; 25 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | server: 4 | container_name: server 5 | build: ./server 6 | ports: 7 | - "8000:8000" 8 | env_file: 9 | - ./server/.env 10 | volumes: 11 | - ./server:/server 12 | - ./server/node_modules 13 | client: 14 | container_name: client 15 | build: ./client 16 | ports: 17 | - "3000:3000" 18 | env_file: 19 | - ./client/.env 20 | stdin_open: true 21 | tty: true 22 | volumes: 23 | - ./client:/client 24 | - ./client/node_modules -------------------------------------------------------------------------------- /server/models/recentBoards.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const recentBoardsSchema = new mongoose.Schema({ 4 | userId: { 5 | type: mongoose.Schema.Types.ObjectId, 6 | ref: "User", 7 | required: true, 8 | }, 9 | boardId: { 10 | type: mongoose.Schema.Types.ObjectId, 11 | ref: "Board", 12 | required: true, 13 | }, 14 | lastVisited: { 15 | type: Date, 16 | default: Date.now, 17 | }, 18 | }); 19 | 20 | const RecentBoard = mongoose.model("RecentBoard", recentBoardsSchema); 21 | 22 | export default RecentBoard; -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | import { Provider } from "react-redux"; 6 | import { store } from "./redux/app"; 7 | import { QueryClient, QueryClientProvider } from "react-query"; 8 | 9 | const queryClient = new QueryClient(); 10 | 11 | ReactDOM.render( 12 | 13 | 14 | 15 | 16 | , 17 | document.getElementById("root") 18 | ); 19 | -------------------------------------------------------------------------------- /client/src/components/Error/Error.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | msg: string; 5 | title?: string; 6 | } 7 | 8 | const Error = ({ msg, title }: Props) => { 9 | return ( 10 |
11 |
12 | {title &&

{title}

} 13 |

{msg}

14 |
15 |
16 | ); 17 | }; 18 | 19 | export default Error; 20 | -------------------------------------------------------------------------------- /client/src/hooks/useEnter.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | const useEnter = (fn: Function) => { 4 | let ref = useRef(null); 5 | 6 | const handler = (e: any) => { 7 | if (e.type === "keydown") { 8 | if (e.key === "Enter") { 9 | return fn(); 10 | } 11 | } 12 | }; 13 | 14 | useEffect(() => { 15 | document.addEventListener("keydown", handler, false); 16 | 17 | return () => { 18 | document.removeEventListener("keydown", handler, false); 19 | }; 20 | }, []); 21 | 22 | return ref; 23 | }; 24 | 25 | export default useEnter; 26 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["./src"] 20 | } 21 | -------------------------------------------------------------------------------- /server/routes/email.route.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import * as emailController from "../controllers/email.controller"; 3 | import { authMiddleware } from "../middlewares/auth"; 4 | 5 | const emailRouter = express.Router(); 6 | 7 | // Protected(Auth) GET /email/verify/:token -> emailverify 8 | emailRouter.get("/verify/:token", authMiddleware, emailController.emailVerify); 9 | // Protected(Auth) POST /email/resend-verify -> resendVerifyEmail 10 | emailRouter.post( 11 | "/resend-verify", 12 | authMiddleware, 13 | emailController.resendVerifyEmail 14 | ); 15 | 16 | export default emailRouter; 17 | -------------------------------------------------------------------------------- /client/src/hooks/useEscClose.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | const useEscClose = (fn: Function) => { 4 | let ref = useRef(null); 5 | 6 | const handler = (e: any) => { 7 | if (e.type === "keydown") { 8 | if (e.key === "Escape") { 9 | return fn(); 10 | } 11 | } 12 | }; 13 | 14 | useEffect(() => { 15 | document.addEventListener("keydown", handler, false); 16 | 17 | return () => { 18 | document.removeEventListener("keydown", handler, false); 19 | }; 20 | }, []); 21 | 22 | return ref; 23 | }; 24 | 25 | export default useEscClose; 26 | -------------------------------------------------------------------------------- /server/utils/multerConfig.ts: -------------------------------------------------------------------------------- 1 | import multer from "multer"; 2 | import { Request } from "express"; 3 | 4 | const storage = multer.memoryStorage(); 5 | 6 | const fileFilter = (req: Request, file: any, cb: multer.FileFilterCallback) => { 7 | const ext = file.mimetype.split("/")[1]; 8 | 9 | if (ext === "jpeg" || ext === "png" || ext === "jpg") { 10 | return cb(null, true); 11 | } 12 | 13 | return cb(new Error("Unsupported image type")); 14 | }; 15 | 16 | const upload = multer({ 17 | storage, 18 | fileFilter, 19 | limits: { 20 | fileSize: 1024 * 1024 * 2, 21 | }, 22 | }); 23 | 24 | export default upload; -------------------------------------------------------------------------------- /client/src/redux/features/sidebarSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | interface SidebarState { 4 | show: boolean; 5 | } 6 | 7 | const initialState: SidebarState = { 8 | show: true, 9 | }; 10 | 11 | const sidebarSlice = createSlice({ 12 | name: "sidebar", 13 | initialState, 14 | reducers: { 15 | showSidebar: (state) => { 16 | state.show = true; 17 | }, 18 | hideSidebar: (state) => { 19 | state.show = false; 20 | }, 21 | }, 22 | }); 23 | 24 | export const { showSidebar, hideSidebar } = sidebarSlice.actions; 25 | 26 | export default sidebarSlice.reducer; 27 | -------------------------------------------------------------------------------- /client/src/redux/features/spaceMenu.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | interface SpaceMenuState { 4 | currentActiveSpace: string | null; 5 | } 6 | 7 | const initialState: SpaceMenuState = { 8 | currentActiveSpace: null, 9 | }; 10 | 11 | const spaceMenu = createSlice({ 12 | name: "spaceMenu", 13 | initialState, 14 | reducers: { 15 | setCurrentActiveSpace: (state, action: PayloadAction) => { 16 | state.currentActiveSpace = action.payload; 17 | }, 18 | }, 19 | }); 20 | 21 | export const { setCurrentActiveSpace } = spaceMenu.actions; 22 | 23 | export default spaceMenu.reducer; 24 | -------------------------------------------------------------------------------- /client/src/redux/features/sidebarMenu.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | interface SidebarMenuState { 4 | currentActiveMenu: number | null; 5 | } 6 | 7 | const initialState: SidebarMenuState = { 8 | currentActiveMenu: 1, 9 | }; 10 | 11 | const sidebarMenu = createSlice({ 12 | name: "sidebarMenu", 13 | initialState, 14 | reducers: { 15 | setCurrentActiveMenu: (state, action: PayloadAction) => { 16 | state.currentActiveMenu = action.payload; 17 | }, 18 | }, 19 | }); 20 | 21 | export const { setCurrentActiveMenu } = sidebarMenu.actions; 22 | 23 | export default sidebarMenu.reducer; 24 | -------------------------------------------------------------------------------- /server/models/favorite.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { BOARD, SPACE } from "../types/constants"; 3 | 4 | const favoriteSchema = new mongoose.Schema( 5 | { 6 | resourceId: { 7 | type: mongoose.Schema.Types.ObjectId, 8 | required: true, 9 | }, 10 | type: { 11 | type: String, 12 | enum: [BOARD, SPACE], 13 | required: true, 14 | }, 15 | userId: { 16 | type: mongoose.Schema.Types.ObjectId, 17 | ref: "User", 18 | required: true, 19 | }, 20 | }, 21 | { timestamps: true } 22 | ); 23 | 24 | const Favorite = mongoose.model("Favorite", favoriteSchema); 25 | 26 | export default Favorite; 27 | -------------------------------------------------------------------------------- /server/routes/auth.route.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import * as authController from "../controllers/auth.controller"; 3 | import { authMiddleware } from "../middlewares/auth"; 4 | 5 | const authRouter = express.Router(); 6 | 7 | // POST /auth/login -> login route 8 | authRouter.post("/login", authController.loginUser); 9 | // POST /auth/register -> register route 10 | authRouter.post("/register", authController.registerUser); 11 | // POST /auth/refresh -> get new accessToken 12 | authRouter.post("/refresh", authController.refreshToken); 13 | // POST /auth/google -> google oauth 14 | authRouter.post("/google", authController.googleAuth); 15 | 16 | export default authRouter; 17 | -------------------------------------------------------------------------------- /client/src/components/layouts/AuthLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useSelector } from "react-redux"; 3 | import { Navigate, Outlet } from "react-router-dom"; 4 | import { RootState } from "../../redux/app"; 5 | 6 | const AuthLayout = () => { 7 | const { accessToken, refreshToken } = useSelector( 8 | (state: RootState) => state.auth 9 | ); 10 | 11 | // redirect, if already logged in 12 | if (accessToken || refreshToken) { 13 | return ; 14 | } 15 | 16 | return ( 17 |
18 | 19 |
20 | ); 21 | }; 22 | 23 | export default AuthLayout; 24 | -------------------------------------------------------------------------------- /server/routes/account.route.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import * as accountController from "../controllers/account.controller"; 3 | import { authMiddleware } from "../middlewares/auth"; 4 | import { multerUploadSingle } from "../middlewares/multerUploadSingle"; 5 | 6 | const accountRouter = express.Router(); 7 | 8 | accountRouter.put( 9 | "/", 10 | authMiddleware, 11 | function (req, res, next) { 12 | multerUploadSingle(req, res, next, "profile"); 13 | }, 14 | accountController.updateAccount 15 | ); 16 | 17 | accountRouter.post("/forgot-password", accountController.forgotPassword); 18 | accountRouter.post("/reset-password/:token", accountController.resetPassword); 19 | 20 | export default accountRouter; 21 | -------------------------------------------------------------------------------- /client/src/components/Skeletons/CardDetailSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Skeleton from "react-loading-skeleton"; 3 | 4 | const CardDetailSkeleton = () => { 5 | return ( 6 |
12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default CardDetailSkeleton; 25 | -------------------------------------------------------------------------------- /server/models/comment.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const commentSchema = new mongoose.Schema( 4 | { 5 | comment: { 6 | type: String, 7 | required: true, 8 | minlength: 1, 9 | trim: true, 10 | }, 11 | isUpdated: { 12 | type: Boolean, 13 | required: true, 14 | default: false, 15 | }, 16 | user: { 17 | type: mongoose.Schema.Types.ObjectId, 18 | ref: "User", 19 | required: true, 20 | }, 21 | cardId: { 22 | type: mongoose.Schema.Types.ObjectId, 23 | ref: "Card", 24 | required: true, 25 | }, 26 | }, 27 | { timestamps: true } 28 | ); 29 | 30 | const Comment = mongoose.model("Comment", commentSchema); 31 | 32 | export default Comment; 33 | -------------------------------------------------------------------------------- /server/models/forgotPassword.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { add } from "date-fns"; 3 | import { FORGOT_PASSWORD_TOKEN_LENGTH } from "../config"; 4 | 5 | const forgotPasswordSchema = new mongoose.Schema({ 6 | userId: { 7 | type: mongoose.Schema.Types.ObjectId, 8 | ref: "User", 9 | required: true, 10 | }, 11 | token: { 12 | type: String, 13 | required: true, 14 | minlength: FORGOT_PASSWORD_TOKEN_LENGTH, 15 | maxlength: FORGOT_PASSWORD_TOKEN_LENGTH, 16 | }, 17 | expiresAt: { 18 | type: Date, 19 | default: add(new Date(), { 20 | days: 3, 21 | }), 22 | }, 23 | }); 24 | 25 | const ForgotPassword = mongoose.model("ForgotPassword", forgotPasswordSchema); 26 | 27 | export default ForgotPassword; 28 | -------------------------------------------------------------------------------- /server/models/emailVerification.model..ts: -------------------------------------------------------------------------------- 1 | import { add } from "date-fns"; 2 | import mongoose from "mongoose"; 3 | import { EMAIL_TOKEN_LENGTH } from "../config"; 4 | 5 | const emailVerificationSchema = new mongoose.Schema({ 6 | userId: { 7 | type: mongoose.Schema.Types.ObjectId, 8 | ref: "User", 9 | required: true, 10 | }, 11 | token: { 12 | type: String, 13 | required: true, 14 | minlength: EMAIL_TOKEN_LENGTH, 15 | maxlength: EMAIL_TOKEN_LENGTH, 16 | }, 17 | expiresAt: { 18 | type: Date, 19 | default: add(new Date(), { 20 | minutes: 30, 21 | }), 22 | }, 23 | }); 24 | 25 | const EmailVerification = mongoose.model( 26 | "EmailVerification", 27 | emailVerificationSchema 28 | ); 29 | 30 | export default EmailVerification; 31 | -------------------------------------------------------------------------------- /client/src/components/CustomOption/CustomOption.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Profile from "../Profile/Profile"; 3 | 4 | const CustomOption = (props: any) => { 5 | const { innerProps, innerRef, data, isDisabled } = props; 6 | 7 | return ( 8 |
13 |
14 | 15 |
16 |
17 |

{data.label}

18 |
19 |
20 | ); 21 | }; 22 | 23 | export default CustomOption; 24 | -------------------------------------------------------------------------------- /server/routes/favorite.route.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { authMiddleware } from "../middlewares/auth"; 3 | import * as favoriteController from "../controllers/favorite.controller"; 4 | 5 | const favoriteRouter = express.Router(); 6 | 7 | // Protected(Auth) GET /favorites -> get all my favorites (for sidebar) 8 | favoriteRouter.get("/", authMiddleware, favoriteController.getMyFavorites); 9 | // Protected(Auth) POST /favorites -> make a space or board favorite 10 | favoriteRouter.post("/", authMiddleware, favoriteController.addToFavorite); 11 | // Protected(Auth) DELETE /favorites/:id -> removes a space or board from favorite 12 | favoriteRouter.delete( 13 | "/:id", 14 | authMiddleware, 15 | favoriteController.removeFavorite 16 | ); 17 | 18 | export default favoriteRouter; 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | .DS_Store 39 | 40 | # .env 41 | .env 42 | 43 | dist 44 | dist-ssr 45 | *.local 46 | -------------------------------------------------------------------------------- /server/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import dotenv from "dotenv"; 3 | import cors from "cors"; 4 | import { connectDB } from "./utils/db"; 5 | import { BASE_PATH, PUBLIC_DIR_NAME, STATIC_PATH } from "./config"; 6 | import path from "path"; 7 | import rootRouter from "./routes"; 8 | 9 | // dotenv config 10 | dotenv.config(); 11 | 12 | // connectDB 13 | connectDB(); 14 | 15 | const app = express(); 16 | 17 | const PORT = process.env.PORT || 8000; 18 | 19 | // middlewares 20 | app.use(cors()); 21 | app.use(express.json()); 22 | // static files -> eg: /api/v1/static -> points to /public dir 23 | app.use( 24 | BASE_PATH + STATIC_PATH, 25 | express.static(path.join(__dirname, PUBLIC_DIR_NAME)) 26 | ); 27 | 28 | // routes 29 | app.use(`${BASE_PATH}`, rootRouter); 30 | 31 | app.listen(PORT, () => { 32 | console.log(`Running on port ${PORT}`); 33 | }); 34 | -------------------------------------------------------------------------------- /client/src/hooks/useClose.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | const useClose = (fn: Function) => { 4 | let ref = useRef(null); 5 | 6 | const handler = (e: any) => { 7 | if (e.type === "mousedown") { 8 | if (ref.current && !ref.current.contains(e.target)) { 9 | return fn(); 10 | } 11 | } else if (e.type === "keydown") { 12 | if (e.key === "Escape") { 13 | return fn(); 14 | } 15 | } 16 | }; 17 | 18 | useEffect(() => { 19 | document.addEventListener("mousedown", handler, false); 20 | document.addEventListener("keydown", handler, false); 21 | 22 | return () => { 23 | document.removeEventListener("mousedown", handler, false); 24 | document.removeEventListener("keydown", handler, false); 25 | }; 26 | }); 27 | 28 | return ref; 29 | }; 30 | 31 | export default useClose; 32 | -------------------------------------------------------------------------------- /server/config.ts: -------------------------------------------------------------------------------- 1 | export const BASE_PATH = "/api/v1"; 2 | export const BASE_PATH_COMPLETE = "http://localhost:8000" + BASE_PATH; 3 | 4 | export const CLIENT_URL = "http://localhost:3000"; 5 | 6 | // paths 7 | export const STATIC_PATH = "/static"; 8 | 9 | export const PUBLIC_DIR_NAME = "public"; 10 | export const PROFILE_PICS_DIR_NAME = "profiles"; 11 | export const SPACE_ICONS_DIR_NAME = "space_icons"; 12 | 13 | // EMAIL TOKEN VALIDITY 14 | export const EMAIL_TOKEN_VALIDITY = 1800; // 1800s = 30mins 15 | export const EMAIL_TOKEN_LENGTH = 94; 16 | 17 | // FORGOT PASSWORD TOKEN 18 | export const FORGOT_PASSWORD_TOKEN_LENGTH = 124; 19 | 20 | // Space icon width and height 21 | export const SPACE_ICON_SIZE = { 22 | WIDTH: 64, 23 | HEIGHT: 64, 24 | }; 25 | 26 | // profile icon width and height 27 | export const PROFILE_SIZE = { 28 | WIDTH: 250, 29 | HEIGHT: 250, 30 | }; 31 | -------------------------------------------------------------------------------- /server/utils/file.ts: -------------------------------------------------------------------------------- 1 | import sharp from "sharp"; 2 | import path from "path"; 3 | import { PUBLIC_DIR_NAME } from "../config"; 4 | import fs from "fs"; 5 | import { createRandomToken } from "./helpers"; 6 | 7 | // save file 8 | export const saveFile = async ( 9 | file: any, 10 | width: number, 11 | height: number, 12 | directory: string 13 | ) => { 14 | const fileName = new Date().toISOString() + createRandomToken(24) + ".jpeg"; 15 | 16 | await sharp(file.buffer) 17 | .resize(width, height) 18 | .toFormat("jpeg") 19 | .jpeg({ quality: 90 }) 20 | .toFile(path.join(PUBLIC_DIR_NAME, directory, fileName)); 21 | 22 | return fileName; 23 | }; 24 | 25 | export const removeFile = async (path: string) => { 26 | fs.unlink(path, (err) => { 27 | if (err) { 28 | console.log(err); 29 | } else { 30 | console.log("successfully deleted file"); 31 | } 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /client/src/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from "react-redux"; 2 | import { Navigate, useLocation } from "react-router-dom"; 3 | import { RootState } from "./redux/app"; 4 | import { addToast } from "./redux/features/toastSlice"; 5 | import { ToastObj } from "./types"; 6 | 7 | interface Props { 8 | toast?: ToastObj; 9 | children: JSX.Element; 10 | } 11 | 12 | const PrivateRoute = ({ toast, children }: Props) => { 13 | const dispatch = useDispatch(); 14 | 15 | const { accessToken, refreshToken } = useSelector( 16 | (state: RootState) => state.auth 17 | ); 18 | 19 | const location = useLocation(); 20 | 21 | if (!accessToken && !refreshToken) { 22 | if (toast) { 23 | dispatch(addToast(toast)); 24 | } 25 | return ; 26 | } 27 | 28 | return children; 29 | }; 30 | 31 | export default PrivateRoute; 32 | -------------------------------------------------------------------------------- /server/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import authRouter from "./auth.route"; 3 | import emailRouter from "./email.route"; 4 | import userRouter from "./user.route"; 5 | import accountRouter from "./account.route"; 6 | import spaceRouter from "./space.route"; 7 | import boardRouter from "./board.route"; 8 | import favoriteRouter from "./favorite.route"; 9 | import listRouter from "./list.route"; 10 | import cardRouter from "./card.route"; 11 | 12 | const rootRouter = express.Router(); 13 | 14 | rootRouter.use("/auth", authRouter); 15 | rootRouter.use("/users", userRouter); 16 | rootRouter.use("/email", emailRouter); 17 | rootRouter.use("/accounts", accountRouter); 18 | rootRouter.use("/spaces", spaceRouter); 19 | rootRouter.use("/boards", boardRouter); 20 | rootRouter.use("/favorites", favoriteRouter); 21 | rootRouter.use("/lists", listRouter); 22 | rootRouter.use("/cards", cardRouter); 23 | 24 | export default rootRouter; 25 | -------------------------------------------------------------------------------- /client/src/components/CardDetail/DueDateStatus.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DUE_DATE_STATUSES } from "../../types/constants"; 3 | import { getStatus } from "../../utils/helpers"; 4 | 5 | interface Props { 6 | date: string; 7 | isComplete: boolean; 8 | } 9 | 10 | const DueDateStatus = ({ date, isComplete }: Props) => { 11 | const status = getStatus(date, isComplete); 12 | 13 | let component = null; 14 | 15 | switch (status) { 16 | case DUE_DATE_STATUSES.COMPLETE: 17 | component =
Complete
; 18 | break; 19 | case DUE_DATE_STATUSES.OVERDUE: 20 | component =
Overdue
; 21 | break; 22 | default: 23 | return null; 24 | } 25 | 26 | return
{component && component}
; 27 | }; 28 | 29 | export default DueDateStatus; 30 | -------------------------------------------------------------------------------- /client/src/components/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | size?: number; 5 | } 6 | 7 | const Loader = ({ size = 32 }: Props) => { 8 | return ( 9 | 19 | 27 | 32 | 33 | ); 34 | }; 35 | 36 | export default Loader; 37 | -------------------------------------------------------------------------------- /server/routes/list.route.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { authMiddleware } from "../middlewares/auth"; 3 | import * as listController from "../controllers/list.controller"; 4 | 5 | const listRouter = express.Router(); 6 | 7 | // Protected(Auth) POST /lists -> create a new list 8 | listRouter.post("/", authMiddleware, listController.createList); 9 | // Protected(Auth) PUT /lists/:id/name -> update list name 10 | listRouter.put("/:id/name", authMiddleware, listController.updateListName); 11 | // Protected(Auth) PUT /lists/:id/dnd -> dnd list 12 | listRouter.put("/:id/dnd", authMiddleware, listController.dndList); 13 | // Protected(Auth) GET /lists?boardId="boardId" -> get all lists under this board 14 | listRouter.get("/", authMiddleware, listController.getLists); 15 | // Protected(Auth) DELETE /lists/:id -> delete list and cards below it 16 | listRouter.delete("/:id", authMiddleware, listController.deleteList); 17 | 18 | export default listRouter; 19 | -------------------------------------------------------------------------------- /client/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import MyCards from "../components/MyCards/MyCards"; 3 | import RecentBoards from "../components/RecentBoards/RecentBoards"; 4 | 5 | const Home = () => { 6 | return ( 7 |
8 |
14 |

Recent Boards

15 | 16 | 17 |
18 | 19 |
25 |

My Tasks

26 | 27 | 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default Home; 34 | -------------------------------------------------------------------------------- /server/models/label.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { LABEL_COLORS } from "../types/constants"; 3 | 4 | const labelSchema = new mongoose.Schema( 5 | { 6 | name: { 7 | type: String, 8 | default: "", 9 | maxlength: 512, 10 | trim: true, 11 | }, 12 | color: { 13 | type: String, 14 | required: true, 15 | validate: { 16 | validator: function (value: string) { 17 | return Object.values(LABEL_COLORS).includes(value); 18 | }, 19 | message: `Invalid value for color`, 20 | }, 21 | trim: true, 22 | }, 23 | pos: { 24 | type: Number, 25 | required: true, 26 | min: 1, 27 | max: 7, 28 | }, 29 | boardId: { 30 | type: mongoose.Schema.Types.ObjectId, 31 | ref: "Board", 32 | required: true, 33 | }, 34 | }, 35 | { timestamps: true } 36 | ); 37 | 38 | const Label = mongoose.model("Label", labelSchema); 39 | 40 | export default Label; 41 | -------------------------------------------------------------------------------- /server/types/constants.ts: -------------------------------------------------------------------------------- 1 | export const BOARD_VISIBILITY = { 2 | PRIVATE: "PRIVATE", 3 | PUBLIC: "PUBLIC", 4 | }; 5 | 6 | // space member roles 7 | export const SPACE_MEMBER_ROLES = { 8 | ADMIN: "ADMIN", 9 | NORMAL: "NORMAL", 10 | GUEST: "GUEST", 11 | }; 12 | 13 | // board member roles 14 | export const BOARD_MEMBER_ROLES = { 15 | ADMIN: "ADMIN", 16 | NORMAL: "NORMAL", 17 | OBSERVER: "OBSERVER", 18 | }; 19 | 20 | // favorite obj types 21 | export const SPACE = "SPACE"; 22 | export const BOARD = "BOARD"; 23 | 24 | // LIST POSSIBLE DRAGS 25 | export const LIST_POSSIBLE_DRAGS = { 26 | LEFT: "LEFT", 27 | RIGHT: "RIGHT", 28 | }; 29 | 30 | // CARD POSSIBLE DRAGS 31 | export const CARD_POSSIBLE_DRAGS = { 32 | DOWN: "DOWN", 33 | UP: "UP", 34 | }; 35 | 36 | // LABEL COLORS 37 | export const LABEL_COLORS = { 38 | GREEN: "#61BD4F", 39 | YELLOW: "#F1D737", 40 | ORANGE: "#FA9F2E", 41 | RED: "#EB5A46", 42 | PURPLE: "#C377E0", 43 | BLUE: "#0079BF", 44 | LIGHTBLUE: "#3BC3E0", 45 | }; 46 | -------------------------------------------------------------------------------- /server/middlewares/multerUploadSingle.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Response } from "express"; 2 | import multer from "multer"; 3 | import upload from "../utils/multerConfig"; 4 | 5 | export const multerUploadSingle = async ( 6 | req: any, 7 | res: Response, 8 | next: NextFunction, 9 | fileName: string 10 | ) => { 11 | // const upload = multer().single() 12 | // upload(req, res, (err) => {}) 13 | // for us, upload = multer, so upload above becames file 14 | const file = upload.single(fileName); 15 | 16 | file(req, res, (err) => { 17 | // file size error 18 | if (err instanceof multer.MulterError) { 19 | return res.status(400).send({ 20 | success: false, 21 | data: {}, 22 | message: err.message, 23 | statusCode: 400, 24 | }); 25 | } else if (err) { 26 | // invalid file type 27 | return res.status(400).send({ 28 | success: false, 29 | data: {}, 30 | message: "Unsupported image type", 31 | statusCode: 400, 32 | }); 33 | } 34 | next(); 35 | }); 36 | }; -------------------------------------------------------------------------------- /server/models/list.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const listSchema = new mongoose.Schema( 4 | { 5 | name: { 6 | type: String, 7 | required: true, 8 | minlength: 1, 9 | maxlength: 512, 10 | trim: true, 11 | }, 12 | boardId: { 13 | type: mongoose.Schema.Types.ObjectId, 14 | ref: "Board", 15 | required: true, 16 | }, 17 | pos: { 18 | type: String, 19 | required: true, 20 | trim: true, 21 | }, 22 | cards: { 23 | type: [ 24 | { 25 | type: mongoose.Schema.Types.ObjectId, 26 | ref: "Card", 27 | required: true, 28 | }, 29 | ], 30 | default: [], 31 | }, 32 | isCollapsed: { 33 | type: Boolean, 34 | default: false, 35 | }, 36 | creator: { 37 | type: mongoose.Schema.Types.ObjectId, 38 | ref: "User", 39 | required: true, 40 | }, 41 | }, 42 | { timestamps: true } 43 | ); 44 | 45 | const List = mongoose.model("List", listSchema); 46 | 47 | export default List; 48 | -------------------------------------------------------------------------------- /server/routes/user.route.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import * as userController from "../controllers/user.controller"; 3 | import { authMiddleware } from "../middlewares/auth"; 4 | 5 | const userRouter = express.Router(); 6 | 7 | // Protected(Auth) GET /users/getCurrentUser -> gets the current user 8 | userRouter.get( 9 | "/getCurrentUser", 10 | authMiddleware, 11 | userController.getCurrentUser 12 | ); 13 | // Protected(Auth) DELETE /users -> deletes the current user 14 | userRouter.delete("/", authMiddleware, userController.deleteCurrentUser); 15 | // Protected(Auth) GET /users/search?q=query&spaceId=id -> search other user 16 | userRouter.get("/search", authMiddleware, userController.searchUser); 17 | // Protected(Auth) GET /users/search/board?q=query&boardId=id -> search other user (board related) 18 | userRouter.get("/search/board", authMiddleware, userController.searchUserBoard); 19 | // Protected(Auth) GET /users/board/id -> get all space & board members 20 | userRouter.get("/board/:id", authMiddleware, userController.getAllMembers); 21 | 22 | export default userRouter; 23 | -------------------------------------------------------------------------------- /client/src/components/Options/OptionsItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IconType } from "react-icons"; 3 | 4 | interface Props { 5 | Icon: IconType; 6 | text: string; 7 | onClick: Function; 8 | iconColor?: string; 9 | iconFillColor?: string; 10 | textColor?: string; 11 | } 12 | 13 | const OptionsItem = ({ 14 | Icon, 15 | text, 16 | onClick, 17 | iconColor, 18 | iconFillColor, 19 | textColor, 20 | }: Props) => { 21 | return ( 22 |
  • onClick()} 24 | className="p-2 hover:bg-slate-100 first:rounded-t last:rounded-b flex items-center cursor-pointer" 25 | > 26 |
    27 | 32 |
    33 |
    38 | {text} 39 |
    40 |
  • 41 | ); 42 | }; 43 | 44 | export default OptionsItem; 45 | -------------------------------------------------------------------------------- /client/src/redux/app/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers, configureStore } from "@reduxjs/toolkit"; 2 | import authReducer from "../features/authSlice"; 3 | import sidebarReducer from "../features/sidebarSlice"; 4 | import toastReducer from "../features/toastSlice"; 5 | import modalReducer from "../features/modalSlice"; 6 | import sidebarMenuReducer from "../features/sidebarMenu"; 7 | import spaceMenuReducer from "../features/spaceMenu"; 8 | 9 | const combinedReducer = combineReducers({ 10 | auth: authReducer, 11 | sidebar: sidebarReducer, 12 | toast: toastReducer, 13 | modal: modalReducer, 14 | sidebarMenu: sidebarMenuReducer, 15 | spaceMenu: spaceMenuReducer, 16 | }); 17 | 18 | // empty store when logout 19 | const rootReducer = (state: any, action: any) => { 20 | // if (action.type === "auth/logoutUser") { 21 | // state = undefined; 22 | // } 23 | 24 | return combinedReducer(state, action); 25 | }; 26 | 27 | export const store = configureStore({ 28 | reducer: rootReducer, 29 | }); 30 | 31 | export type RootState = ReturnType; 32 | export type AppDispatch = typeof store.dispatch; 33 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/SidebarLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { IconType } from "react-icons"; 3 | import { NavLink } from "react-router-dom"; 4 | import { Link } from "react-router-dom"; 5 | 6 | interface Props { 7 | to: string; 8 | Icon: IconType; 9 | text: string; 10 | } 11 | 12 | const SidebarLink = ({ to, Icon, text }: Props) => { 13 | const [currentActive, setCurrentActive] = useState(false); 14 | 15 | return ( 16 | { 20 | setCurrentActive(isActive); 21 | 22 | return `relative flex items-center px-4 py-2 text-sm ${ 23 | isActive ? "bg-primary_light hover:bg-none" : "hover:bg-secondary" 24 | }`; 25 | }} 26 | > 27 | {currentActive && ( 28 | 29 | )} 30 | 31 |
    32 | 33 |
    34 | {text} 35 |
    36 | ); 37 | }; 38 | 39 | export default SidebarLink; 40 | -------------------------------------------------------------------------------- /client/src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ProfileCard from "./ProfileCard"; 3 | import { HiOutlineMenu } from "react-icons/hi"; 4 | import { useDispatch, useSelector } from "react-redux"; 5 | import { showSidebar } from "../../redux/features/sidebarSlice"; 6 | import { RootState } from "../../redux/app"; 7 | 8 | const Header = () => { 9 | const dispatch = useDispatch(); 10 | 11 | const { show } = useSelector((state: RootState) => state.sidebar); 12 | 13 | return ( 14 |
    19 |
    20 | {!show && ( 21 | 27 | )} 28 |
    29 |
    30 | 31 |
    32 |
    33 | ); 34 | }; 35 | 36 | export default Header; 37 | -------------------------------------------------------------------------------- /client/src/redux/features/toastSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { ToastObj } from "../../types"; 3 | 4 | interface ToastState { 5 | toasts: ToastObj[]; 6 | } 7 | 8 | const initialState: ToastState = { 9 | toasts: [], 10 | }; 11 | 12 | const toastSlice = createSlice({ 13 | name: "toast", 14 | initialState, 15 | reducers: { 16 | addToast: (state, action: PayloadAction) => { 17 | // if the new toast already doesn't exists on the array, then only include it 18 | let contains = false; 19 | 20 | state.toasts.forEach((toast) => { 21 | if ( 22 | action.payload.msg === toast.msg && 23 | action.payload.kind === toast.kind 24 | ) { 25 | contains = true; 26 | } 27 | }); 28 | 29 | if (!contains) { 30 | state.toasts.push(action.payload); 31 | } 32 | }, 33 | removeToast: (state, action: PayloadAction) => { 34 | state.toasts = state.toasts.filter( 35 | (t) => action.payload.msg !== t.msg && action.payload.kind !== t.kind 36 | ); 37 | }, 38 | }, 39 | }); 40 | 41 | export const { addToast, removeToast } = toastSlice.actions; 42 | 43 | export default toastSlice.reducer; 44 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "main": "app.ts", 5 | "author": "ak", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "ts-node --transpile-only app.ts", 9 | "dev": "ts-node-dev --respawn --pretty --transpile-only app.ts" 10 | }, 11 | "devDependencies": { 12 | "@types/bcrypt": "^5.0.0", 13 | "@types/cors": "^2.8.12", 14 | "@types/date-fns": "^2.6.0", 15 | "@types/express": "^4.17.13", 16 | "@types/jsonwebtoken": "^8.5.6", 17 | "@types/mongoose": "^5.11.97", 18 | "@types/multer": "^1.4.7", 19 | "@types/nanoid": "^3.0.0", 20 | "@types/nodemailer": "^6.4.4", 21 | "@types/sharp": "^0.29.5", 22 | "@types/validator": "^13.7.1", 23 | "dotenv": "^10.0.0", 24 | "ts-node-dev": "^1.1.8", 25 | "typescript": "^4.6.3" 26 | }, 27 | "dependencies": { 28 | "bcrypt": "^5.0.1", 29 | "cors": "^2.8.5", 30 | "date-fns": "^2.28.0", 31 | "express": "^4.17.2", 32 | "google-auth-library": "^7.11.0", 33 | "jsonwebtoken": "^8.5.1", 34 | "mongoose": "^6.1.5", 35 | "multer": "^1.4.4", 36 | "nanoid": "^3.1.30", 37 | "nodemailer": "^6.7.2", 38 | "sharp": "^0.29.3", 39 | "ts-node": "^10.7.0", 40 | "unique-names-generator": "^4.6.0", 41 | "validator": "^13.7.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/src/pages/Error404.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router-dom"; 3 | import Astronaut from "../assets/astronaut.png"; 4 | 5 | const Error404 = () => { 6 | return ( 7 |
    8 |
    12 |
    13 |
    Oops!
    14 |

    20 | 404 21 |

    22 |
    23 |
    Page Not Found
    24 | 25 | 26 | Go Home 27 | 28 |
    29 | 30 |
    37 | astronaut 42 |
    43 |
    44 |
    45 | ); 46 | }; 47 | 48 | export default Error404; 49 | -------------------------------------------------------------------------------- /client/src/components/FormikComponents/SubmitBtn.tsx: -------------------------------------------------------------------------------- 1 | import { useFormikContext } from "formik"; 2 | import React from "react"; 3 | import Loader from "../Loader/Loader"; 4 | 5 | interface Props { 6 | text: string; 7 | isSubmitting: boolean; 8 | classes?: string; 9 | disabled?: boolean; 10 | noDirtyCheck?: boolean; 11 | } 12 | 13 | const SubmitBtn = ({ 14 | text, 15 | isSubmitting, 16 | classes, 17 | disabled = false, 18 | noDirtyCheck = false, 19 | }: Props) => { 20 | const { isValid, dirty } = useFormikContext(); 21 | 22 | return isSubmitting === true ? ( 23 | 29 | ) : noDirtyCheck ? ( 30 | 37 | ) : ( 38 | 45 | ); 46 | }; 47 | 48 | export default SubmitBtn; 49 | -------------------------------------------------------------------------------- /client/src/components/Profile/Profile.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { HiChevronDoubleUp } from "react-icons/hi"; 3 | import AdminIcon from "../../assets/icons/admin.png"; 4 | 5 | interface Props { 6 | src?: string; 7 | alt?: string; 8 | classes?: string; 9 | isAdmin?: boolean; 10 | styles?: Object; 11 | onClick?: () => void; 12 | } 13 | 14 | const Profile = ({ src, alt, isAdmin, classes, onClick, styles }: Props) => { 15 | return ( 16 |
    17 | {src ? ( 18 | {alt!} 26 | ) : ( 27 |
    32 | * 33 |
    34 | )} 35 | 36 | {isAdmin && ( 37 | admin-indicator 45 | )} 46 |
    47 | ); 48 | }; 49 | 50 | export default Profile; 51 | -------------------------------------------------------------------------------- /client/src/components/CustomNavLink/CustomNavLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useState } from "react"; 3 | import { Link, useLocation } from "react-router-dom"; 4 | 5 | interface Props { 6 | to: string; 7 | list?: string[]; 8 | fn?: Function; 9 | onClick?: Function; 10 | showUnderline?: boolean; 11 | children: React.ReactNode; 12 | } 13 | 14 | const CustomNavLink = ({ 15 | to, 16 | list, 17 | fn, 18 | showUnderline = true, 19 | children, 20 | onClick, 21 | }: Props) => { 22 | const location = useLocation(); 23 | const pathname = location.pathname; 24 | 25 | const [isActive, setIsActive] = useState(); 26 | 27 | useEffect(() => { 28 | setIsActive(pathname === to || list?.includes(pathname)); 29 | }, [pathname, list]); 30 | 31 | useEffect(() => { 32 | if (isActive !== undefined && fn) { 33 | fn(isActive as boolean); 34 | } 35 | }, [isActive]); 36 | 37 | return ( 38 | { 40 | onClick && onClick(e); 41 | }} 42 | to={to} 43 | className={`text-sm ${ 44 | showUnderline 45 | ? "hover:underline decoration-dashed outline-violet-500 underline-offset-4" 46 | : "" 47 | } ${isActive ? "active" : ""}`} 48 | > 49 | {children} 50 | 51 | ); 52 | }; 53 | 54 | export default CustomNavLink; 55 | -------------------------------------------------------------------------------- /client/src/components/ErrorCard/ErrorCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { HiOutlineRefresh } from "react-icons/hi"; 3 | import { useQueryClient } from "react-query"; 4 | import CardDetailSkeleton from "../Skeletons/CardDetailSkeleton"; 5 | import UtilityBtn from "../UtilityBtn/UtilityBtn"; 6 | 7 | interface Props { 8 | msg: string; 9 | isRefetching: boolean; 10 | queryKey: string[]; 11 | } 12 | 13 | const ErrorCard = ({ msg, isRefetching, queryKey }: Props) => { 14 | const queryClient = useQueryClient(); 15 | 16 | return ( 17 |
    24 | {isRefetching ? ( 25 | 26 | ) : ( 27 |
    28 | {msg} 29 | { 35 | queryClient.invalidateQueries(queryKey); 36 | }} 37 | /> 38 |
    39 | )} 40 |
    41 | ); 42 | }; 43 | 44 | export default ErrorCard; 45 | -------------------------------------------------------------------------------- /client/src/components/ErrorBoardLists/ErrorBoardLists.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { HiOutlineRefresh } from "react-icons/hi"; 3 | import { useQueryClient } from "react-query"; 4 | import Loader from "../Loader/Loader"; 5 | import UtilityBtn from "../UtilityBtn/UtilityBtn"; 6 | 7 | interface Props { 8 | msg: string; 9 | isRefetching: boolean; 10 | queryKey: string[]; 11 | } 12 | 13 | const ErrorBoardLists = ({ msg, isRefetching, queryKey }: Props) => { 14 | const queryClient = useQueryClient(); 15 | 16 | return ( 17 |
    24 | {isRefetching ? ( 25 | 26 | ) : ( 27 |
    28 | {msg} 29 | { 35 | queryClient.invalidateQueries(queryKey); 36 | }} 37 | /> 38 |
    39 | )} 40 |
    41 | ); 42 | }; 43 | 44 | export default ErrorBoardLists; 45 | -------------------------------------------------------------------------------- /client/src/redux/features/modalSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { ModalObj } from "../../types"; 3 | 4 | const initialState: ModalObj = { 5 | modalType: null, 6 | modalProps: {}, 7 | modalTitle: "", 8 | showCloseBtn: true, 9 | bgColor: "white", 10 | textColor: "", 11 | }; 12 | 13 | const modalSlice = createSlice({ 14 | name: "modal", 15 | initialState, 16 | reducers: { 17 | showModal: (state, action: PayloadAction) => { 18 | const modal = action.payload; 19 | 20 | state.modalType = modal.modalType; 21 | 22 | if (modal.modalProps) { 23 | state.modalProps = modal.modalProps; 24 | } 25 | 26 | if (modal.modalTitle) { 27 | state.modalTitle = modal.modalTitle; 28 | } 29 | 30 | if (Object.keys(modal).includes("showCloseBtn")) { 31 | state.showCloseBtn = modal.showCloseBtn; 32 | } 33 | 34 | if (modal.bgColor) { 35 | state.bgColor = modal.bgColor; 36 | } 37 | 38 | if (modal.textColor) { 39 | state.textColor = modal.textColor; 40 | } 41 | }, 42 | hideModal: (state) => { 43 | state.modalType = null; 44 | state.modalProps = {}; 45 | state.modalTitle = ""; 46 | state.showCloseBtn = true; 47 | state.bgColor = "white"; 48 | state.textColor = ""; 49 | }, 50 | }, 51 | }); 52 | 53 | export const { showModal, hideModal } = modalSlice.actions; 54 | 55 | export default modalSlice.reducer; 56 | -------------------------------------------------------------------------------- /server/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import { NextFunction, Response } from "express"; 3 | import { UserTokenObj } from "../types"; 4 | import User from "../models/user.model"; 5 | 6 | export const authMiddleware = async ( 7 | req: any, 8 | res: Response, 9 | next: NextFunction 10 | ) => { 11 | const header = req.headers["authorization"]; 12 | const token = header && header?.split(" ")[1]; 13 | 14 | if (!token) { 15 | return res.status(401).send({ 16 | success: false, 17 | data: {}, 18 | message: "Invalid token", 19 | statusCode: 401, 20 | }); 21 | } 22 | 23 | // verify token, user -> {_id: 232} 24 | // jwt.verify() will throw an error if invalid token 25 | try { 26 | const payload = jwt.verify( 27 | token, 28 | process.env.ACCESS_TOKEN_SECRET! 29 | ) as UserTokenObj; 30 | 31 | const user = await User.findById(payload._id).select("_id username profile email emailVerified isOAuth"); 32 | 33 | // if no such user exists (bcz user has been deleted or invalid user _id) 34 | if (!user) { 35 | return res.status(401).send({ 36 | success: false, 37 | data: {}, 38 | message: "Invalid user", 39 | statusCode: 401, 40 | }); 41 | } 42 | 43 | req.user = user; 44 | next(); 45 | } catch (e) { 46 | return res.status(401).send({ 47 | success: false, 48 | data: {}, 49 | message: "Invalid access token", 50 | statusCode: 401, 51 | }); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /server/models/space.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { SPACE_MEMBER_ROLES } from "../types/constants"; 3 | 4 | const spaceMemberSchema = new mongoose.Schema( 5 | { 6 | memberId: { 7 | type: mongoose.Schema.Types.ObjectId, 8 | ref: "User", 9 | required: true, 10 | }, 11 | role: { 12 | type: String, 13 | enum: Object.values(SPACE_MEMBER_ROLES), 14 | default: SPACE_MEMBER_ROLES.NORMAL, 15 | }, 16 | }, 17 | { _id: false, timestamps: true } 18 | ); 19 | 20 | const spaceSchema = new mongoose.Schema( 21 | { 22 | name: { 23 | type: String, 24 | required: true, 25 | minlength: 1, 26 | maxlength: 100, 27 | trim: true, 28 | }, 29 | description: { 30 | type: String, 31 | required: false, 32 | maxlength: 255, 33 | trim: true, 34 | }, 35 | icon: { 36 | type: String, 37 | required: false, 38 | trim: true, 39 | }, 40 | boards: { 41 | type: [ 42 | { 43 | type: mongoose.Schema.Types.ObjectId, 44 | ref: "Board", 45 | required: true, 46 | }, 47 | ], 48 | default: [], 49 | }, 50 | members: { 51 | type: [spaceMemberSchema], 52 | default: [], 53 | }, 54 | creator: { 55 | type: mongoose.Schema.Types.ObjectId, 56 | ref: "User", 57 | required: true, 58 | }, 59 | }, 60 | { timestamps: true } 61 | ); 62 | 63 | const Space = mongoose.model("Space", spaceSchema); 64 | 65 | export default Space; 66 | -------------------------------------------------------------------------------- /server/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import { 3 | BASE_PATH_COMPLETE, 4 | PROFILE_PICS_DIR_NAME, 5 | STATIC_PATH, 6 | } from "../config"; 7 | import path from "path"; 8 | import { LABEL_COLORS } from "../types/constants"; 9 | 10 | // creates 'n' length char long hex string 11 | export const createRandomToken = (length: number) => { 12 | return nanoid(length); 13 | }; 14 | 15 | // get unique array value 16 | export const getUniqueValues = (array: T[]) => { 17 | return array.filter((value, index, self) => { 18 | return self.indexOf(value) === index; 19 | }); 20 | }; 21 | 22 | // get unique array value 23 | export const checkAllString = (array: any[]) => { 24 | return array.every((i) => typeof i === "string"); 25 | }; 26 | 27 | // get profile pic path 28 | export const getProfile = (profile: string) => { 29 | return profile.includes("http") 30 | ? profile 31 | : BASE_PATH_COMPLETE + 32 | path.join(STATIC_PATH, PROFILE_PICS_DIR_NAME, profile); 33 | }; 34 | 35 | // get pos for color 36 | export const getPos = (color: string) => { 37 | if (color === LABEL_COLORS.GREEN) { 38 | return 1; 39 | } else if (color === LABEL_COLORS.YELLOW) { 40 | return 2; 41 | } else if (color === LABEL_COLORS.ORANGE) { 42 | return 3; 43 | } else if (color === LABEL_COLORS.RED) { 44 | return 4; 45 | } else if (color === LABEL_COLORS.PURPLE) { 46 | return 5; 47 | } else if (color === LABEL_COLORS.BLUE) { 48 | return 6; 49 | } else if (color === LABEL_COLORS.LIGHTBLUE) { 50 | return 7; 51 | } else { 52 | return 1; 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /client/src/components/FormikComponents/NextBtn.tsx: -------------------------------------------------------------------------------- 1 | import { FormikErrors, useFormikContext } from "formik"; 2 | import React from "react"; 3 | import { useEffect } from "react"; 4 | 5 | interface Props { 6 | text: string; 7 | fieldsOnPage: string[]; 8 | onClick: () => void; 9 | classes?: string; 10 | } 11 | 12 | const containsError = ( 13 | errors: FormikErrors, 14 | fieldsOnPage: string[] 15 | ) => { 16 | let contains = false; 17 | 18 | fieldsOnPage.forEach((field) => { 19 | if (Object.keys(errors).includes(field)) { 20 | contains = true; 21 | } 22 | }); 23 | 24 | return contains; 25 | }; 26 | 27 | const NextBtn = ({ text, classes, fieldsOnPage, onClick }: Props) => { 28 | const { errors, dirty } = useFormikContext(); 29 | 30 | const handler = (e: any) => { 31 | if (e.type === "keydown") { 32 | if (e.key === "Enter") { 33 | e.preventDefault(); 34 | if (!containsError(errors, fieldsOnPage) && dirty) { 35 | onClick(); 36 | } 37 | } 38 | } 39 | }; 40 | 41 | useEffect(() => { 42 | document.addEventListener("keydown", handler, false); 43 | 44 | return () => { 45 | document.removeEventListener("keydown", handler, false); 46 | }; 47 | }, [errors, dirty]); 48 | 49 | return ( 50 | 58 | ); 59 | }; 60 | 61 | export default NextBtn; 62 | -------------------------------------------------------------------------------- /server/utils/token.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import RefreshToken from "../models/refreshTokens.model"; 3 | import { UserTokenObj } from "../types"; 4 | 5 | // 5 mins 6 | export const generateAccessToken = (payload: UserTokenObj) => { 7 | return jwt.sign(payload, process.env.ACCESS_TOKEN_SECRET!, { 8 | expiresIn: "5m", 9 | }); 10 | }; 11 | 12 | // 7 days 13 | export const generateRefreshToken = async (payload: UserTokenObj) => { 14 | // check if a valid refresh token exists in database, if so return that, else new one 15 | const tokenExists = await RefreshToken.findOne({ userId: payload._id }); 16 | 17 | if (tokenExists) { 18 | try { 19 | await jwt.verify( 20 | tokenExists.refreshToken, 21 | process.env.REFRESH_TOKEN_SECRET! 22 | ); 23 | 24 | return tokenExists.refreshToken; 25 | } catch (e) { 26 | // delete old token 27 | await RefreshToken.deleteOne({ _id: tokenExists._id }); 28 | 29 | // generate new one 30 | const newToken = jwt.sign(payload, process.env.REFRESH_TOKEN_SECRET!, { 31 | expiresIn: "7d", 32 | }); 33 | 34 | const refreshDoc = new RefreshToken({ 35 | userId: payload._id, 36 | refreshToken: newToken, 37 | }); 38 | await refreshDoc.save(); 39 | return newToken; 40 | } 41 | } else { 42 | const newToken = jwt.sign(payload, process.env.REFRESH_TOKEN_SECRET!, { 43 | expiresIn: "7d", 44 | }); 45 | 46 | const refreshDoc = new RefreshToken({ 47 | userId: payload._id, 48 | refreshToken: newToken, 49 | }); 50 | await refreshDoc.save(); 51 | return newToken; 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /server/utils/lexorank.ts: -------------------------------------------------------------------------------- 1 | export class Lexorank { 2 | MIN_CHAR: number = this.byte("0"); 3 | MAX_CHAR: number = this.byte("z"); 4 | 5 | insert(prev: string, next: string): [string, boolean] { 6 | if (prev == "") { 7 | prev = this.string(this.MIN_CHAR); 8 | } 9 | if (next == "") { 10 | next = this.string(this.MAX_CHAR); 11 | } 12 | 13 | let rank: string = ""; 14 | let i: number = 0; 15 | 16 | while (true) { 17 | let prevChar: number = this.getChar(prev, i, this.MIN_CHAR); 18 | let nextChar: number = this.getChar(next, i, this.MAX_CHAR); 19 | 20 | if (prevChar == nextChar) { 21 | rank += this.string(prevChar); 22 | i++; 23 | continue; 24 | } 25 | 26 | let midChar: number = this.mid(prevChar, nextChar); 27 | if (midChar == prevChar || midChar == nextChar) { 28 | rank += this.string(prevChar); 29 | i++; 30 | continue; 31 | } 32 | 33 | rank += this.string(midChar); 34 | break; 35 | } 36 | 37 | if (rank >= next) { 38 | return [prev, false]; 39 | } 40 | return [rank, true]; 41 | } 42 | 43 | string(byte: number): string { 44 | return String.fromCharCode(byte); 45 | } 46 | 47 | byte(char: string): number { 48 | return char.charCodeAt(0); 49 | } 50 | 51 | mid(prev: number, next: number): number { 52 | // TODO: consider to use 8 steps each jump 53 | return Math.floor((prev + next) / 2); 54 | } 55 | 56 | getChar(s: string, i: number, defaultChar: number): number { 57 | if (i >= s.length) { 58 | return defaultChar; 59 | } 60 | return this.byte(s.charAt(i)); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/src/components/BoardMembers/BoardMembers.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BoardMemberObj } from "../../types"; 3 | import { BOARD_ROLES } from "../../types/constants"; 4 | import BoardMember from "./BoardMember"; 5 | 6 | interface Props { 7 | boardId: string; 8 | spaceId: string; 9 | role: 10 | | typeof BOARD_ROLES.ADMIN 11 | | typeof BOARD_ROLES.NORMAL 12 | | typeof BOARD_ROLES.OBSERVER; 13 | members: BoardMemberObj[]; 14 | } 15 | 16 | const BoardMembers = ({ boardId, spaceId, role, members }: Props) => { 17 | const boardAdmins = members.filter((m: any) => m.role === BOARD_ROLES.ADMIN); 18 | 19 | return ( 20 |
    21 | {members.length > 8 ? ( 22 |
    23 | {members.slice(0, 8).map((m: BoardMemberObj) => ( 24 | 32 | ))}{" "} 33 |
    +{members.slice(8).length}
    34 |
    35 | ) : ( 36 | members.map((m: BoardMemberObj) => ( 37 | 45 | )) 46 | )} 47 |
    48 | ); 49 | }; 50 | 51 | export default BoardMembers; 52 | -------------------------------------------------------------------------------- /client/src/components/FormikComponents/ColorLabel.tsx: -------------------------------------------------------------------------------- 1 | import { useField, useFormikContext } from "formik"; 2 | import React, { useEffect, useState } from "react"; 3 | import { LABEL_COLORS } from "../../types/constants"; 4 | 5 | interface Props { 6 | label: string; 7 | name: string; 8 | selected?: string; 9 | classes?: string; 10 | } 11 | 12 | const ColorLabel = ({ label, name, selected, classes }: Props) => { 13 | const { setFieldValue } = useFormikContext(); 14 | const [colors, setColors] = useState(LABEL_COLORS); 15 | const [currentChoosen, setCurrentChoosen] = useState(selected || colors[0]); 16 | 17 | useEffect(() => { 18 | setFieldValue(name, currentChoosen); 19 | }, [currentChoosen]); 20 | 21 | return ( 22 |
    23 |
    24 | 25 | 26 |
    27 | {colors.map((color) => ( 28 | 41 | ))} 42 |
    43 |
    44 |
    45 | ); 46 | }; 47 | 48 | export default ColorLabel; 49 | -------------------------------------------------------------------------------- /client/src/utils/lexorank.ts: -------------------------------------------------------------------------------- 1 | export class Lexorank { 2 | MIN_CHAR: number = this.byte('0'); 3 | MAX_CHAR: number = this.byte('z'); 4 | 5 | insert(prev: string, next: string): [string, boolean] { 6 | if (prev == '') { 7 | prev = this.string(this.MIN_CHAR); 8 | } 9 | if (next == '') { 10 | next = this.string(this.MAX_CHAR); 11 | } 12 | 13 | let rank: string = ''; 14 | let i: number = 0; 15 | 16 | while (true) { 17 | let prevChar: number = this.getChar(prev, i, this.MIN_CHAR); 18 | let nextChar: number = this.getChar(next, i, this.MAX_CHAR); 19 | 20 | if (prevChar == nextChar) { 21 | rank += this.string(prevChar); 22 | i++; 23 | continue; 24 | } 25 | 26 | let midChar: number = this.mid(prevChar, nextChar); 27 | if (midChar == prevChar || midChar == nextChar) { 28 | rank += this.string(prevChar); 29 | i++; 30 | continue; 31 | } 32 | 33 | rank += this.string(midChar); 34 | break; 35 | } 36 | 37 | if (rank >= next) { 38 | return [prev, false]; 39 | } 40 | return [rank, true]; 41 | } 42 | 43 | string(byte: number): string { 44 | return String.fromCharCode(byte); 45 | } 46 | 47 | byte(char: string): number { 48 | return char.charCodeAt(0); 49 | } 50 | 51 | mid(prev: number, next: number): number { 52 | // TODO: consider to use 8 steps each jump 53 | return Math.floor((prev + next) / 2); 54 | } 55 | 56 | getChar(s: string, i: number, defaultChar: number): number { 57 | if (i >= s.length) { 58 | return defaultChar; 59 | } 60 | return this.byte(s.charAt(i)); 61 | } 62 | } -------------------------------------------------------------------------------- /client/src/components/Toasts/Toasts.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { toast } from "react-toastify"; 4 | import { RootState } from "../../redux/app"; 5 | import { removeToast } from "../../redux/features/toastSlice"; 6 | import { DEFAULT, ERROR, INFO, SUCCESS, WARNING } from "../../types/constants"; 7 | 8 | const Toasts = () => { 9 | const { toasts } = useSelector((state: RootState) => state.toast); 10 | 11 | const dispatch = useDispatch(); 12 | 13 | useEffect(() => { 14 | toasts && 15 | toasts.forEach((t) => { 16 | if (t.kind === ERROR) 17 | toast.error(t.msg, { 18 | toastId: t.kind + t.msg, 19 | onClose: () => { 20 | dispatch(removeToast(t)); 21 | }, 22 | }); 23 | if (t.kind === SUCCESS) 24 | toast.success(t.msg, { 25 | toastId: t.kind + t.msg, 26 | onClose: () => { 27 | dispatch(removeToast(t)); 28 | }, 29 | }); 30 | if (t.kind === INFO) 31 | toast.info(t.msg, { 32 | toastId: t.kind + t.msg, 33 | onClose: () => { 34 | dispatch(removeToast(t)); 35 | }, 36 | }); 37 | if (t.kind === WARNING) 38 | toast.warn(t.msg, { 39 | toastId: t.kind + t.msg, 40 | onClose: () => { 41 | dispatch(removeToast(t)); 42 | }, 43 | }); 44 | if (t.kind === DEFAULT) 45 | toast(t.msg, { 46 | toastId: t.kind + t.msg, 47 | onClose: () => { 48 | dispatch(removeToast(t)); 49 | }, 50 | }); 51 | }); 52 | }, [toasts]); 53 | return <>; 54 | }; 55 | 56 | export default Toasts; 57 | -------------------------------------------------------------------------------- /client/src/components/FormikComponents/TextArea.tsx: -------------------------------------------------------------------------------- 1 | import { useField } from "formik"; 2 | import React from "react"; 3 | import ErrorBox from "./ErrorBox"; 4 | 5 | interface Props { 6 | label: string; 7 | id: string; 8 | name: string; 9 | disabled?: boolean; 10 | optional?: boolean; 11 | inline?: boolean; 12 | classes?: string; 13 | } 14 | 15 | const TextArea = ({ 16 | label, 17 | id, 18 | name, 19 | classes, 20 | disabled = false, 21 | optional = false, 22 | inline = false, 23 | ...props 24 | }: Props) => { 25 | // props -> every props except label and type -> { name: 'value', id: 'value' } 26 | const [field, meta] = useField(name); 27 | 28 | return ( 29 |
    30 |
    35 | 44 | 58 |
    59 | {meta.touched && meta.error && } 60 |
    61 | ); 62 | }; 63 | 64 | export default TextArea; 65 | -------------------------------------------------------------------------------- /client/src/components/Options/Options.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useState } from "react"; 3 | import ReactDOM from "react-dom"; 4 | import useClose from "../../hooks/useClose"; 5 | 6 | interface Props { 7 | show: boolean; 8 | setShow: React.Dispatch>; 9 | x: number; 10 | y: number; 11 | } 12 | 13 | const Options: React.FC = ({ children, show, setShow, x, y }) => { 14 | const [isSet, setIsSet] = useState(false); 15 | 16 | useEffect(() => { 17 | setIsSet(true); 18 | 19 | document.body.style.overflow = "hidden"; 20 | 21 | return () => { 22 | document.body.style.overflowX = "hidden"; 23 | document.body.style.overflowY = "auto"; 24 | }; 25 | }, []); 26 | 27 | const optionsBoxRef = useClose(() => setShow(false)); 28 | 29 | if (!isSet) { 30 | return null; 31 | } 32 | 33 | return ( 34 | show && ( 35 |
    41 |
      window.innerHeight / 2 + 20 46 | ? { 47 | left: x - 20, 48 | bottom: window.innerHeight - y - 7, 49 | minWidth: "150px", 50 | } 51 | : { 52 | left: x - 20, 53 | top: y - 50, 54 | minWidth: "150px", 55 | } 56 | } 57 | > 58 | {children} 59 |
    60 |
    61 | ) 62 | ); 63 | }; 64 | 65 | export default Options; 66 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc && vite build", 7 | "preview": "vite preview" 8 | }, 9 | "dependencies": { 10 | "@reduxjs/toolkit": "^1.7.1", 11 | "axios": "^0.24.0", 12 | "date-fns": "^2.28.0", 13 | "debounce-promise": "^3.1.2", 14 | "formik": "^2.2.9", 15 | "javascript-time-ago": "^2.3.13", 16 | "jwt-decode": "^3.1.2", 17 | "react": "^17.0.2", 18 | "react-avatar": "^3.7.0", 19 | "react-beautiful-dnd": "^13.1.0", 20 | "react-dom": "^17.0.2", 21 | "react-google-login": "^5.2.2", 22 | "react-hot-toast": "^2.2.0", 23 | "react-icons": "^4.3.1", 24 | "react-loading-skeleton": "^3.0.3", 25 | "react-masonry-css": "^1.0.16", 26 | "react-query": "^3.34.7", 27 | "react-redux": "^7.2.6", 28 | "react-router-dom": "^6.2.1", 29 | "react-select": "^5.2.2", 30 | "react-toastify": "^8.1.0", 31 | "react-tooltip": "^4.2.21", 32 | "yup": "^0.32.11" 33 | }, 34 | "devDependencies": { 35 | "@types/axios": "^0.14.0", 36 | "@types/debounce-promise": "^3.1.4", 37 | "@types/jwt-decode": "^3.1.0", 38 | "@types/react": "^17.0.33", 39 | "@types/react-beautiful-dnd": "^13.1.2", 40 | "@types/react-dom": "^17.0.10", 41 | "@types/react-icons": "^3.0.0", 42 | "@types/react-query": "^1.2.9", 43 | "@types/react-router-dom": "^5.3.2", 44 | "@types/react-select": "^5.0.1", 45 | "@types/react-toastify": "^4.1.0", 46 | "@types/react-tooltip": "^4.2.4", 47 | "@types/react-virtualized": "^9.21.16", 48 | "@types/react-window": "^1.8.5", 49 | "@types/yup": "^0.29.13", 50 | "@vitejs/plugin-react": "^1.0.7", 51 | "autoprefixer": "^10.4.1", 52 | "postcss": "^8.4.5", 53 | "tailwindcss": "^3.0.8", 54 | "typescript": "^4.4.4", 55 | "vite": "^2.7.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/SpaceList/BoardList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDispatch } from "react-redux"; 3 | import { showModal } from "../../../redux/features/modalSlice"; 4 | import { BoardObj } from "../../../types"; 5 | import { CREATE_BOARD_MODAL } from "../../../types/constants"; 6 | import BoardItem from "./BoardItem"; 7 | 8 | interface Props { 9 | spaceId: string; 10 | boards: BoardObj[]; 11 | setShowPlusIcon: React.Dispatch>; 12 | setShowBoardOptions: React.Dispatch>; 13 | } 14 | 15 | const BoardList = ({ 16 | spaceId, 17 | boards, 18 | setShowPlusIcon, 19 | setShowBoardOptions, 20 | }: Props) => { 21 | const dispatch = useDispatch(); 22 | 23 | return ( 24 |
      25 | {boards.length > 0 ? ( 26 | boards.map((b) => ( 27 | 36 | )) 37 | ) : ( 38 |
    • 39 | Create a{" "} 40 | {" "} 57 |
    • 58 | )} 59 |
    60 | ); 61 | }; 62 | 63 | export default BoardList; 64 | -------------------------------------------------------------------------------- /client/src/components/UtilityBtn/UtilityBtn.tsx: -------------------------------------------------------------------------------- 1 | import ReactTooltip from "react-tooltip"; 2 | import { IconType } from "react-icons"; 3 | 4 | interface Props { 5 | Icon: IconType; 6 | label: string; 7 | uniqueId: string; 8 | classes?: string; 9 | iconSize?: number; 10 | iconColor?: string; 11 | iconFillColor?: string; 12 | iconClasses?: string; 13 | isSubmitting?: boolean; 14 | onClick?: Function; 15 | color?: string; 16 | tooltipPosition?: "bottom" | "right" | "left" | "top"; 17 | } 18 | 19 | const UtilityBtn = ({ 20 | Icon, 21 | label, 22 | uniqueId, 23 | classes, 24 | iconColor, 25 | iconFillColor, 26 | iconSize = 18, 27 | iconClasses, 28 | onClick, 29 | isSubmitting = false, 30 | color = "primary", 31 | tooltipPosition = "bottom", 32 | }: Props) => { 33 | return ( 34 | <> 35 | 56 | {label && ( 57 | 63 | {label} 64 | 65 | )} 66 | 67 | ); 68 | }; 69 | 70 | export default UtilityBtn; 71 | -------------------------------------------------------------------------------- /client/src/components/FormikComponents/Input.tsx: -------------------------------------------------------------------------------- 1 | import { useField } from "formik"; 2 | import ErrorBox from "./ErrorBox"; 3 | 4 | interface Props { 5 | label: string; 6 | type: string; 7 | id: string; 8 | name: string; 9 | disabled?: boolean; 10 | autoFocus?: boolean; 11 | optional?: boolean; 12 | inline?: boolean; 13 | classes?: string; 14 | } 15 | 16 | const Input = ({ 17 | label, 18 | type, 19 | id, 20 | name, 21 | classes, 22 | disabled = false, 23 | autoFocus = false, 24 | optional = false, 25 | inline = false, 26 | ...props 27 | }: Props) => { 28 | // field -> { name: string, value: string, onChange: () => {}, onBlur: () => {} } 29 | // meta -> { touched: boolean, error: string, ... } 30 | const [field, meta] = useField({ name, type }); 31 | 32 | return ( 33 |
    34 |
    39 | 48 | 49 | 63 |
    64 | {meta.touched && meta.error && } 65 |
    66 | ); 67 | }; 68 | 69 | export default Input; 70 | -------------------------------------------------------------------------------- /client/src/pages/EmailVerify.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import React, { useEffect } from "react"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import { useNavigate, useParams, useSearchParams } from "react-router-dom"; 5 | import axiosInstance from "../axiosInstance"; 6 | import { logoutUser } from "../redux/features/authSlice"; 7 | import { addToast } from "../redux/features/toastSlice"; 8 | import { ERROR, SUCCESS } from "../types/constants"; 9 | 10 | const EmailVerify = () => { 11 | const dispatch = useDispatch(); 12 | const navigate = useNavigate(); 13 | 14 | const params = useParams(); 15 | const [searchParams, setSearchParams] = useSearchParams(); 16 | 17 | useEffect(() => { 18 | axiosInstance 19 | .get(`/email/verify/${params.token}?wuid=${searchParams.get("wuid")}`) 20 | .then((response) => { 21 | const { message } = response.data; 22 | 23 | dispatch(addToast({ kind: SUCCESS, msg: message })); 24 | 25 | navigate("/", { replace: true }); 26 | }) 27 | .catch((error) => { 28 | if (error.response) { 29 | const response = error.response; 30 | const { message } = response.data; 31 | 32 | switch (response.status) { 33 | case 400: 34 | case 500: 35 | dispatch(addToast({ kind: ERROR, msg: message })); 36 | break; 37 | default: 38 | dispatch( 39 | addToast({ kind: ERROR, msg: "Oops, something went wrong" }) 40 | ); 41 | break; 42 | } 43 | } else if (error.request) { 44 | dispatch( 45 | addToast({ kind: ERROR, msg: "Oops, something went wrong" }) 46 | ); 47 | } else { 48 | dispatch(addToast({ kind: ERROR, msg: `Error: ${error.message}` })); 49 | } 50 | 51 | navigate("/", { replace: true }); 52 | }); 53 | }, []); 54 | 55 | return
    ; 56 | }; 57 | 58 | export default EmailVerify; 59 | -------------------------------------------------------------------------------- /client/src/redux/features/authSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { UserObj } from "../../types"; 3 | import { 4 | getTokens, 5 | removeTokens, 6 | saveAccessTokens, 7 | saveTokens, 8 | } from "../../utils/helpers"; 9 | 10 | interface Tokens { 11 | accessToken: string; 12 | refreshToken: string; 13 | } 14 | 15 | interface AuthState { 16 | accessToken: string | null; 17 | refreshToken: string | null; 18 | user: UserObj | null; 19 | } 20 | 21 | const initialState: AuthState = { 22 | accessToken: getTokens().accessToken, 23 | refreshToken: getTokens().refreshToken, 24 | user: null, 25 | }; 26 | 27 | const authSlice = createSlice({ 28 | name: "auth", 29 | initialState, 30 | reducers: { 31 | loginUser: (state, action: PayloadAction) => { 32 | const { accessToken, refreshToken } = action.payload; 33 | 34 | state.accessToken = accessToken; 35 | state.refreshToken = refreshToken; 36 | 37 | saveTokens(accessToken, refreshToken); 38 | }, 39 | logoutUser: (state) => { 40 | removeTokens(); 41 | 42 | // state.accessToken = null; 43 | // state.refreshToken = null; 44 | // state.user = null; 45 | window.location.reload(); 46 | }, 47 | setAccessToken: (state, action: PayloadAction) => { 48 | const accessToken = action.payload; 49 | state.accessToken = accessToken; 50 | 51 | saveAccessTokens(accessToken); 52 | }, 53 | setCurrentUser: (state, action: PayloadAction) => { 54 | state.user = action.payload; 55 | }, 56 | setEmailVerified: (state, action: PayloadAction) => { 57 | if (state.user) { 58 | state.user = { 59 | ...state.user, 60 | emailVerified: action.payload, 61 | }; 62 | } 63 | }, 64 | }, 65 | }); 66 | 67 | export const { 68 | loginUser, 69 | logoutUser, 70 | setAccessToken, 71 | setCurrentUser, 72 | setEmailVerified, 73 | } = authSlice.actions; 74 | 75 | export default authSlice.reducer; 76 | -------------------------------------------------------------------------------- /client/src/components/layouts/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useQuery } from "react-query"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import axiosInstance from "../../axiosInstance"; 5 | import EmailNotVerified from "../../pages/EmailNotVerified"; 6 | import { RootState } from "../../redux/app"; 7 | import { logoutUser, setCurrentUser } from "../../redux/features/authSlice"; 8 | import { UserObj } from "../../types"; 9 | import DefaultLayout from "./DefaultLayout"; 10 | 11 | // deciding layout 12 | const MainLayout = () => { 13 | const dispatch = useDispatch(); 14 | 15 | const { user } = useSelector((state: RootState) => state.auth); 16 | 17 | // get current user info 18 | const getCurrentUser = async () => { 19 | const response = await axiosInstance.get(`/users/getCurrentUser`); 20 | const { data } = response.data; 21 | 22 | return data; 23 | }; 24 | 25 | const { data, error } = useQuery( 26 | ["getCurrentUser"], 27 | getCurrentUser 28 | ); 29 | 30 | useEffect(() => { 31 | if (data) { 32 | dispatch( 33 | setCurrentUser({ 34 | _id: data._id, 35 | username: data.username, 36 | email: data.email, 37 | profile: data.profile, 38 | emailVerified: data.emailVerified, 39 | isOAuth: data.isOAuth, 40 | }) 41 | ); 42 | } 43 | }, [data]); 44 | 45 | // if (error) { 46 | // if (error.response) { 47 | // const response = error.response; 48 | 49 | // if (response.status === 401) { 50 | // dispatch(logoutUser()); 51 | // return <>; 52 | // } 53 | // } 54 | // } 55 | 56 | if (user) { 57 | if (user.emailVerified === true) { 58 | return ; 59 | } else { 60 | return ; 61 | } 62 | } 63 | 64 | return ( 65 |
    66 | {error ? "Something went wrong, please refresh the screen" : "Loading..."} 67 |
    68 | ); 69 | }; 70 | 71 | export default MainLayout; 72 | -------------------------------------------------------------------------------- /client/src/components/layouts/DefaultLayout.tsx: -------------------------------------------------------------------------------- 1 | import { lazy, Suspense } from "react"; 2 | import { useSelector } from "react-redux"; 3 | import { Navigate, Route, Routes } from "react-router-dom"; 4 | import BoardDetail from "../../pages/spaces/boards/BoardDetail"; 5 | import { RootState } from "../../redux/app"; 6 | import Header from "../Header/Header"; 7 | import Modal from "../Modal/Modal"; 8 | import Sidebar from "../Sidebar/Sidebar"; 9 | import SpaceLayout from "./SpaceLayout"; 10 | 11 | const Home = lazy(() => import("../../pages/Home")); 12 | const Settings = lazy(() => import("../../pages/Settings")); 13 | const Error404 = lazy(() => import("../../pages/Error404")); 14 | 15 | const DefaultLayout = () => { 16 | const { show } = useSelector((state: RootState) => state.sidebar); 17 | const modal = useSelector((state: RootState) => state.modal); 18 | 19 | return ( 20 |
    21 |
    22 | 23 | 24 |
    30 |
    31 |
    32 | Loading...
    }> 33 | 34 | } /> 35 | } /> 36 | 37 | {/* /s/:id/* */} 38 | } /> 39 | 40 | {/* /b/:id */} 41 | } /> 42 | 43 | {/* /404 */} 44 | } /> 45 | } 48 | /> 49 | 50 | 51 |
    52 | 53 |
    54 | 55 | {/* modal */} 56 | {modal.modalType !== null && } 57 | 58 | ); 59 | }; 60 | 61 | export default DefaultLayout; 62 | -------------------------------------------------------------------------------- /client/src/components/FormikComponents/Select.tsx: -------------------------------------------------------------------------------- 1 | import { useField } from "formik"; 2 | import React, { useEffect } from "react"; 3 | import { Option } from "../../types"; 4 | import ErrorBox from "./ErrorBox"; 5 | 6 | interface Props { 7 | label: string; 8 | id: string; 9 | name: string; 10 | options: Option[]; 11 | selected?: string; 12 | inline?: boolean; 13 | classes?: string; 14 | } 15 | 16 | const Select = ({ 17 | label, 18 | id, 19 | name, 20 | options = [], 21 | selected, 22 | classes, 23 | inline, 24 | ...props 25 | }: Props) => { 26 | // props -> every props except label and options -> { name: 'value', id: 'value' } 27 | const [field, meta, helpers] = useField(name); 28 | 29 | useEffect(() => { 30 | if (options.length > 0 && !field.value) { 31 | const exists = selected 32 | ? options.find((o) => o.value === selected) 33 | : undefined; 34 | 35 | // if selected is given and it is also found in the given options 36 | if (exists) { 37 | helpers.setValue(exists.value); 38 | } else { 39 | helpers.setValue(options[0].value); 40 | } 41 | } 42 | }, [options]); 43 | 44 | return ( 45 |
    46 |
    51 | 57 | 73 |
    74 | {meta.touched && meta.error && } 75 |
    76 | ); 77 | }; 78 | 79 | export default Select; 80 | -------------------------------------------------------------------------------- /client/src/types/constants.ts: -------------------------------------------------------------------------------- 1 | // toast types 2 | export const ERROR = "ERROR"; 3 | export const SUCCESS = "SUCCESS"; 4 | export const INFO = "INFO"; 5 | export const WARNING = "WARNING"; 6 | export const DEFAULT = "DEFAULT"; 7 | 8 | // modal types 9 | export const CREATE_SPACE_MODAL = "CREATE_SPACE_MODAL"; 10 | export const CREATE_BOARD_MODAL = "CREATE_BOARD_MODAL"; 11 | export const INVITE_SPACE_MEMBER_MODAL = "INVITE_SPACE_MEMBER_MODAL"; 12 | export const CONFIRM_LEAVE_SPACE_MODAL = "CONFIRM_LEAVE_SPACE_MODAL"; 13 | export const CONFIRM_REMOVE_SPACE_MEMBER_MODAL = 14 | "CONFIRM_REMOVE_SPACE_MEMBER_MODAL"; 15 | export const CONFIRM_DELETE_SPACE_MODAL = "CONFIRM_DELETE_SPACE_MODAL"; 16 | export const CONFIRM_DELETE_BOARD_MODAL = "CONFIRM_DELETE_BOARD_MODAL"; 17 | export const CONFIRM_LEAVE_BOARD_MODAL = "CONFIRM_LEAVE_BOARD_MODAL"; 18 | export const CONFIRM_REMOVE_BOARD_MEMBER_MODAL = 19 | "CONFIRM_REMOVE_BOARD_MEMBER_MODAL"; 20 | export const CARD_DETAIL_MODAL = "CARD_DETAIL_MODAL"; 21 | export const BOARD_LABEL_MODAL = "BOARD_LABEL_MODAL"; 22 | 23 | // types 24 | export const SPACE = "SPACE"; 25 | export const BOARD = "BOARD"; 26 | 27 | // SPACE ROLES 28 | export const SPACE_ROLES = { 29 | ADMIN: "ADMIN", 30 | NORMAL: "NORMAL", 31 | GUEST: "GUEST", 32 | }; 33 | 34 | // SPACE ROLES 35 | export const BOARD_ROLES = { 36 | ADMIN: "ADMIN", 37 | NORMAL: "NORMAL", 38 | OBSERVER: "OBSERVER", 39 | }; 40 | 41 | // BOARD VISIBILITY TYPES 42 | export const BOARD_VISIBILITY_TYPES = { 43 | PUBLIC: "PUBLIC", 44 | PRIVATE: "PRIVATE", 45 | }; 46 | 47 | // BOARD BG COLORS 48 | export const BOARD_COLORS = [ 49 | "#cdb4db", 50 | "#ffafcc", 51 | "#a2d2ff", 52 | "#ff595e", 53 | "#ffca3a", 54 | "#90be6d", 55 | ]; 56 | 57 | export const LABEL_COLORS = [ 58 | "#61BD4F", 59 | "#F1D737", 60 | "#FA9F2E", 61 | "#EB5A46", 62 | "#C377E0", 63 | "#0079BF", 64 | "#3BC3E0", 65 | ]; 66 | 67 | // LIST POSSIBLE DRAGS 68 | export const LIST_POSSIBLE_DRAGS = { 69 | LEFT: "LEFT", 70 | RIGHT: "RIGHT", 71 | }; 72 | 73 | // CARD POSSIBLE DRAGS 74 | export const CARD_POSSIBLE_DRAGS = { 75 | DOWN: "DOWN", 76 | UP: "UP", 77 | }; 78 | 79 | // DUE DATE STATUSES 80 | export const DUE_DATE_STATUSES = { 81 | OVERDUE: "OVERDUE", 82 | COMPLETE: "COMPLETE", 83 | }; 84 | -------------------------------------------------------------------------------- /server/models/card.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import validator from "validator"; 3 | 4 | const cardSchema = new mongoose.Schema( 5 | { 6 | name: { 7 | type: String, 8 | required: true, 9 | minlength: 1, 10 | maxlength: 512, 11 | trim: true, 12 | }, 13 | pos: { 14 | type: String, 15 | required: true, 16 | trim: true, 17 | }, 18 | listId: { 19 | type: mongoose.Schema.Types.ObjectId, 20 | ref: "List", 21 | required: true, 22 | }, 23 | description: { 24 | type: String, 25 | required: false, 26 | trim: true, 27 | }, 28 | coverImg: { 29 | type: String, 30 | required: false, 31 | validate: { 32 | validator: function (value: string) { 33 | return value 34 | ? validator.isURL(value, { 35 | require_protocol: true, 36 | }) 37 | : true; 38 | }, 39 | message: `Invalid Image URL`, 40 | }, 41 | trim: true, 42 | }, 43 | color: { 44 | type: String, 45 | required: false, 46 | trim: true, 47 | }, 48 | dueDate: { 49 | type: Date, 50 | required: false, 51 | }, 52 | isComplete: { 53 | type: Boolean, 54 | default: false, 55 | }, 56 | members: { 57 | type: [ 58 | { 59 | type: mongoose.Schema.Types.ObjectId, 60 | ref: "User", 61 | required: true, 62 | }, 63 | ], 64 | default: [], 65 | }, 66 | labels: { 67 | type: [ 68 | { 69 | type: mongoose.Schema.Types.ObjectId, 70 | ref: "Label", 71 | required: true, 72 | }, 73 | ], 74 | default: [], 75 | }, 76 | comments: { 77 | type: [ 78 | { 79 | type: mongoose.Schema.Types.ObjectId, 80 | ref: "Comment", 81 | required: true, 82 | }, 83 | ], 84 | default: [], 85 | }, 86 | creator: { 87 | type: mongoose.Schema.Types.ObjectId, 88 | ref: "User", 89 | required: true, 90 | }, 91 | }, 92 | { timestamps: true } 93 | ); 94 | 95 | const Card = mongoose.model("Card", cardSchema); 96 | 97 | export default Card; 98 | -------------------------------------------------------------------------------- /client/src/components/RecentBoards/RecentBoard.tsx: -------------------------------------------------------------------------------- 1 | import { AxiosError } from "axios"; 2 | import React, { useCallback, useState } from "react"; 3 | import { HiOutlineLockClosed, HiOutlineStar } from "react-icons/hi"; 4 | import { useQueryClient } from "react-query"; 5 | import { useDispatch } from "react-redux"; 6 | import { Link, useNavigate } from "react-router-dom"; 7 | import axiosInstance from "../../axiosInstance"; 8 | import { addToast } from "../../redux/features/toastSlice"; 9 | import { BoardObj, SpaceObj } from "../../types"; 10 | import { BOARD_VISIBILITY_TYPES, ERROR } from "../../types/constants"; 11 | import UtilityBtn from "../UtilityBtn/UtilityBtn"; 12 | 13 | interface Props { 14 | board: BoardObj; 15 | } 16 | 17 | const RecentBoard = ({ board }: Props) => { 18 | const [isIn, setIsIn] = useState(false); 19 | 20 | return ( 21 | setIsIn(true)} 23 | onMouseLeave={() => setIsIn(false)} 24 | to={`/b/${board._id}`} 25 | className="board relative h-28 rounded cursor-pointer text-white font-semibold hover:bg-gradient-to-r from-slate-500 to-slate-500 bg-blend-darken" 26 | style={{ 27 | background: board.bgImg ? `url(${board.bgImg})` : board.color, 28 | backgroundRepeat: "no-repeat", 29 | boxShadow: `inset 0 0 0 2000px rgba(0, 0, 0, 0.22)`, 30 | backgroundPosition: "50%", 31 | backgroundOrigin: "border-box", 32 | backgroundSize: "cover", 33 | width: 230, 34 | maxWidth: 230, 35 | backgroundBlendMode: "overlay", 36 | }} 37 | > 38 | {isIn && ( 39 |
    40 | )} 41 |
    42 | {board.name} 43 |
    44 | 45 |
    46 | {board.visibility === BOARD_VISIBILITY_TYPES.PRIVATE && ( 47 | 54 | )} 55 |
    56 | 57 | ); 58 | }; 59 | 60 | export default RecentBoard; 61 | -------------------------------------------------------------------------------- /client/src/components/RecentBoards/RecentBoards.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { HiOutlineRefresh } from "react-icons/hi"; 3 | import { useQuery, useQueryClient } from "react-query"; 4 | import axiosInstance from "../../axiosInstance"; 5 | import { BoardObj } from "../../types"; 6 | import Board from "../Board/Board"; 7 | import Error from "../Error/Error"; 8 | import Loader from "../Loader/Loader"; 9 | import UtilityBtn from "../UtilityBtn/UtilityBtn"; 10 | import RecentBoard from "./RecentBoard"; 11 | 12 | const RecentBoards = () => { 13 | const queryClient = useQueryClient(); 14 | 15 | const getRecentBoards = async ({ queryKey }: any) => { 16 | const response = await axiosInstance.get(`/boards/recentBoards`); 17 | 18 | const { data } = response.data; 19 | 20 | return data; 21 | }; 22 | 23 | const { 24 | data: boards, 25 | isLoading, 26 | error, 27 | } = useQuery( 28 | ["getRecentBoards"], 29 | getRecentBoards 30 | ); 31 | 32 | if (isLoading) { 33 | return ( 34 |
    35 | 36 |
    37 | ); 38 | } 39 | 40 | // handle each error accordingly & specific to that situation 41 | if (error) { 42 | return ( 43 |
    44 |
    45 |

    Oops, something went wrong.

    46 | { 53 | queryClient.invalidateQueries(["getRecentBoards"]); 54 | }} 55 | /> 56 |
    57 |
    58 | ); 59 | } 60 | 61 | return ( 62 |
    63 |
    64 | {boards && boards.length > 0 ? ( 65 |
    66 | {boards.map((b) => ( 67 | 68 | ))} 69 |
    70 | ) : ( 71 |

    No Boards!

    72 | )} 73 |
    74 |
    75 | ); 76 | }; 77 | 78 | export default RecentBoards; 79 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; 2 | import { ToastContainer } from "react-toastify"; 3 | import AuthLayout from "./components/layouts/AuthLayout"; 4 | import Toasts from "./components/Toasts/Toasts"; 5 | import Login from "./pages/auth/Login"; 6 | import Register from "./pages/auth/Register"; 7 | 8 | import "react-toastify/dist/ReactToastify.css"; 9 | 10 | import PrivateRoute from "./PrivateRoute"; 11 | import MainLayout from "./components/layouts/MainLayout"; 12 | import EmailVerify from "./pages/EmailVerify"; 13 | import { WARNING } from "./types/constants"; 14 | import ResetPassword from "./pages/ResetPassword"; 15 | import ForgotPassword from "./pages/ForgotPassword"; 16 | 17 | const App = () => { 18 | return ( 19 |
    20 | 21 | 22 | 23 | 24 | 25 | {/* /password/recover/:token password - new password page */} 26 | } /> 27 | 28 | {/* /reset-password put email here */} 29 | }> 30 | } /> 31 | 32 | 33 | {/* /auth */} 34 | }> 35 | } /> 36 | } /> 37 | 38 | } 41 | /> 42 | 43 | 44 | 53 | 54 | 55 | } 56 | /> 57 | 58 | {/* /* */} 59 | 63 | 64 | 65 | } 66 | /> 67 | 68 | 69 |
    70 | ); 71 | }; 72 | 73 | export default App; 74 | -------------------------------------------------------------------------------- /server/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import bcrypt from "bcrypt"; 3 | import validator from "validator"; 4 | 5 | const saltRounds = 10; 6 | 7 | const userSchema = new mongoose.Schema( 8 | { 9 | username: { 10 | type: String, 11 | required: true, 12 | minlength: 2, 13 | validate: { 14 | validator: function (value: string) { 15 | return /^[A-Za-z0-9_-]*$/.test(value); 16 | }, 17 | message: 18 | "Username must only contain letters, numbers, underscores and dashes", 19 | }, 20 | trim: true, 21 | }, 22 | email: { 23 | type: String, 24 | required: true, 25 | unique: true, 26 | validate: { 27 | validator: function (value: string) { 28 | return validator.isEmail(value); 29 | }, 30 | message: "Invalid email", 31 | }, 32 | trim: true, 33 | }, 34 | profile: { 35 | type: String, 36 | default: "default.jpg", 37 | trim: true, 38 | }, 39 | emailVerified: { 40 | type: Boolean, 41 | required: true, 42 | default: false, 43 | }, 44 | isOAuth: { 45 | type: Boolean, 46 | default: false, 47 | required: true, 48 | }, 49 | password: { 50 | type: String, 51 | required: function (): boolean { 52 | return !this.isOAuth; 53 | }, 54 | minlength: 8, 55 | validate: { 56 | validator: function (value: string) { 57 | if (!/\d/.test(value) || !/[a-zA-Z]/.test(value)) { 58 | return false; 59 | } 60 | }, 61 | message: "Password must contain at least one letter and one number", 62 | }, 63 | trim: true, 64 | }, 65 | joinedAt: { 66 | type: Date, 67 | default: Date.now, 68 | }, 69 | }, 70 | { timestamps: true } 71 | ); 72 | 73 | // middlewares 74 | userSchema.pre("save", async function (next) { 75 | // if password modified, hash it 76 | if (this.isModified("password")) { 77 | // else hash 78 | const salt = await bcrypt.genSalt(saltRounds); 79 | (this as any).password = await bcrypt.hash((this as any).password, salt); 80 | } 81 | 82 | next(); 83 | }); 84 | 85 | // methods 86 | userSchema.methods.comparePassword = async function (enteredPassword: string) { 87 | return await bcrypt.compare(enteredPassword, this.password); 88 | }; 89 | 90 | const User = mongoose.model("User", userSchema); 91 | 92 | export default User; 93 | -------------------------------------------------------------------------------- /client/src/components/FormikComponents/SelectDropDownAsync.tsx: -------------------------------------------------------------------------------- 1 | import { useField } from "formik"; 2 | import React from "react"; 3 | import AsyncSelect from "react-select/async"; 4 | import ErrorBox from "./ErrorBox"; 5 | 6 | interface Props { 7 | label: string; 8 | id: string; 9 | name: string; 10 | loadOptions: (val: string) => Promise; 11 | components?: Object; 12 | isMulti?: boolean; 13 | autoFocus?: boolean; 14 | optional?: boolean; 15 | inline?: boolean; 16 | classes?: string; 17 | } 18 | 19 | const customStyles = { 20 | control: (provided: any, state: any) => { 21 | return { 22 | ...provided, 23 | borderWidth: 2, 24 | boxShadow: "none", 25 | ":hover": { 26 | borderColor: "#8b5cf6", 27 | cursor: "text", 28 | }, 29 | borderColor: state.isFocused ? '#8b5cf6' : '#ddd6fe' 30 | }; 31 | }, 32 | }; 33 | 34 | const SelectDropDownAsync = ({ 35 | label, 36 | id, 37 | name, 38 | loadOptions, 39 | classes, 40 | components = {}, 41 | isMulti = false, 42 | autoFocus = false, 43 | optional = false, 44 | inline = false, 45 | }: Props) => { 46 | const [field, meta, helpers] = useField({ name }); 47 | 48 | return ( 49 |
    50 |
    55 | 64 | 65 | { 72 | helpers.setValue(value); 73 | }} 74 | value={field.value} 75 | autoFocus={autoFocus} 76 | theme={theme => ({ 77 | ...theme, 78 | colors: { 79 | ...theme.colors, 80 | primary25: '#8b5cf6', 81 | } 82 | })} 83 | /> 84 |
    85 | {meta.touched && meta.error && } 86 |
    87 | ); 88 | }; 89 | 90 | export default SelectDropDownAsync; 91 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/FavoritesList/FavoritesList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useQuery, useQueryClient } from "react-query"; 3 | import axiosInstance from "../../../axiosInstance"; 4 | import { FavoriteObj } from "../../../types"; 5 | import { SPACE } from "../../../types/constants"; 6 | import Loader from "../../Loader/Loader"; 7 | import FavoriteItemBoard from "./FavoriteItemBoard"; 8 | import FavoriteItemSpace from "./FavoriteItemSpace"; 9 | 10 | const FavoritesList = () => { 11 | const queryClient = useQueryClient(); 12 | 13 | const getFavorites = async () => { 14 | const response = await axiosInstance.get(`/favorites`); 15 | const { data } = response.data; 16 | 17 | return data; 18 | }; 19 | 20 | const { data, isLoading, error } = useQuery< 21 | FavoriteObj[] | undefined, 22 | any, 23 | FavoriteObj[], 24 | string[] 25 | >(["getFavorites"], getFavorites); 26 | 27 | if (error) { 28 | return ( 29 |
    35 |
    36 | Unable to get data. 37 | 46 |
    47 |
    48 | ); 49 | } 50 | 51 | if (isLoading) { 52 | return ( 53 |
    59 | 60 |
    61 | ); 62 | } 63 | 64 | return ( 65 |
      66 | {data && data.length > 0 ? ( 67 | data.map((fav: FavoriteObj) => { 68 | return fav.type === SPACE ? ( 69 | 70 | ) : ( 71 | 72 | ); 73 | }) 74 | ) : ( 75 |
    • 76 |

      77 | Nothing here 78 |

      79 |
    • 80 | )} 81 |
    82 | ); 83 | }; 84 | 85 | export default FavoritesList; 86 | -------------------------------------------------------------------------------- /client/src/components/GoogleAuth/GoogleAuthBtn.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import GoogleLogin from "react-google-login"; 3 | import { useDispatch } from "react-redux"; 4 | import { useNavigate } from "react-router-dom"; 5 | import axiosInstance from "../../axiosInstance"; 6 | import { loginUser } from "../../redux/features/authSlice"; 7 | 8 | interface Props { 9 | setCommonError: React.Dispatch>; 10 | setIsSubmitting: React.Dispatch>; 11 | } 12 | 13 | const GoogleAuthBtn = ({ setCommonError, setIsSubmitting }: Props) => { 14 | const dispatch = useDispatch(); 15 | 16 | const googleSuccess = async (response: any) => { 17 | const tokenId = response?.tokenId; 18 | 19 | if (!tokenId) { 20 | setCommonError("Oops, something went wrong"); 21 | } else { 22 | axiosInstance 23 | .post(`/auth/google`, { 24 | tokenId: tokenId, 25 | }) 26 | .then((response) => { 27 | const { data } = response.data; 28 | 29 | setCommonError(""); 30 | 31 | setIsSubmitting(false); 32 | 33 | dispatch( 34 | loginUser({ 35 | accessToken: data.accessToken, 36 | refreshToken: data.refreshToken, 37 | }) 38 | ); 39 | }) 40 | .catch((error) => { 41 | setCommonError(""); 42 | setIsSubmitting(false); 43 | 44 | if (error.response) { 45 | const response = error.response; 46 | const { message } = response.data; 47 | 48 | switch (response.status) { 49 | case 400: 50 | case 500: 51 | setCommonError(message); 52 | break; 53 | default: 54 | setCommonError("Oops, something went wrong"); 55 | break; 56 | } 57 | } else if (error.request) { 58 | setCommonError("Oops, something went wrong"); 59 | } else { 60 | setCommonError(`Error: ${error.message}`); 61 | } 62 | }); 63 | } 64 | }; 65 | 66 | const googleFailure = (error: any) => { 67 | setCommonError("Unable to get profile information from Google"); 68 | }; 69 | 70 | return ( 71 | 78 | ); 79 | }; 80 | 81 | export default GoogleAuthBtn; 82 | -------------------------------------------------------------------------------- /client/src/components/MyCards/MyCards.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { HiOutlineRefresh } from "react-icons/hi"; 3 | import { useQuery, useQueryClient } from "react-query"; 4 | import Masonry from "react-masonry-css"; 5 | import axiosInstance from "../../axiosInstance"; 6 | import { BoardObj, CardObj, CardObjExt } from "../../types"; 7 | import Board from "../Board/Board"; 8 | import Error from "../Error/Error"; 9 | import Loader from "../Loader/Loader"; 10 | import UtilityBtn from "../UtilityBtn/UtilityBtn"; 11 | import MyCard from "./MyCard"; 12 | 13 | const MyCards = () => { 14 | const queryClient = useQueryClient(); 15 | 16 | const getAllMyCards = async ({ queryKey }: any) => { 17 | const response = await axiosInstance.get(`/cards/all`); 18 | 19 | const { data } = response.data; 20 | 21 | return data; 22 | }; 23 | 24 | const { 25 | data: cards, 26 | isLoading, 27 | error, 28 | } = useQuery( 29 | ["getAllMyCards"], 30 | getAllMyCards 31 | ); 32 | 33 | if (isLoading) { 34 | return ( 35 |
    36 | 37 |
    38 | ); 39 | } 40 | 41 | // handle each error accordingly & specific to that situation 42 | if (error) { 43 | return ( 44 |
    45 |
    46 |

    Oops, something went wrong.

    47 | { 54 | queryClient.invalidateQueries(["getAllMyCards"]); 55 | }} 56 | /> 57 |
    58 |
    59 | ); 60 | } 61 | 62 | return ( 63 |
    64 |
    65 | {cards && cards.length > 0 ? ( 66 | 76 | {cards.map((c) => ( 77 | 78 | ))} 79 | 80 | ) : ( 81 |

    No Cards!

    82 | )} 83 |
    84 |
    85 | ); 86 | }; 87 | 88 | export default MyCards; 89 | -------------------------------------------------------------------------------- /server/models/board.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import validator from "validator"; 3 | import { BOARD_MEMBER_ROLES, BOARD_VISIBILITY } from "../types/constants"; 4 | 5 | const boardMemberSchema = new mongoose.Schema( 6 | { 7 | memberId: { 8 | type: mongoose.Schema.Types.ObjectId, 9 | ref: "User", 10 | required: true, 11 | }, 12 | role: { 13 | type: String, 14 | enum: Object.values(BOARD_MEMBER_ROLES), 15 | default: BOARD_MEMBER_ROLES.NORMAL, 16 | }, 17 | fallbackRole: { 18 | type: String, 19 | enum: [...Object.values(BOARD_MEMBER_ROLES)], 20 | required: false, 21 | }, 22 | }, 23 | { _id: false, timestamps: true } 24 | ); 25 | 26 | const boardSchema = new mongoose.Schema( 27 | { 28 | name: { 29 | type: String, 30 | required: true, 31 | minlength: 1, 32 | maxlength: 512, 33 | trim: true, 34 | }, 35 | visibility: { 36 | type: String, 37 | enum: Object.values(BOARD_VISIBILITY), 38 | default: BOARD_VISIBILITY.PUBLIC, 39 | }, 40 | description: { 41 | type: String, 42 | required: false, 43 | trim: true, 44 | }, 45 | labels: { 46 | type: [ 47 | { 48 | type: mongoose.Schema.Types.ObjectId, 49 | ref: "Label", 50 | required: true, 51 | }, 52 | ], 53 | default: [], 54 | }, 55 | bgImg: { 56 | type: String, 57 | required: false, 58 | validate: { 59 | validator: function (value: string) { 60 | return value 61 | ? validator.isURL(value, { 62 | require_protocol: true, 63 | }) 64 | : true; 65 | }, 66 | message: `Invalid Image URL`, 67 | }, 68 | trim: true, 69 | }, 70 | color: { 71 | type: String, 72 | required: true, 73 | trim: true, 74 | }, 75 | spaceId: { 76 | type: mongoose.Schema.Types.ObjectId, 77 | ref: "Space", 78 | required: true, 79 | }, 80 | lists: { 81 | type: [ 82 | { 83 | type: mongoose.Schema.Types.ObjectId, 84 | ref: "List", 85 | required: true, 86 | }, 87 | ], 88 | default: [], 89 | }, 90 | members: { 91 | type: [boardMemberSchema], 92 | default: [], 93 | }, 94 | creator: { 95 | type: mongoose.Schema.Types.ObjectId, 96 | ref: "User", 97 | required: true, 98 | }, 99 | }, 100 | { timestamps: true } 101 | ); 102 | 103 | const Board = mongoose.model("Board", boardSchema); 104 | 105 | export default Board; 106 | -------------------------------------------------------------------------------- /client/src/pages/spaces/SpaceBoards.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useQuery } from "react-query"; 3 | import { useDispatch } from "react-redux"; 4 | import { Navigate, useParams } from "react-router-dom"; 5 | import axiosInstance from "../../axiosInstance"; 6 | import Board from "../../components/Board/Board"; 7 | import Error from "../../components/Error/Error"; 8 | import Loader from "../../components/Loader/Loader"; 9 | import { addToast } from "../../redux/features/toastSlice"; 10 | import { BoardObj } from "../../types"; 11 | import { ERROR } from "../../types/constants"; 12 | 13 | interface Props { 14 | spaceId: string; 15 | } 16 | 17 | const SpaceBoards = ({ spaceId }: Props) => { 18 | const dispatch = useDispatch(); 19 | 20 | const getSpaceBoards = async ({ queryKey }: any) => { 21 | const response = await axiosInstance.get(`/spaces/${queryKey[1]}/boards`); 22 | 23 | const { data } = response.data; 24 | 25 | return data; 26 | }; 27 | 28 | const { 29 | data: boards, 30 | isLoading, 31 | error, 32 | } = useQuery( 33 | ["getSpaceBoards", spaceId], 34 | getSpaceBoards 35 | ); 36 | 37 | if (isLoading) { 38 | return ( 39 |
    40 | 41 |
    42 | ); 43 | } 44 | 45 | // handle each error accordingly & specific to that situation 46 | if (error) { 47 | if (error?.response) { 48 | const response = error.response; 49 | const { message } = response.data; 50 | 51 | switch (response.status) { 52 | case 400: 53 | case 404: 54 | dispatch(addToast({ kind: ERROR, msg: message })); 55 | // redirect them to home page 56 | return ; 57 | case 500: 58 | return ; 59 | default: 60 | return ; 61 | } 62 | } else if (error?.request) { 63 | return ( 64 | 67 | ); 68 | } else { 69 | return ; 70 | } 71 | } 72 | 73 | return ( 74 |
    75 |
    76 | {boards && boards.length > 0 ? ( 77 |
    78 | {boards.map((b) => ( 79 | 80 | ))} 81 |
    82 | ) : ( 83 |

    No Boards!

    84 | )} 85 |
    86 |
    87 | ); 88 | }; 89 | 90 | export default SpaceBoards; 91 | -------------------------------------------------------------------------------- /client/src/components/FormikComponents/FileInput.tsx: -------------------------------------------------------------------------------- 1 | import { useField } from "formik"; 2 | import { HiOutlineX } from "react-icons/hi"; 3 | import ErrorBox from "./ErrorBox"; 4 | 5 | interface Props { 6 | label: string; 7 | id: string; 8 | name: string; 9 | disabled?: boolean; 10 | autoFocus?: boolean; 11 | optional?: boolean; 12 | inline?: boolean; 13 | classes?: string; 14 | } 15 | 16 | const FileInput = ({ 17 | label, 18 | id, 19 | name, 20 | classes, 21 | disabled = false, 22 | autoFocus = false, 23 | optional = false, 24 | inline = false, 25 | ...props 26 | }: Props) => { 27 | // field -> { name: string, value: string, onChange: () => {}, onBlur: () => {} } 28 | // meta -> { touched: boolean, error: string, ... } 29 | const [field, meta, helpers] = useField({ name, type: "file" }); 30 | 31 | const handleFileChange = (e: React.ChangeEvent) => { 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 | 59 | 60 |
    61 | 77 | 78 | {field.value && ( 79 | 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 | 64 |

    65 |

    66 | Wrong address?{" "} 67 | {" "} 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 | 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 | 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 | 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 | 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 | 70 |
    71 | 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 | 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 | 96 | 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 | 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 | 102 | 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 | 111 | 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 |
    88 |

    89 | Enter your email to
    reset password 90 |

    91 | 92 | 93 | 94 |
    95 | 100 | 101 | 112 |
    113 |
    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 |
    95 | {commonError && ( 96 |
    97 | 98 |
    99 | )} 100 | 104 | 105 |
    OR
    106 | 107 | 108 | 109 | 110 |
    111 | 116 |
    117 | 118 |
    119 | 120 | Forgot Password? 121 | 122 |
    123 | 124 |

    125 | Don't have an account?{" "} 126 | 127 | Register 128 | 129 |

    130 | 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 | 112 | 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 | 116 | 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 | 119 | 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 |
    126 | 127 | 128 | 129 | 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 |
    110 | {commonError && ( 111 |
    112 | 113 |
    114 | )} 115 | 119 |
    OR
    120 | 121 | 122 | 123 | 124 | 125 |
    126 | 131 |
    132 | 133 |

    134 | Already have an account?{" "} 135 | 136 | Login 137 | {" "} 138 |

    139 | 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 | 80 | ) : ( 81 |
    82 | )} 83 |
    Menu
    84 | 96 |
    97 | 98 | {!showOption ? ( 99 |
      100 |
    • 101 | 107 |
    • 108 | {[BOARD_ROLES.ADMIN, BOARD_ROLES.NORMAL].includes(myRole) && ( 109 |
    • 110 | 116 |
    • 117 | )} 118 |
    • 119 | 125 |
    • 126 | 127 | {myRole === BOARD_ROLES.ADMIN && ( 128 |
    • 129 | 146 |
    • 147 | )} 148 |
    149 | ) : ( 150 |
    {component && component}
    151 | )} 152 |
    153 | ); 154 | }; 155 | 156 | export default BoardMenu; 157 | --------------------------------------------------------------------------------