Implemented the API, split the teams page up into multiple components

This commit is contained in:
Alex van Andel 2021-06-05 22:53:33 +00:00
parent e2942224ab
commit 7a31cb0f6a
14 changed files with 741 additions and 159 deletions

View File

@ -1,43 +0,0 @@
import { ChevronDownIcon, ChevronUpIcon, UserAddIcon, TrashIcon, UsersIcon } from "@heroicons/react/outline";
export default function TeamListItem(props) {
return (<li className="mb-2 mt-2 divide-y">
<div className="flex justify-between mb-2 mt-2">
<div>
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline"/>
<div className="inline-block -mt-1">
{props.team.userRole === "Owner" && <button className="text-blue-700 font-bold text-sm" onClick={ () => props.onManage() }>{props.team.name}</button>}
{props.team.userRole !== "Owner" && <span className="font-bold text-gray-500 text-sm">{props.team.name}</span>}
<span className="text-xs text-gray-400 font-bold -mt-1 block">{props.team.userRole}</span>
</div>
</div>
{props.team.userRole === 'Invitee' && <div>
<button className="btn-sm bg-transparent text-green-500 border border-green-500 px-3 py-1 rounded ml-2">Accept invitation</button>
<button className="btn-sm bg-transparent px-2 ml-1">
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" />
</button>
</div>}
{props.team.userRole === 'Member' && <div>
<button className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded ml-2">Leave</button>
</div>}
</div>
{/*{props.team.userRole === 'Owner' && expanded && <div className="pt-2">
{props.team.members.length > 0 && <div>
<h2 className="text-lg font-medium text-gray-900 mb-1">Members</h2>
<table className="table-auto mb-2 w-full">
<tbody>
{props.team.members.map( (member) => <tr key={member.email}>
<td className="py-1 pl-2">Alex van Andel ({ member.email })</td>
<td>Owner</td>
<td className="text-right p-1">
<button className="btn-sm text-xs bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded ml-2"><UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline"/>Remove</button>
</td>
</tr>)}
</tbody>
</table>
</div>}
<button className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded"><UserAddIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 h-6 w-6 inline"/> Invite member</button>
<button className="btn-sm bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded ml-2">Disband</button>
</div>}*/}
</li>);
}

View File

@ -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 (<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="h-6 w-6 text-blue-600" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit {props.team.name}</h3>
</div>
</div>
<form>
<div>
<div className="mb-4">
{members.length > 0 && <div>
<div className="flex justify-between mb-2">
<h2 className="text-lg font-medium text-gray-900">Members</h2>
</div>
<table className="table-auto mb-2 w-full text-sm">
<tbody>
{members.map( (member) => <tr key={member.email}>
<td className="p-1">{member.name} {member.name && '(' + member.email + ')' }{!member.name && member.email}</td>
<td className="capitalize">{member.role.toLowerCase()}</td>
<td className="text-right py-2 px-1">
{member.email !== session.user.email &&
<button
onClick={() => removeMember(member)}
className="btn-sm text-xs bg-transparent px-3 py-1 rounded ml-2">
<UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline"/>
</button>
}
</td>
</tr>)}
</tbody>
</table>
</div>}
</div>
<div className="mb-4 border border-red-400 rounded p-2 px-4">
<p className="block text-sm font-medium text-gray-700">Tick the box to disband this team.</p>
<label className="mt-1">
<input type="checkbox" name="title" id="title" onChange={(e) => setCheckedDisbandTeam(e.target.checked)} required className="shadow-sm mr-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 rounded-md" />
Disband this team
</label>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
{!checkedDisbandTeam && <button type="submit" className="btn btn-primary">
Update
</button>}
{checkedDisbandTeam && <button onClick={deleteTeam} className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
Disband Team
</button>}
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>);
}

View File

@ -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 (<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="h-6 w-6 text-blue-600" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Member Invitation</h3>
</div>
</div>
<form onSubmit={inviteMember}>
<div>
<div className="mb-4">
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">Email or Username</label>
<input type="text" name="inviteUser" id="inviteUser" placeholder="email@example.com" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" />
</div>
<div className="mb-4">
<label className="block tracking-wide text-gray-700 text-sm font-medium mb-2"
htmlFor="role">
Role
</label>
<select id="role" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md">
<option value="MEMBER">Member</option>
<option value="OWNER">Owner</option>
</select>
</div>
<div className="mb-4">
<label className="mt-1">
<input type="checkbox" name="sendInviteEmail" defaultChecked id="sendInviteEmail" className="shadow-sm mr-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 rounded-md" />
Send invite email
</label>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Invite
</button>
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>);
}

View File

@ -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 (<div>
<ul className="border px-2 mb-2 rounded divide-y divide-gray-200">
{props.teams.map(
(team: any) => <TeamListItem key={team.id} team={team} onActionSelect={
(action: string) => selectAction(action, team)
}></TeamListItem>
)}
</ul>
{showEditTeamModal && <EditTeamModal team={team} onExit={() => setShowEditTeamModal(false)}></EditTeamModal>}
{showMemberInvitationModal &&
<MemberInvitationModal
team={team}
onExit={() => setShowMemberInvitationModal(false)}></MemberInvitationModal>
}
</div>);
}

View File

@ -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 && <li className="mb-2 mt-2 divide-y">
<div className="flex justify-between mb-2 mt-2">
<div>
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline"/>
<div className="inline-block -mt-1">
<span className="font-bold text-blue-700 text-sm">{props.team.name}</span>
<span className="text-xs text-gray-400 font-bold -mt-1 block capitalize">{props.team.role.toLowerCase()}</span>
</div>
</div>
{props.team.role === 'INVITEE' && <div>
<button className="btn-sm bg-transparent text-green-500 border border-green-500 px-3 py-1 rounded ml-2" onClick={acceptInvite}>Accept invitation</button>
<button className="btn-sm bg-transparent px-2 py-1 ml-1">
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" onClick={declineInvite} />
</button>
</div>}
{props.team.role === 'MEMBER' && <div>
<button onClick={declineInvite} className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded ml-2">Leave</button>
</div>}
{props.team.role === 'OWNER' && <div>
<Dropdown className="relative inline-block text-left">
<button className="btn-sm bg-transparent text-gray-400 px-3 py-1 rounded ml-2">
<CogIcon className="h-6 w-6 inline text-gray-400" />
</button>
<ul role="menu" className="z-10 origin-top-right absolute right-0 w-36 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
<a className="block px-4 py-2" onClick={() => props.onActionSelect('invite')}>Invite member(s)</a>
</li>
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
<a className="block px-4 py-2" onClick={() => props.onActionSelect('edit')}>Manage team</a>
</li>
</ul>
</Dropdown>
</div>}
</div>
{/*{props.team.userRole === 'Owner' && expanded && <div className="pt-2">
{props.team.members.length > 0 && <div>
<h2 className="text-lg font-medium text-gray-900 mb-1">Members</h2>
<table className="table-auto mb-2 w-full">
<tbody>
{props.team.members.map( (member) => <tr key={member.email}>
<td className="py-1 pl-2">Alex van Andel ({ member.email })</td>
<td>Owner</td>
<td className="text-right p-1">
<button className="btn-sm text-xs bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded ml-2"><UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline"/>Remove</button>
</td>
</tr>)}
</tbody>
</table>
</div>}
<button className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded"><UserAddIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 h-6 w-6 inline"/> Invite member</button>
<button className="btn-sm bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded ml-2">Disband</button>
</div>}*/}
</li>);
}

View File

@ -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 (<div onClick={() => setOpen(!open)} {...props}>
{props.children[0]}
{open && props.children[1]}
</div>);
}

77
lib/emails/invitation.ts Normal file
View File

@ -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) => `
<table style="width: 100%;">
<tr>
<td>
<center>
<table style="width: 640px; border: 1px solid gray; padding: 15px; margin: 0 auto; text-align: left;">
<tr>
<td>
Hi,<br />
<br />
${invitation.from} invited you to join the team "${invitation.teamName}" in Calendso.<br />
<br />
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td>
<div>
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://calendso.alexvanandel.com/settings/teams" style="height:40px;v-text-anchor:middle;width:130px;" arcsize="5%" strokecolor="#19cca3" fillcolor="#19cca3;width: 130;">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Helvetica, sans-serif;font-size:18px; font-weight: 600;">Join team</center>
</v:roundrect>
<![endif]-->
<a href="https://calendso.alexvanandel.com/settings/teams" style="display: inline-block; mso-hide:all; background-color: #19cca3; color: #FFFFFF; border:1px solid #19cca3; border-radius: 6px; line-height: 220%; width: 200px; font-family: Helvetica, sans-serif; font-size:18px; font-weight:600; text-align: center; text-decoration: none; -webkit-text-size-adjust:none; " target="_blank">Join team</a>
</a>
</div>
</td>
</tr>
</table><br />
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.
</td>
</tr>
</table>
</center>
</td>
</tr>
</table>
`;
// just strip all HTML and convert <br /> to \n
const text = (evt: any) => html(evt).replace('<br />', "\n").replace(/<[^>]+>/g, '');

35
pages/api/teams.ts Normal file
View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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({});
}

View File

@ -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({});
}

View File

@ -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);
}
}

View File

@ -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 <p className="text-gray-400">Loading...</p>;
}
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(
<Shell heading="Teams">
@ -51,120 +57,81 @@ export default function Teams(props) {
<div className="flex justify-between">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900">Your teams</h2>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-gray-500 mb-2">
View, edit and create teams to organise relationships between users
</p>
{teams.length === 0 && <div className="border rounded text-center p-4 pt-3 mt-4">
{!(invites.length || teams.length) && <div className="border rounded text-center p-4 pt-3 m-4">
<p className="text-sm text-gray-500">Team up with other users<br /> by adding a new team</p>
<UsersIcon className="text-blue-500 w-32 h-32 mx-auto"/>
<button className="btn-lg btn-primary">New team</button>
<button className="btn-lg btn-primary" onClick={() => setShowCreateTeamModal(true)}>New team</button>
</div>}
</div>
{teams.length > 0 && <div>
<Button className="btn-sm btn-primary">New team</Button>
{!!(invites.length || teams.length) && <div>
<button className="btn-sm btn-primary" onClick={() => setShowCreateTeamModal(true)}>New team</button>
</div>}
</div>
<div>
<ul className="border px-2 rounded mt-2 mb-2 divide-y divide-gray-200">
{teams.map(
(team: any) => <TeamListItem key={team.name} team={team} onManage={() => setSelectedTeam(team) }></TeamListItem>
)}
</ul>
<h2 className="text-lg leading-6 font-medium text-gray-900">Open Invitations</h2>
<ul className="border px-2 rounded mt-2 mb-2 divide-y divide-gray-200">
{invitations.map( (team) => <TeamListItem key={team.name} team={team}></TeamListItem>)}
</ul>
{!!teams.length &&
<TeamList teams={teams}>
</TeamList>
}
{!!invites.length && <div>
<h2 className="text-lg leading-6 font-medium text-gray-900">Open Invitations</h2>
<ul className="border px-2 rounded mt-2 mb-2 divide-y divide-gray-200">
{invites.map( (team) => <TeamListItem key={team.id} team={team}></TeamListItem>)}
</ul>
</div>}
</div>
{/*<div>
<h2 className="text-lg leading-6 font-medium text-gray-900">Transform account</h2>
<p className="mt-1 text-sm text-gray-500">
You cannot convert this account into a team until you leave all teams that youre a member of.
</p>
<Button className="mt-2 btn-sm btn-primary opacity-50 cursor-not-allowed" disabled>Convert {props.user.username} into a team</Button>
</div>*/}
{/*{teamsLoaded && <div className="flex justify-between">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900 mb-1">Transform account</h2>
<p className="text-sm text-gray-500 mb-1">
{membership.length !== 0 && "You cannot convert this account into a team until you leave all teams that youre a member of."}
{membership.length === 0 && "A user account can be turned into a team, as a team ...."}
</p>
</div>
<div>
<button className="mt-2 btn-sm btn-primary opacity-50 cursor-not-allowed" disabled>Convert {session.user.username} into a team</button>
</div>
</div>}*/}
</div>
</div>
{Object.keys(selectedTeam).length > 0 &&
{showCreateTeamModal &&
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="h-6 w-6 text-blue-600" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit {selectedTeam.name}</h3>
</div>
</div>
<form>
<div>
<div className="mb-4">
{selectedTeam.members.length > 0 && <div>
<div className="flex justify-between mb-2">
<h2 className="text-lg font-medium text-gray-900">Members</h2>
<button className="btn-xs btn-primary">Invite member</button>
</div>
<table className="table-auto mb-2 w-full text-sm">
<tbody>
{selectedTeam.members.map( (member) => <tr key={member.email}>
<td className="p-1">{member.name} {member.name && '(' + member.email + ')' }{!member.name && member.email}</td>
<td>{member.role}</td>
<td className="text-right py-2 px-1">
{/*<button className="btn-sm text-xs bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded ml-2"><UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline"/>Remove</button>*/}
</td>
</tr>)}
</tbody>
</table>
</div>}
</div>
<div className="mb-4 border border-red-400 rounded p-2">
<label htmlFor="title" className="block text-sm font-medium text-gray-700">Tick the box below to disband this team.</label>
<label className="mt-1">
<input type="checkbox" name="title" id="title" required className="shadow-sm mr-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 rounded-md" />
Disband this team
</label>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Update
</button>
<button onClick={() => setSelectedTeam({})} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</form>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="h-6 w-6 text-blue-600" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">New team</h3>
</div>
</div>
<form onSubmit={createTeam}>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Name</label>
<input type="text" name="name" id="name" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" />
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Create team
</button>
<button onClick={() => setShowCreateTeamModal(false)} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
}
</SettingsShell>
</Shell>
);
}
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
}
}

View File

@ -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])
}