diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index dfe51666..391987b7 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -51,6 +51,7 @@ const options = { pages: { newUser: '/user/newuser', + signIn: '/auth/email-signin', }, callbacks: { diff --git a/src/pages/api/group/[id].js b/src/pages/api/group/[id].js index 73dae62a..33469a27 100644 --- a/src/pages/api/group/[id].js +++ b/src/pages/api/group/[id].js @@ -4,7 +4,7 @@ import jwt from 'next-auth/jwt'; import middleware from '../../../middlewares/middleware'; const secret = process.env.AUTH_SECRET; -const fakeUserId = '7b639ae33efb36eaf6447c55'; + const handler = nc() .use(middleware) .get( @@ -47,9 +47,6 @@ const handler = nc() async (req, res) => { const token = await jwt.getToken({ req, secret }); if (token && token.exp > 0) { - const memberQuery = (process.env.NODE_ENV === 'development') - ? [{ 'members.id': token.id }, { 'members.id': fakeUserId }] - : [{ 'members.id': token.id }]; const nameToUpdate = req.body.name ? { name: req.body.name } : {}; @@ -97,7 +94,6 @@ const handler = nc() .findOneAndUpdate( { _id: ObjectID(req.query.id), - $or: memberQuery, ...documentById, ...memberById, }, diff --git a/src/pages/api/group/index.js b/src/pages/api/group/index.js index 622fcf3c..4d0964b4 100644 --- a/src/pages/api/group/index.js +++ b/src/pages/api/group/index.js @@ -1,5 +1,4 @@ import nc from 'next-connect'; -import { ObjectID } from 'mongodb'; import jwt from 'next-auth/jwt'; import middleware from '../../../middlewares/middleware'; diff --git a/src/pages/api/invite/[token].js b/src/pages/api/invite/[token].js new file mode 100644 index 00000000..6cf2bafa --- /dev/null +++ b/src/pages/api/invite/[token].js @@ -0,0 +1,29 @@ +import nc from 'next-connect'; +import jwt from 'next-auth/jwt'; +import middleware from '../../../middlewares/middleware'; + +const secret = process.env.AUTH_SECRET; + +const handler = nc() + .use(middleware) + .get( + async (req, res) => { + const jwtTok = await jwt.getToken({ req, secret }); + if (jwtTok && jwtTok.exp > 0) { + const { token } = req.query; + await req.db + .collection('inviteTokens') + .findOne( + { + token, + }, + (err, doc) => { + if (err) throw err; + res.status(200).json(doc); + }, + ); + } else res.status(403).json({ error: '403 Invalid or expired token' }); + }, + ); + +export default handler; diff --git a/src/pages/api/invite/index.js b/src/pages/api/invite/index.js new file mode 100644 index 00000000..41a4c508 --- /dev/null +++ b/src/pages/api/invite/index.js @@ -0,0 +1,33 @@ +import nc from 'next-connect'; +import jwt from 'next-auth/jwt'; +import cryptoRandomString from 'crypto-random-string'; +import middleware from '../../../middlewares/middleware'; + +const secret = process.env.AUTH_SECRET; + +const handler = nc() + .use(middleware) + .post( + async (req, res) => { + const jwtTok = await jwt.getToken({ req, secret }); + if (jwtTok && jwtTok.exp > 0) { + const createdAt = new Date(Date.now()); + const updatedAt = createdAt; + const token = cryptoRandomString({ length: 32, type: 'url-safe' }); + const { group } = req.body; + await req.db + .collection('inviteTokens') + .insertOne( + { + token, group, createdAt, updatedAt, + }, + (err, doc) => { + if (err) throw err; + res.status(200).json(doc); + }, + ); + } else res.status(403).json({ error: '403 Invalid or expired token' }); + }, + ); + +export default handler; diff --git a/src/pages/auth/email-signin.js b/src/pages/auth/email-signin.js new file mode 100644 index 00000000..3f0550b2 --- /dev/null +++ b/src/pages/auth/email-signin.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { csrfToken } from 'next-auth/client'; +import { Button, Card, Form } from 'react-bootstrap'; +import Layout from '../../components/Layout'; + +// eslint-disable-next-line no-shadow +const SignIn = ({ csrfToken, groupToken }) => ( + + + Log In / Sign Up + +
+ + + Email address + + + +
+
+
+); + +SignIn.getInitialProps = async (context) => ({ + csrfToken: await csrfToken(context), + groupToken: await context.query.groupToken, +}); + +export default SignIn; From 5acac0c4ce37a411e8c141fedca5913ae4856d68 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Mon, 31 Aug 2020 18:31:33 -0600 Subject: [PATCH 53/62] #28 Create preliminary Group Invites system --- package-lock.json | 37 ++++++-- package.json | 6 +- src/components/Alerts/Alerts.js | 8 ++ src/pages/api/group/[id].js | 13 ++- src/pages/api/invite/[token].js | 30 +++---- src/pages/api/invite/index.js | 2 +- src/pages/auth/email-signin.js | 125 ++++++++++++++++++++------ src/pages/groups/[id]/edit.jsx | 91 +++++++++++++++++-- src/pages/groups/new.jsx | 2 +- src/pages/index.jsx | 65 +++++++++++++- src/pages/user/[slug]/editprofile.jsx | 2 +- src/pages/user/newuser.jsx | 52 +++++++++-- src/utils/groupUtil.js | 54 +++++++++-- 13 files changed, 405 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index d445ae4f..2ed83a48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4500,9 +4500,9 @@ } }, "bl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.0.tgz", - "integrity": "sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", "requires": { "readable-stream": "^2.3.5", "safe-buffer": "^5.1.1" @@ -5223,6 +5223,11 @@ "safe-buffer": "~5.1.1" } }, + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + }, "copy-concurrently": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", @@ -10105,9 +10110,9 @@ } }, "mongodb": { - "version": "3.5.9", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.5.9.tgz", - "integrity": "sha512-vXHBY1CsGYcEPoVWhwgxIBeWqP3dSu9RuRDsoLRPTITrcrgm1f0Ubu1xqF9ozMwv53agmEiZm0YGo+7WL3Nbug==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.0.tgz", + "integrity": "sha512-/XWWub1mHZVoqEsUppE0GV7u9kanLvHxho6EvBxQbShXTKYF9trhZC2NzbulRGeG7xMJHD8IOWRcdKx5LPjAjQ==", "requires": { "bl": "^2.2.0", "bson": "^1.1.4", @@ -10118,9 +10123,9 @@ }, "dependencies": { "bson": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.4.tgz", - "integrity": "sha512-S/yKGU1syOMzO86+dGpg2qGoDL0zvzcb262G+gqEy6TgP6rt6z6qxSFX/8X6vLC91P7G7C3nLs0+bvDzmvBA3Q==" + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.5.tgz", + "integrity": "sha512-kDuEzldR21lHciPQAIulLs1LZlCXdLziXI6Mb/TDkwXhb//UORJNPXgcRs2CuO4H0DcMkpfT3/ySsP3unoZjBg==" } } }, @@ -10444,6 +10449,15 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.4.10.tgz", "integrity": "sha512-j+pS9CURhPgk6r0ENr7dji+As2xZiHSvZeVnzKniLOw1eRAyM/7flP0u65tCnsapV8JFu+t0l/5VeHsCZEeh9g==" }, + "nookies": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/nookies/-/nookies-2.4.0.tgz", + "integrity": "sha512-QC1+Ih9/BedZOIUVXneHBQgp8n9NzvsqyfmzAoUTrwOdwJOjB+phWzeLXuLqcxgxztTq3t3PeHtuosFKclL+Hw==", + "requires": { + "cookie": "^0.4.0", + "set-cookie-parser": "^2.4.3" + } + }, "normalize-html-whitespace": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/normalize-html-whitespace/-/normalize-html-whitespace-1.0.0.tgz", @@ -12476,6 +12490,11 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" }, + "set-cookie-parser": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.4.6.tgz", + "integrity": "sha512-mNCnTUF0OYPwYzSHbdRdCfNNHqrne+HS5tS5xNb6yJbdP9wInV0q5xPLE0EyfV/Q3tImo3y/OXpD8Jn0Jtnjrg==" + }, "set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", diff --git a/package.json b/package.json index daa16ba5..c8f28365 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,12 @@ "moment": "^2.27.0", "mongo-seeding": "^3.4.1", "mongo-seeding-cli": "^3.4.1", - "mongodb": "^3.5.9", + "mongodb": "^3.6.0", "next": "^9.5.1", "next-auth": "^3.1.0", "next-connect": "^0.8.1", "nodemailer": "^6.4.10", + "nookies": "^2.4.0", "popper.js": "^1.16.1", "react": "^16.13.1", "react-bootstrap": "^1.0.1", @@ -62,5 +63,8 @@ "jest": "^25.1.0", "jest-fetch-mock": "^3.0.3", "react-test-renderer": "^16.12.0" + }, + "peerOptionalDependencies": { + "mongodb": "^3.6.0" } } diff --git a/src/components/Alerts/Alerts.js b/src/components/Alerts/Alerts.js index b4ccc955..a0f1cdf5 100644 --- a/src/components/Alerts/Alerts.js +++ b/src/components/Alerts/Alerts.js @@ -36,6 +36,14 @@ function getData(alertName) { text: 'Group successfully renamed.', variant: 'success', }, + joinedGroup: { + text: 'Group successfully joined.', + variant: 'success', + }, + createdToken: { + text: 'Group invite token created successfully.', + variant: 'success', + }, }; return alerts[alertName]; } diff --git a/src/pages/api/group/[id].js b/src/pages/api/group/[id].js index 33469a27..a1fdca27 100644 --- a/src/pages/api/group/[id].js +++ b/src/pages/api/group/[id].js @@ -24,6 +24,7 @@ const handler = nc() name, members, documents, + inviteToken, createdAt, updatedAt, } = doc; @@ -32,6 +33,7 @@ const handler = nc() name, members, documents, + inviteToken, createdAt, updatedAt, }); @@ -80,8 +82,17 @@ const handler = nc() ? { documents: { id: req.body.removedDocumentId } } : {}; + const inviteTokenToUpdate = req.body.inviteToken + ? { inviteToken: req.body.inviteToken } + : {}; + const updateMethods = {}; - const fieldsToSet = { ...nameToUpdate, ...documentToUpdate, ...memberToChangeRole }; + const fieldsToSet = { + ...nameToUpdate, + ...documentToUpdate, + ...memberToChangeRole, + ...inviteTokenToUpdate, + }; if (Object.keys(fieldsToSet).length !== 0) updateMethods.$set = fieldsToSet; const fieldsToPush = { ...memberToPush, ...documentToPush }; if (Object.keys(fieldsToPush).length !== 0) updateMethods.$push = fieldsToPush; diff --git a/src/pages/api/invite/[token].js b/src/pages/api/invite/[token].js index 6cf2bafa..17d07415 100644 --- a/src/pages/api/invite/[token].js +++ b/src/pages/api/invite/[token].js @@ -1,28 +1,22 @@ import nc from 'next-connect'; -import jwt from 'next-auth/jwt'; import middleware from '../../../middlewares/middleware'; -const secret = process.env.AUTH_SECRET; - const handler = nc() .use(middleware) .get( async (req, res) => { - const jwtTok = await jwt.getToken({ req, secret }); - if (jwtTok && jwtTok.exp > 0) { - const { token } = req.query; - await req.db - .collection('inviteTokens') - .findOne( - { - token, - }, - (err, doc) => { - if (err) throw err; - res.status(200).json(doc); - }, - ); - } else res.status(403).json({ error: '403 Invalid or expired token' }); + const { token } = req.query; + await req.db + .collection('inviteTokens') + .findOne( + { + token, + }, + (err, doc) => { + if (err) throw err; + res.status(200).json(doc); + }, + ); }, ); diff --git a/src/pages/api/invite/index.js b/src/pages/api/invite/index.js index 41a4c508..5d688b99 100644 --- a/src/pages/api/invite/index.js +++ b/src/pages/api/invite/index.js @@ -13,7 +13,7 @@ const handler = nc() if (jwtTok && jwtTok.exp > 0) { const createdAt = new Date(Date.now()); const updatedAt = createdAt; - const token = cryptoRandomString({ length: 32, type: 'url-safe' }); + const token = cryptoRandomString({ length: 32, type: 'base64' }); const { group } = req.body; await req.db .collection('inviteTokens') diff --git a/src/pages/auth/email-signin.js b/src/pages/auth/email-signin.js index 3f0550b2..c166e95d 100644 --- a/src/pages/auth/email-signin.js +++ b/src/pages/auth/email-signin.js @@ -1,33 +1,106 @@ +import { setCookie } from 'nookies'; import React from 'react'; -import { csrfToken } from 'next-auth/client'; +import fetch from 'isomorphic-unfetch'; +import { csrfToken, useSession } from 'next-auth/client'; import { Button, Card, Form } from 'react-bootstrap'; +import Router from 'next/router'; import Layout from '../../components/Layout'; +import LoadingSpinner from '../../components/LoadingSpinner'; +import { AddUserToGroup } from '../../utils/groupUtil'; -// eslint-disable-next-line no-shadow -const SignIn = ({ csrfToken, groupToken }) => ( - - - Log In / Sign Up - -
- - - Email address - - - -
-
-
-); +const SignIn = ({ props }) => { + const [session, loading] = useSession(); -SignIn.getInitialProps = async (context) => ({ - csrfToken: await csrfToken(context), - groupToken: await context.query.groupToken, -}); + const { csrfToken, groupId } = props; // eslint-disable-line no-shadow + return ( + + {!session && loading && ( + + )} + {!session && !loading && ( + + Log In / Sign Up + +
+ + Email address + + + +
+
+ )} + {session && groupId !== '' && ( + + Join Group + + Click below to join the group: +
+ +
+
+ )} + {session && groupId === '' && ( + + Log In / Sign Up + + You are already logged in. +
+ +
+
+ )} +
+ ); +}; + +SignIn.getInitialProps = async (context) => { + const { groupToken } = await context.query; + let groupId = ''; + if (groupToken) { + setCookie(context, 'ans_grouptoken', groupToken, { + maxAge: 30 * 24 * 60 * 60, + path: '/', + }); + const url = `${process.env.SITE}/api/invite/${groupToken}`; + const res = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.status === 200) { + const result = await res.json(); + groupId = result.group; + } + } + return { + props: { + csrfToken: await csrfToken(context), + groupId, + }, + }; +}; export default SignIn; diff --git a/src/pages/groups/[id]/edit.jsx b/src/pages/groups/[id]/edit.jsx index 1d1618ac..87d558b8 100644 --- a/src/pages/groups/[id]/edit.jsx +++ b/src/pages/groups/[id]/edit.jsx @@ -1,6 +1,6 @@ import { useSession } from 'next-auth/client'; import { Pencil, TrashFill } from 'react-bootstrap-icons'; -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { Button, Card, @@ -8,8 +8,10 @@ import { Dropdown, FormControl, InputGroup, + Overlay, Row, Table, + Tooltip, Form, } from 'react-bootstrap'; @@ -26,16 +28,32 @@ import { DeleteGroup, RemoveUserFromGroup, RenameGroup, + GenerateInviteToken, } from '../../../utils/groupUtil'; const EditGroup = ({ group }) => { const [session, loading] = useSession(); - const [state, setState] = useState({ showModal: false, editingGroupName: false }); + const target = useRef(null); + const [state, setState] = useState( + { showModal: false, editingGroupName: false, showTooltip: false }, + ); const handleCloseModal = () => setState({ ...state, showModal: false }); const handleShowModal = () => setState({ ...state, showModal: true }); const editGroupName = () => setState({ ...state, editingGroupName: true }); + const handleShowTooltip = () => setState({ ...state, showTooltip: true }); + const handleHideTooltip = () => setState({ ...state, showTooltip: false }); + + const copyInviteUrl = () => { + if (typeof navigator !== 'undefined') { + // eslint-disable-next-line no-undef + navigator.clipboard.writeText(group.inviteUrl); + handleShowTooltip(); + target.current.focus(); + target.current.select(); + } + }; const roleInGroup = (currentSession) => currentSession.user.groups.find((o) => Object.entries(o).some(([k, value]) => k === 'id' && value === group.id)).role; @@ -181,10 +199,65 @@ const EditGroup = ({ group }) => {
Invite link
-

Generate an invite link to send to registered or new users.

- + {group.inviteUrl === '' && ( + <> +

Generate an invite link to send to registered or new users.

+ + + )} + {group.inviteUrl !== '' && ( + <> +

+ Send this invite link to registered or + new users to add them to the group. +

+ + + + + + {(props) => ( + // eslint-disable-next-line react/jsx-props-no-spreading + + Copied! + + )} + + + + + )}
Add registered user
@@ -199,7 +272,7 @@ const EditGroup = ({ group }) => { })} onSubmit={(values, actions) => { setTimeout(() => { - AddUserToGroup(group, values.email); + AddUserToGroup(group, values.email, true); actions.setSubmitting(false); }, 1000); }} @@ -300,6 +373,7 @@ export async function getServerSideProps(context) { }); if (res.status === 200) { const foundGroup = await res.json(); + console.log(foundGroup); const { name, members, @@ -309,6 +383,9 @@ export async function getServerSideProps(context) { name, members, }; + group.inviteUrl = foundGroup.inviteToken + ? `${process.env.SITE}/auth/email-signin?callbackUrl=${process.env.SITE}&groupToken=${foundGroup.inviteToken}` + : ''; return { props: { group }, }; diff --git a/src/pages/groups/new.jsx b/src/pages/groups/new.jsx index ade88742..691e6d2a 100644 --- a/src/pages/groups/new.jsx +++ b/src/pages/groups/new.jsx @@ -34,7 +34,7 @@ const NewGroup = () => { const user = { id: result.ops[0].members[0].id, }; - return AddGroupToUser(group, user, true); + return AddGroupToUser(group, user, true, false); } return Promise.reject(Error(`Unable to create group: error ${res.status} received from server`)); }; diff --git a/src/pages/index.jsx b/src/pages/index.jsx index e76ad208..7f095c93 100644 --- a/src/pages/index.jsx +++ b/src/pages/index.jsx @@ -1,15 +1,72 @@ +import { parseCookies, destroyCookie } from 'nookies'; +import { Button, Card } from 'react-bootstrap'; +import Router from 'next/router'; +import fetch from 'isomorphic-unfetch'; +import { useSession } from 'next-auth/client'; import Layout from '../components/Layout'; +import { AddUserToGroup } from '../utils/groupUtil'; -export default function Home() { +export default function Home({ props }) { + const [session, loading] = useSession(); + const { groupId } = props; return ( + {session && !loading && groupId !== '' && ( + + Join Group + + You have been invited to join a group. +
+ +
+
+ )} + {' '} Welcome to Annotation Studio.
); } -export async function getServerSideProps(context) { +Home.getInitialProps = async (context) => { + const { query } = context; + const cookies = parseCookies(context); + let groupId = ''; + if (query.alert === 'completeRegistration') { + destroyCookie(context, 'ans_grouptoken', { + path: '/', + }); + } else if (cookies.ans_grouptoken) { + const url = `${process.env.SITE}/api/invite/${cookies.ans_grouptoken}`; + const res = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.status === 200) { + const result = await res.json(); + groupId = result.group; + } + } return { - props: { query: context.query }, // will be passed to the page component as props + props: { + query, + groupId, + }, }; -} +}; diff --git a/src/pages/user/[slug]/editprofile.jsx b/src/pages/user/[slug]/editprofile.jsx index fd177e96..d1b4a90f 100644 --- a/src/pages/user/[slug]/editprofile.jsx +++ b/src/pages/user/[slug]/editprofile.jsx @@ -6,7 +6,7 @@ import { Button, Card, Col, Form, Row, } from 'react-bootstrap'; import Router from 'next/router'; -import FullName from '../../../utils/nameUtil'; +import { FullName } from '../../../utils/nameUtil'; import Layout from '../../../components/Layout'; import LoadingSpinner from '../../../components/LoadingSpinner'; diff --git a/src/pages/user/newuser.jsx b/src/pages/user/newuser.jsx index 3b3985fc..1b9d5f53 100644 --- a/src/pages/user/newuser.jsx +++ b/src/pages/user/newuser.jsx @@ -1,6 +1,8 @@ +/* eslint-disable camelcase */ +import { parseCookies, destroyCookie } from 'nookies'; import { useState } from 'react'; import { useSession, getSession } from 'next-auth/client'; -import fetch from 'unfetch'; +import fetch from 'isomorphic-unfetch'; import { Formik } from 'formik'; import * as yup from 'yup'; import { @@ -12,12 +14,20 @@ import Router from 'next/router'; import { FullName } from '../../utils/nameUtil'; import Layout from '../../components/Layout'; import LoadingSpinner from '../../components/LoadingSpinner'; +import { AddUserToGroup } from '../../utils/groupUtil'; -const NewUser = () => { +const NewUser = ({ groupId }) => { const [session] = useSession(); const [errorMsg, setErrorMsg] = useState(''); + const pushToHome = () => { + Router.push({ + pathname: '/', + query: { alert: 'completeRegistration' }, + }); + }; + const submitHandler = async (values) => { const body = { email: session.user.email, @@ -36,10 +46,14 @@ const NewUser = () => { if (res.status === 200) { await res.json(); getSession(); - Router.push({ - pathname: '/', - query: { alert: 'completeRegistration' }, - }); + if (groupId !== '') { + destroyCookie(null, 'ans_grouptoken', { + path: '/', + }); + AddUserToGroup({ id: groupId }, session.user.email, false).then(() => { + pushToHome(); + }); + } else pushToHome(); } else { setErrorMsg(await res.text()); } @@ -66,6 +80,12 @@ const NewUser = () => { Welcome to Annotation Studio Please fill out the following form to complete your registration. + {groupId && groupId !== '' && ( + <> + {' '} + On submit, you will be automatically added to the group that invited you. + + )} { ); }; +NewUser.getInitialProps = async (context) => { + const cookies = parseCookies(context); + const { ans_grouptoken } = cookies; + let groupId = ''; + if (ans_grouptoken) { + const url = `${process.env.SITE}/api/invite/${ans_grouptoken}`; + const res = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.status === 200) { + const result = await res.json(); + groupId = result.group; + } + } + return { groupId }; +}; + export default NewUser; diff --git a/src/utils/groupUtil.js b/src/utils/groupUtil.js index f22a1fb2..9a1c091e 100644 --- a/src/utils/groupUtil.js +++ b/src/utils/groupUtil.js @@ -27,7 +27,7 @@ const UpdateMemberCounts = async (group) => { ); }; -const AddGroupToUser = async (group, user, isNewGroup) => { +const AddGroupToUser = async (group, user, isNewGroup, existingUser) => { const url = `/api/user/${user.id}`; const { id, @@ -56,16 +56,18 @@ const AddGroupToUser = async (group, user, isNewGroup) => { const result = await res.json(); let query = { alert: 'addUser' }; if (isNewGroup) query = { alert: 'newGroup' }; - Router.push({ - pathname: `/groups/${group.id}/edit`, - query, - }); + if (isNewGroup || existingUser) { + Router.push({ + pathname: `/groups/${group.id}/edit`, + query, + }); + } return Promise.resolve(result); } return Promise.reject(Error(`Unable to add group to user: error ${res.status} received from server`)); }; -const AddUserToGroup = async (group, email) => { +const AddUserToGroup = async (group, email, fromGroupEditPage) => { const user = await GetUserByEmail(email); if (!user.error) { const url = `/api/group/${group.id}`; @@ -96,7 +98,8 @@ const AddUserToGroup = async (group, email) => { ownerName, role: 'member', }; - return AddGroupToUser(groupToAdd, user).then(UpdateMemberCounts(result.value)); + return AddGroupToUser(groupToAdd, user, false, fromGroupEditPage) + .then(UpdateMemberCounts(result.value)); } return Promise.reject(Error(`Unable to add user to group: error ${res.status} received from server`)); } return Promise.reject(Error(`Unable to add user with email ${email}: error ${user.error}.`)); }; @@ -251,12 +254,49 @@ const RenameGroup = async (group, newName) => { } return Promise.reject(Error(`Unable to update user: error ${res.status} received from server`)); }; +const GenerateInviteToken = async (group) => { + const { id } = group; + const url = '/api/invite'; + const body = { group: id }; + const res = await fetch(url, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.status === 200) { + const response = await res.json(); + if (!response.ops[0].token) { + return Promise.reject(Error(`Unable to add token to group: ${JSON.stringify(response)}`)); + } + const groupUrl = `/api/group/${id}`; + const groupBody = { inviteToken: response.ops[0].token }; + const groupRes = await fetch(groupUrl, { + method: 'PATCH', + body: JSON.stringify(groupBody), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (groupRes.status === 200) { + const query = { alert: 'createdToken' }; + Router.push({ + pathname: `/groups/${id}/edit`, + query, + }); + return Promise.resolve(response); + } return Promise.reject(Error(`Unable to add token to group: error ${res.status} received from server`)); + } return Promise.reject(Error(`Unable to generate token: error ${res.status} received from server`)); +}; + export { AddGroupToUser, AddUserToGroup, ChangeUserRole, DeleteGroup, DeleteGroupFromId, + GenerateInviteToken, RemoveUserFromGroup, RenameGroup, }; From 548a4251d7853eb83b145a03f983a76dd5e0f3b9 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Tue, 1 Sep 2020 12:57:45 -0600 Subject: [PATCH 54/62] #28 Create + handle err when user already in group --- src/components/Alerts/Alerts.js | 12 ++++++++++-- src/pages/api/user/email/[email].js | 4 ++-- src/pages/groups/[id]/edit.jsx | 15 ++++++++++++--- src/utils/groupUtil.js | 5 ++++- src/utils/stringUtil.js | 4 ++++ 5 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 src/utils/stringUtil.js diff --git a/src/components/Alerts/Alerts.js b/src/components/Alerts/Alerts.js index a0f1cdf5..f2c3127a 100644 --- a/src/components/Alerts/Alerts.js +++ b/src/components/Alerts/Alerts.js @@ -48,13 +48,21 @@ function getData(alertName) { return alerts[alertName]; } +function getError(error) { + return { + text: error, + variant: 'danger', + }; +} + function Alerts() { const [show, setShow] = useState(true); const router = useRouter(); let alertData; if (router && router.query) { - const { alert } = router.query; - alertData = getData(alert); + const { alert, error } = router.query; + if (alert) alertData = getData(alert); + if (error) alertData = getError(error); } return ( <> diff --git a/src/pages/api/user/email/[email].js b/src/pages/api/user/email/[email].js index 5a60ac74..0dc3a06f 100644 --- a/src/pages/api/user/email/[email].js +++ b/src/pages/api/user/email/[email].js @@ -18,10 +18,10 @@ const handler = nc() if (doc) { // eslint-disable-next-line no-underscore-dangle const id = doc._id; - const { name } = doc; + const { name, groups } = doc; if (err) throw err; res.status(200).json({ - id, name, + id, name, groups, }); } else { res.status(404).json({ error: '404 Not Found' }); diff --git a/src/pages/groups/[id]/edit.jsx b/src/pages/groups/[id]/edit.jsx index 87d558b8..fcd9950d 100644 --- a/src/pages/groups/[id]/edit.jsx +++ b/src/pages/groups/[id]/edit.jsx @@ -14,14 +14,15 @@ import { Tooltip, Form, } from 'react-bootstrap'; - import * as yup from 'yup'; +import { useRouter } from 'next/router'; import { Formik } from 'formik'; import Layout from '../../../components/Layout'; import LoadingSpinner from '../../../components/LoadingSpinner'; import ConfirmationDialog from '../../../components/ConfirmationDialog'; import GroupRoleSummaries from '../../../components/GroupRoleSummaries'; import GroupRoleBadge from '../../../components/GroupRoleBadge'; +import { StripQuery } from '../../../utils/stringUtil'; import { AddUserToGroup, ChangeUserRole, @@ -35,6 +36,8 @@ import { const EditGroup = ({ group }) => { const [session, loading] = useSession(); + const router = useRouter(); + const target = useRef(null); const [state, setState] = useState( { showModal: false, editingGroupName: false, showTooltip: false }, @@ -272,7 +275,14 @@ const EditGroup = ({ group }) => { })} onSubmit={(values, actions) => { setTimeout(() => { - AddUserToGroup(group, values.email, true); + AddUserToGroup(group, values.email, true).catch((err) => { + router.push( + { + pathname: StripQuery(router.asPath), + query: { error: err.message }, + }, + ); + }); actions.setSubmitting(false); }, 1000); }} @@ -373,7 +383,6 @@ export async function getServerSideProps(context) { }); if (res.status === 200) { const foundGroup = await res.json(); - console.log(foundGroup); const { name, members, diff --git a/src/utils/groupUtil.js b/src/utils/groupUtil.js index 9a1c091e..f150ab00 100644 --- a/src/utils/groupUtil.js +++ b/src/utils/groupUtil.js @@ -69,6 +69,9 @@ const AddGroupToUser = async (group, user, isNewGroup, existingUser) => { const AddUserToGroup = async (group, email, fromGroupEditPage) => { const user = await GetUserByEmail(email); + let alreadyInGroup = false; + alreadyInGroup = user.groups.some((userGroup) => (userGroup.id === group.id)); + user.error = (alreadyInGroup === true) ? 'User is already in group' : undefined; if (!user.error) { const url = `/api/group/${group.id}`; const { @@ -101,7 +104,7 @@ const AddUserToGroup = async (group, email, fromGroupEditPage) => { return AddGroupToUser(groupToAdd, user, false, fromGroupEditPage) .then(UpdateMemberCounts(result.value)); } return Promise.reject(Error(`Unable to add user to group: error ${res.status} received from server`)); - } return Promise.reject(Error(`Unable to add user with email ${email}: error ${user.error}.`)); + } return Promise.reject(Error(`Unable to add user with email ${email}: ${user.error}.`)); }; const RemoveGroupFromUser = async (group, user, groupDeletion) => { diff --git a/src/utils/stringUtil.js b/src/utils/stringUtil.js new file mode 100644 index 00000000..0a8f9034 --- /dev/null +++ b/src/utils/stringUtil.js @@ -0,0 +1,4 @@ +const StripQuery = (uri) => uri.substring(0, uri.lastIndexOf('?')); + +// eslint-disable-next-line import/prefer-default-export +export { StripQuery }; From 4580bb57bcdd553dbddd956e202f3d5a073b82c4 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Tue, 1 Sep 2020 13:17:04 -0600 Subject: [PATCH 55/62] #28 Error handling for AddUserToGroup calls --- src/pages/auth/email-signin.js | 8 ++++++++ src/pages/index.jsx | 14 +++++++++++--- src/pages/user/newuser.jsx | 8 ++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/pages/auth/email-signin.js b/src/pages/auth/email-signin.js index c166e95d..b2585e56 100644 --- a/src/pages/auth/email-signin.js +++ b/src/pages/auth/email-signin.js @@ -7,6 +7,7 @@ import Router from 'next/router'; import Layout from '../../components/Layout'; import LoadingSpinner from '../../components/LoadingSpinner'; import { AddUserToGroup } from '../../utils/groupUtil'; +import { StripQuery } from '../../utils/stringUtil'; const SignIn = ({ props }) => { const [session, loading] = useSession(); @@ -48,6 +49,13 @@ const SignIn = ({ props }) => { pathname: '/', query: { alert: 'joinedGroup' }, }); + }).catch((err) => { + Router.push( + { + pathname: StripQuery(Router.asPath), + query: { error: err.message }, + }, + ); }); }} > diff --git a/src/pages/index.jsx b/src/pages/index.jsx index 7f095c93..a3572616 100644 --- a/src/pages/index.jsx +++ b/src/pages/index.jsx @@ -5,6 +5,7 @@ import fetch from 'isomorphic-unfetch'; import { useSession } from 'next-auth/client'; import Layout from '../components/Layout'; import { AddUserToGroup } from '../utils/groupUtil'; +import { StripQuery } from '../utils/stringUtil'; export default function Home({ props }) { const [session, loading] = useSession(); @@ -20,14 +21,21 @@ export default function Home({ props }) { + , then generate a new one. + The old link will no longer work.)

{ } return Promise.reject(Error(`Unable to generate token: error ${res.status} received from server`)); }; +const DeleteInviteToken = async (group) => { + const { inviteToken } = group; + const url = `/api/invite/${inviteToken}`; + const res = await fetch(url, { + method: 'DELETE', + }); + if (res.status === 200) { + const groupUrl = `/api/group/${group.id}`; + const body = { tokenToRemove: inviteToken }; + const groupRes = await fetch(groupUrl, { + method: 'PATCH', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (groupRes.status === 200) { + return Promise.resolve(groupRes.json()); + } return Promise.reject(Error(`Unable to remove token from group: error ${groupRes.status} received from server`)); + } return Promise.reject(Error(`Unable to delete token: error ${res.status} received from server`)); +}; + export { AddGroupToUser, AddUserToGroup, ChangeUserRole, DeleteGroup, DeleteGroupFromId, + DeleteInviteToken, GenerateInviteToken, RemoveUserFromGroup, RenameGroup, From 551b1f25fb6e0e6ef57573006374b52b42328bca Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Tue, 1 Sep 2020 15:13:05 -0600 Subject: [PATCH 58/62] #28 Improve styling for delete invite token button --- src/pages/groups/[id]/edit.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/groups/[id]/edit.jsx b/src/pages/groups/[id]/edit.jsx index 078b3b7d..3705c044 100644 --- a/src/pages/groups/[id]/edit.jsx +++ b/src/pages/groups/[id]/edit.jsx @@ -226,6 +226,9 @@ const EditGroup = ({ group }) => { {' '} diff --git a/src/pages/groups/[id]/index.jsx b/src/pages/groups/[id]/index.jsx index 21b01c9d..ffde5aa2 100644 --- a/src/pages/groups/[id]/index.jsx +++ b/src/pages/groups/[id]/index.jsx @@ -1,17 +1,19 @@ import { useSession } from 'next-auth/client'; import { useState } from 'react'; +import Router from 'next/router'; import { Button, ButtonGroup, Card, Table, } from 'react-bootstrap'; import { - PencilSquare, TrashFill, + PencilSquare, TrashFill, BoxArrowRight, } from 'react-bootstrap-icons'; import Layout from '../../../components/Layout'; import LoadingSpinner from '../../../components/LoadingSpinner'; import ConfirmationDialog from '../../../components/ConfirmationDialog'; import GroupRoleSummaries from '../../../components/GroupRoleSummaries'; import GroupRoleBadge from '../../../components/GroupRoleBadge'; -import { DeleteGroup } from '../../../utils/groupUtil'; +import { DeleteGroup, RemoveUserFromGroup } from '../../../utils/groupUtil'; +import { GetUserByEmail } from '../../../utils/userUtil'; const ViewGroup = ({ group }) => { const [session, loading] = useSession(); @@ -52,37 +54,63 @@ const ViewGroup = ({ group }) => { ))} - {(roleInGroup(session) === 'owner' || roleInGroup(session) === 'manager') && ( - + + {(roleInGroup(session) === 'owner' || roleInGroup(session) === 'manager') && ( - {roleInGroup(session) === 'owner' && ( - <> - - { - event.target.setAttribute('disabled', 'true'); - DeleteGroup(group); - handleCloseModal(); - }} - /> - - )} - - )} + )} + {(roleInGroup(session) === 'member' || roleInGroup(session) === 'manager') && ( + + )} + {roleInGroup(session) === 'owner' && ( + <> + + { + event.target.setAttribute('disabled', 'true'); + DeleteGroup(group); + handleCloseModal(); + }} + /> + + )} + )} diff --git a/src/pages/groups/index.jsx b/src/pages/groups/index.jsx index 85f82734..9f3b98ef 100644 --- a/src/pages/groups/index.jsx +++ b/src/pages/groups/index.jsx @@ -6,15 +6,17 @@ import { Button, ButtonGroup, Card, Table, } from 'react-bootstrap'; import { - PencilSquare, TrashFill, Plus, + PencilSquare, TrashFill, Plus, BoxArrowRight, } from 'react-bootstrap-icons'; +import Router from 'next/router'; import Layout from '../../components/Layout'; import LoadingSpinner from '../../components/LoadingSpinner'; import ConfirmationDialog from '../../components/ConfirmationDialog'; import GroupRoleSummaries from '../../components/GroupRoleSummaries'; import GroupRoleBadge from '../../components/GroupRoleBadge'; import { FirstNameLastInitial } from '../../utils/nameUtil'; -import { DeleteGroupFromId } from '../../utils/groupUtil'; +import { DeleteGroupFromId, RemoveUserFromGroup } from '../../utils/groupUtil'; +import { GetUserByEmail } from '../../utils/userUtil'; const GroupList = ({ query }) => { const [session, loading] = useSession(); @@ -65,12 +67,40 @@ const GroupList = ({ query }) => { {FirstNameLastInitial(value.ownerName)} {value.memberCount} - {(value.role === 'owner' || value.role === 'manager') && ( - + {(value.role === 'owner' || value.role === 'manager') && ( + + )} + {(value.role === 'member' || value.role === 'manager') && ( + + )} {value.role === 'owner' && ( <>