Edit {props.team.name}
+Member Invitation
+-
+ {props.teams.map(
+ (team: any) =>
Members
+Alex van Andel ({ member.email }) | +Owner | ++ + | +
+
|
+
to \n +const text = (evt: any) => html(evt).replace('
', "\n").replace(/<[^>]+>/g, ''); \ No newline at end of file diff --git a/next.config.js b/next.config.js index e8b36e89..b0ef7d5c 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,11 @@ const withTM = require('next-transpile-modules')(['react-timezone-select']); +// TODO: Revisit this later with getStaticProps in App +if (process.env.NEXTAUTH_URL) { + process.env.BASE_URL = process.env.NEXTAUTH_URL.replace('/api/auth', ''); +} + if ( ! process.env.EMAIL_FROM ) { console.warn('\x1b[33mwarn', '\x1b[0m', 'EMAIL_FROM environment variable is not set, this may indicate mailing is currently disabled. Please refer to the .env.example file.'); } diff --git a/pages/api/teams.ts b/pages/api/teams.ts new file mode 100644 index 00000000..1ab69adb --- /dev/null +++ b/pages/api/teams.ts @@ -0,0 +1,37 @@ +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) { + res.status(401).json({message: "Not authenticated"}); + return; + } + + if (req.method === "POST") { + + // TODO: Prevent creating a team with identical names? + + 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', process.env.BASE_URL + '/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 00000000..3fde85bc --- /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 00000000..1a8f2415 --- /dev/null +++ b/pages/api/teams/[team]/invite.ts @@ -0,0 +1,71 @@ +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: "Invalid team"}); + } + + const invitee = await prisma.user.findFirst({ + where: { + OR: [ + { username: req.body.usernameOrEmail }, + { email: req.body.usernameOrEmail } + ] + } + }); + + if (!invitee) { + return res.status(400).json({ + message: `Invite failed because there is no corresponding user for ${req.body.usernameOrEmail}`}); + } + + // create provisional membership + try { + const createMembership = await prisma.membership.create({ + data: { + teamId: parseInt(req.query.team), + userId: invitee.id, + role: req.body.role, + }, + }); + } + catch (err) { + if (err.code === "P2002") { // unique constraint violation + return res.status(409).json({ + message: 'This user is a member of this team / has a pending invitation.', + }); + } else { + throw err; // rethrow + } + }; + + // 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 00000000..19d93abe --- /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 00000000..d05ae5b3 --- /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 new file mode 100644 index 00000000..e5b1e5d3 --- /dev/null +++ b/pages/settings/teams.tsx @@ -0,0 +1,137 @@ +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 {useEffect, useState} from 'react'; +import { useSession, getSession } from 'next-auth/client'; +import { + UsersIcon, +} from "@heroicons/react/outline"; +import TeamList from "../../components/team/TeamList"; +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 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 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( +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
Open Invitations
+-
+ {invites.map( (team) =>
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 ...."} +
+