Merge branch 'main' into feature/cancel-reschedule-links

pull/253/head
nicolas 2021-06-10 08:33:39 +02:00
commit 3d4222c631
23 changed files with 990 additions and 65 deletions

View File

@ -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 (
<div>
<main className="relative -mt-32">
@ -35,9 +33,9 @@ export default function SettingsShell(props) {
<ActiveLink href="/settings/embed">
<a><CodeIcon /> Embed</a>
</ActiveLink>
{/*<ActiveLink href="/settings/teams">
<ActiveLink href="/settings/teams">
<a><UserGroupIcon /> Teams</a>
</ActiveLink>*/}
</ActiveLink>
{/* <Link href="/settings/notifications">
<a className={router.pathname == "/settings/notifications" ? "bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-50 hover:text-blue-700 group border-l-4 px-3 py-2 flex items-center text-sm font-medium" : "border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"}>

View File

@ -0,0 +1,105 @@
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 the {props.team.name} team</h3>
<div>
<p className="text-sm text-gray-400">
Manage and delete your team.
</p>
</div>
</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
type="button"
onClick={(e) => 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" onChange={(e) => setCheckedDisbandTeam(e.target.checked)} 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">
Close
</button>
</div>
</form>
</div>
</div>
</div>);
}

View File

@ -0,0 +1,97 @@
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();
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(handleError).then(props.onExit).catch( (e) => {
// do nothing.
});
};
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">Invite a new member</h3>
<div>
<p className="text-sm text-gray-400">
Invite someone to your team.
</p>
</div>
</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 text-gray-600">
<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 an invite email
</label>
</div>
</div>
{errorMessage && <p className="text-red-700 text-sm"><span className="font-bold">Error: </span>{errorMessage}</p>}
<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,42 @@
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 onChange={props.onChange} key={team.id} team={team} onActionSelect={
(action: string) => selectAction(action, team)
}></TeamListItem>
)}
</ul>
{showEditTeamModal && <EditTeamModal team={team} onExit={() => {
props.onChange();
setShowEditTeamModal(false);
}}></EditTeamModal>}
{showMemberInvitationModal &&
<MemberInvitationModal
team={team}
onExit={() => setShowMemberInvitationModal(false)}></MemberInvitationModal>
}
</div>);
}

View File

@ -0,0 +1,77 @@
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);
props.onChange();
});
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 -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

@ -1,17 +1,16 @@
import { useState } from 'react';
export default function Button(props) {
const [loading, setLoading] = useState(false);
return(
<button type="submit" className="btn btn-primary" onClick={setLoading}>
{!loading && props.children}
{loading &&
<svg className="animate-spin mx-4 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
}
</button>
);
const [loading, setLoading] = useState(false);
return(
<button type="submit" className="btn btn-primary" onClick={setLoading}>
{!loading && props.children}
{loading &&
<svg className="animate-spin mx-4 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
}
</button>
);
}

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

View File

@ -0,0 +1,17 @@
import React from "react";
export const UsernameInput = React.forwardRef( (props, ref) => (
// todo, check if username is already taken here?
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<div className="mt-1 rounded-md shadow-sm flex">
<span className="bg-gray-50 border border-r-0 border-gray-300 rounded-l-md px-3 inline-flex items-center text-gray-500 sm:text-sm">
{typeof window !== "undefined" && window.location.hostname}/
</span>
<input ref={ref} type="text" name="username" id="username" autoComplete="username" required defaultValue={props.defaultValue}
className="focus:ring-blue-500 focus:border-blue-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"/>
</div>
</div>
));

View File

@ -0,0 +1,22 @@
import { XCircleIcon } from '@heroicons/react/solid'
export default function ErrorAlert(props) {
return (
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Something went wrong</h3>
<div className="text-sm text-red-700">
<p>
{props.message}
</p>
</div>
</div>
</div>
</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="${process.env.BASE_URL}/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="${process.env.BASE_URL}/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, '');

View File

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

View File

@ -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') {

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

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

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

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,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 !== session.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'});
}

View File

@ -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 <p className="text-gray-400">Loading...</p>;
}
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,13 @@ export default function Settings(props) {
headers: {
'Content-Type': 'application/json'
}
}).then(handleError).then( () => {
setSuccessModalOpen(true);
setHasErrors(false); // dismiss any open errors
}).catch( (err) => {
setHasErrors(true);
setErrorMessage(err.message);
});
router.replace(router.asPath);
setSuccessModalOpen(true);
}
return(
@ -60,6 +75,7 @@ export default function Settings(props) {
</Head>
<SettingsShell>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
{hasErrors && <ErrorAlert message={errorMessage} />}
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900">Profile</h2>
@ -72,15 +88,7 @@ export default function Settings(props) {
<div className="flex-grow space-y-6">
<div className="flex">
<div className="w-1/2 mr-2">
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<div className="mt-1 rounded-md shadow-sm flex">
<span className="bg-gray-50 border border-r-0 border-gray-300 rounded-l-md px-3 inline-flex items-center text-gray-500 sm:text-sm">
{window.location.hostname}/
</span>
<input ref={usernameRef} type="text" name="username" id="username" autoComplete="username" required className="focus:ring-blue-500 focus:border-blue-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300" defaultValue={props.user.username} />
</div>
<UsernameInput ref={usernameRef} defaultValue={props.user.username} />
</div>
<div className="w-1/2 ml-2">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Full name</label>

156
pages/settings/teams.tsx Normal file
View File

@ -0,0 +1,156 @@
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 <p className="text-gray-400">Loading...</p>;
}
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">
<Head>
<title>Teams | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<SettingsShell>
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<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 mb-4">
View, edit and create teams to organise relationships between users
</p>
{!(invites.length || teams.length) &&
<div className="bg-gray-50 sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Create a team to get started</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>Create your first team and invite other users to work together with you.</p>
</div>
<div className="mt-5">
<button
type="button"
onClick={() => setShowCreateTeamModal(true)}
className="btn btn-primary"
>
Create new team
</button>
</div>
</div>
</div>
}
</div>
{!!(invites.length || teams.length) && <div>
<button className="btn-sm btn-primary" onClick={() => setShowCreateTeamModal(true)}>Create new team</button>
</div>}
</div>
<div>
{!!teams.length &&
<TeamList teams={teams} onChange={loadTeams}>
</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 onChange={loadTeams} key={team.id} team={team}></TeamListItem>)}
</ul>
</div>}
</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>
{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>
<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">Create a new team</h3>
<div>
<p className="text-sm text-gray-400">
Create a new team to collaborate with users.
</p>
</div>
</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" placeholder="Acme Inc." 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>
);
}

View File

@ -46,10 +46,33 @@ model User {
createdDate DateTime @default(now()) @map(name: "created")
eventTypes EventType[]
credentials Credential[]
teams Membership[]
bookings Booking[]
@@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])
}
model BookingReference {
id Int @default(autoincrement()) @id
type String

View File

@ -0,0 +1,4 @@
table tbody tr:nth-child(odd) {
@apply bg-gray-50;
}

View File

@ -5,6 +5,7 @@
@import './components/buttons.css';
@import './components/spinner.css';
@import './components/activelink.css';
@import './components/table.css';
body {
background-color: #f3f4f6;