From 1f6e3f8f2e5bfffb799dceae175c4a9c2a76fee5 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Thu, 3 Jun 2021 00:05:54 +0000 Subject: [PATCH 01/10] Removed calendar scope, we shouldn't need it. --- pages/api/integrations/googlecalendar/add.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/api/integrations/googlecalendar/add.ts b/pages/api/integrations/googlecalendar/add.ts index c568e65231..5d2c4ae0a3 100644 --- a/pages/api/integrations/googlecalendar/add.ts +++ b/pages/api/integrations/googlecalendar/add.ts @@ -4,7 +4,7 @@ import prisma from '../../../../lib/prisma'; const {google} = require('googleapis'); const credentials = process.env.GOOGLE_API_CREDENTIALS; -const scopes = ['https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/calendar']; +const scopes = ['https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/calendar.events']; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === 'GET') { From e0563de28e95a749f24acb3b097da3c68571c0a7 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Thu, 3 Jun 2021 20:42:05 +0000 Subject: [PATCH 02/10] stroke-width -> strokeWidth + 4-tab to 2-tab --- components/ui/Button.tsx | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index 80777b0964..647d633cb0 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -1,17 +1,16 @@ import { useState } from 'react'; export default function Button(props) { - const [loading, setLoading] = useState(false); - - return( - - ); + const [loading, setLoading] = useState(false); + return( + + ); } \ No newline at end of file From e2942224ab3437e217bbe8c8cbc2eba958f07d6e Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Thu, 3 Jun 2021 20:55:34 +0000 Subject: [PATCH 03/10] Tracking work in progress changes --- components/Settings.tsx | 6 +- components/TeamListItem.tsx | 43 +++++++++ pages/settings/teams.tsx | 170 ++++++++++++++++++++++++++++++++++++ styles/components/table.css | 4 + styles/globals.css | 1 + 5 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 components/TeamListItem.tsx create mode 100644 pages/settings/teams.tsx create mode 100644 styles/components/table.css diff --git a/components/Settings.tsx b/components/Settings.tsx index 382141d84b..ff23e35bd1 100644 --- a/components/Settings.tsx +++ b/components/Settings.tsx @@ -1,9 +1,7 @@ import ActiveLink from '../components/ActiveLink'; -import { useRouter } from "next/router"; import { UserCircleIcon, KeyIcon, CodeIcon, UserGroupIcon } from '@heroicons/react/outline'; export default function SettingsShell(props) { - const router = useRouter(); return (
@@ -35,9 +33,9 @@ export default function SettingsShell(props) { Embed - {/* + Teams - */} + {/* diff --git a/components/TeamListItem.tsx b/components/TeamListItem.tsx new file mode 100644 index 0000000000..c5cb181484 --- /dev/null +++ b/components/TeamListItem.tsx @@ -0,0 +1,43 @@ +import { ChevronDownIcon, ChevronUpIcon, UserAddIcon, TrashIcon, UsersIcon } from "@heroicons/react/outline"; + +export default function TeamListItem(props) { + return (
  • +
    +
    + +
    + {props.team.userRole === "Owner" && } + {props.team.userRole !== "Owner" && {props.team.name}} + {props.team.userRole} +
    +
    + {props.team.userRole === 'Invitee' &&
    + + +
    } + {props.team.userRole === 'Member' &&
    + +
    } +
    + {/*{props.team.userRole === 'Owner' && expanded &&
    + {props.team.members.length > 0 &&
    +

    Members

    + + + {props.team.members.map( (member) => + + + + )} + +
    Alex van Andel ({ member.email })Owner + +
    +
    } + + +
    }*/} +
  • ); +} \ No newline at end of file diff --git a/pages/settings/teams.tsx b/pages/settings/teams.tsx new file mode 100644 index 0000000000..2ae8954cb6 --- /dev/null +++ b/pages/settings/teams.tsx @@ -0,0 +1,170 @@ +import Head from 'next/head'; +import prisma from '../../lib/prisma'; +import Modal from '../../components/Modal'; +import Shell from '../../components/Shell'; +import SettingsShell from '../../components/Settings'; +import { useState } from 'react'; +import { useSession, getSession } from 'next-auth/client'; +import Button from "../../components/ui/Button"; +import { + UsersIcon, + UserAddIcon, + UserRemoveIcon, + ChevronDownIcon, + ChevronUpIcon, + LocationMarkerIcon +} from "@heroicons/react/outline"; +import { ShieldCheckIcon } from "@heroicons/react/solid"; +import TeamListItem from "../../components/TeamListItem"; + +export default function Teams(props) { + + const [ session, loading ] = useSession(); + const [ selectedTeam, setSelectedTeam ] = useState({}); + + if (loading) { + return

    Loading...

    ; + } + + const teams = [ + { name: "Flying Colours Life", userRole: "Owner", members: [ + { "name": "Alex van Andel", "email": "bartfalij@gmail.com", "role": "Owner" }, + { "email": "me@alexvanandel.com", "role": "Member" }, + { "email": "avanandel@flyingcolourslife.com", "role": "Member" }, + ] }, + { name: "Partner Wealth", userRole: "Member" } + ]; + + const invitations = [ + { name: "Asset Management", userRole: "Invitee" } + ]; + + return( + + + Teams | Calendso + + + +
    +
    +
    +
    +

    Your teams

    +

    + View, edit and create teams to organise relationships between users +

    + {teams.length === 0 &&
    +

    Team up with other users
    by adding a new team

    + + +
    } +
    + {teams.length > 0 &&
    + +
    } +
    +
    +
      + {teams.map( + (team: any) => setSelectedTeam(team) }> + )} +
    +

    Open Invitations

    +
      + {invitations.map( (team) => )} +
    +
    + {/*
    +

    Transform account

    +

    + You cannot convert this account into a team until you leave all teams that you’re a member of. +

    + +
    */} +
    +
    + {Object.keys(selectedTeam).length > 0 && +
    +
    + + + + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + {selectedTeam.members.length > 0 &&
    +
    +

    Members

    + +
    + + + {selectedTeam.members.map( (member) => + + + + )} + +
    {member.name} {member.name && '(' + member.email + ')' }{!member.name && member.email}{member.role} + {/**/} +
    +
    } +
    +
    + + +
    +
    +
    + + +
    +
    +
    +
    +
    + } +
    +
    + ); +} + +export async function getServerSideProps(context) { + const session = await getSession(context); + if (!session) { + return { redirect: { permanent: false, destination: '/auth/login' } }; + } + + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + username: true, + name: true + } + }); + + return { + props: {user}, // will be passed to the page component as props + } +} \ No newline at end of file diff --git a/styles/components/table.css b/styles/components/table.css new file mode 100644 index 0000000000..a2d88616a6 --- /dev/null +++ b/styles/components/table.css @@ -0,0 +1,4 @@ + +table tbody tr:nth-child(odd) { + @apply bg-gray-50; +} \ No newline at end of file diff --git a/styles/globals.css b/styles/globals.css index 89a86dfbfe..926cc766ad 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -5,6 +5,7 @@ @import './components/buttons.css'; @import './components/spinner.css'; @import './components/activelink.css'; +@import './components/table.css'; body { background-color: #f3f4f6; From 7a31cb0f6a0e0f744866a700a18b7cfee5d0ffdb Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Sat, 5 Jun 2021 22:53:33 +0000 Subject: [PATCH 04/10] Implemented the API, split the teams page up into multiple components --- components/TeamListItem.tsx | 43 ----- components/team/EditTeamModal.tsx | 99 +++++++++++ components/team/MemberInvitationModal.tsx | 75 ++++++++ components/team/TeamList.tsx | 39 +++++ components/team/TeamListItem.tsx | 76 +++++++++ components/ui/Dropdown.tsx | 19 +++ lib/emails/invitation.ts | 77 +++++++++ pages/api/teams.ts | 35 ++++ pages/api/teams/[team]/index.ts | 26 +++ pages/api/teams/[team]/invite.ts | 59 +++++++ pages/api/teams/[team]/membership.ts | 67 ++++++++ pages/api/user/membership.ts | 62 +++++++ pages/settings/teams.tsx | 199 +++++++++------------- prisma/schema.prisma | 24 +++ 14 files changed, 741 insertions(+), 159 deletions(-) delete mode 100644 components/TeamListItem.tsx create mode 100644 components/team/EditTeamModal.tsx create mode 100644 components/team/MemberInvitationModal.tsx create mode 100644 components/team/TeamList.tsx create mode 100644 components/team/TeamListItem.tsx create mode 100644 components/ui/Dropdown.tsx create mode 100644 lib/emails/invitation.ts create mode 100644 pages/api/teams.ts create mode 100644 pages/api/teams/[team]/index.ts create mode 100644 pages/api/teams/[team]/invite.ts create mode 100644 pages/api/teams/[team]/membership.ts create mode 100644 pages/api/user/membership.ts diff --git a/components/TeamListItem.tsx b/components/TeamListItem.tsx deleted file mode 100644 index c5cb181484..0000000000 --- a/components/TeamListItem.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { ChevronDownIcon, ChevronUpIcon, UserAddIcon, TrashIcon, UsersIcon } from "@heroicons/react/outline"; - -export default function TeamListItem(props) { - return (
  • -
    -
    - -
    - {props.team.userRole === "Owner" && } - {props.team.userRole !== "Owner" && {props.team.name}} - {props.team.userRole} -
    -
    - {props.team.userRole === 'Invitee' &&
    - - -
    } - {props.team.userRole === 'Member' &&
    - -
    } -
    - {/*{props.team.userRole === 'Owner' && expanded &&
    - {props.team.members.length > 0 &&
    -

    Members

    - - - {props.team.members.map( (member) => - - - - )} - -
    Alex van Andel ({ member.email })Owner - -
    -
    } - - -
    }*/} -
  • ); -} \ No newline at end of file diff --git a/components/team/EditTeamModal.tsx b/components/team/EditTeamModal.tsx new file mode 100644 index 0000000000..b03fdb9976 --- /dev/null +++ b/components/team/EditTeamModal.tsx @@ -0,0 +1,99 @@ +import {useEffect, useState} from "react"; +import {UsersIcon,UserRemoveIcon} from "@heroicons/react/outline"; +import {useSession} from "next-auth/client"; + +export default function EditTeamModal(props) { + + const [ session, loading ] = useSession(); + const [ members, setMembers ] = useState([]); + const [ checkedDisbandTeam, setCheckedDisbandTeam ] = useState(false); + + const loadMembers = () => fetch('/api/teams/' + props.team.id + '/membership') + .then( (res: any) => res.json() ).then( (data) => setMembers(data.members) ); + + useEffect( () => { + loadMembers(); + }, []); + + const deleteTeam = (e) => { + e.preventDefault(); + return fetch('/api/teams/' + props.team.id, { + method: 'DELETE', + }).then(props.onExit); + } + + const removeMember = (member) => { + return fetch('/api/teams/' + props.team.id + '/membership', { + method: 'DELETE', + body: JSON.stringify({ userId: member.id }), + headers: { + 'Content-Type': 'application/json' + } + }).then(loadMembers); + } + + return (
    +
    + + + + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + {members.length > 0 &&
    +
    +

    Members

    +
    + + + {members.map( (member) => + + + + )} + +
    {member.name} {member.name && '(' + member.email + ')' }{!member.name && member.email}{member.role.toLowerCase()} + {member.email !== session.user.email && + + } +
    +
    } +
    +
    +

    Tick the box to disband this team.

    + +
    +
    +
    + {!checkedDisbandTeam && } + {checkedDisbandTeam && } + +
    +
    +
    +
    +
    ); +} \ No newline at end of file diff --git a/components/team/MemberInvitationModal.tsx b/components/team/MemberInvitationModal.tsx new file mode 100644 index 0000000000..e4472b09b9 --- /dev/null +++ b/components/team/MemberInvitationModal.tsx @@ -0,0 +1,75 @@ +import {useEffect, useState} from "react"; +import {UsersIcon} from "@heroicons/react/outline"; + +export default function MemberInvitationModal(props) { + + const inviteMember = (e) => { + + e.preventDefault(); + + const payload = { + role: e.target.elements['role'].value, + usernameOrEmail: e.target.elements['inviteUser'].value, + sendEmailInvitation: e.target.elements['sendInviteEmail'].checked, + } + + return fetch('/api/teams/' + props.team.id + '/invite', { + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json' + } + }).then(props.onExit); + }; + + return (
    +
    + + + + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    +
    ); +} \ No newline at end of file diff --git a/components/team/TeamList.tsx b/components/team/TeamList.tsx new file mode 100644 index 0000000000..b0dc52ba52 --- /dev/null +++ b/components/team/TeamList.tsx @@ -0,0 +1,39 @@ +import {useEffect, useState} from "react"; +import TeamListItem from "./TeamListItem"; +import EditTeamModal from "./EditTeamModal"; +import MemberInvitationModal from "./MemberInvitationModal"; + +export default function TeamList(props) { + + const [ showMemberInvitationModal, setShowMemberInvitationModal ] = useState(false); + const [ showEditTeamModal, setShowEditTeamModal ] = useState(false); + const [ team, setTeam ] = useState(null); + + const selectAction = (action: string, team: any) => { + setTeam(team); + switch (action) { + case 'edit': + setShowEditTeamModal(true); + break; + case 'invite': + setShowMemberInvitationModal(true); + break; + } + }; + + return (
    +
      + {props.teams.map( + (team: any) => selectAction(action, team) + }> + )} +
    + {showEditTeamModal && setShowEditTeamModal(false)}>} + {showMemberInvitationModal && + setShowMemberInvitationModal(false)}> + } +
    ); +} \ No newline at end of file diff --git a/components/team/TeamListItem.tsx b/components/team/TeamListItem.tsx new file mode 100644 index 0000000000..c2308ea349 --- /dev/null +++ b/components/team/TeamListItem.tsx @@ -0,0 +1,76 @@ +import {CogIcon, TrashIcon, UserAddIcon, UsersIcon} from "@heroicons/react/outline"; +import Dropdown from "../ui/Dropdown"; +import {useState} from "react"; + +export default function TeamListItem(props) { + + const [ team, setTeam ] = useState(props.team); + + const acceptInvite = () => invitationResponse(true); + const declineInvite = () => invitationResponse(false); + + const invitationResponse = (accept: boolean) => fetch('/api/user/membership', { + method: accept ? 'PATCH' : 'DELETE', + body: JSON.stringify({ teamId: props.team.id }), + headers: { + 'Content-Type': 'application/json' + } + }).then( () => { + // success + setTeam(null); + }); + + return (team &&
  • + + {/*{props.team.userRole === 'Owner' && expanded &&
    + {props.team.members.length > 0 &&
    +

    Members

    + + + {props.team.members.map( (member) => + + + + )} + +
    Alex van Andel ({ member.email })Owner + +
    +
    } + + +
    }*/} +
  • ); +} \ No newline at end of file diff --git a/components/ui/Dropdown.tsx b/components/ui/Dropdown.tsx new file mode 100644 index 0000000000..7cf8c97e0f --- /dev/null +++ b/components/ui/Dropdown.tsx @@ -0,0 +1,19 @@ +import {useEffect, useState} from "react"; + +export default function Dropdown(props) { + + const [ open, setOpen ] = useState(false); + + useEffect( () => { + document.addEventListener('keyup', (e) => { + if (e.key === "Escape") { + setOpen(false); + } + }); + }, [open]); + + return (
    setOpen(!open)} {...props}> + {props.children[0]} + {open && props.children[1]} +
    ); +} \ No newline at end of file diff --git a/lib/emails/invitation.ts b/lib/emails/invitation.ts new file mode 100644 index 0000000000..bd4ed79d92 --- /dev/null +++ b/lib/emails/invitation.ts @@ -0,0 +1,77 @@ + +import {serverConfig} from "../serverConfig"; +import nodemailer from 'nodemailer'; + +export default function createInvitationEmail(data: any, options: any = {}) { + return sendEmail(data, { + provider: { + transport: serverConfig.transport, + from: serverConfig.from, + }, + ...options + }); +} + +const sendEmail = (invitation: any, { + provider, +}) => new Promise( (resolve, reject) => { + const { transport, from } = provider; + + nodemailer.createTransport(transport).sendMail( + { + from: `Calendso <${from}>`, + to: invitation.toEmail, + subject: `${invitation.from} invited you to join ${invitation.teamName}`, + html: html(invitation), + text: text(invitation), + }, + (error) => { + if (error) { + console.error("SEND_INVITATION_NOTIFICATION_ERROR", invitation.toEmail, error); + return reject(new Error(error)); + } + return resolve(); + }); +}); + +const html = (invitation: any) => ` + + + + +
    +
    + + + + +
    + Hi,
    +
    + ${invitation.from} invited you to join the team "${invitation.teamName}" in Calendso.
    +
    + + + + +
    +
    + + Join team + +
    +

    + If you prefer not to use "${invitation.toEmail}" as your Calendso email or already have a Calendso account, please request another invitation to that email. +
    +
    +
    +`; + +// just strip all HTML and convert
    to \n +const text = (evt: any) => html(evt).replace('
    ', "\n").replace(/<[^>]+>/g, ''); \ No newline at end of file diff --git a/pages/api/teams.ts b/pages/api/teams.ts new file mode 100644 index 0000000000..abd96c3da9 --- /dev/null +++ b/pages/api/teams.ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import prisma from '../../lib/prisma'; +import {getSession} from "next-auth/client"; +import {create} from "domain"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + + const session = await getSession({req: req}); + + if (!session) { + res.status(401).json({message: "Not authenticated"}); + return; + } + + if (req.method === "POST") { + const createTeam = await prisma.team.create({ + data: { + name: req.body.name, + }, + }); + + const createMembership = await prisma.membership.create({ + data: { + teamId: createTeam.id, + userId: session.user.id, + role: 'OWNER', + accepted: true, + } + }); + + return res.status(201).setHeader('Location', 'https://calendso.alexvanandel.com/api/teams/1').send(null); + } + + res.status(404).send(null); +} diff --git a/pages/api/teams/[team]/index.ts b/pages/api/teams/[team]/index.ts new file mode 100644 index 0000000000..3fde85bc80 --- /dev/null +++ b/pages/api/teams/[team]/index.ts @@ -0,0 +1,26 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import prisma from '../../../../lib/prisma'; +import {getSession} from "next-auth/client"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + + const session = await getSession({req: req}); + if (!session) { + return res.status(401).json({message: "Not authenticated"}); + } + + // DELETE /api/teams/{team} + if (req.method === "DELETE") { + const deleteMembership = await prisma.membership.delete({ + where: { + userId_teamId: { userId: session.user.id, teamId: parseInt(req.query.team) } + } + }); + const deleteTeam = await prisma.team.delete({ + where: { + id: parseInt(req.query.team), + }, + }); + return res.status(204).send(null); + } +} \ No newline at end of file diff --git a/pages/api/teams/[team]/invite.ts b/pages/api/teams/[team]/invite.ts new file mode 100644 index 0000000000..d8170401fe --- /dev/null +++ b/pages/api/teams/[team]/invite.ts @@ -0,0 +1,59 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import prisma from '../../../../lib/prisma'; +import createInvitationEmail from "../../../../lib/emails/invitation"; +import {getSession} from "next-auth/client"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + + if (req.method !== "POST") { + return res.status(400).json({ message: "Bad request" }); + } + + const session = await getSession({req: req}); + if (!session) { + return res.status(401).json({message: "Not authenticated"}); + } + + const team = await prisma.team.findFirst({ + where: { + id: parseInt(req.query.team) + } + }); + + if (!team) { + return res.status(404).json({message: "Unable to find team to invite user to."}); + } + + const invitee = await prisma.user.findFirst({ + where: { + OR: [ + { username: req.body.usernameOrEmail }, + { email: req.body.usernameOrEmail } + ] + } + }); + + if (!invitee) { + return res.status(404).json({message: "Missing user, currently unsupported."}); + } + + // create provisional membership + const createMembership = await prisma.membership.create({ + data: { + teamId: parseInt(req.query.team), + userId: invitee.id, + role: req.body.role, + }, + }); + + // inform user of membership by email + if (req.body.sendEmailInvitation) { + createInvitationEmail({ + toEmail: invitee.email, + from: session.user.name, + teamName: team.name + }); + } + + res.status(201).json({}); +} diff --git a/pages/api/teams/[team]/membership.ts b/pages/api/teams/[team]/membership.ts new file mode 100644 index 0000000000..19d93abe91 --- /dev/null +++ b/pages/api/teams/[team]/membership.ts @@ -0,0 +1,67 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import prisma from '../../../../lib/prisma'; +import {getSession} from "next-auth/client"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + + const session = await getSession({req}); + + if (!session) { + res.status(401).json({message: "Not authenticated"}); + return; + } + + const isTeamOwner = !!await prisma.membership.findFirst({ + where: { + userId: session.user.id, + teamId: parseInt(req.query.team), + role: 'OWNER' + } + }); + + if ( ! isTeamOwner) { + res.status(403).json({message: "You are not authorized to manage this team"}); + return; + } + + // List members + if (req.method === "GET") { + const memberships = await prisma.membership.findMany({ + where: { + teamId: parseInt(req.query.team), + } + }); + + let members = await prisma.user.findMany({ + where: { + id: { + in: memberships.map( (membership) => membership.userId ), + } + } + }); + + members = members.map( (member) => { + const membership = memberships.find( (membership) => member.id === membership.userId ); + return { + ...member, + role: membership.accepted ? membership.role : 'INVITEE', + } + }); + + return res.status(200).json({ members: members }); + } + + // Cancel a membership (invite) + if (req.method === "DELETE") { + const memberships = await prisma.membership.delete({ + where: { + userId_teamId: { userId: req.body.userId, teamId: parseInt(req.query.team) }, + } + }); + return res.status(204).send(null); + } + + // Promote or demote a member of the team + + res.status(200).json({}); +} diff --git a/pages/api/user/membership.ts b/pages/api/user/membership.ts new file mode 100644 index 0000000000..d05ae5b39d --- /dev/null +++ b/pages/api/user/membership.ts @@ -0,0 +1,62 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import prisma from '../../../lib/prisma'; +import { getSession } from "next-auth/client"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + + const session = await getSession({req: req}); + if (!session) { + return res.status(401).json({message: "Not authenticated"}); + } + + if (req.method === "GET") { + const memberships = await prisma.membership.findMany({ + where: { + userId: session.user.id, + } + }); + + const teams = await prisma.team.findMany({ + where: { + id: { + in: memberships.map(membership => membership.teamId), + } + } + }); + + return res.status(200).json({ + membership: memberships.map((membership) => ({ + role: membership.accepted ? membership.role : 'INVITEE', + ...teams.find(team => team.id === membership.teamId) + })) + }); + } + + if (!req.body.teamId) { + return res.status(400).json({ message: "Bad request" }); + } + + // Leave team or decline membership invite of current user + if (req.method === "DELETE") { + const memberships = await prisma.membership.delete({ + where: { + userId_teamId: { userId: session.user.id, teamId: req.body.teamId } + } + }); + return res.status(204).send(null); + } + + // Accept team invitation + if (req.method === "PATCH") { + const memberships = await prisma.membership.update({ + where: { + userId_teamId: { userId: session.user.id, teamId: req.body.teamId } + }, + data: { + accepted: true + } + }); + + return res.status(204).send(null); + } +} \ No newline at end of file diff --git a/pages/settings/teams.tsx b/pages/settings/teams.tsx index 2ae8954cb6..840c1bec24 100644 --- a/pages/settings/teams.tsx +++ b/pages/settings/teams.tsx @@ -3,41 +3,47 @@ import prisma from '../../lib/prisma'; import Modal from '../../components/Modal'; import Shell from '../../components/Shell'; import SettingsShell from '../../components/Settings'; -import { useState } from 'react'; +import {useEffect, useState} from 'react'; import { useSession, getSession } from 'next-auth/client'; -import Button from "../../components/ui/Button"; import { UsersIcon, - UserAddIcon, - UserRemoveIcon, - ChevronDownIcon, - ChevronUpIcon, - LocationMarkerIcon } from "@heroicons/react/outline"; -import { ShieldCheckIcon } from "@heroicons/react/solid"; -import TeamListItem from "../../components/TeamListItem"; +import TeamList from "../../components/team/TeamList"; +import TeamListItem from "../../components/team/TeamListItem"; export default function Teams(props) { const [ session, loading ] = useSession(); - const [ selectedTeam, setSelectedTeam ] = useState({}); + const [ teams, setTeams ] = useState([]); + const [ invites, setInvites ] = useState([]); + const [ showCreateTeamModal, setShowCreateTeamModal ] = useState(false); + + const loadTeams = () => fetch('/api/user/membership').then( (res: any) => res.json() ).then( + (data) => { + setTeams(data.membership.filter( (m) => m.role !== "INVITEE" )); + setInvites(data.membership.filter( (m) => m.role === "INVITEE" )); + } + ); + + useEffect( () => { loadTeams(); }, []); if (loading) { return

    Loading...

    ; } - const teams = [ - { name: "Flying Colours Life", userRole: "Owner", members: [ - { "name": "Alex van Andel", "email": "bartfalij@gmail.com", "role": "Owner" }, - { "email": "me@alexvanandel.com", "role": "Member" }, - { "email": "avanandel@flyingcolourslife.com", "role": "Member" }, - ] }, - { name: "Partner Wealth", userRole: "Member" } - ]; - - const invitations = [ - { name: "Asset Management", userRole: "Invitee" } - ]; + const createTeam = (e) => { + e.preventDefault(); + return fetch('/api/teams', { + method: 'POST', + body: JSON.stringify({ name: e.target.elements['name'].value }), + headers: { + 'Content-Type': 'application/json' + } + }).then( () => { + loadTeams(); + setShowCreateTeamModal(false); + }); + } return( @@ -51,120 +57,81 @@ export default function Teams(props) {

    Your teams

    -

    +

    View, edit and create teams to organise relationships between users

    - {teams.length === 0 &&
    + {!(invites.length || teams.length) &&

    Team up with other users
    by adding a new team

    - +
    }
    - {teams.length > 0 &&
    - + {!!(invites.length || teams.length) &&
    +
    }
    -
      - {teams.map( - (team: any) => setSelectedTeam(team) }> - )} -
    -

    Open Invitations

    -
      - {invitations.map( (team) => )} -
    + {!!teams.length && + + + } + + {!!invites.length &&
    +

    Open Invitations

    +
      + {invites.map( (team) => )} +
    +
    }
    - {/*
    -

    Transform account

    -

    - You cannot convert this account into a team until you leave all teams that you’re a member of. -

    - -
    */} + {/*{teamsLoaded &&
    +
    +

    Transform account

    +

    + {membership.length !== 0 && "You cannot convert this account into a team until you leave all teams that you’re a member of."} + {membership.length === 0 && "A user account can be turned into a team, as a team ...."} +

    +
    +
    + +
    +
    }*/}
    - {Object.keys(selectedTeam).length > 0 && + {showCreateTeamModal &&
    -
    - +
    + - + -
    -
    -
    - -
    -
    - -
    -
    -
    -
    -
    - {selectedTeam.members.length > 0 &&
    -
    -

    Members

    - -
    - - - {selectedTeam.members.map( (member) => - - - - )} - -
    {member.name} {member.name && '(' + member.email + ')' }{!member.name && member.email}{member.role} - {/**/} -
    -
    } -
    -
    - - -
    -
    -
    - - -
    -
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    } ); -} - -export async function getServerSideProps(context) { - const session = await getSession(context); - if (!session) { - return { redirect: { permanent: false, destination: '/auth/login' } }; - } - - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - id: true, - username: true, - name: true - } - }); - - return { - props: {user}, // will be passed to the page component as props - } } \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 69b586d229..b13197819c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,5 +45,29 @@ model User { createdDate DateTime @default(now()) @map(name: "created") eventTypes EventType[] credentials Credential[] + teams Membership[] + @@map(name: "users") +} + +model Team { + id Int @default(autoincrement()) @id + name String? + members Membership[] +} + +enum MembershipRole { + MEMBER + OWNER +} + +model Membership { + teamId Int + userId Int + accepted Boolean @default(false) + role MembershipRole + team Team @relation(fields: [teamId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@id([userId,teamId]) } \ No newline at end of file From 9f12ccf5c1c18eee0b92ffc71ebcf1d08c2d683b Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Sat, 5 Jun 2021 23:41:05 +0000 Subject: [PATCH 05/10] Teams are now refreshed properly when TeamListItems change --- components/team/TeamList.tsx | 7 +++++-- components/team/TeamListItem.tsx | 1 + pages/settings/teams.tsx | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/components/team/TeamList.tsx b/components/team/TeamList.tsx index b0dc52ba52..7f04d322df 100644 --- a/components/team/TeamList.tsx +++ b/components/team/TeamList.tsx @@ -24,12 +24,15 @@ export default function TeamList(props) { return (
      {props.teams.map( - (team: any) => selectAction(action, team) }> )}
    - {showEditTeamModal && setShowEditTeamModal(false)}>} + {showEditTeamModal && { + props.onChange(); + setShowEditTeamModal(false); + }}>} {showMemberInvitationModal && { // success setTeam(null); + props.onChange(); }); return (team &&
  • diff --git a/pages/settings/teams.tsx b/pages/settings/teams.tsx index 840c1bec24..e5b1e5d386 100644 --- a/pages/settings/teams.tsx +++ b/pages/settings/teams.tsx @@ -72,14 +72,14 @@ export default function Teams(props) {
  • {!!teams.length && - + } {!!invites.length &&

    Open Invitations

      - {invites.map( (team) => )} + {invites.map( (team) => )}
    }
    From 5d3e39ea6e25e547b431db6927c399cb4d524cb5 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Mon, 7 Jun 2021 15:12:00 +0000 Subject: [PATCH 06/10] Better error handling during team member invitation Now tells you if you have already added this member / invite is pending. Behaviour a little bit more predictable during team editting. --- components/team/EditTeamModal.tsx | 11 +++++---- components/team/MemberInvitationModal.tsx | 23 ++++++++++++++--- pages/api/teams.ts | 4 ++- pages/api/teams/[team]/invite.ts | 30 ++++++++++++++++------- 4 files changed, 50 insertions(+), 18 deletions(-) diff --git a/components/team/EditTeamModal.tsx b/components/team/EditTeamModal.tsx index b03fdb9976..347a7ba68c 100644 --- a/components/team/EditTeamModal.tsx +++ b/components/team/EditTeamModal.tsx @@ -62,7 +62,8 @@ export default function EditTeamModal(props) { {member.email !== session.user.email && @@ -76,20 +77,20 @@ export default function EditTeamModal(props) {

    Tick the box to disband this team.

    - {!checkedDisbandTeam && } + */} {checkedDisbandTeam && }
    diff --git a/components/team/MemberInvitationModal.tsx b/components/team/MemberInvitationModal.tsx index e4472b09b9..36e0d5640f 100644 --- a/components/team/MemberInvitationModal.tsx +++ b/components/team/MemberInvitationModal.tsx @@ -1,8 +1,22 @@ -import {useEffect, useState} from "react"; -import {UsersIcon} from "@heroicons/react/outline"; +import { UsersIcon } from "@heroicons/react/outline"; +import { useState } from "react"; export default function MemberInvitationModal(props) { + const [ errorMessage, setErrorMessage ] = useState(''); + + const handleError = async (res) => { + + const responseData = await res.json(); + + if (res.ok === false) { + setErrorMessage(responseData.message); + throw new Error(responseData.message); + } + + return responseData; + }; + const inviteMember = (e) => { e.preventDefault(); @@ -19,7 +33,9 @@ export default function MemberInvitationModal(props) { headers: { 'Content-Type': 'application/json' } - }).then(props.onExit); + }).then(handleError).then(props.onExit).catch( (e) => { + // do nothing. + }); }; return (
    @@ -60,6 +76,7 @@ export default function MemberInvitationModal(props) {
    + {errorMessage &&

    Error: {errorMessage}

    }
    - + +
    +

    + Manage and delete your team. +

    +
    diff --git a/components/team/MemberInvitationModal.tsx b/components/team/MemberInvitationModal.tsx index 36e0d5640f..2e9a82610d 100644 --- a/components/team/MemberInvitationModal.tsx +++ b/components/team/MemberInvitationModal.tsx @@ -50,7 +50,12 @@ export default function MemberInvitationModal(props) {
    - + +
    +

    + Invite someone to your team. +

    +
    @@ -70,13 +75,13 @@ export default function MemberInvitationModal(props) {
    -
    - {errorMessage &&

    Error: {errorMessage}

    } + {errorMessage &&

    Error: {errorMessage}

    }
    {props.team.role === 'INVITEE' &&
    diff --git a/pages/settings/teams.tsx b/pages/settings/teams.tsx index e5b1e5d386..e4d1dc5f0b 100644 --- a/pages/settings/teams.tsx +++ b/pages/settings/teams.tsx @@ -3,7 +3,7 @@ import prisma from '../../lib/prisma'; import Modal from '../../components/Modal'; import Shell from '../../components/Shell'; import SettingsShell from '../../components/Settings'; -import {useEffect, useState} from 'react'; +import { useEffect, useState } from 'react'; import { useSession, getSession } from 'next-auth/client'; import { UsersIcon, @@ -13,19 +13,19 @@ import TeamListItem from "../../components/team/TeamListItem"; export default function Teams(props) { - const [ session, loading ] = useSession(); - const [ teams, setTeams ] = useState([]); - const [ invites, setInvites ] = useState([]); - const [ showCreateTeamModal, setShowCreateTeamModal ] = useState(false); + const [session, loading] = useSession(); + const [teams, setTeams] = useState([]); + const [invites, setInvites] = useState([]); + const [showCreateTeamModal, setShowCreateTeamModal] = useState(false); - const loadTeams = () => fetch('/api/user/membership').then( (res: any) => res.json() ).then( + const loadTeams = () => fetch('/api/user/membership').then((res: any) => res.json()).then( (data) => { - setTeams(data.membership.filter( (m) => m.role !== "INVITEE" )); - setInvites(data.membership.filter( (m) => m.role === "INVITEE" )); + setTeams(data.membership.filter((m) => m.role !== "INVITEE")); + setInvites(data.membership.filter((m) => m.role === "INVITEE")); } ); - useEffect( () => { loadTeams(); }, []); + useEffect(() => { loadTeams(); }, []); if (loading) { return

    Loading...

    ; @@ -39,13 +39,13 @@ export default function Teams(props) { headers: { 'Content-Type': 'application/json' } - }).then( () => { + }).then(() => { loadTeams(); setShowCreateTeamModal(false); }); } - return( + return ( Teams | Calendso @@ -57,17 +57,31 @@ export default function Teams(props) {

    Your teams

    -

    +

    View, edit and create teams to organise relationships between users

    - {!(invites.length || teams.length) &&
    -

    Team up with other users
    by adding a new team

    - - -
    } + {!(invites.length || teams.length) && +
    +
    +

    Create a team to get started

    +
    +

    Create your first team and invite other users to work together with you.

    +
    +
    + +
    +
    +
    + }
    {!!(invites.length || teams.length) &&
    - +
    }
    @@ -79,7 +93,7 @@ export default function Teams(props) { {!!invites.length &&

    Open Invitations

      - {invites.map( (team) => )} + {invites.map((team) => )}
    }
    @@ -98,38 +112,43 @@ export default function Teams(props) {
    {showCreateTeamModal && -
    -
    - +
    +
    + - + -
    -
    -
    - -
    -
    - +
    +
    +
    + +
    +
    + +
    +

    + Create a new team to collaborate with users. +

    +
    +
    + +
    + + +
    +
    + + +
    +
    -
    -
    - - -
    -
    - - -
    -
    -
    } From e8a5357a89ca97ef3375e9fbef671e869db7ae1c Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 9 Jun 2021 12:26:00 +0000 Subject: [PATCH 09/10] Restricted usernames to be unique, removes the potential for username conflicts --- components/ui/UsernameInput.tsx | 17 +++++++ components/ui/alerts/Error.tsx | 22 +++++++++ pages/api/user/profile.ts | 82 +++++++++++++++++++-------------- pages/settings/profile.tsx | 29 +++++++----- 4 files changed, 103 insertions(+), 47 deletions(-) create mode 100644 components/ui/UsernameInput.tsx create mode 100644 components/ui/alerts/Error.tsx diff --git a/components/ui/UsernameInput.tsx b/components/ui/UsernameInput.tsx new file mode 100644 index 0000000000..31d2ba0333 --- /dev/null +++ b/components/ui/UsernameInput.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +export const UsernameInput = React.forwardRef( (props, ref) => ( + // todo, check if username is already taken here? +
    + +
    + + {typeof window !== "undefined" && window.location.hostname}/ + + +
    +
    +)); \ No newline at end of file diff --git a/components/ui/alerts/Error.tsx b/components/ui/alerts/Error.tsx new file mode 100644 index 0000000000..8cc239988d --- /dev/null +++ b/components/ui/alerts/Error.tsx @@ -0,0 +1,22 @@ + +import { XCircleIcon } from '@heroicons/react/solid' + +export default function ErrorAlert(props) { + return ( +
    +
    +
    +
    +
    +

    Something went wrong

    +
    +

    + {props.message} +

    +
    +
    +
    +
    + ) +} \ No newline at end of file diff --git a/pages/api/user/profile.ts b/pages/api/user/profile.ts index f1b2e18d68..fb47f5d221 100644 --- a/pages/api/user/profile.ts +++ b/pages/api/user/profile.ts @@ -3,46 +3,58 @@ import { getSession } from 'next-auth/client'; import prisma from '../../../lib/prisma'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const session = await getSession({req: req}); + const session = await getSession({req: req}); - if (!session) { - res.status(401).json({message: "Not authenticated"}); - return; + if (!session) { + res.status(401).json({message: "Not authenticated"}); + return; + } + + // Get user + const user = await prisma.user.findUnique({ + where: { + email: session.user.email, + }, + select: { + id: true, + password: true } + }); - // Get user - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - id: true, - password: true - } + if (!user) { res.status(404).json({message: 'User not found'}); return; } + + const username = req.body.username; + // username is changed: username is optional but it is necessary to be unique, enforce here + if (username !== user.username) { + const userConflict = await prisma.user.findFirst({ + where: { + username, + } }); + if (userConflict) { + return res.status(409).json({ message: 'Username already taken' }); + } + } - if (!user) { res.status(404).json({message: 'User not found'}); return; } + const name = req.body.name; + const description = req.body.description; + const avatar = req.body.avatar; + const timeZone = req.body.timeZone; + const weekStart = req.body.weekStart; - const username = req.body.username; - const name = req.body.name; - const description = req.body.description; - const avatar = req.body.avatar; - const timeZone = req.body.timeZone; - const weekStart = req.body.weekStart; + const updateUser = await prisma.user.update({ + where: { + id: user.id, + }, + data: { + username, + name, + avatar, + bio: description, + timeZone: timeZone, + weekStart: weekStart, + }, + }); - const updateUser = await prisma.user.update({ - where: { - id: user.id, - }, - data: { - username, - name, - avatar, - bio: description, - timeZone: timeZone, - weekStart: weekStart, - }, - }); - - res.status(200).json({message: 'Profile updated successfully'}); + return res.status(200).json({message: 'Profile updated successfully'}); } \ No newline at end of file diff --git a/pages/settings/profile.tsx b/pages/settings/profile.tsx index d0a112943b..2078796636 100644 --- a/pages/settings/profile.tsx +++ b/pages/settings/profile.tsx @@ -9,6 +9,8 @@ import SettingsShell from '../../components/Settings'; import Avatar from '../../components/Avatar'; import { signIn, useSession, getSession } from 'next-auth/client'; import TimezoneSelect from 'react-timezone-select'; +import {UsernameInput} from "../../components/ui/UsernameInput"; +import ErrorAlert from "../../components/ui/alerts/Error"; export default function Settings(props) { const [ session, loading ] = useSession(); @@ -22,12 +24,22 @@ export default function Settings(props) { const [ selectedTimeZone, setSelectedTimeZone ] = useState({ value: props.user.timeZone }); const [ selectedWeekStartDay, setSelectedWeekStartDay ] = useState(props.user.weekStart || 'Sunday'); + const [ hasErrors, setHasErrors ] = useState(false); + const [ errorMessage, setErrorMessage ] = useState(''); + if (loading) { return

    Loading...

    ; } const closeSuccessModal = () => { setSuccessModalOpen(false); } + const handleError = async (resp) => { + if (!resp.ok) { + const error = await resp.json(); + throw new Error(error.message); + } + } + async function updateProfileHandler(event) { event.preventDefault(); @@ -46,10 +58,10 @@ export default function Settings(props) { headers: { 'Content-Type': 'application/json' } + }).then(handleError).then( () => setSuccessModalOpen(true) ).catch( (err) => { + setHasErrors(true); + setErrorMessage(err.message); }); - - router.replace(router.asPath); - setSuccessModalOpen(true); } return( @@ -60,6 +72,7 @@ export default function Settings(props) {
    + {hasErrors && }

    Profile

    @@ -72,15 +85,7 @@ export default function Settings(props) {
    - -
    - - {window.location.hostname}/ - - -
    +
    From f24ca5b6721d0f66c5f251a7e0c65fa4b39b7c70 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 9 Jun 2021 20:32:02 +0000 Subject: [PATCH 10/10] Fixed incorrect variable & also clears old errors now --- pages/api/user/profile.ts | 2 +- pages/settings/profile.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pages/api/user/profile.ts b/pages/api/user/profile.ts index fb47f5d221..cc9aef6a5d 100644 --- a/pages/api/user/profile.ts +++ b/pages/api/user/profile.ts @@ -25,7 +25,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const username = req.body.username; // username is changed: username is optional but it is necessary to be unique, enforce here - if (username !== user.username) { + if (username !== session.user.username) { const userConflict = await prisma.user.findFirst({ where: { username, diff --git a/pages/settings/profile.tsx b/pages/settings/profile.tsx index 2078796636..a00f9efaf6 100644 --- a/pages/settings/profile.tsx +++ b/pages/settings/profile.tsx @@ -58,7 +58,10 @@ export default function Settings(props) { headers: { 'Content-Type': 'application/json' } - }).then(handleError).then( () => setSuccessModalOpen(true) ).catch( (err) => { + }).then(handleError).then( () => { + setSuccessModalOpen(true); + setHasErrors(false); // dismiss any open errors + }).catch( (err) => { setHasErrors(true); setErrorMessage(err.message); });