From 692bdfc451a89631b961382cb04fa45370091217 Mon Sep 17 00:00:00 2001 From: LeeJongBeom <52884648+devleejb@users.noreply.github.com> Date: Tue, 23 Jan 2024 14:55:57 +0900 Subject: [PATCH] (FE) Add Workspace Invitation (#87) * Add create invitation url * Add join workspace --- backend/src/workspaces/workspaces.service.ts | 4 +- frontend/package-lock.json | 224 +++++++++++++++++- frontend/package.json | 1 + .../src/components/modals/MemberModal.tsx | 86 ++++++- frontend/src/hooks/api/types/workspace.d.ts | 14 ++ frontend/src/hooks/api/workspace.ts | 27 +++ frontend/src/pages/workspace/join/Index.tsx | 26 ++ frontend/src/routes.tsx | 6 + frontend/src/utils/invitation.ts | 8 + 9 files changed, 385 insertions(+), 11 deletions(-) create mode 100644 frontend/src/pages/workspace/join/Index.tsx create mode 100644 frontend/src/utils/invitation.ts diff --git a/backend/src/workspaces/workspaces.service.ts b/backend/src/workspaces/workspaces.service.ts index a4ee8c5c..ff379b7a 100644 --- a/backend/src/workspaces/workspaces.service.ts +++ b/backend/src/workspaces/workspaces.service.ts @@ -6,7 +6,7 @@ import { CreateInvitationTokenResponse } from "./types/create-inviation-token-re import { WorkspaceRoleConstants } from "src/utils/constants/auth-role"; import slugify from "slugify"; import { generateRandomKey } from "src/utils/functions/random-string"; -import moment from "moment"; +import * as moment from "moment"; @Injectable() export class WorkspacesService { @@ -174,7 +174,7 @@ export class WorkspacesService { }, }); - if (!userWorkspace) { + if (userWorkspace) { return userWorkspace.workspace; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b3aba5b8..c6647dbd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "@uiw/codemirror-themes": "^4.21.21", "@uiw/react-markdown-preview": "^5.0.7", "axios": "^1.6.5", + "clipboardy": "^4.0.0", "codemirror": "^6.0.1", "codemirror-markdown-commands": "^0.0.3", "codemirror-markdown-slug": "^0.0.3", @@ -2763,6 +2764,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/clipboardy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz", + "integrity": "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==", + "dependencies": { + "execa": "^8.0.1", + "is-wsl": "^3.1.0", + "is64bit": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", @@ -2918,7 +2935,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3429,6 +3445,28 @@ "node": ">=0.10.0" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3623,6 +3661,17 @@ "node": ">=6.9.0" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -4050,6 +4099,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/ignore": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", @@ -4160,6 +4217,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4190,6 +4261,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4219,11 +4307,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is64bit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is64bit/-/is64bit-2.0.0.tgz", + "integrity": "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==", + "dependencies": { + "system-architecture": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/isomorphic.js": { "version": "0.2.5", @@ -4674,6 +4800,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5250,6 +5381,17 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -5349,6 +5491,31 @@ "node": ">=6" } }, + "node_modules/npm-run-path": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", + "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -5377,6 +5544,20 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -5514,7 +5695,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -6256,7 +6436,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -6268,11 +6447,21 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -6346,6 +6535,17 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6398,6 +6598,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/system-architecture": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", + "integrity": "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -6760,7 +6971,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, diff --git a/frontend/package.json b/frontend/package.json index 9330e10e..2adb15dd 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@uiw/codemirror-themes": "^4.21.21", "@uiw/react-markdown-preview": "^5.0.7", "axios": "^1.6.5", + "clipboardy": "^4.0.0", "codemirror": "^6.0.1", "codemirror-markdown-commands": "^0.0.3", "codemirror-markdown-slug": "^0.0.3", diff --git a/frontend/src/components/modals/MemberModal.tsx b/frontend/src/components/modals/MemberModal.tsx index bf21b3b6..83f2dc47 100644 --- a/frontend/src/components/modals/MemberModal.tsx +++ b/frontend/src/components/modals/MemberModal.tsx @@ -1,20 +1,32 @@ import { Avatar, Box, + Button, CircularProgress, + FormControl, IconButton, Modal, Paper, Stack, + Tooltip, Typography, } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import { useGetWorkspaceUserListQuery } from "../../hooks/api/workspaceUser"; import { useParams } from "react-router-dom"; -import { useGetWorkspaceQuery } from "../../hooks/api/workspace"; -import { useMemo } from "react"; +import { + useCreateWorkspaceInvitationTokenMutation, + useGetWorkspaceQuery, +} from "../../hooks/api/workspace"; +import { useMemo, useState } from "react"; import { User } from "../../hooks/api/types/user"; import InfiniteScroll from "react-infinite-scroller"; +import { FormContainer, SelectElement } from "react-hook-form-mui"; +import { invitationExpiredStringList } from "../../utils/invitation"; +import moment, { unitOfTime } from "moment"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import clipboard from "clipboardy"; +import { useSnackbar } from "notistack"; interface MemeberModalProps { open: boolean; @@ -30,6 +42,8 @@ function MemeberModal(props: MemeberModalProps) { fetchNextPage, hasNextPage, } = useGetWorkspaceUserListQuery(workspace?.id); + const { mutateAsync: createWorkspaceInvitationToken } = + useCreateWorkspaceInvitationTokenMutation(workspace?.id || ""); const userList = useMemo(() => { return ( workspaceUserPageList?.pages.reduce((prev, page) => { @@ -37,6 +51,34 @@ function MemeberModal(props: MemeberModalProps) { }, [] as Array) ?? [] ); }, [workspaceUserPageList?.pages]); + const { enqueueSnackbar } = useSnackbar(); + const [invitationUrl, setInvitationUrl] = useState(null); + + const handleCreateInviteUrl = async (data: { expiredString: string }) => { + let addedTime: Date | null; + + if (data.expiredString === invitationExpiredStringList[0]) { + addedTime = null; + } else { + const [num, unit] = data.expiredString.split(" "); + addedTime = moment() + .add(Number(num), unit as unitOfTime.DurationConstructor) + .toDate(); + } + + const { invitationToken } = await createWorkspaceInvitationToken({ + expiredAt: addedTime, + }); + + setInvitationUrl(`${window.location.origin}/join/${invitationToken}`); + }; + + const handleCopyInviteUrl = async () => { + if (!invitationUrl) return; + + await clipboard.write(invitationUrl); + enqueueSnackbar("URL Copied!", { variant: "success" }); + }; return ( @@ -62,6 +104,46 @@ function MemeberModal(props: MemeberModalProps) { Members + + Invite Link + + + + ({ + id: expiredString, + label: expiredString, + }) + )} + size="small" + sx={{ + width: 1, + }} + variant="filled" + /> + + + + + {Boolean(invitationUrl) && ( + + {invitationUrl} + + + + + + + )} + { @@ -59,3 +63,26 @@ export const useCreateWorkspaceMutation = () => { }, }); }; + +export const useCreateWorkspaceInvitationTokenMutation = (workspaceId: string) => { + return useMutation({ + mutationFn: async (data: CreateWorkspaceInviteTokenRequest) => { + const res = await axios.post( + `/workspaces/${workspaceId}/invite-token`, + data + ); + + return res.data; + }, + }); +}; + +export const useJoinWorkspaceMutation = () => { + return useMutation({ + mutationFn: async (data: JoinWorkspaceRequest) => { + const res = await axios.post("/workspaces/join", data); + + return res.data; + }, + }); +}; diff --git a/frontend/src/pages/workspace/join/Index.tsx b/frontend/src/pages/workspace/join/Index.tsx new file mode 100644 index 00000000..bc43af69 --- /dev/null +++ b/frontend/src/pages/workspace/join/Index.tsx @@ -0,0 +1,26 @@ +import { Backdrop, CircularProgress } from "@mui/material"; +import { useNavigate, useParams } from "react-router"; +import { useJoinWorkspaceMutation } from "../../../hooks/api/workspace"; +import { useEffect } from "react"; + +function JoinIndex() { + const params = useParams(); + const navigate = useNavigate(); + const { mutateAsync: joinWorkspace } = useJoinWorkspaceMutation(); + + useEffect(() => { + if (!params.invitationToken) return; + + joinWorkspace({ invitationToken: params.invitationToken }).then((data) => { + navigate(`/workspace/${data.slug}`); + }); + }, [joinWorkspace, navigate, params.invitationToken]); + + return ( + + + + ); +} + +export default JoinIndex; diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 9d7ce6ac..de28305a 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -8,6 +8,7 @@ import GuestRoute from "./components/common/GuestRoute"; import PrivateRoute from "./components/common/PrivateRoute"; import WorkspaceIndex from "./pages/workspace/Index"; import CodePairError from "./components/common/CodePairError"; +import JoinIndex from "./pages/workspace/join/Index"; interface CodePairRoute { path: string; @@ -65,6 +66,11 @@ const codePairRoutes: Array = [ accessType: AccessType.GUEST, element: , }, + { + path: "join/:invitationToken", + accessType: AccessType.PRIVATE, + element: , + }, ]; const injectProtectedRoute = (routes: typeof codePairRoutes) => { diff --git a/frontend/src/utils/invitation.ts b/frontend/src/utils/invitation.ts new file mode 100644 index 00000000..4ab59437 --- /dev/null +++ b/frontend/src/utils/invitation.ts @@ -0,0 +1,8 @@ +export const invitationExpiredStringList = [ + "No Limit", + "30 minutes", + "1 hour", + "8 hours", + "24 hours", + "7 days", +];