Merge branch 'main' of github.com:calendso/calendso

This commit is contained in:
Peer Richelsen 2021-08-09 12:35:28 +02:00
commit 5d5a90d4f8
46 changed files with 1489 additions and 1167 deletions

View file

@ -1,6 +1,5 @@
import { GiftIcon } from "@heroicons/react/outline"; import { GiftIcon } from "@heroicons/react/outline";
export default function DonateBanner() { export default function DonateBanner() {
if (location.hostname.endsWith(".calendso.com")) { if (location.hostname.endsWith(".calendso.com")) {
return null; return null;
} }
@ -17,21 +16,19 @@ return null;
<GiftIcon className="h-6 w-6 text-white" aria-hidden="true" /> <GiftIcon className="h-6 w-6 text-white" aria-hidden="true" />
</span> </span>
<p className="ml-3 font-medium text-white truncate"> <p className="ml-3 font-medium text-white truncate">
<span className="md:hidden"> <span className="md:hidden">Support the ongoing development</span>
Support the ongoing development
</span>
<span className="hidden md:inline"> <span className="hidden md:inline">
You&apos;re using the free self-hosted version. Support the You&apos;re using the free self-hosted version. Support the ongoing development by making
ongoing development by making a donation. a donation.
</span> </span>
</p> </p>
</div> </div>
<div className="order-3 mt-2 flex-shrink-0 w-full sm:order-2 sm:mt-0 sm:w-auto"> <div className="order-3 mt-2 flex-shrink-0 w-full sm:order-2 sm:mt-0 sm:w-auto">
<a <a
target="_blank" target="_blank"
rel="noreferrer"
href="https://calendso.com/donate" href="https://calendso.com/donate"
className="flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-blue-600 bg-white hover:bg-blue-50" className="flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-blue-600 bg-white hover:bg-blue-50">
>
Donate Donate
</a> </a>
</div> </div>

View file

@ -1,7 +1,14 @@
export default function Logo({ small }: { small?: boolean }) { export default function Logo({ small }: { small?: boolean }) {
return <h1 className="brand-logo inline"> return (
<h1 className="brand-logo inline">
<strong> <strong>
<img className={small ? "h-4 w-auto" : "h-5 w-auto"} alt="Calendso" title="Calendso" src="/calendso-logo-white-word.svg" /> <img
className={small ? "h-4 w-auto" : "h-5 w-auto"}
alt="Calendso"
title="Calendso"
src="/calendso-logo-white-word.svg"
/>
</strong> </strong>
</h1>; </h1>
);
} }

View file

@ -1,11 +1,16 @@
import { Fragment } from 'react' import { Fragment } from "react";
import { Dialog, Transition } from '@headlessui/react' import { Dialog, Transition } from "@headlessui/react";
import { CheckIcon } from '@heroicons/react/outline' import { CheckIcon } from "@heroicons/react/outline";
export default function Modal(props) { export default function Modal(props) {
return ( return (
<Transition.Root show={props.open} as={Fragment}> <Transition.Root show={props.open} as={Fragment}>
<Dialog as="div" static className="fixed z-50 inset-0 overflow-y-auto" open={props.open} onClose={props.handleClose}> <Dialog
as="div"
static
className="fixed z-50 inset-0 overflow-y-auto"
open={props.open}
onClose={props.handleClose}>
<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="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
@ -14,8 +19,7 @@ export default function Modal(props) {
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0">
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" /> <Dialog.Overlay className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" />
</Transition.Child> </Transition.Child>
@ -30,8 +34,7 @@ export default function Modal(props) {
enterTo="opacity-100 translate-y-0 sm:scale-100" enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"> <div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
<div> <div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100"> <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
@ -42,18 +45,12 @@ export default function Modal(props) {
{props.heading} {props.heading}
</Dialog.Title> </Dialog.Title>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">{props.description}</p>
{props.description}
</p>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-5 sm:mt-6"> <div className="mt-5 sm:mt-6">
<button <button type="button" className="btn-wide btn-primary" onClick={() => props.handleClose()}>
type="button"
className="btn-wide btn-primary"
onClick={() => props.handleClose()}
>
Dismiss Dismiss
</button> </button>
</div> </div>
@ -62,5 +59,5 @@ export default function Modal(props) {
</div> </div>
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>
) );
} }

View file

@ -1,5 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { CreditCardIcon, UserIcon, CodeIcon, KeyIcon, UserGroupIcon } from "@heroicons/react/solid"; import { CodeIcon, CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";

View file

@ -7,13 +7,13 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../lib
import { SelectorIcon } from "@heroicons/react/outline"; import { SelectorIcon } from "@heroicons/react/outline";
import { import {
CalendarIcon, CalendarIcon,
ClockIcon,
PuzzleIcon,
CogIcon,
ChatAltIcon, ChatAltIcon,
LogoutIcon, ClockIcon,
CogIcon,
ExternalLinkIcon, ExternalLinkIcon,
LinkIcon, LinkIcon,
LogoutIcon,
PuzzleIcon,
} from "@heroicons/react/solid"; } from "@heroicons/react/solid";
import Logo from "./Logo"; import Logo from "./Logo";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";

View file

@ -1,15 +1,16 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import {UsersIcon,UserRemoveIcon} from "@heroicons/react/outline"; import { UserRemoveIcon, UsersIcon } from "@heroicons/react/outline";
import { useSession } from "next-auth/client"; import { useSession } from "next-auth/client";
export default function EditTeamModal(props) { export default function EditTeamModal(props) {
const [session] = useSession();
const [ session, loading ] = useSession();
const [members, setMembers] = useState([]); const [members, setMembers] = useState([]);
const [checkedDisbandTeam, setCheckedDisbandTeam] = useState(false); const [checkedDisbandTeam, setCheckedDisbandTeam] = useState(false);
const loadMembers = () => fetch('/api/teams/' + props.team.id + '/membership') const loadMembers = () =>
.then( (res: any) => res.json() ).then( (data) => setMembers(data.members) ); fetch("/api/teams/" + props.team.id + "/membership")
.then((res: any) => res.json())
.then((data) => setMembers(data.members));
useEffect(() => { useEffect(() => {
loadMembers(); loadMembers();
@ -17,26 +18,35 @@ export default function EditTeamModal(props) {
const deleteTeam = (e) => { const deleteTeam = (e) => {
e.preventDefault(); e.preventDefault();
return fetch('/api/teams/' + props.team.id, { return fetch("/api/teams/" + props.team.id, {
method: 'DELETE', method: "DELETE",
}).then(props.onExit); }).then(props.onExit);
} };
const removeMember = (member) => { const removeMember = (member) => {
return fetch('/api/teams/' + props.team.id + '/membership', { return fetch("/api/teams/" + props.team.id + "/membership", {
method: 'DELETE', method: "DELETE",
body: JSON.stringify({ userId: member.id }), body: JSON.stringify({ userId: member.id }),
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}).then(loadMembers); }).then(loadMembers);
} };
return (<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> return (
<div
className="fixed z-50 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="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 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></div> <div
className="fixed inset-0 bg-gray-500 z-0 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="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="sm:flex sm:items-start mb-4">
@ -44,45 +54,56 @@ export default function EditTeamModal(props) {
<UsersIcon className="h-6 w-6 text-black" /> <UsersIcon className="h-6 w-6 text-black" />
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <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> <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
Edit the {props.team.name} team
</h3>
<div> <div>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">Manage and delete your team.</p>
Manage and delete your team.
</p>
</div> </div>
</div> </div>
</div> </div>
<form> <form>
<div> <div>
<div className="mb-4"> <div className="mb-4">
{members.length > 0 && <div> {members.length > 0 && (
<div>
<div className="flex justify-between mb-2"> <div className="flex justify-between mb-2">
<h2 className="text-lg font-medium text-gray-900">Members</h2> <h2 className="text-lg font-medium text-gray-900">Members</h2>
</div> </div>
<table className="table-auto mb-2 w-full text-sm"> <table className="table-auto mb-2 w-full text-sm">
<tbody> <tbody>
{members.map( (member) => <tr key={member.email}> {members.map((member) => (
<td className="p-1">{member.name} {member.name && '(' + member.email + ')' }{!member.name && member.email}</td> <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="capitalize">{member.role.toLowerCase()}</td>
<td className="text-right py-2 px-1"> <td className="text-right py-2 px-1">
{member.email !== session.user.email && {member.email !== session.user.email && (
<button <button
type="button" type="button"
onClick={(e) => removeMember(member)} onClick={() => removeMember(member)}
className="btn-sm text-xs bg-transparent px-3 py-1 rounded ml-2"> 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" /> <UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline" />
</button> </button>
} )}
</td> </td>
</tr>)} </tr>
))}
</tbody> </tbody>
</table> </table>
</div>} </div>
)}
</div> </div>
<div className="mb-4 border border-red-400 rounded p-2 px-4"> <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> <p className="block text-sm font-medium text-gray-700">Tick the box to disband this team.</p>
<label className="mt-1"> <label className="mt-1">
<input type="checkbox" onChange={(e) => setCheckedDisbandTeam(e.target.checked)} className="shadow-sm mr-2 focus:ring-black focus:border-black sm:text-sm border-gray-300 rounded-md" /> <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 Disband this team
</label> </label>
</div> </div>
@ -91,9 +112,13 @@ export default function EditTeamModal(props) {
{/*!checkedDisbandTeam && <button type="submit" className="btn btn-primary"> {/*!checkedDisbandTeam && <button type="submit" className="btn btn-primary">
Update Update
</button>*/} </button>*/}
{checkedDisbandTeam && <button onClick={deleteTeam} className="btn bg-red-700 rounded text-white px-2 font-medium text-sm"> {checkedDisbandTeam && (
<button
onClick={deleteTeam}
className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
Disband Team Disband Team
</button>} </button>
)}
<button onClick={props.onExit} type="button" className="btn btn-white mr-2"> <button onClick={props.onExit} type="button" className="btn btn-white mr-2">
Close Close
</button> </button>
@ -101,5 +126,6 @@ export default function EditTeamModal(props) {
</form> </form>
</div> </div>
</div> </div>
</div>); </div>
);
} }

View file

@ -2,11 +2,9 @@ import { UsersIcon } from "@heroicons/react/outline";
import { useState } from "react"; import { useState } from "react";
export default function MemberInvitationModal(props) { export default function MemberInvitationModal(props) {
const [errorMessage, setErrorMessage] = useState("");
const [ errorMessage, setErrorMessage ] = useState('');
const handleError = async (res) => { const handleError = async (res) => {
const responseData = await res.json(); const responseData = await res.json();
if (res.ok === false) { if (res.ok === false) {
@ -18,22 +16,24 @@ export default function MemberInvitationModal(props) {
}; };
const inviteMember = (e) => { const inviteMember = (e) => {
e.preventDefault(); e.preventDefault();
const payload = { const payload = {
role: e.target.elements['role'].value, role: e.target.elements["role"].value,
usernameOrEmail: e.target.elements['inviteUser'].value, usernameOrEmail: e.target.elements["inviteUser"].value,
sendEmailInvitation: e.target.elements['sendInviteEmail'].checked, sendEmailInvitation: e.target.elements["sendInviteEmail"].checked,
} };
return fetch('/api/teams/' + props.team.id + '/invite', { return fetch("/api/teams/" + props.team.id + "/invite", {
method: 'POST', method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}).then(handleError).then(props.onExit).catch( (e) => { })
.then(handleError)
.then(props.onExit)
.catch(() => {
// do nothing. // do nothing.
}); });
}; };
@ -45,7 +45,9 @@ export default function MemberInvitationModal(props) {
role="dialog" role="dialog"
aria-modal="true"> 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="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 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></div> <div
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203; &#8203;

View file

@ -1,10 +1,9 @@
import {useEffect, useState} from "react"; import { useState } from "react";
import TeamListItem from "./TeamListItem"; import TeamListItem from "./TeamListItem";
import EditTeamModal from "./EditTeamModal"; import EditTeamModal from "./EditTeamModal";
import MemberInvitationModal from "./MemberInvitationModal"; import MemberInvitationModal from "./MemberInvitationModal";
export default function TeamList(props) { export default function TeamList(props) {
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false); const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [showEditTeamModal, setShowEditTeamModal] = useState(false); const [showEditTeamModal, setShowEditTeamModal] = useState(false);
const [team, setTeam] = useState(null); const [team, setTeam] = useState(null);
@ -12,31 +11,39 @@ export default function TeamList(props) {
const selectAction = (action: string, team: any) => { const selectAction = (action: string, team: any) => {
setTeam(team); setTeam(team);
switch (action) { switch (action) {
case 'edit': case "edit":
setShowEditTeamModal(true); setShowEditTeamModal(true);
break; break;
case 'invite': case "invite":
setShowMemberInvitationModal(true); setShowMemberInvitationModal(true);
break; break;
} }
}; };
return (<div> return (
<div>
<ul className="bg-white border px-2 mb-2 rounded divide-y divide-gray-200"> <ul className="bg-white border px-2 mb-2 rounded divide-y divide-gray-200">
{props.teams.map( {props.teams.map((team: any) => (
(team: any) => <TeamListItem onChange={props.onChange} key={team.id} team={team} onActionSelect={ <TeamListItem
(action: string) => selectAction(action, team) onChange={props.onChange}
}></TeamListItem> key={team.id}
)} team={team}
onActionSelect={(action: string) => selectAction(action, team)}></TeamListItem>
))}
</ul> </ul>
{showEditTeamModal && <EditTeamModal team={team} onExit={() => { {showEditTeamModal && (
<EditTeamModal
team={team}
onExit={() => {
props.onChange(); props.onChange();
setShowEditTeamModal(false); setShowEditTeamModal(false);
}}></EditTeamModal>} }}></EditTeamModal>
{showMemberInvitationModal && )}
{showMemberInvitationModal && (
<MemberInvitationModal <MemberInvitationModal
team={team} team={team}
onExit={() => setShowMemberInvitationModal(false)}></MemberInvitationModal> onExit={() => setShowMemberInvitationModal(false)}></MemberInvitationModal>
} )}
</div>); </div>
);
} }

View file

@ -1,59 +1,87 @@
import {CogIcon, TrashIcon, UserAddIcon, UsersIcon} from "@heroicons/react/outline"; import { CogIcon, TrashIcon, UsersIcon } from "@heroicons/react/outline";
import Dropdown from "../ui/Dropdown"; import Dropdown from "../ui/Dropdown";
import { useState } from "react"; import { useState } from "react";
export default function TeamListItem(props) { export default function TeamListItem(props) {
const [team, setTeam] = useState(props.team); const [team, setTeam] = useState(props.team);
const acceptInvite = () => invitationResponse(true); const acceptInvite = () => invitationResponse(true);
const declineInvite = () => invitationResponse(false); const declineInvite = () => invitationResponse(false);
const invitationResponse = (accept: boolean) => fetch('/api/user/membership', { const invitationResponse = (accept: boolean) =>
method: accept ? 'PATCH' : 'DELETE', fetch("/api/user/membership", {
method: accept ? "PATCH" : "DELETE",
body: JSON.stringify({ teamId: props.team.id }), body: JSON.stringify({ teamId: props.team.id }),
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}).then(() => { }).then(() => {
// success // success
setTeam(null); setTeam(null);
props.onChange(); props.onChange();
}); });
return (team && <li className="mb-2 mt-2 divide-y"> return (
team && (
<li className="mb-2 mt-2 divide-y">
<div className="flex justify-between mb-2 mt-2"> <div className="flex justify-between mb-2 mt-2">
<div> <div>
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline" /> <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"> <div className="inline-block -mt-1">
<span className="font-bold text-neutral-700 text-sm">{props.team.name}</span> <span className="font-bold text-neutral-700 text-sm">{props.team.name}</span>
<span className="text-xs text-gray-400 -mt-1 block capitalize">{props.team.role.toLowerCase()}</span> <span className="text-xs text-gray-400 -mt-1 block capitalize">
{props.team.role.toLowerCase()}
</span>
</div> </div>
</div> </div>
{props.team.role === 'INVITEE' && <div> {props.team.role === "INVITEE" && (
<button className="btn-sm bg-transparent text-green-500 border border-green-500 px-3 py-1 rounded-sm ml-2" onClick={acceptInvite}>Accept invitation</button> <div>
<button
className="btn-sm bg-transparent text-green-500 border border-green-500 px-3 py-1 rounded-sm ml-2"
onClick={acceptInvite}>
Accept invitation
</button>
<button className="btn-sm bg-transparent px-2 py-1 ml-1"> <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} /> <TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" onClick={declineInvite} />
</button> </button>
</div>} </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-sm ml-2">Leave</button> {props.team.role === "MEMBER" && (
</div>} <div>
{props.team.role === 'OWNER' && <div> <button
onClick={declineInvite}
className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm ml-2">
Leave
</button>
</div>
)}
{props.team.role === "OWNER" && (
<div>
<Dropdown className="relative inline-block text-left"> <Dropdown className="relative inline-block text-left">
<button className="btn-sm bg-transparent text-gray-400 px-3 py-1 rounded-sm ml-2"> <button className="btn-sm bg-transparent text-gray-400 px-3 py-1 rounded-sm ml-2">
<CogIcon className="h-6 w-6 inline text-gray-400" /> <CogIcon className="h-6 w-6 inline text-gray-400" />
</button> </button>
<ul role="menu" className="z-10 origin-top-right absolute right-0 w-36 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none"> <ul
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem"> role="menu"
<button className="block px-4 py-2" onClick={() => props.onActionSelect('invite')}>Invite members</button> className="z-10 origin-top-right absolute right-0 w-36 rounded-sm 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">
<button className="block px-4 py-2" onClick={() => props.onActionSelect("invite")}>
Invite members
</button>
</li> </li>
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem"> <li
<button className="block px-4 py-2" onClick={() => props.onActionSelect('edit')}>Manage team</button> className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
role="menuitem">
<button className="block px-4 py-2" onClick={() => props.onActionSelect("edit")}>
Manage team
</button>
</li> </li>
</ul> </ul>
</Dropdown> </Dropdown>
</div>} </div>
)}
</div> </div>
{/*{props.team.userRole === 'Owner' && expanded && <div className="pt-2"> {/*{props.team.userRole === 'Owner' && expanded && <div className="pt-2">
{props.team.members.length > 0 && <div> {props.team.members.length > 0 && <div>
@ -73,5 +101,7 @@ export default function TeamListItem(props) {
<button className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm"><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-gray-400 border border-gray-400 px-3 py-1 rounded-sm"><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-sm ml-2">Disband</button> <button className="btn-sm bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded-sm ml-2">Disband</button>
</div>}*/} </div>}*/}
</li>); </li>
)
);
} }

View file

@ -33,7 +33,9 @@ export default function SetTimesModal(props) {
role="dialog" role="dialog"
aria-modal="true"> 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="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 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></div> <div
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203; &#8203;

View file

@ -6,10 +6,12 @@ import { stripHtml } from "./emails/helpers";
const translator = short(); const translator = short();
export default class CalEventParser { export default class CalEventParser {
calEvent: CalendarEvent; protected calEvent: CalendarEvent;
protected maybeUid: string;
constructor(calEvent: CalendarEvent) { constructor(calEvent: CalendarEvent, maybeUid: string = null) {
this.calEvent = calEvent; this.calEvent = calEvent;
this.maybeUid = maybeUid;
} }
/** /**
@ -30,7 +32,7 @@ export default class CalEventParser {
* Returns a unique identifier for the given calendar event. * Returns a unique identifier for the given calendar event.
*/ */
public getUid(): string { public getUid(): string {
return translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL)); return this.maybeUid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
} }
/** /**

View file

@ -5,19 +5,17 @@ import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"
import prisma from "./prisma"; import prisma from "./prisma";
import { Credential } from "@prisma/client"; import { Credential } from "@prisma/client";
import CalEventParser from "./CalEventParser"; import CalEventParser from "./CalEventParser";
import { EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger";
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const { google } = require("googleapis"); const { google } = require("googleapis");
const googleAuth = (credential) => { const googleAuth = (credential) => {
const { client_secret, client_id, redirect_uris } = JSON.parse( const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web;
process.env.GOOGLE_API_CREDENTIALS const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
).web;
const myGoogleAuth = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris[0]
);
myGoogleAuth.setCredentials(credential.key); myGoogleAuth.setCredentials(credential.key);
const isExpired = () => myGoogleAuth.isTokenExpiring(); const isExpired = () => myGoogleAuth.isTokenExpiring();
@ -49,8 +47,7 @@ const googleAuth = (credential) => {
}); });
return { return {
getToken: () => getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()),
!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken(),
}; };
}; };
@ -88,9 +85,7 @@ const o365Auth = (credential) => {
.then(handleErrorsJson) .then(handleErrorsJson)
.then((responseBody) => { .then((responseBody) => {
credential.key.access_token = responseBody.access_token; credential.key.access_token = responseBody.access_token;
credential.key.expiry_date = Math.round( credential.key.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
+new Date() / 1000 + responseBody.expires_in
);
return prisma.credential return prisma.credential
.update({ .update({
where: { where: {
@ -148,11 +143,7 @@ export interface CalendarApiAdapter {
deleteEvent(uid: string); deleteEvent(uid: string);
getAvailability( getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<unknown>;
dateFrom,
dateTo,
selectedCalendars: IntegrationCalendar[]
): Promise<unknown>;
listCalendars(): Promise<IntegrationCalendar[]>; listCalendars(): Promise<IntegrationCalendar[]>;
} }
@ -336,9 +327,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
} }
(selectedCalendarIds.length == 0 (selectedCalendarIds.length == 0
? calendar.calendarList ? calendar.calendarList.list().then((cals) => cals.data.items.map((cal) => cal.id))
.list()
.then((cals) => cals.data.items.map((cal) => cal.id))
: Promise.resolve(selectedCalendarIds) : Promise.resolve(selectedCalendarIds)
) )
.then((calsIds) => { .then((calsIds) => {
@ -354,19 +343,12 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
if (err) { if (err) {
reject(err); reject(err);
} }
resolve( resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"]));
Object.values(apires.data.calendars).flatMap(
(item) => item["busy"]
)
);
} }
); );
}) })
.catch((err) => { .catch((err) => {
console.error( console.error("There was an error contacting google calendar service: ", err);
"There was an error contacting google calendar service: ",
err
);
reject(err); reject(err);
}); });
}) })
@ -413,10 +395,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
}, },
function (err, event) { function (err, event) {
if (err) { if (err) {
console.error( console.error("There was an error contacting google calendar service: ", err);
"There was an error contacting google calendar service: ",
err
);
return reject(err); return reject(err);
} }
return resolve(event.data); return resolve(event.data);
@ -464,10 +443,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
}, },
function (err, event) { function (err, event) {
if (err) { if (err) {
console.error( console.error("There was an error contacting google calendar service: ", err);
"There was an error contacting google calendar service: ",
err
);
return reject(err); return reject(err);
} }
return resolve(event.data); return resolve(event.data);
@ -492,10 +468,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
}, },
function (err, event) { function (err, event) {
if (err) { if (err) {
console.error( console.error("There was an error contacting google calendar service: ", err);
"There was an error contacting google calendar service: ",
err
);
return reject(err); return reject(err);
} }
return resolve(event.data); return resolve(event.data);
@ -526,10 +499,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
); );
}) })
.catch((err) => { .catch((err) => {
console.error( console.error("There was an error contacting google calendar service: ", err);
"There was an error contacting google calendar service: ",
err
);
reject(err); reject(err);
}); });
}) })
@ -552,30 +522,25 @@ const calendars = (withCredentials): CalendarApiAdapter[] =>
}) })
.filter(Boolean); .filter(Boolean);
const getBusyCalendarTimes = ( const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) =>
withCredentials,
dateFrom,
dateTo,
selectedCalendars
) =>
Promise.all( Promise.all(
calendars(withCredentials).map((c) => calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
c.getAvailability(dateFrom, dateTo, selectedCalendars)
)
).then((results) => { ).then((results) => {
return results.reduce((acc, availability) => acc.concat(availability), []); return results.reduce((acc, availability) => acc.concat(availability), []);
}); });
const listCalendars = (withCredentials) => const listCalendars = (withCredentials) =>
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then( Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
(results) => results.reduce((acc, calendars) => acc.concat(calendars), []) results.reduce((acc, calendars) => acc.concat(calendars), [])
); );
const createEvent = async ( const createEvent = async (
credential: Credential, credential: Credential,
calEvent: CalendarEvent calEvent: CalendarEvent,
): Promise<unknown> => { noMail = false,
const parser: CalEventParser = new CalEventParser(calEvent); maybeUid: string = null
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
const uid: string = parser.getUid(); const uid: string = parser.getUid();
/* /*
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r). * Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r).
@ -584,14 +549,22 @@ const createEvent = async (
*/ */
const richEvent: CalendarEvent = parser.asRichEventPlain(); const richEvent: CalendarEvent = parser.asRichEventPlain();
let success = true;
const creationResult = credential const creationResult = credential
? await calendars([credential])[0].createEvent(richEvent) ? await calendars([credential])[0]
.createEvent(richEvent)
.catch((e) => {
log.error("createEvent failed", e, calEvent);
success = false;
})
: null; : null;
const maybeHangoutLink = creationResult?.hangoutLink; const maybeHangoutLink = creationResult?.hangoutLink;
const maybeEntryPoints = creationResult?.entryPoints; const maybeEntryPoints = creationResult?.entryPoints;
const maybeConferenceData = creationResult?.conferenceData; const maybeConferenceData = creationResult?.conferenceData;
if (!noMail) {
const organizerMail = new EventOrganizerMail(calEvent, uid, { const organizerMail = new EventOrganizerMail(calEvent, uid, {
hangoutLink: maybeHangoutLink, hangoutLink: maybeHangoutLink,
conferenceData: maybeConferenceData, conferenceData: maybeConferenceData,
@ -617,26 +590,39 @@ const createEvent = async (
console.error("attendeeMail.sendEmail failed", e); console.error("attendeeMail.sendEmail failed", e);
} }
} }
}
return { return {
type: credential.type,
success,
uid, uid,
createdEvent: creationResult, createdEvent: creationResult,
originalEvent: calEvent,
}; };
}; };
const updateEvent = async ( const updateEvent = async (
credential: Credential, credential: Credential,
uidToUpdate: string, uidToUpdate: string,
calEvent: CalendarEvent calEvent: CalendarEvent,
): Promise<unknown> => { noMail = false
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent); const parser: CalEventParser = new CalEventParser(calEvent);
const newUid: string = parser.getUid(); const newUid: string = parser.getUid();
const richEvent: CalendarEvent = parser.asRichEventPlain(); const richEvent: CalendarEvent = parser.asRichEventPlain();
let success = true;
const updateResult = credential const updateResult = credential
? await calendars([credential])[0].updateEvent(uidToUpdate, richEvent) ? await calendars([credential])[0]
.updateEvent(uidToUpdate, richEvent)
.catch((e) => {
log.error("updateEvent failed", e, calEvent);
success = false;
})
: null; : null;
if (!noMail) {
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
try { try {
@ -652,10 +638,14 @@ const updateEvent = async (
console.error("attendeeMail.sendEmail failed", e); console.error("attendeeMail.sendEmail failed", e);
} }
} }
}
return { return {
type: credential.type,
success,
uid: newUid, uid: newUid,
updatedEvent: updateResult, updatedEvent: updateResult,
originalEvent: calEvent,
}; };
}; };
@ -667,12 +657,4 @@ const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
return Promise.resolve({}); return Promise.resolve({});
}; };
export { export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, listCalendars };
getBusyCalendarTimes,
createEvent,
updateEvent,
deleteEvent,
CalendarEvent,
listCalendars,
IntegrationCalendar,
};

View file

@ -4,7 +4,7 @@ import { CalendarEvent, ConferenceData } from "../calendarClient";
import { serverConfig } from "../serverConfig"; import { serverConfig } from "../serverConfig";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
interface EntryPoint { export interface EntryPoint {
entryPointType?: string; entryPointType?: string;
uri?: string; uri?: string;
label?: string; label?: string;
@ -15,7 +15,7 @@ interface EntryPoint {
password?: string; password?: string;
} }
interface AdditionInformation { export interface AdditionInformation {
conferenceData?: ConferenceData; conferenceData?: ConferenceData;
entryPoints?: EntryPoint[]; entryPoints?: EntryPoint[];
hangoutLink?: string; hangoutLink?: string;
@ -34,11 +34,12 @@ export default abstract class EventMail {
* *
* @param calEvent * @param calEvent
* @param uid * @param uid
* @param additionInformation
*/ */
constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) { constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) {
this.calEvent = calEvent; this.calEvent = calEvent;
this.uid = uid; this.uid = uid;
this.parser = new CalEventParser(calEvent); this.parser = new CalEventParser(calEvent, uid);
this.additionInformation = additionInformation; this.additionInformation = additionInformation;
} }

View file

@ -2,13 +2,20 @@ import {CalendarEvent} from "../calendarClient";
import EventAttendeeMail from "./EventAttendeeMail"; import EventAttendeeMail from "./EventAttendeeMail";
import { getFormattedMeetingId, getIntegrationName } from "./helpers"; import { getFormattedMeetingId, getIntegrationName } from "./helpers";
import { VideoCallData } from "../videoClient"; import { VideoCallData } from "../videoClient";
import { AdditionInformation } from "@lib/emails/EventMail";
export default class VideoEventAttendeeMail extends EventAttendeeMail { export default class VideoEventAttendeeMail extends EventAttendeeMail {
videoCallData: VideoCallData; videoCallData: VideoCallData;
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) { constructor(
calEvent: CalendarEvent,
uid: string,
videoCallData: VideoCallData,
additionInformation: AdditionInformation = null
) {
super(calEvent, uid); super(calEvent, uid);
this.videoCallData = videoCallData; this.videoCallData = videoCallData;
this.additionInformation = additionInformation;
} }
/** /**

View file

@ -2,13 +2,20 @@ import { CalendarEvent } from "../calendarClient";
import EventOrganizerMail from "./EventOrganizerMail"; import EventOrganizerMail from "./EventOrganizerMail";
import { VideoCallData } from "../videoClient"; import { VideoCallData } from "../videoClient";
import { getFormattedMeetingId, getIntegrationName } from "./helpers"; import { getFormattedMeetingId, getIntegrationName } from "./helpers";
import { AdditionInformation } from "@lib/emails/EventMail";
export default class VideoEventOrganizerMail extends EventOrganizerMail { export default class VideoEventOrganizerMail extends EventOrganizerMail {
videoCallData: VideoCallData; videoCallData: VideoCallData;
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) { constructor(
calEvent: CalendarEvent,
uid: string,
videoCallData: VideoCallData,
additionInformation: AdditionInformation = null
) {
super(calEvent, uid); super(calEvent, uid);
this.videoCallData = videoCallData; this.videoCallData = videoCallData;
this.additionInformation = additionInformation;
} }
/** /**

306
lib/events/EventManager.ts Normal file
View file

@ -0,0 +1,306 @@
import { CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient";
import { Credential } from "@prisma/client";
import async from "async";
import { createMeeting, updateMeeting } from "@lib/videoClient";
import prisma from "@lib/prisma";
import { LocationType } from "@lib/location";
import { v5 as uuidv5 } from "uuid";
import merge from "lodash.merge";
export interface EventResult {
type: string;
success: boolean;
uid: string;
createdEvent?: unknown;
updatedEvent?: unknown;
originalEvent: CalendarEvent;
}
export interface CreateUpdateResult {
results: Array<EventResult>;
referencesToCreate: Array<PartialReference>;
}
export interface PartialBooking {
id: number;
references: Array<PartialReference>;
}
export interface PartialReference {
id?: number;
type: string;
uid: string;
}
interface GetLocationRequestFromIntegrationRequest {
location: string;
}
export default class EventManager {
calendarCredentials: Array<Credential>;
videoCredentials: Array<Credential>;
/**
* Takes an array of credentials and initializes a new instance of the EventManager.
*
* @param credentials
*/
constructor(credentials: Array<Credential>) {
this.calendarCredentials = credentials.filter((cred) => cred.type.endsWith("_calendar"));
this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video"));
}
/**
* Takes a CalendarEvent and creates all necessary integration entries for it.
* When a video integration is chosen as the event's location, a video integration
* event will be scheduled for it as well.
* An optional uid can be set to override the auto-generated uid.
*
* @param event
* @param maybeUid
*/
public async create(event: CalendarEvent, maybeUid: string = null): Promise<CreateUpdateResult> {
event = EventManager.processLocation(event);
const isDedicated = EventManager.isDedicatedIntegration(event.location);
// First, create all calendar events. If this is a dedicated integration event, don't send a mail right here.
const results: Array<EventResult> = await this.createAllCalendarEvents(event, isDedicated, maybeUid);
// If and only if event type is a dedicated meeting, create a dedicated video meeting as well.
if (isDedicated) {
results.push(await this.createVideoEvent(event, maybeUid));
}
const referencesToCreate: Array<PartialReference> = results.map((result) => {
return {
type: result.type,
uid: result.createdEvent.id.toString(),
};
});
return {
results,
referencesToCreate,
};
}
/**
* Takes a calendarEvent and a rescheduleUid and updates the event that has the
* given uid using the data delivered in the given CalendarEvent.
*
* @param event
* @param rescheduleUid
*/
public async update(event: CalendarEvent, rescheduleUid: string): Promise<CreateUpdateResult> {
event = EventManager.processLocation(event);
// Get details of existing booking.
const booking = await prisma.booking.findFirst({
where: {
uid: rescheduleUid,
},
select: {
id: true,
references: {
select: {
id: true,
type: true,
uid: true,
},
},
},
});
const isDedicated = EventManager.isDedicatedIntegration(event.location);
// First, update all calendar events. If this is a dedicated event, don't send a mail right here.
const results: Array<EventResult> = await this.updateAllCalendarEvents(event, booking, isDedicated);
// If and only if event type is a dedicated meeting, update the dedicated video meeting as well.
if (isDedicated) {
results.push(await this.updateVideoEvent(event, booking));
}
// Now we can delete the old booking and its references.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
bookingId: booking.id,
},
});
const attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: booking.id,
},
});
const bookingDeletes = prisma.booking.delete({
where: {
uid: rescheduleUid,
},
});
// Wait for all deletions to be applied.
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
return {
results,
referencesToCreate: [...booking.references],
};
}
/**
* Creates event entries for all calendar integrations given in the credentials.
* When noMail is true, no mails will be sent. This is used when the event is
* a video meeting because then the mail containing the video credentials will be
* more important than the mails created for these bare calendar events.
*
* When the optional uid is set, it will be used instead of the auto generated uid.
*
* @param event
* @param noMail
* @param maybeUid
* @private
*/
private createAllCalendarEvents(
event: CalendarEvent,
noMail: boolean,
maybeUid: string = null
): Promise<Array<EventResult>> {
return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => {
return createEvent(credential, event, noMail, maybeUid);
});
}
/**
* Checks which video integration is needed for the event's location and returns
* credentials for that - if existing.
* @param event
* @private
*/
private getVideoCredential(event: CalendarEvent): Credential | undefined {
const integrationName = event.location.replace("integrations:", "");
return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName));
}
/**
* Creates a video event entry for the selected integration location.
*
* When optional uid is set, it will be used instead of the auto generated uid.
*
* @param event
* @param maybeUid
* @private
*/
private createVideoEvent(event: CalendarEvent, maybeUid: string = null): Promise<EventResult> {
const credential = this.getVideoCredential(event);
if (credential) {
return createMeeting(credential, event, maybeUid);
} else {
return Promise.reject("No suitable credentials given for the requested integration name.");
}
}
/**
* Updates the event entries for all calendar integrations given in the credentials.
* When noMail is true, no mails will be sent. This is used when the event is
* a video meeting because then the mail containing the video credentials will be
* more important than the mails created for these bare calendar events.
*
* @param event
* @param booking
* @param noMail
* @private
*/
private updateAllCalendarEvents(
event: CalendarEvent,
booking: PartialBooking,
noMail: boolean
): Promise<Array<EventResult>> {
return async.mapLimit(this.calendarCredentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0]?.uid;
return updateEvent(credential, bookingRefUid, event, noMail);
});
}
/**
* Updates a single video event.
*
* @param event
* @param booking
* @private
*/
private updateVideoEvent(event: CalendarEvent, booking: PartialBooking) {
const credential = this.getVideoCredential(event);
if (credential) {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
return updateMeeting(credential, bookingRefUid, event);
} else {
return Promise.reject("No suitable credentials given for the requested integration name.");
}
}
/**
* Returns true if the given location describes a dedicated integration that
* delivers meeting credentials. Zoom, for example, is dedicated, because it
* needs to be called independently from any calendar APIs to receive meeting
* credentials. Google Meetings, in contrast, are not dedicated, because they
* are created while scheduling a regular calendar event by simply adding some
* attributes to the payload JSON.
*
* @param location
* @private
*/
private static isDedicatedIntegration(location: string): boolean {
// Hard-coded for now, because Zoom and Google Meet are both integrations, but one is dedicated, the other one isn't.
return location === "integrations:zoom";
}
/**
* Helper function for processLocation: Returns the conferenceData object to be merged
* with the CalendarEvent.
*
* @param locationObj
* @private
*/
private static getLocationRequestFromIntegration(locationObj: GetLocationRequestFromIntegrationRequest) {
const location = locationObj.location;
if (location === LocationType.GoogleMeet.valueOf() || location === LocationType.Zoom.valueOf()) {
const requestId = uuidv5(location, uuidv5.URL);
return {
conferenceData: {
createRequest: {
requestId: requestId,
},
},
location,
};
}
return null;
}
/**
* Takes a CalendarEvent and adds a ConferenceData object to the event
* if the event has an integration-related location.
*
* @param event
* @private
*/
private static processLocation(event: CalendarEvent): CalendarEvent {
// If location is set to an integration location
// Build proper transforms for evt object
// Extend evt object with those transformations
if (event.location?.includes("integration")) {
const maybeLocationRequestObject = EventManager.getLocationRequestFromIntegration({
location: event.location,
});
event = merge(event, maybeLocationRequestObject);
}
return event;
}
}

View file

@ -2,10 +2,18 @@ import prisma from "./prisma";
import { CalendarEvent } from "./calendarClient"; import { CalendarEvent } from "./calendarClient";
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail"; import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
import {v5 as uuidv5} from 'uuid'; import { v5 as uuidv5 } from "uuid";
import short from 'short-uuid'; import short from "short-uuid";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import { EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger";
import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail";
import { getIntegrationName } from "@lib/emails/helpers";
import CalEventParser from "@lib/CalEventParser";
import { Credential } from "@prisma/client";
const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
const translator = short(); const translator = short();
@ -33,54 +41,58 @@ function handleErrorsRaw(response) {
} }
const zoomAuth = (credential) => { const zoomAuth = (credential) => {
const isExpired = (expiryDate) => expiryDate < +new Date();
const authHeader =
"Basic " +
Buffer.from(process.env.ZOOM_CLIENT_ID + ":" + process.env.ZOOM_CLIENT_SECRET).toString("base64");
const isExpired = (expiryDate) => expiryDate < +(new Date()); const refreshAccessToken = (refreshToken) =>
const authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64'); fetch("https://zoom.us/oauth/token", {
method: "POST",
const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', {
method: 'POST',
headers: { headers: {
'Authorization': authHeader, Authorization: authHeader,
'Content-Type': 'application/x-www-form-urlencoded' "Content-Type": "application/x-www-form-urlencoded",
}, },
body: new URLSearchParams({ body: new URLSearchParams({
'refresh_token': refreshToken, refresh_token: refreshToken,
'grant_type': 'refresh_token', grant_type: "refresh_token",
}) }),
}) })
.then(handleErrorsJson) .then(handleErrorsJson)
.then(async (responseBody) => { .then(async (responseBody) => {
// Store new tokens in database. // Store new tokens in database.
await prisma.credential.update({ await prisma.credential.update({
where: { where: {
id: credential.id id: credential.id,
}, },
data: { data: {
key: responseBody key: responseBody,
} },
}); });
credential.key.access_token = responseBody.access_token; credential.key.access_token = responseBody.access_token;
credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in); credential.key.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
return credential.key.access_token; return credential.key.access_token;
}) });
return { return {
getToken: () => !isExpired(credential.key.expires_in) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token) getToken: () =>
!isExpired(credential.key.expires_in)
? Promise.resolve(credential.key.access_token)
: refreshAccessToken(credential.key.refresh_token),
}; };
}; };
interface VideoApiAdapter { interface VideoApiAdapter {
createMeeting(event: CalendarEvent): Promise<any>; createMeeting(event: CalendarEvent): Promise<any>;
updateMeeting(uid: String, event: CalendarEvent); updateMeeting(uid: string, event: CalendarEvent);
deleteMeeting(uid: String); deleteMeeting(uid: string): Promise<unknown>;
getAvailability(dateFrom, dateTo): Promise<any>; getAvailability(dateFrom, dateTo): Promise<any>;
} }
const ZoomVideo = (credential): VideoApiAdapter => { const ZoomVideo = (credential): VideoApiAdapter => {
const auth = zoomAuth(credential); const auth = zoomAuth(credential);
const translateEvent = (event: CalendarEvent) => { const translateEvent = (event: CalendarEvent) => {
@ -89,7 +101,7 @@ const ZoomVideo = (credential): VideoApiAdapter => {
topic: event.title, topic: event.title,
type: 2, // Means that this is a scheduled meeting type: 2, // Means that this is a scheduled meeting
start_time: event.startTime, start_time: event.startTime,
duration: ((new Date(event.endTime)).getTime() - (new Date(event.startTime)).getTime()) / 60000, duration: (new Date(event.endTime).getTime() - new Date(event.startTime).getTime()) / 60000,
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?) //schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
timezone: event.attendees[0].timeZone, timezone: event.attendees[0].timeZone,
//password: "string", TODO: Should we use a password? Maybe generate a random one? //password: "string", TODO: Should we use a password? Maybe generate a random one?
@ -107,82 +119,112 @@ const ZoomVideo = (credential): VideoApiAdapter => {
audio: "both", audio: "both",
auto_recording: "none", auto_recording: "none",
enforce_login: false, enforce_login: false,
registrants_email_notification: true registrants_email_notification: true,
} },
}; };
}; };
return { return {
getAvailability: (dateFrom, dateTo) => { getAvailability: () => {
return auth.getToken().then( return auth
.getToken()
.then(
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled. // TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
(accessToken) => fetch('https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300', { (accessToken) =>
method: 'get', fetch("https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300", {
method: "get",
headers: { headers: {
'Authorization': 'Bearer ' + accessToken Authorization: "Bearer " + accessToken,
} },
}) })
.then(handleErrorsJson) .then(handleErrorsJson)
.then(responseBody => { .then((responseBody) => {
return responseBody.meetings.map((meeting) => ({ return responseBody.meetings.map((meeting) => ({
start: meeting.start_time, start: meeting.start_time,
end: (new Date((new Date(meeting.start_time)).getTime() + meeting.duration * 60000)).toISOString() end: new Date(
})) new Date(meeting.start_time).getTime() + meeting.duration * 60000
).toISOString(),
}));
}) })
).catch((err) => { )
.catch((err) => {
console.log(err); console.log(err);
}); });
}, },
createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', { createMeeting: (event: CalendarEvent) =>
method: 'POST', auth.getToken().then((accessToken) =>
fetch("https://api.zoom.us/v2/users/me/meetings", {
method: "POST",
headers: { headers: {
'Authorization': 'Bearer ' + accessToken, Authorization: "Bearer " + accessToken,
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify(translateEvent(event)) body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsJson)), }).then(handleErrorsJson)
deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { ),
method: 'DELETE', deleteMeeting: (uid: string) =>
auth.getToken().then((accessToken) =>
fetch("https://api.zoom.us/v2/meetings/" + uid, {
method: "DELETE",
headers: { headers: {
'Authorization': 'Bearer ' + accessToken Authorization: "Bearer " + accessToken,
}
}).then(handleErrorsRaw)),
updateMeeting: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, {
method: 'PATCH',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
}, },
body: JSON.stringify(translateEvent(event)) }).then(handleErrorsRaw)
}).then(handleErrorsRaw)), ),
} updateMeeting: (uid: string, event: CalendarEvent) =>
auth.getToken().then((accessToken) =>
fetch("https://api.zoom.us/v2/meetings/" + uid, {
method: "PATCH",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw)
),
};
}; };
// factory // factory
const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => { const videoIntegrations = (withCredentials): VideoApiAdapter[] =>
withCredentials
.map((cred) => {
switch (cred.type) { switch (cred.type) {
case 'zoom_video': case "zoom_video":
return ZoomVideo(cred); return ZoomVideo(cred);
default: default:
return; // unknown credential, could be legacy? In any case, ignore return; // unknown credential, could be legacy? In any case, ignore
} }
}).filter(Boolean); })
.filter(Boolean);
const getBusyVideoTimes: (withCredentials) => Promise<unknown[]> = (withCredentials) =>
const getBusyVideoTimes = (withCredentials, dateFrom, dateTo) => Promise.all( Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) =>
videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)) results.reduce((acc, availability) => acc.concat(availability), [])
).then(
(results) => results.reduce((acc, availability) => acc.concat(availability), [])
); );
const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any> => { const createMeeting = async (
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); credential: Credential,
calEvent: CalendarEvent,
maybeUid: string = null
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
const uid: string = parser.getUid();
if (!credential) { if (!credential) {
throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."); throw new Error(
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
);
} }
const creationResult = await videoIntegrations([credential])[0].createMeeting(calEvent); let success = true;
const creationResult = await videoIntegrations([credential])[0]
.createMeeting(calEvent)
.catch((e) => {
log.error("createMeeting failed", e, calEvent);
success = false;
});
const videoCallData: VideoCallData = { const videoCallData: VideoCallData = {
type: credential.type, type: credential.type,
@ -191,60 +233,92 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any>
url: creationResult.join_url, url: creationResult.join_url,
}; };
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData); const entryPoint: EntryPoint = {
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData); entryPointType: getIntegrationName(videoCallData),
uri: videoCallData.url,
label: "Enter Meeting",
pin: videoCallData.password,
};
const additionInformation: AdditionInformation = {
entryPoints: [entryPoint],
};
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData, additionInformation);
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData, additionInformation);
try { try {
await organizerMail.sendEmail(); await organizerMail.sendEmail();
} catch (e) { } catch (e) {
console.error("organizerMail.sendEmail failed", e) console.error("organizerMail.sendEmail failed", e);
} }
if (!creationResult || !creationResult.disableConfirmationEmail) { if (!creationResult || !creationResult.disableConfirmationEmail) {
try { try {
await attendeeMail.sendEmail(); await attendeeMail.sendEmail();
} catch (e) { } catch (e) {
console.error("attendeeMail.sendEmail failed", e) console.error("attendeeMail.sendEmail failed", e);
} }
} }
return { return {
type: credential.type,
success,
uid, uid,
createdEvent: creationResult createdEvent: creationResult,
originalEvent: calEvent,
}; };
}; };
const updateMeeting = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise<any> => { const updateMeeting = async (
credential: Credential,
uidToUpdate: string,
calEvent: CalendarEvent
): Promise<EventResult> => {
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
if (!credential) { if (!credential) {
throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."); throw new Error(
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
);
} }
const updateResult = credential ? await videoIntegrations([credential])[0].updateMeeting(uidToUpdate, calEvent) : null; let success = true;
const updateResult = credential
? await videoIntegrations([credential])[0]
.updateMeeting(uidToUpdate, calEvent)
.catch((e) => {
log.error("updateMeeting failed", e, calEvent);
success = false;
})
: null;
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
try { try {
await organizerMail.sendEmail(); await organizerMail.sendEmail();
} catch (e) { } catch (e) {
console.error("organizerMail.sendEmail failed", e) console.error("organizerMail.sendEmail failed", e);
} }
if (!updateResult || !updateResult.disableConfirmationEmail) { if (!updateResult || !updateResult.disableConfirmationEmail) {
try { try {
await attendeeMail.sendEmail(); await attendeeMail.sendEmail();
} catch (e) { } catch (e) {
console.error("attendeeMail.sendEmail failed", e) console.error("attendeeMail.sendEmail failed", e);
} }
} }
return { return {
type: credential.type,
success,
uid: newUid, uid: newUid,
updatedEvent: updateResult updatedEvent: updateResult,
originalEvent: calEvent,
}; };
}; };
const deleteMeeting = (credential, uid: String): Promise<any> => { const deleteMeeting = (credential: Credential, uid: string): Promise<unknown> => {
if (credential) { if (credential) {
return videoIntegrations([credential])[0].deleteMeeting(uid); return videoIntegrations([credential])[0].deleteMeeting(uid);
} }

View file

@ -1,16 +1,20 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const withTM = require('next-transpile-modules')(['react-timezone-select']); const withTM = require("next-transpile-modules")(["react-timezone-select"]);
// TODO: Revisit this later with getStaticProps in App // TODO: Revisit this later with getStaticProps in App
if (process.env.NEXTAUTH_URL) { if (process.env.NEXTAUTH_URL) {
process.env.BASE_URL = process.env.NEXTAUTH_URL.replace('/api/auth', ''); process.env.BASE_URL = process.env.NEXTAUTH_URL.replace("/api/auth", "");
} }
if (!process.env.EMAIL_FROM) { 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.'); 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."
);
} }
if (process.env.BASE_URL) { if (process.env.BASE_URL) {
process.env.NEXTAUTH_URL = process.env.BASE_URL + '/api/auth'; process.env.NEXTAUTH_URL = process.env.BASE_URL + "/api/auth";
} }
const validJson = (jsonString) => { const validJson = (jsonString) => {
@ -19,13 +23,18 @@ const validJson = (jsonString) => {
if (o && typeof o === "object") { if (o && typeof o === "object") {
return o; return o;
} }
} catch (e) {
console.error(e);
} }
catch (e) { console.error(e); }
return false; return false;
} };
if (process.env.GOOGLE_API_CREDENTIALS && !validJson(process.env.GOOGLE_API_CREDENTIALS)) { if (process.env.GOOGLE_API_CREDENTIALS && !validJson(process.env.GOOGLE_API_CREDENTIALS)) {
console.warn('\x1b[33mwarn', '\x1b[0m', "- Disabled 'Google Calendar' integration. Reason: Invalid value for GOOGLE_API_CREDENTIALS environment variable. When set, this value needs to contain valid JSON like {\"web\":{\"client_id\":\"<clid>\",\"client_secret\":\"<secret>\",\"redirect_uris\":[\"<yourhost>/api/integrations/googlecalendar/callback>\"]}. You can download this JSON from your OAuth Client @ https://console.cloud.google.com/apis/credentials."); console.warn(
"\x1b[33mwarn",
"\x1b[0m",
'- Disabled \'Google Calendar\' integration. Reason: Invalid value for GOOGLE_API_CREDENTIALS environment variable. When set, this value needs to contain valid JSON like {"web":{"client_id":"<clid>","client_secret":"<secret>","redirect_uris":["<yourhost>/api/integrations/googlecalendar/callback>"]}. You can download this JSON from your OAuth Client @ https://console.cloud.google.com/apis/credentials.'
);
} }
module.exports = withTM({ module.exports = withTM({
@ -42,10 +51,10 @@ module.exports = withTM({
async redirects() { async redirects() {
return [ return [
{ {
source: '/settings', source: "/settings",
destination: '/settings/profile', destination: "/settings/profile",
permanent: true, permanent: true,
} },
] ];
} },
}); });

View file

@ -1,5 +1,5 @@
import { ChevronRightIcon } from "@heroicons/react/solid"; import { ChevronRightIcon } from "@heroicons/react/solid";
import { DocumentTextIcon, BookOpenIcon, CodeIcon, CheckIcon } from "@heroicons/react/outline"; import { BookOpenIcon, CheckIcon, CodeIcon, DocumentTextIcon } from "@heroicons/react/outline";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";

View file

@ -9,10 +9,7 @@ function MyApp({ Component, pageProps }: AppProps) {
<TelemetryProvider value={createTelemetryClient()}> <TelemetryProvider value={createTelemetryClient()}>
<Provider session={pageProps.session}> <Provider session={pageProps.session}>
<Head> <Head>
<meta <meta name="viewport" content="width=device-width, initial-scale=1.0" />
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
</Head> </Head>
<Component {...pageProps} /> <Component {...pageProps} />
</Provider> </Provider>

View file

@ -1,4 +1,4 @@
import Document, { Html, Head, Main, NextScript } from "next/document"; import Document, { Head, Html, Main, NextScript } from "next/document";
class MyDocument extends Document { class MyDocument extends Document {
static async getInitialProps(ctx) { static async getInitialProps(ctx) {

View file

@ -1,16 +1,15 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "../../../lib/prisma"; import prisma from "../../../lib/prisma";
import { CalendarEvent, createEvent, getBusyCalendarTimes, updateEvent } from "../../../lib/calendarClient"; import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
import async from "async";
import { v5 as uuidv5 } from "uuid"; import { v5 as uuidv5 } from "uuid";
import short from "short-uuid"; import short from "short-uuid";
import { createMeeting, getBusyVideoTimes, updateMeeting } from "../../../lib/videoClient"; import { getBusyVideoTimes } from "@lib/videoClient";
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail"; import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
import { getEventName } from "../../../lib/event"; import { getEventName } from "@lib/event";
import { LocationType } from "../../../lib/location";
import merge from "lodash.merge";
import dayjs from "dayjs"; import dayjs from "dayjs";
import logger from "../../../lib/logger"; import logger from "../../../lib/logger";
import EventManager, { CreateUpdateResult, EventResult } from "@lib/events/EventManager";
import { User } from "@prisma/client";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
@ -35,11 +34,6 @@ function isAvailable(busyTimes, time, length) {
const startTime = dayjs(busyTime.start); const startTime = dayjs(busyTime.start);
const endTime = dayjs(busyTime.end); const endTime = dayjs(busyTime.end);
// Check if start times are the same
if (dayjs(time).format("HH:mm") == startTime.format("HH:mm")) {
t = false;
}
// Check if time is between start and end times // Check if time is between start and end times
if (dayjs(time).isBetween(startTime, endTime)) { if (dayjs(time).isBetween(startTime, endTime)) {
t = false; t = false;
@ -86,167 +80,8 @@ function isOutOfBounds(
} }
} }
interface GetLocationRequestFromIntegrationRequest {
location: string;
}
const getLocationRequestFromIntegration = ({ location }: GetLocationRequestFromIntegrationRequest) => {
if (location === LocationType.GoogleMeet.valueOf()) {
const requestId = uuidv5(location, uuidv5.URL);
return {
conferenceData: {
createRequest: {
requestId: requestId,
},
},
};
}
return null;
};
async function rescheduleEvent(
rescheduleUid: string | string[],
results: unknown[],
calendarCredentials: unknown[],
evt: CalendarEvent,
videoCredentials: unknown[],
referencesToCreate: { type: string; uid: string }[]
): Promise<{
referencesToCreate: { type: string; uid: string }[];
results: unknown[];
error: { errorCode: string; message: string } | null;
}> {
// Reschedule event
const booking = await prisma.booking.findFirst({
where: {
uid: rescheduleUid,
},
select: {
id: true,
references: {
select: {
id: true,
type: true,
uid: true,
},
},
},
});
// Use all integrations
results = results.concat(
await async.mapLimit(calendarCredentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
return updateEvent(credential, bookingRefUid, evt)
.then((response) => ({ type: credential.type, success: true, response }))
.catch((e) => {
log.error("updateEvent failed", e, evt);
return { type: credential.type, success: false };
});
})
);
results = results.concat(
await async.mapLimit(videoCredentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
return updateMeeting(credential, bookingRefUid, evt)
.then((response) => ({ type: credential.type, success: true, response }))
.catch((e) => {
log.error("updateMeeting failed", e, evt);
return { type: credential.type, success: false };
});
})
);
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
return { referencesToCreate: [], results: [], error: error };
}
// Clone elements
referencesToCreate = [...booking.references];
// Now we can delete the old booking and its references.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
bookingId: booking.id,
},
});
const attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: booking.id,
},
});
const bookingDeletes = prisma.booking.delete({
where: {
uid: rescheduleUid,
},
});
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
return { error: undefined, results, referencesToCreate };
}
export async function scheduleEvent(
results: unknown[],
calendarCredentials: unknown[],
evt: CalendarEvent,
videoCredentials: unknown[],
referencesToCreate: { type: string; uid: string }[]
): Promise<{
referencesToCreate: { type: string; uid: string }[];
results: unknown[];
error: { errorCode: string; message: string } | null;
}> {
// Schedule event
results = results.concat(
await async.mapLimit(calendarCredentials, 5, async (credential) => {
return createEvent(credential, evt)
.then((response) => ({ type: credential.type, success: true, response }))
.catch((e) => {
log.error("createEvent failed", e, evt);
return { type: credential.type, success: false };
});
})
);
results = results.concat(
await async.mapLimit(videoCredentials, 5, async (credential) => {
return createMeeting(credential, evt)
.then((response) => ({ type: credential.type, success: true, response }))
.catch((e) => {
log.error("createMeeting failed", e, evt);
return { type: credential.type, success: false };
});
})
);
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
message: "Booking failed",
};
return { referencesToCreate: [], results: [], error: error };
}
referencesToCreate = results.map((result) => {
return {
type: result.type,
uid: result.response.createdEvent.id.toString(),
};
});
return { error: undefined, results, referencesToCreate };
}
export async function handleLegacyConfirmationMail( export async function handleLegacyConfirmationMail(
results: unknown[], results: Array<EventResult>,
selectedEventType: { requiresConfirmation: boolean }, selectedEventType: { requiresConfirmation: boolean },
evt: CalendarEvent, evt: CalendarEvent,
hashUID: string hashUID: string
@ -283,7 +118,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json(error); return res.status(400).json(error);
} }
let currentUser = await prisma.user.findFirst({ let currentUser: User = await prisma.user.findFirst({
where: { where: {
username: user, username: user,
}, },
@ -302,10 +137,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}, },
}); });
// Split credentials up into calendar credentials and video credentials
let calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
let videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
const hasCalendarIntegrations = const hasCalendarIntegrations =
currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0; currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0;
const hasVideoIntegrations = const hasVideoIntegrations =
@ -317,11 +148,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
dayjs(req.body.end).endOf("day").utc().format("YYYY-MM-DDTHH:mm:ss[Z]"), dayjs(req.body.end).endOf("day").utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
selectedCalendars selectedCalendars
); );
const videoAvailability = await getBusyVideoTimes( const videoAvailability = await getBusyVideoTimes(currentUser.credentials);
currentUser.credentials,
dayjs(req.body.start).startOf("day").utc().format(),
dayjs(req.body.end).endOf("day").utc().format()
);
let commonAvailability = []; let commonAvailability = [];
if (hasCalendarIntegrations && hasVideoIntegrations) { if (hasCalendarIntegrations && hasVideoIntegrations) {
@ -347,9 +174,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
name: true, name: true,
}, },
}); });
calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
// Initialize EventManager with credentials
const eventManager = new EventManager(currentUser.credentials);
const rescheduleUid = req.body.rescheduleUid; const rescheduleUid = req.body.rescheduleUid;
const selectedEventType = await prisma.eventType.findFirst({ const selectedEventType = await prisma.eventType.findFirst({
@ -370,20 +197,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}, },
}); });
const rawLocation = req.body.location;
const invitee = [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }]; const invitee = [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }];
const guests = req.body.guests.map(guest=>{ const guests = req.body.guests.map((guest) => {
const g = { const g = {
'email': guest, email: guest,
'name': '', name: "",
'timeZone': req.body.timeZone timeZone: req.body.timeZone,
} };
return g; return g;
}); });
const attendeesList = [...invitee, ...guests]; const attendeesList = [...invitee, ...guests];
let evt: CalendarEvent = { const evt: CalendarEvent = {
type: selectedEventType.title, type: selectedEventType.title,
title: getEventName(req.body.name, selectedEventType.title, selectedEventType.eventName), title: getEventName(req.body.name, selectedEventType.title, selectedEventType.eventName),
description: req.body.notes, description: req.body.notes,
@ -391,25 +216,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
endTime: req.body.end, endTime: req.body.end,
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone }, organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
attendees: attendeesList, attendees: attendeesList,
location: req.body.location, // Will be processed by the EventManager later.
}; };
// If phone or inPerson use raw location
// set evt.location to req.body.location
if (!rawLocation?.includes("integration")) {
evt.location = rawLocation;
}
// If location is set to an integration location
// Build proper transforms for evt object
// Extend evt object with those transformations
if (rawLocation?.includes("integration")) {
const maybeLocationRequestObject = getLocationRequestFromIntegration({
location: rawLocation,
});
evt = merge(evt, maybeLocationRequestObject);
}
const eventType = await prisma.eventType.findFirst({ const eventType = await prisma.eventType.findFirst({
where: { where: {
userId: currentUser.id, userId: currentUser.id,
@ -468,44 +277,47 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json(error); return res.status(400).json(error);
} }
let results = []; let results: Array<EventResult> = [];
let referencesToCreate = []; let referencesToCreate = [];
if (rescheduleUid) { if (rescheduleUid) {
const __ret = await rescheduleEvent( // Use EventManager to conditionally use all needed integrations.
rescheduleUid, const updateResults: CreateUpdateResult = await eventManager.update(evt, rescheduleUid);
results,
calendarCredentials, if (results.length > 0 && results.every((res) => !res.success)) {
evt, const error = {
videoCredentials, errorCode: "BookingReschedulingMeetingFailed",
referencesToCreate message: "Booking Rescheduling failed",
); };
if (__ret.error) {
log.error(`Booking ${user} failed`, __ret.error, results); log.error(`Booking ${user} failed`, error, results);
return res.status(500).json(__ret.error); return res.status(500).json(error);
} }
results = __ret.results;
referencesToCreate = __ret.referencesToCreate; // Forward results
results = updateResults.results;
referencesToCreate = updateResults.referencesToCreate;
} else if (!selectedEventType.requiresConfirmation) { } else if (!selectedEventType.requiresConfirmation) {
const __ret = await scheduleEvent( // Use EventManager to conditionally use all needed integrations.
results, const createResults: CreateUpdateResult = await eventManager.create(evt);
calendarCredentials,
evt, if (results.length > 0 && results.every((res) => !res.success)) {
videoCredentials, const error = {
referencesToCreate errorCode: "BookingCreatingMeetingFailed",
); message: "Booking failed",
if (__ret.error) { };
log.error(`Booking ${user} failed`, __ret.error, results);
return res.status(500).json(__ret.error); log.error(`Booking ${user} failed`, error, results);
return res.status(500).json(error);
} }
results = __ret.results;
referencesToCreate = __ret.referencesToCreate; // Forward results
results = createResults.results;
referencesToCreate = createResults.referencesToCreate;
} }
const hashUID = const hashUID =
results.length > 0 results.length > 0 ? results[0].uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
? results[0].response.uid
: translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
// TODO Should just be set to the true case as soon as we have a "bare email" integration class. // TODO Should just be set to the true case as soon as we have a "bare email" integration class.
// UID generation should happen in the integration itself, not here. // UID generation should happen in the integration itself, not here.
const legacyMailError = await handleLegacyConfirmationMail(results, selectedEventType, evt, hashUID); const legacyMailError = await handleLegacyConfirmationMail(results, selectedEventType, evt, hashUID);
@ -532,6 +344,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
attendees: { attendees: {
create: evt.attendees, create: evt.attendees,
}, },
location: evt.location, // This is the raw location that can be processed by the EventManager.
confirmed: !selectedEventType.requiresConfirmation, confirmed: !selectedEventType.requiresConfirmation,
}, },
}); });

View file

@ -1,9 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/client"; import { getSession } from "next-auth/client";
import prisma from "../../../lib/prisma"; import prisma from "../../../lib/prisma";
import { handleLegacyConfirmationMail, scheduleEvent } from "./[user]"; import { handleLegacyConfirmationMail } from "./[user]";
import { CalendarEvent } from "@lib/calendarClient"; import { CalendarEvent } from "@lib/calendarClient";
import EventRejectionMail from "@lib/emails/EventRejectionMail"; import EventRejectionMail from "@lib/emails/EventRejectionMail";
import EventManager from "@lib/events/EventManager";
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> { export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
const session = await getSession({ req: req }); const session = await getSession({ req: req });
@ -41,6 +42,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
endTime: true, endTime: true,
confirmed: true, confirmed: true,
attendees: true, attendees: true,
location: true,
userId: true, userId: true,
id: true, id: true,
uid: true, uid: true,
@ -54,9 +56,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "booking already confirmed" }); return res.status(400).json({ message: "booking already confirmed" });
} }
const calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
const evt: CalendarEvent = { const evt: CalendarEvent = {
type: booking.title, type: booking.title,
title: booking.title, title: booking.title,
@ -65,10 +64,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
endTime: booking.endTime.toISOString(), endTime: booking.endTime.toISOString(),
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone }, organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
attendees: booking.attendees, attendees: booking.attendees,
location: booking.location,
}; };
if (req.body.confirmed) { if (req.body.confirmed) {
const scheduleResult = await scheduleEvent([], calendarCredentials, evt, videoCredentials, []); const eventManager = new EventManager(currentUser.credentials);
const scheduleResult = await eventManager.create(evt, booking.uid);
await handleLegacyConfirmationMail( await handleLegacyConfirmationMail(
scheduleResult.results, scheduleResult.results,

View file

@ -1,6 +1,6 @@
import prisma from '../../lib/prisma'; import prisma from "../../lib/prisma";
import { deleteEvent } from "../../lib/calendarClient"; import { deleteEvent } from "../../lib/calendarClient";
import async from 'async'; import async from "async";
import { deleteMeeting } from "../../lib/videoClient"; import { deleteMeeting } from "../../lib/videoClient";
export default async function handler(req, res) { export default async function handler(req, res) {
@ -15,36 +15,38 @@ export default async function handler(req, res) {
id: true, id: true,
user: { user: {
select: { select: {
credentials: true credentials: true,
} },
}, },
attendees: true, attendees: true,
references: { references: {
select: { select: {
uid: true, uid: true,
type: true type: true,
} },
} },
} },
}); });
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => { const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid; const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
if (bookingRefUid) {
if (credential.type.endsWith("_calendar")) { if (credential.type.endsWith("_calendar")) {
return await deleteEvent(credential, bookingRefUid); return await deleteEvent(credential, bookingRefUid);
} else if (credential.type.endsWith("_video")) { } else if (credential.type.endsWith("_video")) {
return await deleteMeeting(credential, bookingRefUid); return await deleteMeeting(credential, bookingRefUid);
} }
}
}); });
const attendeeDeletes = prisma.attendee.deleteMany({ const attendeeDeletes = prisma.attendee.deleteMany({
where: { where: {
bookingId: bookingToDelete.id bookingId: bookingToDelete.id,
} },
}); });
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: { where: {
bookingId: bookingToDelete.id bookingId: bookingToDelete.id,
} },
}); });
const bookingDeletes = prisma.booking.delete({ const bookingDeletes = prisma.booking.delete({
where: { where: {
@ -52,17 +54,12 @@ export default async function handler(req, res) {
}, },
}); });
await Promise.all([ await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes, bookingDeletes]);
apiDeletes,
attendeeDeletes,
bookingReferenceDeletes,
bookingDeletes
]);
//TODO Perhaps send emails to user and client to tell about the cancellation //TODO Perhaps send emails to user and client to tell about the cancellation
res.status(200).json({message: 'Booking successfully deleted.'}); res.status(200).json({ message: "Booking successfully deleted." });
} else { } else {
res.status(405).json({message: 'This endpoint only accepts POST requests.'}); res.status(405).json({ message: "This endpoint only accepts POST requests." });
} }
} }

View file

@ -1,20 +1,26 @@
import { useRouter } from 'next/router'; import { useRouter } from "next/router";
import { XIcon } from '@heroicons/react/outline'; import { XIcon } from "@heroicons/react/outline";
import Head from 'next/head'; import Head from "next/head";
import Link from 'next/link'; import Link from "next/link";
export default function Error() { export default function Error() {
const router = useRouter(); const router = useRouter();
const { error } = router.query; const { error } = router.query;
return ( return (
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> <div
className="fixed z-50 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<Head> <Head>
<title>{error} - Calendso</title> <title>{error} - Calendso</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<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="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<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 overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"> <div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
<div> <div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100"> <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">

View file

@ -2,11 +2,10 @@ import { getCsrfToken } from "next-auth/client";
import prisma from "../../../lib/prisma"; import prisma from "../../../lib/prisma";
import Head from "next/head"; import Head from "next/head";
import React from "react"; import React, { useMemo } from "react";
import debounce from "lodash.debounce"; import debounce from "lodash.debounce";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ResetPasswordRequest } from "@prisma/client"; import { ResetPasswordRequest } from "@prisma/client";
import { useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";

View file

@ -1,16 +1,22 @@
import Head from 'next/head'; import Head from "next/head";
import Link from 'next/link'; import Link from "next/link";
import { CheckIcon } from '@heroicons/react/outline'; import { CheckIcon } from "@heroicons/react/outline";
export default function Logout() { export default function Logout() {
return ( return (
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> <div
className="fixed z-50 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<Head> <Head>
<title>Logged out - Calendso</title> <title>Logged out - Calendso</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<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="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<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 overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"> <div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
<div> <div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100"> <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
@ -21,9 +27,7 @@ export default function Logout() {
You&apos;ve been logged out You&apos;ve been logged out
</h3> </h3>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">We hope to see you again soon!</p>
We hope to see you again soon!
</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,27 +1,25 @@
import Head from 'next/head'; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import {signIn} from 'next-auth/client' import { signIn } from "next-auth/client";
import ErrorAlert from "../../components/ui/alerts/Error"; import ErrorAlert from "../../components/ui/alerts/Error";
import { useState } from "react"; import { useState } from "react";
import { UsernameInput } from "../../components/ui/UsernameInput"; import { UsernameInput } from "../../components/ui/UsernameInput";
import prisma from "../../lib/prisma"; import prisma from "../../lib/prisma";
export default function Signup(props) { export default function Signup(props) {
const router = useRouter(); const router = useRouter();
const [hasErrors, setHasErrors] = useState(false); const [hasErrors, setHasErrors] = useState(false);
const [ errorMessage, setErrorMessage ] = useState(''); const [errorMessage, setErrorMessage] = useState("");
const handleErrors = async (resp) => { const handleErrors = async (resp) => {
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json(); const err = await resp.json();
throw new Error(err.message); throw new Error(err.message);
} }
} };
const signUp = (e) => { const signUp = (e) => {
e.preventDefault(); e.preventDefault();
if (e.target.password.value !== e.target.passwordcheck.value) { if (e.target.password.value !== e.target.passwordcheck.value) {
@ -31,23 +29,19 @@ export default function Signup(props) {
const email: string = e.target.email.value; const email: string = e.target.email.value;
const password: string = e.target.password.value; const password: string = e.target.password.value;
fetch('/api/auth/signup', fetch("/api/auth/signup", {
{
body: JSON.stringify({ body: JSON.stringify({
username: e.target.username.value, username: e.target.username.value,
password, password,
email, email,
}), }),
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
method: 'POST' method: "POST",
} })
)
.then(handleErrors) .then(handleErrors)
.then( .then(() => signIn("Calendso", { callbackUrl: (router.query.callbackUrl || "") as string }))
() => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string })
)
.catch((err) => { .catch((err) => {
setHasErrors(true); setHasErrors(true);
setErrorMessage(err.message); setErrorMessage(err.message);
@ -55,15 +49,17 @@ export default function Signup(props) {
}; };
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8" aria-labelledby="modal-title" role="dialog" aria-modal="true"> <div
className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<Head> <Head>
<title>Sign up</title> <title>Sign up</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<div className="sm:mx-auto sm:w-full sm:max-w-md"> <div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="text-center text-3xl font-extrabold text-gray-900"> <h2 className="text-center text-3xl font-extrabold text-gray-900">Create your account</h2>
Create your account
</h2>
</div> </div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow mx-2 sm:rounded-lg sm:px-10"> <div className="bg-white py-8 px-4 shadow mx-2 sm:rounded-lg sm:px-10">
@ -74,23 +70,60 @@ export default function Signup(props) {
<UsernameInput required /> <UsernameInput required />
</div> </div>
<div className="mb-2"> <div className="mb-2">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label> <label htmlFor="email" className="block text-sm font-medium text-gray-700">
<input type="email" name="email" id="email" placeholder="jdoe@example.com" disabled={!!props.email} readOnly={!!props.email} value={props.email} className="bg-gray-100 mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm" /> Email
</label>
<input
type="email"
name="email"
id="email"
placeholder="jdoe@example.com"
disabled={!!props.email}
readOnly={!!props.email}
value={props.email}
className="bg-gray-100 mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
/>
</div> </div>
<div className="mb-2"> <div className="mb-2">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label> <label htmlFor="password" className="block text-sm font-medium text-gray-700">
<input type="password" name="password" id="password" required placeholder="•••••••••••••" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm" /> Password
</label>
<input
type="password"
name="password"
id="password"
required
placeholder="•••••••••••••"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
/>
</div> </div>
<div> <div>
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">Confirm password</label> <label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">
<input type="password" name="passwordcheck" id="passwordcheck" required placeholder="•••••••••••••" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm" /> Confirm password
</label>
<input
type="password"
name="passwordcheck"
id="passwordcheck"
required
placeholder="•••••••••••••"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
/>
</div> </div>
</div> </div>
<div className="mt-3 sm:mt-4 flex"> <div className="mt-3 sm:mt-4 flex">
<input type="submit" value="Create Account" <input
className="btn btn-primary w-7/12 mr-2 inline-flex justify-center rounded-md border border-transparent cursor-pointer shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm" /> type="submit"
<a onClick={() => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string })} value="Create Account"
className="w-5/12 inline-flex justify-center text-sm text-gray-500 font-medium border px-4 py-2 rounded btn cursor-pointer">Login instead</a> className="btn btn-primary w-7/12 mr-2 inline-flex justify-center rounded-md border border-transparent cursor-pointer shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm"
/>
<a
onClick={() =>
signIn("Calendso", { callbackUrl: (router.query.callbackUrl || "") as string })
}
className="w-5/12 inline-flex justify-center text-sm text-gray-500 font-medium border px-4 py-2 rounded btn cursor-pointer">
Login instead
</a>
</div> </div>
</form> </form>
</div> </div>
@ -103,38 +136,40 @@ export async function getServerSideProps(ctx) {
if (!ctx.query.token) { if (!ctx.query.token) {
return { return {
notFound: true, notFound: true,
} };
} }
const verificationRequest = await prisma.verificationRequest.findUnique({ const verificationRequest = await prisma.verificationRequest.findUnique({
where: { where: {
token: ctx.query.token, token: ctx.query.token,
} },
}); });
// for now, disable if no verificationRequestToken given or token expired // for now, disable if no verificationRequestToken given or token expired
if (!verificationRequest || verificationRequest.expires < new Date()) { if (!verificationRequest || verificationRequest.expires < new Date()) {
return { return {
notFound: true, notFound: true,
} };
} }
const existingUser = await prisma.user.findFirst({ const existingUser = await prisma.user.findFirst({
where: { where: {
AND: [ AND: [
{ {
email: verificationRequest.identifier email: verificationRequest.identifier,
}, },
{ {
emailVerified: { emailVerified: {
not: null, not: null,
}, },
} },
] ],
} },
}); });
if (existingUser) { if (existingUser) {
return { redirect: { permanent: false, destination: '/auth/login?callbackUrl=' + ctx.query.callbackUrl } }; return {
redirect: { permanent: false, destination: "/auth/login?callbackUrl=" + ctx.query.callbackUrl },
};
} }
return { props: { email: verificationRequest.identifier } }; return { props: { email: verificationRequest.identifier } };

View file

@ -7,7 +7,7 @@ import { useRouter } from "next/router";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { getSession, useSession } from "next-auth/client"; import { getSession, useSession } from "next-auth/client";
import { ClockIcon } from "@heroicons/react/outline"; import { ClockIcon } from "@heroicons/react/outline";
import Loader from '@components/Loader'; import Loader from "@components/Loader";
export default function Availability(props) { export default function Availability(props) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -53,7 +53,7 @@ export default function Availability(props) {
m = m < 10 ? "0" + m : m; m = m < 10 ? "0" + m : m;
return `${h}:${m}`; return `${h}:${m}`;
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function createEventTypeHandler(event) { async function createEventTypeHandler(event) {
event.preventDefault(); event.preventDefault();
@ -64,7 +64,7 @@ export default function Availability(props) {
const enteredIsHidden = isHiddenRef.current.checked; const enteredIsHidden = isHiddenRef.current.checked;
// TODO: Add validation // TODO: Add validation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const response = await fetch("/api/availability/eventtype", { const response = await fetch("/api/availability/eventtype", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
@ -100,7 +100,7 @@ export default function Availability(props) {
const bufferMins = enteredBufferHours * 60 + enteredBufferMins; const bufferMins = enteredBufferHours * 60 + enteredBufferMins;
// TODO: Add validation // TODO: Add validation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const response = await fetch("/api/availability/day", { const response = await fetch("/api/availability/day", {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ start: startMins, end: endMins, buffer: bufferMins }), body: JSON.stringify({ start: startMins, end: endMins, buffer: bufferMins }),

View file

@ -6,13 +6,15 @@ import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { GetServerSideProps } from "next"; import { GetServerSideProps } from "next";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import Loader from '@components/Loader'; import Loader from "@components/Loader";
dayjs.extend(utc); dayjs.extend(utc);
export default function Troubleshoot({ user }) { export default function Troubleshoot({ user }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession(); const [session, loading] = useSession();
const [availability, setAvailability] = useState([]); const [availability, setAvailability] = useState([]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [selectedDate, setSelectedDate] = useState(dayjs()); const [selectedDate, setSelectedDate] = useState(dayjs());
if (loading) { if (loading) {

View file

@ -1,13 +1,13 @@
import {useState} from 'react'; import { useState } from "react";
import Head from 'next/head'; import Head from "next/head";
import prisma from '../../lib/prisma'; import prisma from "../../lib/prisma";
import {useRouter} from 'next/router'; import { useRouter } from "next/router";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import {CalendarIcon, ClockIcon, XIcon} from '@heroicons/react/solid'; import { CalendarIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import isBetween from 'dayjs/plugin/isBetween'; import isBetween from "dayjs/plugin/isBetween";
import utc from 'dayjs/plugin/utc'; import utc from "dayjs/plugin/utc";
import timezone from 'dayjs/plugin/timezone'; import timezone from "dayjs/plugin/timezone";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
dayjs.extend(isSameOrBefore); dayjs.extend(isSameOrBefore);
@ -20,58 +20,61 @@ export default function Type(props) {
const router = useRouter(); const router = useRouter();
const { uid } = router.query; const { uid } = router.query;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [is24h, setIs24h] = useState(false); const [is24h, setIs24h] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const telemetry = useTelemetry(); const telemetry = useTelemetry();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const cancellationHandler = async (event) => { const cancellationHandler = async (event) => {
setLoading(true); setLoading(true);
let payload = { const payload = {
uid: uid uid: uid,
}; };
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters())); telemetry.withJitsu((jitsu) =>
const res = await fetch( jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters())
'/api/cancel', );
{ const res = await fetch("/api/cancel", {
body: JSON.stringify(payload), body: JSON.stringify(payload),
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
method: 'POST' method: "POST",
} });
);
if (res.status >= 200 && res.status < 300) { if (res.status >= 200 && res.status < 300) {
router.push('/cancel/success?user=' + props.user.username + '&title=' + props.eventType.title); router.push("/cancel/success?user=" + props.user.username + "&title=" + props.eventType.title);
} else { } else {
setLoading(false); setLoading(false);
setError("An error with status code " + res.status + " occurred. Please try again later."); setError("An error with status code " + res.status + " occurred. Please try again later.");
} }
} };
return ( return (
<div> <div>
<Head> <Head>
<title> <title>
Cancel {props.booking.title} | {props.user.name || props.user.username} | Cancel {props.booking.title} | {props.user.name || props.user.username} | Calendso
Calendso
</title> </title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<main className="max-w-3xl mx-auto my-24"> <main className="max-w-3xl mx-auto my-24">
<div className="fixed z-50 inset-0 overflow-y-auto"> <div className="fixed z-50 inset-0 overflow-y-auto">
<div <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
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 my-4 sm:my-0 transition-opacity" aria-hidden="true"> <div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
aria-hidden="true">&#8203;</span> &#8203;
</span>
<div <div
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6" className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
role="dialog" aria-modal="true" aria-labelledby="modal-headline"> role="dialog"
{error && <div> aria-modal="true"
aria-labelledby="modal-headline">
{error && (
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100"> <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<XIcon className="h-6 w-6 text-red-600" /> <XIcon className="h-6 w-6 text-red-600" />
</div> </div>
@ -80,10 +83,11 @@ export default function Type(props) {
{error} {error}
</h3> </h3>
</div> </div>
</div>} </div>
{!error && <div> )}
<div {!error && (
className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100"> <div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<XIcon className="h-6 w-6 text-red-600" /> <XIcon className="h-6 w-6 text-red-600" />
</div> </div>
<div className="mt-3 text-center sm:mt-5"> <div className="mt-3 text-center sm:mt-5">
@ -91,9 +95,7 @@ export default function Type(props) {
Really cancel your booking? Really cancel your booking?
</h3> </h3>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">Instead, you could also reschedule it.</p>
Instead, you could also reschedule it.
</p>
</div> </div>
<div className="mt-4 border-t border-b py-4"> <div className="mt-4 border-t border-b py-4">
<h2 className="text-lg font-medium text-gray-600 mb-2">{props.booking.title}</h2> <h2 className="text-lg font-medium text-gray-600 mb-2">{props.booking.title}</h2>
@ -103,18 +105,27 @@ export default function Type(props) {
</p> </p>
<p className="text-gray-500"> <p className="text-gray-500">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> <CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{dayjs.utc(props.booking.startTime).format((is24h ? 'H:mm' : 'h:mma') + ", dddd DD MMMM YYYY")} {dayjs
.utc(props.booking.startTime)
.format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
</p> </p>
</div> </div>
</div> </div>
</div>} </div>
)}
<div className="mt-5 sm:mt-6 text-center"> <div className="mt-5 sm:mt-6 text-center">
<div className="mt-5"> <div className="mt-5">
<button onClick={cancellationHandler} disabled={loading} type="button" <button
onClick={cancellationHandler}
disabled={loading}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm mx-2 btn-white"> className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm mx-2 btn-white">
Cancel Cancel
</button> </button>
<button onClick={() => router.push('/reschedule/' + uid)} disabled={loading} type="button" <button
onClick={() => router.push("/reschedule/" + uid)}
disabled={loading}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white"> className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
Reschedule Reschedule
</button> </button>
@ -147,22 +158,22 @@ export async function getServerSideProps(context) {
id: true, id: true,
username: true, username: true,
name: true, name: true,
} },
} },
} },
}); });
// Workaround since Next.js has problems serializing date objects (see https://github.com/vercel/next.js/issues/11993) // Workaround since Next.js has problems serializing date objects (see https://github.com/vercel/next.js/issues/11993)
const bookingObj = Object.assign({}, booking, { const bookingObj = Object.assign({}, booking, {
startTime: booking.startTime.toString(), startTime: booking.startTime.toString(),
endTime: booking.endTime.toString() endTime: booking.endTime.toString(),
}); });
return { return {
props: { props: {
user: booking.user, user: booking.user,
eventType: booking.eventType, eventType: booking.eventType,
booking: bookingObj booking: bookingObj,
}, },
} };
} }

View file

@ -1,11 +1,11 @@
import Head from 'next/head'; import Head from "next/head";
import prisma from '../../lib/prisma'; import prisma from "../../lib/prisma";
import {useRouter} from 'next/router'; import { useRouter } from "next/router";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import isBetween from 'dayjs/plugin/isBetween'; import isBetween from "dayjs/plugin/isBetween";
import utc from 'dayjs/plugin/utc'; import utc from "dayjs/plugin/utc";
import timezone from 'dayjs/plugin/timezone'; import timezone from "dayjs/plugin/timezone";
import { CheckIcon } from "@heroicons/react/outline"; import { CheckIcon } from "@heroicons/react/outline";
dayjs.extend(isSameOrBefore); dayjs.extend(isSameOrBefore);
@ -21,21 +21,22 @@ export default function Type(props) {
<div> <div>
<Head> <Head>
<title> <title>
Cancelled {props.title} | {props.user.name || props.user.username} | Cancelled {props.title} | {props.user.name || props.user.username} | Calendso
Calendso
</title> </title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<main className="max-w-3xl mx-auto my-24"> <main className="max-w-3xl mx-auto my-24">
<div className="fixed z-50 inset-0 overflow-y-auto"> <div className="fixed z-50 inset-0 overflow-y-auto">
<div <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
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 my-4 sm:my-0 transition-opacity" aria-hidden="true"> <div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
aria-hidden="true">&#8203;</span> &#8203;
</span>
<div <div
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6" className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
role="dialog" aria-modal="true" aria-labelledby="modal-headline"> role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div> <div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100"> <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<CheckIcon className="h-6 w-6 text-green-600" /> <CheckIcon className="h-6 w-6 text-green-600" />
@ -45,15 +46,15 @@ export default function Type(props) {
Cancellation successful Cancellation successful
</h3> </h3>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">Feel free to pick another event anytime.</p>
Feel free to pick another event anytime.
</p>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-5 sm:mt-6 text-center"> <div className="mt-5 sm:mt-6 text-center">
<div className="mt-5"> <div className="mt-5">
<button onClick={() => router.push('/' + props.user.username)} type="button" <button
onClick={() => router.push("/" + props.user.username)}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white"> className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
Pick another Pick another
</button> </button>
@ -78,14 +79,14 @@ export async function getServerSideProps(context) {
name: true, name: true,
bio: true, bio: true,
avatar: true, avatar: true,
eventTypes: true eventTypes: true,
} },
}); });
return { return {
props: { props: {
user, user,
title: context.query.title title: context.query.title,
}, },
} };
} }

View file

@ -9,19 +9,19 @@ import { LocationType } from "@lib/location";
import Shell from "@components/Shell"; import Shell from "@components/Shell";
import { getSession } from "next-auth/client"; import { getSession } from "next-auth/client";
import { Scheduler } from "@components/ui/Scheduler"; import { Scheduler } from "@components/ui/Scheduler";
import { Disclosure } from "@headlessui/react"; import { Disclosure, RadioGroup } from "@headlessui/react";
import { PhoneIcon, XIcon } from "@heroicons/react/outline"; import { PhoneIcon, XIcon } from "@heroicons/react/outline";
import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput"; import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
import { import {
LocationMarkerIcon,
LinkIcon,
PlusIcon,
DocumentIcon,
ChevronRightIcon, ChevronRightIcon,
ClockIcon, ClockIcon,
TrashIcon, DocumentIcon,
ExternalLinkIcon, ExternalLinkIcon,
LinkIcon,
LocationMarkerIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/solid"; } from "@heroicons/react/solid";
import dayjs from "dayjs"; import dayjs from "dayjs";
@ -29,7 +29,6 @@ import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import { Availability, EventType, User } from "@prisma/client"; import { Availability, EventType, User } from "@prisma/client";
import { validJson } from "@lib/jsonUtils"; import { validJson } from "@lib/jsonUtils";
import { RadioGroup } from "@headlessui/react";
import classnames from "classnames"; import classnames from "classnames";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import "react-dates/initialize"; import "react-dates/initialize";

View file

@ -7,9 +7,7 @@ function RedirectPage() {
router.push("/event-types"); router.push("/event-types");
return; return;
} }
return ( return <Loader />;
<Loader/>
);
} }
RedirectPage.getInitialProps = (ctx) => { RedirectPage.getInitialProps = (ctx) => {

View file

@ -4,7 +4,7 @@ import { getIntegrationName, getIntegrationType } from "../../lib/integrations";
import Shell from "../../components/Shell"; import Shell from "../../components/Shell";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useSession, getSession } from "next-auth/client"; import { getSession, useSession } from "next-auth/client";
import Loader from "@components/Loader"; import Loader from "@components/Loader";
export default function Integration(props) { export default function Integration(props) {

View file

@ -9,7 +9,7 @@ import { InformationCircleIcon } from "@heroicons/react/outline";
import { Switch } from "@headlessui/react"; import { Switch } from "@headlessui/react";
import Loader from "@components/Loader"; import Loader from "@components/Loader";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
import { Dialog, DialogClose, DialogContent, DialogTrigger, DialogHeader } from "@components/Dialog"; import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog";
export default function IntegrationHome({ integrations }) { export default function IntegrationHome({ integrations }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars

View file

@ -2,8 +2,8 @@ import Head from "next/head";
import prisma from "../../lib/prisma"; import prisma from "../../lib/prisma";
import Shell from "../../components/Shell"; import Shell from "../../components/Shell";
import SettingsShell from "../../components/Settings"; import SettingsShell from "../../components/Settings";
import { useSession, getSession } from "next-auth/client"; import { getSession, useSession } from "next-auth/client";
import Loader from '@components/Loader'; import Loader from "@components/Loader";
export default function Embed(props) { export default function Embed(props) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars

View file

@ -4,7 +4,7 @@ import prisma from "../../lib/prisma";
import Modal from "../../components/Modal"; import Modal from "../../components/Modal";
import Shell from "../../components/Shell"; import Shell from "../../components/Shell";
import SettingsShell from "../../components/Settings"; import SettingsShell from "../../components/Settings";
import { useSession, getSession } from "next-auth/client"; import { getSession, useSession } from "next-auth/client";
import Loader from "@components/Loader"; import Loader from "@components/Loader";
export default function Settings() { export default function Settings() {

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "location" TEXT;

View file

@ -133,6 +133,7 @@ model Booking {
endTime DateTime endTime DateTime
attendees Attendee[] attendees Attendee[]
location String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime? updatedAt DateTime?

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve"> viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fillRule:evenodd;clipRule:evenodd;fill:#26282C;} .st0{fillRule:evenodd;clipRule:evenodd;fill:#26282C;}

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve"> viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fillRule:evenodd;clipRule:evenodd;fill:#fff;} .st0{fillRule:evenodd;clipRule:evenodd;fill:#fff;}

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve"> viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fillRule:evenodd;clipRule:evenodd;fill:#104D86;} .st0{fillRule:evenodd;clipRule:evenodd;fill:#104D86;}

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 29.2 33" style="enable-background:new 0 0 29.2 33;" xml:space="preserve"> viewBox="0 0 29.2 33" style="enable-background:new 0 0 29.2 33;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fill:#F68D2E;} .st0{fill:#F68D2E;}

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -10,8 +10,8 @@
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/"> <!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/"> <!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
]> ]>
<svg version="1.1" id="Livello_1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;" <svg version="1.1" id="Livello_1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2228.833 2073.333" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 2228.833 2073.333"
enable-background="new 0 0 2228.833 2073.333" xml:space="preserve"> enable-background="new 0 0 2228.833 2073.333" xml:space="preserve">
<metadata> <metadata>
<sfw xmlns="&ns_sfw;"> <sfw xmlns="&ns_sfw;">

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -1 +1 @@
<svg height="64" viewBox="0 0 32 32" width="64" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><clipPath id="a"><path d="m-200-175h1000v562h-1000z"/></clipPath><clipPath id="b"><circle cx="107" cy="106" r="102"/></clipPath><clipPath id="c"><circle cx="107" cy="106" r="100"/></clipPath><clipPath id="d"><circle cx="107" cy="106" r="92"/></clipPath><clipPath id="e"><path clipRule="evenodd" d="m135 94.06 26-19c2.27-1.85 4-1.42 4 2v57.94c0 3.84-2.16 3.4-4 2l-26-19zm-88-16.86v43.2a17.69 17.69 0 0 0 17.77 17.6h63a3.22 3.22 0 0 0 3.23-3.2v-43.2a17.69 17.69 0 0 0 -17.77-17.6h-63a3.22 3.22 0 0 0 -3.23 3.2z"/></clipPath><g clip-path="url(#a)" transform="translate(0 -178)"><path d="m232 61h366v90h-366z" fill="#4a8cff"/></g><g clip-path="url(#a)" transform="matrix(.156863 0 0 .156863 -.784314 -.627496)"><g clip-path="url(#b)"><path d="m0-1h214v214h-214z" fill="#e5e5e4"/></g><g clip-path="url(#c)"><path d="m2 1h210v210h-210z" fill="#fff"/></g><g clip-path="url(#d)"><path d="m10 9h194v194h-194z" fill="#4a8cff"/></g><g clip-path="url(#e)"><path d="m42 69h128v74h-128z" fill="#fff"/></g></g><g clip-path="url(#a)" transform="translate(0 -178)"><path d="m232 19.25h180v38.17h-180z" fill="#90908f"/></g></svg> <svg height="64" viewBox="0 0 32 32" width="64" xmlns="http://www.w3.org/2000/svg"><clipPath id="a"><path d="m-200-175h1000v562h-1000z"/></clipPath><clipPath id="b"><circle cx="107" cy="106" r="102"/></clipPath><clipPath id="c"><circle cx="107" cy="106" r="100"/></clipPath><clipPath id="d"><circle cx="107" cy="106" r="92"/></clipPath><clipPath id="e"><path clipRule="evenodd" d="m135 94.06 26-19c2.27-1.85 4-1.42 4 2v57.94c0 3.84-2.16 3.4-4 2l-26-19zm-88-16.86v43.2a17.69 17.69 0 0 0 17.77 17.6h63a3.22 3.22 0 0 0 3.23-3.2v-43.2a17.69 17.69 0 0 0 -17.77-17.6h-63a3.22 3.22 0 0 0 -3.23 3.2z"/></clipPath><g clip-path="url(#a)" transform="translate(0 -178)"><path d="m232 61h366v90h-366z" fill="#4a8cff"/></g><g clip-path="url(#a)" transform="matrix(.156863 0 0 .156863 -.784314 -.627496)"><g clip-path="url(#b)"><path d="m0-1h214v214h-214z" fill="#e5e5e4"/></g><g clip-path="url(#c)"><path d="m2 1h210v210h-210z" fill="#fff"/></g><g clip-path="url(#d)"><path d="m10 9h194v194h-194z" fill="#4a8cff"/></g><g clip-path="url(#e)"><path d="m42 69h128v74h-128z" fill="#fff"/></g></g><g clip-path="url(#a)" transform="translate(0 -178)"><path d="m232 19.25h180v38.17h-180z" fill="#90908f"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB