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,9 +1,8 @@
import { GiftIcon } from "@heroicons/react/outline";
export default function DonateBanner() {
if (location.hostname.endsWith(".calendso.com")) {
return null;
}
if (location.hostname.endsWith(".calendso.com")) {
return null;
}
return (
<>
@ -17,21 +16,19 @@ return null;
<GiftIcon className="h-6 w-6 text-white" aria-hidden="true" />
</span>
<p className="ml-3 font-medium text-white truncate">
<span className="md:hidden">
Support the ongoing development
</span>
<span className="md:hidden">Support the ongoing development</span>
<span className="hidden md:inline">
You&apos;re using the free self-hosted version. Support the
ongoing development by making a donation.
You&apos;re using the free self-hosted version. Support the ongoing development by making
a donation.
</span>
</p>
</div>
<div className="order-3 mt-2 flex-shrink-0 w-full sm:order-2 sm:mt-0 sm:w-auto">
<a
target="_blank"
rel="noreferrer"
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
</a>
</div>

View file

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

View file

@ -1,66 +1,63 @@
import { Fragment } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { CheckIcon } from '@heroicons/react/outline'
import { Fragment } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { CheckIcon } from "@heroicons/react/outline";
export default function Modal(props) {
return (
<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}>
<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
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" />
</Transition.Child>
return (
<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}>
<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
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<Dialog.Overlay className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
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>
<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" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-5">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
{props.heading}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
{props.description}
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<button
type="button"
className="btn-wide btn-primary"
onClick={() => props.handleClose()}
>
Dismiss
</button>
</div>
</div>
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
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>
<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" aria-hidden="true" />
</div>
</Dialog>
</Transition.Root>
)
<div className="mt-3 text-center sm:mt-5">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
{props.heading}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">{props.description}</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<button type="button" className="btn-wide btn-primary" onClick={() => props.handleClose()}>
Dismiss
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
}

View file

@ -1,5 +1,5 @@
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 classNames from "@lib/classNames";

View file

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

View file

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

View file

@ -2,11 +2,9 @@ import { UsersIcon } from "@heroicons/react/outline";
import { useState } from "react";
export default function MemberInvitationModal(props) {
const [ errorMessage, setErrorMessage ] = useState('');
const [errorMessage, setErrorMessage] = useState("");
const handleError = async (res) => {
const responseData = await res.json();
if (res.ok === false) {
@ -18,24 +16,26 @@ export default function MemberInvitationModal(props) {
};
const inviteMember = (e) => {
e.preventDefault();
const payload = {
role: e.target.elements['role'].value,
usernameOrEmail: e.target.elements['inviteUser'].value,
sendEmailInvitation: e.target.elements['sendInviteEmail'].checked,
}
role: e.target.elements["role"].value,
usernameOrEmail: e.target.elements["inviteUser"].value,
sendEmailInvitation: e.target.elements["sendInviteEmail"].checked,
};
return fetch('/api/teams/' + props.team.id + '/invite', {
method: 'POST',
return fetch("/api/teams/" + props.team.id + "/invite", {
method: "POST",
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
}
}).then(handleError).then(props.onExit).catch( (e) => {
// do nothing.
});
"Content-Type": "application/json",
},
})
.then(handleError)
.then(props.onExit)
.catch(() => {
// do nothing.
});
};
return (
@ -45,7 +45,9 @@ export default function MemberInvitationModal(props) {
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 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;

View file

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

View file

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

View file

@ -33,7 +33,9 @@ export default function SetTimesModal(props) {
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 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;

View file

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

View file

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

View file

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

View file

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

@ -1,11 +1,19 @@
import prisma from "./prisma";
import {CalendarEvent} from "./calendarClient";
import { CalendarEvent } from "./calendarClient";
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
import {v5 as uuidv5} from 'uuid';
import short from 'short-uuid';
import { v5 as uuidv5 } from "uuid";
import short from "short-uuid";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
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();
@ -33,63 +41,67 @@ function handleErrorsRaw(response) {
}
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 authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64');
const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', {
method: 'POST',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
'refresh_token': refreshToken,
'grant_type': 'refresh_token',
const refreshAccessToken = (refreshToken) =>
fetch("https://zoom.us/oauth/token", {
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
})
})
.then(handleErrorsJson)
.then(async (responseBody) => {
// Store new tokens in database.
await prisma.credential.update({
where: {
id: credential.id
},
data: {
key: responseBody
}
.then(handleErrorsJson)
.then(async (responseBody) => {
// Store new tokens in database.
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: responseBody,
},
});
credential.key.access_token = responseBody.access_token;
credential.key.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
return credential.key.access_token;
});
credential.key.access_token = responseBody.access_token;
credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in);
return credential.key.access_token;
})
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 {
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>;
}
const ZoomVideo = (credential): VideoApiAdapter => {
const auth = zoomAuth(credential);
const translateEvent = (event: CalendarEvent) => {
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
return {
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,
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?)
timezone: event.attendees[0].timeZone,
//password: "string", TODO: Should we use a password? Maybe generate a random one?
@ -97,8 +109,8 @@ const ZoomVideo = (credential): VideoApiAdapter => {
settings: {
host_video: true,
participant_video: true,
cn_meeting: false, // TODO: true if host meeting in China
in_meeting: false, // TODO: true if host meeting in India
cn_meeting: false, // TODO: true if host meeting in China
in_meeting: false, // TODO: true if host meeting in India
join_before_host: true,
mute_upon_entry: false,
watermark: false,
@ -107,82 +119,112 @@ const ZoomVideo = (credential): VideoApiAdapter => {
audio: "both",
auto_recording: "none",
enforce_login: false,
registrants_email_notification: true
}
registrants_email_notification: true,
},
};
};
return {
getAvailability: (dateFrom, dateTo) => {
return auth.getToken().then(
// 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', {
method: 'get',
headers: {
'Authorization': 'Bearer ' + accessToken
}
})
.then(handleErrorsJson)
.then(responseBody => {
return responseBody.meetings.map((meeting) => ({
start: meeting.start_time,
end: (new Date((new Date(meeting.start_time)).getTime() + meeting.duration * 60000)).toISOString()
}))
})
).catch((err) => {
console.log(err);
});
getAvailability: () => {
return auth
.getToken()
.then(
// 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", {
method: "get",
headers: {
Authorization: "Bearer " + accessToken,
},
})
.then(handleErrorsJson)
.then((responseBody) => {
return responseBody.meetings.map((meeting) => ({
start: meeting.start_time,
end: new Date(
new Date(meeting.start_time).getTime() + meeting.duration * 60000
).toISOString(),
}));
})
)
.catch((err) => {
console.log(err);
});
},
createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(translateEvent(event))
}).then(handleErrorsJson)),
deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, {
method: 'DELETE',
headers: {
'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)),
}
createMeeting: (event: CalendarEvent) =>
auth.getToken().then((accessToken) =>
fetch("https://api.zoom.us/v2/users/me/meetings", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsJson)
),
deleteMeeting: (uid: string) =>
auth.getToken().then((accessToken) =>
fetch("https://api.zoom.us/v2/meetings/" + uid, {
method: "DELETE",
headers: {
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)
),
};
};
// factory
const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => {
switch (cred.type) {
case 'zoom_video':
return ZoomVideo(cred);
default:
return; // unknown credential, could be legacy? In any case, ignore
}
}).filter(Boolean);
const videoIntegrations = (withCredentials): VideoApiAdapter[] =>
withCredentials
.map((cred) => {
switch (cred.type) {
case "zoom_video":
return ZoomVideo(cred);
default:
return; // unknown credential, could be legacy? In any case, ignore
}
})
.filter(Boolean);
const getBusyVideoTimes: (withCredentials) => Promise<unknown[]> = (withCredentials) =>
Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) =>
results.reduce((acc, availability) => acc.concat(availability), [])
);
const getBusyVideoTimes = (withCredentials, dateFrom, dateTo) => Promise.all(
videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo))
).then(
(results) => results.reduce((acc, availability) => acc.concat(availability), [])
);
const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any> => {
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
const createMeeting = async (
credential: Credential,
calEvent: CalendarEvent,
maybeUid: string = null
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
const uid: string = parser.getUid();
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 = {
type: credential.type,
@ -191,60 +233,92 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any>
url: creationResult.join_url,
};
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData);
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData);
const entryPoint: EntryPoint = {
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 {
await organizerMail.sendEmail();
} catch (e) {
console.error("organizerMail.sendEmail failed", e)
console.error("organizerMail.sendEmail failed", e);
}
if (!creationResult || !creationResult.disableConfirmationEmail) {
try {
await attendeeMail.sendEmail();
} catch (e) {
console.error("attendeeMail.sendEmail failed", e)
console.error("attendeeMail.sendEmail failed", e);
}
}
return {
type: credential.type,
success,
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));
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 attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
try {
await organizerMail.sendEmail();
} catch (e) {
console.error("organizerMail.sendEmail failed", e)
console.error("organizerMail.sendEmail failed", e);
}
if (!updateResult || !updateResult.disableConfirmationEmail) {
try {
await attendeeMail.sendEmail();
} catch (e) {
console.error("attendeeMail.sendEmail failed", e)
console.error("attendeeMail.sendEmail failed", e);
}
}
return {
type: credential.type,
success,
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) {
return videoIntegrations([credential])[0].deleteMeeting(uid);
}
@ -252,4 +326,4 @@ const deleteMeeting = (credential, uid: String): Promise<any> => {
return Promise.resolve({});
};
export {getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting};
export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting };

View file

@ -1,31 +1,40 @@
const withTM = require('next-transpile-modules')(['react-timezone-select']);
/* eslint-disable @typescript-eslint/no-var-requires */
const withTM = require("next-transpile-modules")(["react-timezone-select"]);
// TODO: Revisit this later with getStaticProps in App
if (process.env.NEXTAUTH_URL) {
process.env.BASE_URL = process.env.NEXTAUTH_URL.replace('/api/auth', '');
process.env.BASE_URL = process.env.NEXTAUTH_URL.replace("/api/auth", "");
}
if ( ! process.env.EMAIL_FROM ) {
console.warn('\x1b[33mwarn', '\x1b[0m', 'EMAIL_FROM environment variable is not set, this may indicate mailing is currently disabled. Please refer to the .env.example file.');
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."
);
}
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) => {
try {
const o = JSON.parse(jsonString);
if (o && typeof o === "object") {
return o;
}
try {
const o = JSON.parse(jsonString);
if (o && typeof o === "object") {
return o;
}
catch (e) { console.error(e); }
return false;
}
} catch (e) {
console.error(e);
}
return false;
};
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.");
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.'
);
}
module.exports = withTM({
@ -42,10 +51,10 @@ module.exports = withTM({
async redirects() {
return [
{
source: '/settings',
destination: '/settings/profile',
source: "/settings",
destination: "/settings/profile",
permanent: true,
}
]
}
},
];
},
});

View file

@ -1,5 +1,5 @@
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 React from "react";
import Link from "next/link";

View file

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

View file

@ -1,16 +1,15 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "../../../lib/prisma";
import { CalendarEvent, createEvent, getBusyCalendarTimes, updateEvent } from "../../../lib/calendarClient";
import async from "async";
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
import { v5 as uuidv5 } from "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 { getEventName } from "../../../lib/event";
import { LocationType } from "../../../lib/location";
import merge from "lodash.merge";
import { getEventName } from "@lib/event";
import dayjs from "dayjs";
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 timezone from "dayjs/plugin/timezone";
@ -35,11 +34,6 @@ function isAvailable(busyTimes, time, length) {
const startTime = dayjs(busyTime.start);
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
if (dayjs(time).isBetween(startTime, endTime)) {
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(
results: unknown[],
results: Array<EventResult>,
selectedEventType: { requiresConfirmation: boolean },
evt: CalendarEvent,
hashUID: string
@ -283,7 +118,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json(error);
}
let currentUser = await prisma.user.findFirst({
let currentUser: User = await prisma.user.findFirst({
where: {
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 =
currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0;
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]"),
selectedCalendars
);
const videoAvailability = await getBusyVideoTimes(
currentUser.credentials,
dayjs(req.body.start).startOf("day").utc().format(),
dayjs(req.body.end).endOf("day").utc().format()
);
const videoAvailability = await getBusyVideoTimes(currentUser.credentials);
let commonAvailability = [];
if (hasCalendarIntegrations && hasVideoIntegrations) {
@ -347,9 +174,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
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 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 guests = req.body.guests.map(guest=>{
const guests = req.body.guests.map((guest) => {
const g = {
'email': guest,
'name': '',
'timeZone': req.body.timeZone
}
email: guest,
name: "",
timeZone: req.body.timeZone,
};
return g;
});
const attendeesList = [...invitee,...guests];
const attendeesList = [...invitee, ...guests];
let evt: CalendarEvent = {
const evt: CalendarEvent = {
type: selectedEventType.title,
title: getEventName(req.body.name, selectedEventType.title, selectedEventType.eventName),
description: req.body.notes,
@ -391,25 +216,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
endTime: req.body.end,
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
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({
where: {
userId: currentUser.id,
@ -468,44 +277,47 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json(error);
}
let results = [];
let results: Array<EventResult> = [];
let referencesToCreate = [];
if (rescheduleUid) {
const __ret = await rescheduleEvent(
rescheduleUid,
results,
calendarCredentials,
evt,
videoCredentials,
referencesToCreate
);
if (__ret.error) {
log.error(`Booking ${user} failed`, __ret.error, results);
return res.status(500).json(__ret.error);
// Use EventManager to conditionally use all needed integrations.
const updateResults: CreateUpdateResult = await eventManager.update(evt, rescheduleUid);
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
log.error(`Booking ${user} failed`, error, results);
return res.status(500).json(error);
}
results = __ret.results;
referencesToCreate = __ret.referencesToCreate;
// Forward results
results = updateResults.results;
referencesToCreate = updateResults.referencesToCreate;
} else if (!selectedEventType.requiresConfirmation) {
const __ret = await scheduleEvent(
results,
calendarCredentials,
evt,
videoCredentials,
referencesToCreate
);
if (__ret.error) {
log.error(`Booking ${user} failed`, __ret.error, results);
return res.status(500).json(__ret.error);
// Use EventManager to conditionally use all needed integrations.
const createResults: CreateUpdateResult = await eventManager.create(evt);
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
message: "Booking failed",
};
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 =
results.length > 0
? results[0].response.uid
: translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
results.length > 0 ? results[0].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.
// UID generation should happen in the integration itself, not here.
const legacyMailError = await handleLegacyConfirmationMail(results, selectedEventType, evt, hashUID);
@ -532,6 +344,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
attendees: {
create: evt.attendees,
},
location: evt.location, // This is the raw location that can be processed by the EventManager.
confirmed: !selectedEventType.requiresConfirmation,
},
});

View file

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

View file

@ -1,7 +1,7 @@
import prisma from '../../lib/prisma';
import {deleteEvent} from "../../lib/calendarClient";
import async from 'async';
import {deleteMeeting} from "../../lib/videoClient";
import prisma from "../../lib/prisma";
import { deleteEvent } from "../../lib/calendarClient";
import async from "async";
import { deleteMeeting } from "../../lib/videoClient";
export default async function handler(req, res) {
if (req.method == "POST") {
@ -15,36 +15,38 @@ export default async function handler(req, res) {
id: true,
user: {
select: {
credentials: true
}
credentials: true,
},
},
attendees: true,
references: {
select: {
uid: true,
type: true
}
}
}
type: true,
},
},
},
});
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid;
if(credential.type.endsWith("_calendar")) {
return await deleteEvent(credential, bookingRefUid);
} else if(credential.type.endsWith("_video")) {
return await deleteMeeting(credential, bookingRefUid);
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
if (bookingRefUid) {
if (credential.type.endsWith("_calendar")) {
return await deleteEvent(credential, bookingRefUid);
} else if (credential.type.endsWith("_video")) {
return await deleteMeeting(credential, bookingRefUid);
}
}
});
const attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: bookingToDelete.id
}
bookingId: bookingToDelete.id,
},
});
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
bookingId: bookingToDelete.id
}
bookingId: bookingToDelete.id,
},
});
const bookingDeletes = prisma.booking.delete({
where: {
@ -52,17 +54,12 @@ export default async function handler(req, res) {
},
});
await Promise.all([
apiDeletes,
attendeeDeletes,
bookingReferenceDeletes,
bookingDeletes
]);
await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes, bookingDeletes]);
//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 {
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,45 +1,51 @@
import { useRouter } from 'next/router';
import { XIcon } from '@heroicons/react/outline';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from "next/router";
import { XIcon } from "@heroicons/react/outline";
import Head from "next/head";
import Link from "next/link";
export default function Error() {
const router = useRouter();
const { error } = router.query;
const router = useRouter();
const { error } = router.query;
return (
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<Head>
<title>{error} - Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<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>
<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 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" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{error}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
An error occurred when logging you in. Head back to the login screen and try again.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<a className="inline-flex justify-center w-full rounded-sm border border-transparent shadow-sm px-4 py-2 bg-neutral-900 text-base font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500 sm:text-sm">
Go back to the login page
</a>
</Link>
</div>
</div>
return (
<div
className="fixed z-50 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<Head>
<title>{error} - Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<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>
<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 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" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{error}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
An error occurred when logging you in. Head back to the login screen and try again.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<a className="inline-flex justify-center w-full rounded-sm border border-transparent shadow-sm px-4 py-2 bg-neutral-900 text-base font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500 sm:text-sm">
Go back to the login page
</a>
</Link>
</div>
</div>
);
</div>
</div>
);
}

View file

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

View file

@ -1,41 +1,45 @@
import Head from 'next/head';
import Link from 'next/link';
import { CheckIcon } from '@heroicons/react/outline';
import Head from "next/head";
import Link from "next/link";
import { CheckIcon } from "@heroicons/react/outline";
export default function Logout() {
return (
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<Head>
<title>Logged out - Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<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>
<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 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" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
You&apos;ve been logged out
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
We hope to see you again soon!
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<a className="inline-flex justify-center w-full rounded-md border border-transparent shadow-sm px-4 py-2 bg-black text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm">
Go back to the login page
</a>
</Link>
</div>
</div>
return (
<div
className="fixed z-50 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<Head>
<title>Logged out - Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<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>
<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 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" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
You&apos;ve been logged out
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">We hope to see you again soon!</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<a className="inline-flex justify-center w-full rounded-md border border-transparent shadow-sm px-4 py-2 bg-black text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm">
Go back to the login page
</a>
</Link>
</div>
</div>
);
</div>
</div>
);
}

View file

@ -1,27 +1,25 @@
import Head from 'next/head';
import {useRouter} from "next/router";
import {signIn} from 'next-auth/client'
import Head from "next/head";
import { useRouter } from "next/router";
import { signIn } from "next-auth/client";
import ErrorAlert from "../../components/ui/alerts/Error";
import {useState} from "react";
import {UsernameInput} from "../../components/ui/UsernameInput";
import { useState } from "react";
import { UsernameInput } from "../../components/ui/UsernameInput";
import prisma from "../../lib/prisma";
export default function Signup(props) {
const router = useRouter();
const [ hasErrors, setHasErrors ] = useState(false);
const [ errorMessage, setErrorMessage ] = useState('');
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const handleErrors = async (resp) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
}
};
const signUp = (e) => {
e.preventDefault();
if (e.target.password.value !== e.target.passwordcheck.value) {
@ -31,39 +29,37 @@ export default function Signup(props) {
const email: string = e.target.email.value;
const password: string = e.target.password.value;
fetch('/api/auth/signup',
{
body: JSON.stringify({
username: e.target.username.value,
password,
email,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST'
}
)
fetch("/api/auth/signup", {
body: JSON.stringify({
username: e.target.username.value,
password,
email,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
})
.then(handleErrors)
.then(
() => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string })
)
.catch( (err) => {
.then(() => signIn("Calendso", { callbackUrl: (router.query.callbackUrl || "") as string }))
.catch((err) => {
setHasErrors(true);
setErrorMessage(err.message);
});
};
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>
<title>Sign up</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<h2 className="text-center text-3xl font-extrabold text-gray-900">Create your account</h2>
</div>
<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">
@ -74,23 +70,60 @@ export default function Signup(props) {
<UsernameInput required />
</div>
<div className="mb-2">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">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" />
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
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 className="mb-2">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">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" />
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
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>
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">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" />
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">
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 className="mt-3 sm:mt-4 flex">
<input type="submit" value="Create Account"
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>
<input
type="submit"
value="Create Account"
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>
</form>
</div>
@ -103,38 +136,40 @@ export async function getServerSideProps(ctx) {
if (!ctx.query.token) {
return {
notFound: true,
}
};
}
const verificationRequest = await prisma.verificationRequest.findUnique({
where: {
token: ctx.query.token,
}
},
});
// for now, disable if no verificationRequestToken given or token expired
if ( ! verificationRequest || verificationRequest.expires < new Date() ) {
if (!verificationRequest || verificationRequest.expires < new Date()) {
return {
notFound: true,
}
};
}
const existingUser = await prisma.user.findFirst({
where: {
AND: [
{
email: verificationRequest.identifier
email: verificationRequest.identifier,
},
{
emailVerified: {
not: null,
},
}
]
}
},
],
},
});
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 } };

View file

@ -7,7 +7,7 @@ import { useRouter } from "next/router";
import { useRef, useState } from "react";
import { getSession, useSession } from "next-auth/client";
import { ClockIcon } from "@heroicons/react/outline";
import Loader from '@components/Loader';
import Loader from "@components/Loader";
export default function Availability(props) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -53,7 +53,7 @@ export default function Availability(props) {
m = m < 10 ? "0" + m : m;
return `${h}:${m}`;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function createEventTypeHandler(event) {
event.preventDefault();
@ -64,7 +64,7 @@ export default function Availability(props) {
const enteredIsHidden = isHiddenRef.current.checked;
// TODO: Add validation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const response = await fetch("/api/availability/eventtype", {
method: "POST",
body: JSON.stringify({
@ -100,7 +100,7 @@ export default function Availability(props) {
const bufferMins = enteredBufferHours * 60 + enteredBufferMins;
// TODO: Add validation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const response = await fetch("/api/availability/day", {
method: "PATCH",
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 { GetServerSideProps } from "next";
import prisma from "@lib/prisma";
import Loader from '@components/Loader';
import Loader from "@components/Loader";
dayjs.extend(utc);
export default function Troubleshoot({ user }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession();
const [availability, setAvailability] = useState([]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [selectedDate, setSelectedDate] = useState(dayjs());
if (loading) {

View file

@ -1,14 +1,14 @@
import {useState} from 'react';
import Head from 'next/head';
import prisma from '../../lib/prisma';
import {useRouter} from 'next/router';
import dayjs from 'dayjs';
import {CalendarIcon, ClockIcon, XIcon} from '@heroicons/react/solid';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import isBetween from 'dayjs/plugin/isBetween';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
import { useState } from "react";
import Head from "next/head";
import prisma from "../../lib/prisma";
import { useRouter } from "next/router";
import dayjs from "dayjs";
import { CalendarIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween);
@ -16,153 +16,164 @@ dayjs.extend(utc);
dayjs.extend(timezone);
export default function Type(props) {
// Get router variables
const router = useRouter();
const { uid } = router.query;
// Get router variables
const router = useRouter();
const { uid } = router.query;
const [is24h, setIs24h] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const telemetry = useTelemetry();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [is24h, setIs24h] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const telemetry = useTelemetry();
const cancellationHandler = async (event) => {
setLoading(true);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const cancellationHandler = async (event) => {
setLoading(true);
let payload = {
uid: uid
};
const payload = {
uid: uid,
};
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters()));
const res = await fetch(
'/api/cancel',
{
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
}
);
if(res.status >= 200 && res.status < 300) {
router.push('/cancel/success?user=' + props.user.username + '&title=' + props.eventType.title);
} else {
setLoading(false);
setError("An error with status code " + res.status + " occurred. Please try again later.");
}
}
return (
<div>
<Head>
<title>
Cancel {props.booking.title} | {props.user.name || props.user.username} |
Calendso
</title>
<link rel="icon" href="/favicon.ico"/>
</Head>
<main className="max-w-3xl mx-auto my-24">
<div className="fixed z-50 inset-0 overflow-y-auto">
<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 my-4 sm:my-0 transition-opacity" aria-hidden="true">
<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"
role="dialog" 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">
<XIcon className="h-6 w-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{error}
</h3>
</div>
</div>}
{!error && <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"/>
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
Really cancel your booking?
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
Instead, you could also reschedule it.
</p>
</div>
<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>
<p className="text-gray-500 mb-1">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1"/>
{props.eventType.length} minutes
</p>
<p className="text-gray-500">
<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")}
</p>
</div>
</div>
</div>}
<div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<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">
Cancel
</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">
Reschedule
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters())
);
const res = await fetch("/api/cancel", {
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
if (res.status >= 200 && res.status < 300) {
router.push("/cancel/success?user=" + props.user.username + "&title=" + props.eventType.title);
} else {
setLoading(false);
setError("An error with status code " + res.status + " occurred. Please try again later.");
}
};
return (
<div>
<Head>
<title>
Cancel {props.booking.title} | {props.user.name || props.user.username} | Calendso
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="max-w-3xl mx-auto my-24">
<div className="fixed z-50 inset-0 overflow-y-auto">
<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 my-4 sm:my-0 transition-opacity" aria-hidden="true">
<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"
role="dialog"
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">
<XIcon className="h-6 w-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{error}
</h3>
</div>
</div>
)}
{!error && (
<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" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
Really cancel your booking?
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">Instead, you could also reschedule it.</p>
</div>
<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>
<p className="text-gray-500 mb-1">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
<p className="text-gray-500">
<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")}
</p>
</div>
</div>
</div>
)}
<div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<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">
Cancel
</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">
Reschedule
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
export async function getServerSideProps(context) {
const booking = await prisma.booking.findFirst({
where: {
uid: context.query.uid,
},
const booking = await prisma.booking.findFirst({
where: {
uid: context.query.uid,
},
select: {
id: true,
title: true,
description: true,
startTime: true,
endTime: true,
attendees: true,
eventType: true,
user: {
select: {
id: true,
title: true,
description: true,
startTime: true,
endTime: true,
attendees: true,
eventType: true,
user: {
select: {
id: true,
username: true,
name: true,
}
}
}
});
// Workaround since Next.js has problems serializing date objects (see https://github.com/vercel/next.js/issues/11993)
const bookingObj = Object.assign({}, booking, {
startTime: booking.startTime.toString(),
endTime: booking.endTime.toString()
});
return {
props: {
user: booking.user,
eventType: booking.eventType,
booking: bookingObj
id: true,
username: true,
name: true,
},
}
},
},
});
// Workaround since Next.js has problems serializing date objects (see https://github.com/vercel/next.js/issues/11993)
const bookingObj = Object.assign({}, booking, {
startTime: booking.startTime.toString(),
endTime: booking.endTime.toString(),
});
return {
props: {
user: booking.user,
eventType: booking.eventType,
booking: bookingObj,
},
};
}

View file

@ -1,12 +1,12 @@
import Head from 'next/head';
import prisma from '../../lib/prisma';
import {useRouter} from 'next/router';
import dayjs from 'dayjs';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import isBetween from 'dayjs/plugin/isBetween';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import {CheckIcon} from "@heroicons/react/outline";
import Head from "next/head";
import prisma from "../../lib/prisma";
import { useRouter } from "next/router";
import dayjs from "dayjs";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { CheckIcon } from "@heroicons/react/outline";
dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween);
@ -14,78 +14,79 @@ dayjs.extend(utc);
dayjs.extend(timezone);
export default function Type(props) {
// Get router variables
const router = useRouter();
// Get router variables
const router = useRouter();
return (
<div>
<Head>
<title>
Cancelled {props.title} | {props.user.name || props.user.username} |
Calendso
</title>
<link rel="icon" href="/favicon.ico"/>
</Head>
<main className="max-w-3xl mx-auto my-24">
<div className="fixed z-50 inset-0 overflow-y-auto">
<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 my-4 sm:my-0 transition-opacity" aria-hidden="true">
<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"
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
<div>
<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" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
Cancellation successful
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
Feel free to pick another event anytime.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<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">
Pick another
</button>
</div>
</div>
</div>
</div>
return (
<div>
<Head>
<title>
Cancelled {props.title} | {props.user.name || props.user.username} | Calendso
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="max-w-3xl mx-auto my-24">
<div className="fixed z-50 inset-0 overflow-y-auto">
<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 my-4 sm:my-0 transition-opacity" aria-hidden="true">
<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"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<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" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
Cancellation successful
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">Feel free to pick another event anytime.</p>
</div>
</div>
</div>
</main>
<div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<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">
Pick another
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
</main>
</div>
);
}
export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({
where: {
username: context.query.user,
},
select: {
username: true,
name: true,
bio: true,
avatar: true,
eventTypes: true
}
});
const user = await prisma.user.findFirst({
where: {
username: context.query.user,
},
select: {
username: true,
name: true,
bio: true,
avatar: true,
eventTypes: true,
},
});
return {
props: {
user,
title: context.query.title
},
}
return {
props: {
user,
title: context.query.title,
},
};
}

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@ import { InformationCircleIcon } from "@heroicons/react/outline";
import { Switch } from "@headlessui/react";
import Loader from "@components/Loader";
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 }) {
// 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 Shell from "../../components/Shell";
import SettingsShell from "../../components/Settings";
import { useSession, getSession } from "next-auth/client";
import Loader from '@components/Loader';
import { getSession, useSession } from "next-auth/client";
import Loader from "@components/Loader";
export default function Embed(props) {
// 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 Shell from "../../components/Shell";
import SettingsShell from "../../components/Settings";
import { useSession, getSession } from "next-auth/client";
import { getSession, useSession } from "next-auth/client";
import Loader from "@components/Loader";
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
attendees Attendee[]
location String?
createdAt DateTime @default(now())
updatedAt DateTime?

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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"
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<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">
<style type="text/css">
.st0{fillRule:evenodd;clipRule:evenodd;fill:#26282C;}
</style>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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"
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<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">
<style type="text/css">
.st0{fillRule:evenodd;clipRule:evenodd;fill:#fff;}
</style>

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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"
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<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">
<style type="text/css">
.st0{fillRule:evenodd;clipRule:evenodd;fill:#104D86;}
</style>

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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"
viewBox="0 0 29.2 33" style="enable-background:new 0 0 29.2 33;" xml:space="preserve">
<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">
<style type="text/css">
.st0{fill:#F68D2E;}
</style>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -10,9 +10,9 @@
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/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;"
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"
enable-background="new 0 0 2228.833 2073.333" xml:space="preserve">
<svg version="1.1" id="Livello_1"
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">
<metadata>
<sfw xmlns="&ns_sfw;">
<slices></slices>

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