-
-
{props.user.name || props.user.username} | Calendso
-
-
+export default function User(props): User {
+ const eventTypes = props.eventTypes.map((type) => (
+
+
+
+
+ {type.title}
+ {type.description}
+
+
+
+ ));
+ return (
+
+
+
{props.user.name || props.user.username} | Calendso
+
+
-
-
-
-
{props.user.name || props.user.username}
-
{props.user.bio}
-
-
-
- {eventTypes.length == 0 &&
-
-
Uh oh!
-
This user hasn't set up any event types yet.
-
- }
-
-
+
+
+
+
+ {props.user.name || props.user.username}
+
+
{props.user.bio}
- )
+
+
+ {eventTypes.length == 0 && (
+
+
Uh oh!
+
This user hasn't set up any event types yet.
+
+ )}
+
+
+
+ );
}
-export async function getServerSideProps(context) {
- const user = await prisma.user.findFirst({
- where: {
- username: context.query.user,
- },
- select: {
- id: true,
- username: true,
- email:true,
- name: true,
- bio: true,
- avatar: true,
- eventTypes: true
- }
- });
-
- if (!user) {
- return {
- notFound: true,
- }
- }
-
- const eventTypes = await prisma.eventType.findMany({
- where: {
- userId: user.id,
- hidden: false
- }
- });
+export const getServerSideProps: GetServerSideProps = async (context) => {
+ const user = await prisma.user.findFirst({
+ where: {
+ username: context.query.user.toLowerCase(),
+ },
+ select: {
+ id: true,
+ username: true,
+ email: true,
+ name: true,
+ bio: true,
+ avatar: true,
+ eventTypes: true,
+ },
+ });
+ if (!user) {
return {
- props: {
- user,
- eventTypes
- },
- }
-}
+ notFound: true,
+ };
+ }
+
+ const eventTypes = await prisma.eventType.findMany({
+ where: {
+ userId: user.id,
+ hidden: false,
+ },
+ });
+
+ return {
+ props: {
+ user,
+ eventTypes,
+ },
+ };
+};
// Auxiliary methods
-
-export function getRandomColorCode() {
- let color = '#';
- for (let idx = 0; idx < 6; idx++) {
- color += Math.floor(Math.random() * 10);
- }
- return color;
-}
\ No newline at end of file
+export function getRandomColorCode(): string {
+ let color = "#";
+ for (let idx = 0; idx < 6; idx++) {
+ color += Math.floor(Math.random() * 10);
+ }
+ return color;
+}
diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx
index 66f3640e..973c8d78 100644
--- a/pages/[user]/[type].tsx
+++ b/pages/[user]/[type].tsx
@@ -1,14 +1,10 @@
import { useEffect, useState, useMemo } from "react";
+import { GetServerSideProps } from "next";
import Head from "next/head";
-import prisma from "../../lib/prisma";
-import dayjs, { Dayjs } from "dayjs";
import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid";
-import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
-import utc from "dayjs/plugin/utc";
-import timezone from "dayjs/plugin/timezone";
-dayjs.extend(isSameOrBefore);
-dayjs.extend(utc);
-dayjs.extend(timezone);
+import prisma from "../../lib/prisma";
+import { useRouter } from "next/router";
+import dayjs, { Dayjs } from "dayjs";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
import AvailableTimes from "../../components/booking/AvailableTimes";
@@ -17,10 +13,10 @@ import Avatar from "../../components/Avatar";
import { timeZone } from "../../lib/clock";
import DatePicker from "../../components/booking/DatePicker";
import PoweredByCalendso from "../../components/ui/PoweredByCalendso";
-import { useRouter } from "next/router";
import getSlots from "@lib/slots";
-export default function Type(props) {
+export default function Type(props): Type {
+ // Get router variables
const router = useRouter();
const { rescheduleUid } = router.query;
@@ -47,10 +43,10 @@ export default function Type(props) {
const changeDate = (date: Dayjs) => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
- setSelectedDate(date);
+ setSelectedDate(date.tz(timeZone()));
};
- const handleSelectTimeZone = (selectedTimeZone: string) => {
+ const handleSelectTimeZone = (selectedTimeZone: string): void => {
if (selectedDate) {
setSelectedDate(selectedDate.tz(selectedTimeZone));
}
@@ -67,7 +63,21 @@ export default function Type(props) {
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
Calendso
-
+
+
+
+
+
+
+
+
" + props.eventType.description).replace(/'/g, "%27") + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + encodeURIComponent(props.user.avatar)} />
+
+
+
+
+
+
" + props.eventType.description).replace(/'/g, "%27") + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + encodeURIComponent(props.user.avatar)} />
+
setIsTimeOptionsOpen(true)}
+ onClick={() => setIsTimeOptionsOpen(!isTimeOptionsOpen)}
className="text-gray-500 mb-1 px-2 py-1 -ml-2">
{timeZone()}
@@ -133,10 +143,10 @@ interface WorkingHours {
type Availability = WorkingHours;
-export async function getServerSideProps(context) {
+export const getServerSideProps: GetServerSideProps = async (context) => {
const user = await prisma.user.findFirst({
where: {
- username: context.query.user,
+ username: context.query.user.toLowerCase(),
},
select: {
id: true,
@@ -192,7 +202,7 @@ export async function getServerSideProps(context) {
getWorkingHours(user) ||
[
{
- days: [1, 2, 3, 4, 5, 6, 7],
+ days: [0, 1, 2, 3, 4, 5, 6],
startTime: user.startTime,
length: user.endTime,
},
@@ -205,4 +215,4 @@ export async function getServerSideProps(context) {
workingHours,
},
};
-}
+};
diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx
index 31b7f23a..ccf2844c 100644
--- a/pages/[user]/book.tsx
+++ b/pages/[user]/book.tsx
@@ -1,288 +1,415 @@
-import Head from 'next/head';
-import Link from 'next/link';
-import {useRouter} from 'next/router';
-import {CalendarIcon, ClockIcon, LocationMarkerIcon} from '@heroicons/react/solid';
-import prisma from '../../lib/prisma';
-import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
-import {useEffect, useState} from "react";
-import dayjs from 'dayjs';
-import utc from 'dayjs/plugin/utc';
-import timezone from 'dayjs/plugin/timezone';
-import 'react-phone-number-input/style.css';
-import PhoneInput from 'react-phone-number-input';
-import {LocationType} from '../../lib/location';
-import Avatar from '../../components/Avatar';
-import Button from '../../components/ui/Button';
-import {EventTypeCustomInputType} from "../../lib/eventTypeInput";
+import Head from "next/head";
+import Link from "next/link";
+import { useRouter } from "next/router";
+import { CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon } from "@heroicons/react/solid";
+import prisma from "../../lib/prisma";
+import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
+import { useEffect, useState } from "react";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import timezone from "dayjs/plugin/timezone";
+import "react-phone-number-input/style.css";
+import PhoneInput from "react-phone-number-input";
+import { LocationType } from "../../lib/location";
+import Avatar from "../../components/Avatar";
+import Button from "../../components/ui/Button";
+import { EventTypeCustomInputType } from "../../lib/eventTypeInput";
dayjs.extend(utc);
dayjs.extend(timezone);
-export default function Book(props) {
- const router = useRouter();
- const { date, user, rescheduleUid } = router.query;
+export default function Book(props: any): JSX.Element {
+ const router = useRouter();
+ const { date, user, rescheduleUid } = router.query;
- const [ is24h, setIs24h ] = useState(false);
- const [ preferredTimeZone, setPreferredTimeZone ] = useState('');
+ const [is24h, setIs24h] = useState(false);
+ const [preferredTimeZone, setPreferredTimeZone] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(false);
- const locations = props.eventType.locations || [];
+ const locations = props.eventType.locations || [];
- const [ selectedLocation, setSelectedLocation ] = useState(locations.length === 1 ? locations[0].type : '');
- const telemetry = useTelemetry();
- useEffect(() => {
+ const [selectedLocation, setSelectedLocation] = useState(
+ locations.length === 1 ? locations[0].type : ""
+ );
+ const telemetry = useTelemetry();
+ useEffect(() => {
+ setPreferredTimeZone(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess());
+ setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
- setPreferredTimeZone(localStorage.getItem('timeOption.preferredTimeZone') || dayjs.tz.guess());
- setIs24h(!!localStorage.getItem('timeOption.is24hClock'));
+ telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
+ });
- telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
- });
+ const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
- const locationInfo = (type: LocationType) => locations.find(
- (location) => location.type === type
- );
+ // TODO: Move to translations
+ const locationLabels = {
+ [LocationType.InPerson]: "In-person meeting",
+ [LocationType.Phone]: "Phone call",
+ [LocationType.GoogleMeet]: "Google Meet",
+ [LocationType.Zoom]: "Zoom Video",
+ };
- // TODO: Move to translations
- const locationLabels = {
- [LocationType.InPerson]: 'In-person meeting',
- [LocationType.Phone]: 'Phone call',
- [LocationType.GoogleMeet]: 'Google Meet',
+ const bookingHandler = (event) => {
+ const book = async () => {
+ setLoading(true);
+ setError(false);
+ let notes = "";
+ if (props.eventType.customInputs) {
+ notes = props.eventType.customInputs
+ .map((input) => {
+ const data = event.target["custom_" + input.id];
+ if (data) {
+ if (input.type === EventTypeCustomInputType.Bool) {
+ return input.label + "\n" + (data.value ? "Yes" : "No");
+ } else {
+ return input.label + "\n" + data.value;
+ }
+ }
+ })
+ .join("\n\n");
+ }
+ if (!!notes && !!event.target.notes.value) {
+ notes += "\n\nAdditional notes:\n" + event.target.notes.value;
+ } else {
+ notes += event.target.notes.value;
+ }
+
+ const payload = {
+ start: dayjs(date).format(),
+ end: dayjs(date).add(props.eventType.length, "minute").format(),
+ name: event.target.name.value,
+ email: event.target.email.value,
+ notes: notes,
+ timeZone: preferredTimeZone,
+ eventTypeId: props.eventType.id,
+ rescheduleUid: rescheduleUid,
+ };
+
+ if (selectedLocation) {
+ switch (selectedLocation) {
+ case LocationType.Phone:
+ payload["location"] = event.target.phone.value;
+ break;
+
+ case LocationType.InPerson:
+ payload["location"] = locationInfo(selectedLocation).address;
+ break;
+
+ // Catches all other location types, such as Google Meet, Zoom etc.
+ default:
+ payload["location"] = selectedLocation;
+ }
+ }
+
+ telemetry.withJitsu((jitsu) =>
+ jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
+ );
+
+ /*const res = await */ fetch("/api/book/" + user, {
+ body: JSON.stringify(payload),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ method: "POST",
+ });
+ // TODO When the endpoint is fixed, change this to await the result again
+ //if (res.ok) {
+ let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${
+ props.user.username
+ }&reschedule=${!!rescheduleUid}&name=${payload.name}`;
+ if (payload["location"]) {
+ if (payload["location"].includes("integration")) {
+ successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow.");
+ } else {
+ successUrl += "&location=" + encodeURIComponent(payload["location"]);
+ }
+ }
+
+ await router.push(successUrl);
+ /*} else {
+ setLoading(false);
+ setError(true);
+ }*/
};
- const bookingHandler = (event) => {
- event.preventDefault();
+ event.preventDefault();
+ book();
+ };
- let notes = "";
- if (props.eventType.customInputs) {
- notes = props.eventType.customInputs.map(input => {
- const data = event.target["custom_" + input.id];
- if (!!data) {
- if (input.type === EventTypeCustomInputType.Bool) {
- return input.label + "\n" + (data.value ? "Yes" : "No")
- } else {
- return input.label + "\n" + data.value
- }
- }
- }).join("\n\n")
- }
- if (!!notes && !!event.target.notes.value) {
- notes += "\n\nAdditional notes:\n" + event.target.notes.value;
- } else {
- notes += event.target.notes.value;
- }
+ return (
+
+
+
+ {rescheduleUid ? "Reschedule" : "Confirm"} your {props.eventType.title} with{" "}
+ {props.user.name || props.user.username} | Calendso
+
+
+
- let payload = {
- start: dayjs(date).format(),
- end: dayjs(date).add(props.eventType.length, 'minute').format(),
- name: event.target.name.value,
- email: event.target.email.value,
- notes: notes,
- timeZone: preferredTimeZone,
- eventTypeId: props.eventType.id,
- rescheduleUid: rescheduleUid
- };
-
- if (selectedLocation) {
- switch (selectedLocation) {
- case LocationType.Phone:
- payload['location'] = event.target.phone.value
- break
-
- case LocationType.InPerson:
- payload['location'] = locationInfo(selectedLocation).address
- break
-
- case LocationType.GoogleMeet:
- payload['location'] = LocationType.GoogleMeet
- break
- }
- }
-
- telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters()));
- const res = fetch(
- '/api/book/' + user,
- {
- body: JSON.stringify(payload),
- headers: {
- 'Content-Type': 'application/json'
- },
- method: 'POST'
- }
- );
-
- let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
- if (payload['location']) {
- if (payload['location'].includes('integration')) {
- successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow.");
- }
- else {
- successUrl += "&location=" + encodeURIComponent(payload['location']);
- }
- }
-
- router.push(successUrl);
- }
-
- return (
-
-
-
{rescheduleUid ? 'Reschedule' : 'Confirm'} your {props.eventType.title} with {props.user.name || props.user.username} | Calendso
-
-
-
-
-
-
-
-
-
{props.user.name}
-
{props.eventType.title}
-
-
- {props.eventType.length} minutes
-
- {selectedLocation === LocationType.InPerson &&
-
- {locationInfo(selectedLocation).address}
-
}
-
-
- {preferredTimeZone && dayjs(date).tz(preferredTimeZone).format( (is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
-
-
{props.eventType.description}
-
-
-
+
+
+
+
+
+
{props.user.name}
+
{props.eventType.title}
+
+
+ {props.eventType.length} minutes
+
+ {selectedLocation === LocationType.InPerson && (
+
+
+ {locationInfo(selectedLocation).address}
+
+ )}
+
+
+ {preferredTimeZone &&
+ dayjs(date)
+ .tz(preferredTimeZone)
+ .format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
+
+
{props.eventType.description}
+
+
+
+
-
+
+
+ Email address
+
+
+
+
+
+ {locations.length > 1 && (
+
+ Location
+ {locations.map((location) => (
+
+ setSelectedLocation(e.target.value)}
+ className="location"
+ name="location"
+ value={location.type}
+ checked={selectedLocation === location.type}
+ />
+ {locationLabels[location.type]}
+
+ ))}
+
+ )}
+ {selectedLocation === LocationType.Phone && (
+
+ )}
+ {props.eventType.customInputs &&
+ props.eventType.customInputs
+ .sort((a, b) => a.id - b.id)
+ .map((input) => (
+
+ ))}
+
+
+ Additional notes
+
+
+
+
+
+ {rescheduleUid ? "Reschedule" : "Confirm"}
+
+
+
Cancel
+
+
+
+ {error && (
+
+ )}
+
+
- )
+
+
+ );
}
export async function getServerSideProps(context) {
- const user = await prisma.user.findFirst({
- where: {
- username: context.query.user,
- },
- select: {
- username: true,
+ const user = await prisma.user.findFirst({
+ where: {
+ username: context.query.user,
+ },
+ select: {
+ username: true,
+ name: true,
+ email: true,
+ bio: true,
+ avatar: true,
+ eventTypes: true,
+ },
+ });
+
+ const eventType = await prisma.eventType.findUnique({
+ where: {
+ id: parseInt(context.query.type),
+ },
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ description: true,
+ length: true,
+ locations: true,
+ customInputs: true,
+ },
+ });
+
+ let booking = null;
+
+ if (context.query.rescheduleUid) {
+ booking = await prisma.booking.findFirst({
+ where: {
+ uid: context.query.rescheduleUid,
+ },
+ select: {
+ description: true,
+ attendees: {
+ select: {
+ email: true,
name: true,
- email:true,
- bio: true,
- avatar: true,
- eventTypes: true
- }
- });
-
- const eventType = await prisma.eventType.findUnique({
- where: {
- id: parseInt(context.query.type),
+ },
},
- select: {
- id: true,
- title: true,
- slug: true,
- description: true,
- length: true,
- locations: true,
- customInputs: true,
- }
+ },
});
+ }
- let booking = null;
-
- if(context.query.rescheduleUid) {
- booking = await prisma.booking.findFirst({
- where: {
- uid: context.query.rescheduleUid
- },
- select: {
- description: true,
- attendees: {
- select: {
- email: true,
- name: true
- }
- }
- }
- });
- }
-
- return {
- props: {
- user,
- eventType,
- booking
- },
- }
+ return {
+ props: {
+ user,
+ eventType,
+ booking,
+ },
+ };
}
diff --git a/pages/api/auth/forgot-password.ts b/pages/api/auth/forgot-password.ts
new file mode 100644
index 00000000..54f1427a
--- /dev/null
+++ b/pages/api/auth/forgot-password.ts
@@ -0,0 +1,77 @@
+import { NextApiRequest, NextApiResponse } from "next";
+import prisma from "../../../lib/prisma";
+import dayjs from "dayjs";
+import { User, ResetPasswordRequest } from "@prisma/client";
+import sendEmail from "../../../lib/emails/sendMail";
+import { buildForgotPasswordMessage } from "../../../lib/forgot-password/messaging/forgot-password";
+import timezone from "dayjs/plugin/timezone";
+import utc from "dayjs/plugin/utc";
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== "POST") {
+ return res.status(405).json({ message: "" });
+ }
+
+ try {
+ const rawEmail = req.body?.email;
+
+ const maybeUser: User = await prisma.user.findUnique({
+ where: {
+ email: rawEmail,
+ },
+ select: {
+ name: true,
+ },
+ });
+
+ if (!maybeUser) {
+ return res.status(400).json({ message: "Couldn't find an account for this email" });
+ }
+
+ const now = dayjs().toDate();
+ const maybePreviousRequest = await prisma.resetPasswordRequest.findMany({
+ where: {
+ email: rawEmail,
+ expires: {
+ gt: now,
+ },
+ },
+ });
+
+ let passwordRequest: ResetPasswordRequest;
+
+ if (maybePreviousRequest && maybePreviousRequest?.length >= 1) {
+ passwordRequest = maybePreviousRequest[0];
+ } else {
+ const expiry = dayjs().add(6, "hours").toDate();
+ const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({
+ data: {
+ email: rawEmail,
+ expires: expiry,
+ },
+ });
+ passwordRequest = createdResetPasswordRequest;
+ }
+
+ const passwordResetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`;
+ const { subject, message } = buildForgotPasswordMessage({
+ user: {
+ name: maybeUser.name,
+ },
+ link: passwordResetLink,
+ });
+
+ await sendEmail({
+ to: rawEmail,
+ subject: subject,
+ text: message,
+ });
+
+ return res.status(201).json({ message: "Reset Requested", data: passwordRequest });
+ } catch (reason) {
+ console.error(reason);
+ return res.status(500).json({ message: "Unable to create password reset request" });
+ }
+}
diff --git a/pages/api/auth/reset-password.ts b/pages/api/auth/reset-password.ts
new file mode 100644
index 00000000..f43b93ca
--- /dev/null
+++ b/pages/api/auth/reset-password.ts
@@ -0,0 +1,60 @@
+import { NextApiRequest, NextApiResponse } from "next";
+import prisma from "../../../lib/prisma";
+import dayjs from "dayjs";
+import { User, ResetPasswordRequest } from "@prisma/client";
+import timezone from "dayjs/plugin/timezone";
+import utc from "dayjs/plugin/utc";
+dayjs.extend(utc);
+dayjs.extend(timezone);
+import { hashPassword } from "../../../lib/auth";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== "POST") {
+ return res.status(400).json({ message: "" });
+ }
+
+ try {
+ const rawPassword = req.body?.password;
+ const rawRequestId = req.body?.requestId;
+
+ if (!rawPassword || !rawRequestId) {
+ return res.status(400).json({ message: "Couldn't find an account for this email" });
+ }
+
+ const maybeRequest: ResetPasswordRequest = await prisma.resetPasswordRequest.findUnique({
+ where: {
+ id: rawRequestId,
+ },
+ });
+
+ if (!maybeRequest) {
+ return res.status(400).json({ message: "Couldn't find an account for this email" });
+ }
+
+ const maybeUser: User = await prisma.user.findUnique({
+ where: {
+ email: maybeRequest.email,
+ },
+ });
+
+ if (!maybeUser) {
+ return res.status(400).json({ message: "Couldn't find an account for this email" });
+ }
+
+ const hashedPassword = await hashPassword(rawPassword);
+
+ await prisma.user.update({
+ where: {
+ id: maybeUser.id,
+ },
+ data: {
+ password: hashedPassword,
+ },
+ });
+
+ return res.status(201).json({ message: "Password reset." });
+ } catch (reason) {
+ console.error(reason);
+ return res.status(500).json({ message: "Unable to create password reset request" });
+ }
+}
diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts
index a519a549..e50fc4ab 100644
--- a/pages/api/availability/eventtype.ts
+++ b/pages/api/availability/eventtype.ts
@@ -22,6 +22,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
customInputs: !req.body.customInputs
? undefined
: {
+ deleteMany: {
+ eventTypeId: req.body.id,
+ NOT: {
+ id: {in: req.body.customInputs.filter(input => !!input.id).map(e => e.id)}
+ }
+ },
createMany: {
data: req.body.customInputs.filter(input => !input.id).map(input => ({
type: input.type,
diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts
index d10358e4..c6d41e07 100644
--- a/pages/api/book/[user].ts
+++ b/pages/api/book/[user].ts
@@ -1,38 +1,70 @@
-import type {NextApiRequest, NextApiResponse} from 'next';
-import prisma from '../../../lib/prisma';
-import {CalendarEvent, createEvent, updateEvent} from '../../../lib/calendarClient';
-import async from 'async';
-import {v5 as uuidv5} from 'uuid';
-import short from 'short-uuid';
-import {createMeeting, updateMeeting} from "../../../lib/videoClient";
+import type { NextApiRequest, NextApiResponse } from "next";
+import prisma from "../../../lib/prisma";
+import { CalendarEvent, createEvent, updateEvent, getBusyCalendarTimes } from "../../../lib/calendarClient";
+import async from "async";
+import { v5 as uuidv5 } from "uuid";
+import short from "short-uuid";
+import { createMeeting, updateMeeting, 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 { LocationType } from "../../../lib/location";
+import merge from "lodash.merge";
const translator = short();
+import dayjs from "dayjs";
-interface p {
- location: string
+const isAvailable = (busyTimes, time, length) => {
+ // Check for conflicts
+ let t = true;
+ busyTimes.forEach((busyTime) => {
+ 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;
+ }
+
+ // Check if slot end time is between start and end time
+ if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) {
+ t = false;
+ }
+
+ // Check if startTime is between slot
+ if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) {
+ t = false;
+ }
+ });
+
+ return t;
+};
+
+interface GetLocationRequestFromIntegrationRequest {
+ location: string;
}
-const getLocationRequestFromIntegration = ({location}: p) => {
+const getLocationRequestFromIntegration = ({ location }: GetLocationRequestFromIntegrationRequest) => {
if (location === LocationType.GoogleMeet.valueOf()) {
- const requestId = uuidv5(location, uuidv5.URL)
+ const requestId = uuidv5(location, uuidv5.URL);
return {
conferenceData: {
createRequest: {
- requestId: requestId
- }
- }
- }
+ requestId: requestId,
+ },
+ },
+ };
}
- return null
-}
+ return null;
+};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- const {user} = req.query;
+ const { user } = req.query;
const currentUser = await prisma.user.findFirst({
where: {
@@ -44,27 +76,61 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
timeZone: true,
email: true,
name: true,
- }
+ },
});
+ const selectedCalendars = await prisma.selectedCalendar.findMany({
+ where: {
+ userId: currentUser.id,
+ },
+ });
// Split credentials up into calendar credentials and video credentials
- const calendarCredentials = currentUser.credentials.filter(cred => cred.type.endsWith('_calendar'));
- const videoCredentials = currentUser.credentials.filter(cred => cred.type.endsWith('_video'));
+ const calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
+ const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
+
+ const hasCalendarIntegrations =
+ currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0;
+ const hasVideoIntegrations =
+ currentUser.credentials.filter((cred) => cred.type.endsWith("_video")).length > 0;
+
+ const calendarAvailability = await getBusyCalendarTimes(
+ currentUser.credentials,
+ dayjs(req.body.start).startOf("day").utc().format(),
+ dayjs(req.body.end).endOf("day").utc().format(),
+ selectedCalendars
+ );
+ const videoAvailability = await getBusyVideoTimes(
+ currentUser.credentials,
+ dayjs(req.body.start).startOf("day").utc().format(),
+ dayjs(req.body.end).endOf("day").utc().format()
+ );
+ let commonAvailability = [];
+
+ if (hasCalendarIntegrations && hasVideoIntegrations) {
+ commonAvailability = calendarAvailability.filter((availability) =>
+ videoAvailability.includes(availability)
+ );
+ } else if (hasVideoIntegrations) {
+ commonAvailability = videoAvailability;
+ } else if (hasCalendarIntegrations) {
+ commonAvailability = calendarAvailability;
+ }
const rescheduleUid = req.body.rescheduleUid;
const selectedEventType = await prisma.eventType.findFirst({
where: {
userId: currentUser.id,
- id: req.body.eventTypeId
+ id: req.body.eventTypeId,
},
select: {
eventName: true,
- title: true
- }
+ title: true,
+ length: true,
+ },
});
- let rawLocation = req.body.location
+ const rawLocation = req.body.location;
let evt: CalendarEvent = {
type: selectedEventType.title,
@@ -72,40 +138,44 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
description: req.body.notes,
startTime: req.body.start,
endTime: req.body.end,
- organizer: {email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone},
- attendees: [
- {email: req.body.email, name: req.body.name, timeZone: req.body.timeZone}
- ]
+ organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
+ attendees: [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }],
};
// If phone or inPerson use raw location
// set evt.location to req.body.location
- if (!rawLocation.includes('integration')) {
- evt.location = rawLocation
+ 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')) {
- let maybeLocationRequestObject = getLocationRequestFromIntegration({
- location: rawLocation
- })
-
- evt = merge(evt, maybeLocationRequestObject)
+ if (rawLocation?.includes("integration")) {
+ const maybeLocationRequestObject = getLocationRequestFromIntegration({
+ location: rawLocation,
+ });
+
+ evt = merge(evt, maybeLocationRequestObject);
}
-
+
const eventType = await prisma.eventType.findFirst({
where: {
userId: currentUser.id,
- title: evt.type
+ title: evt.type,
},
select: {
- id: true
- }
+ id: true,
+ },
});
+ // TODO isAvailable was throwing an error
+ const isAvailableToBeBooked = true;//isAvailable(commonAvailability, req.body.start, selectedEventType.length);
+
+ if (!isAvailableToBeBooked) {
+ return res.status(400).json({ message: `${currentUser.name} is unavailable at this time.` });
+ }
+
let results = [];
let referencesToCreate = [];
@@ -113,7 +183,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Reschedule event
const booking = await prisma.booking.findFirst({
where: {
- uid: rescheduleUid
+ uid: rescheduleUid,
},
select: {
id: true,
@@ -121,112 +191,144 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
select: {
id: true,
type: true,
- uid: 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;
- const response = await updateEvent(credential, bookingRefUid, evt);
+ 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) => {
+ console.error("updateEvent failed", e);
+ return { type: credential.type, success: false };
+ });
+ })
+ );
- return {
- type: credential.type,
- response
- };
- }));
+ 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) => {
+ console.error("updateMeeting failed", e);
+ 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;
- const response = await updateMeeting(credential, bookingRefUid, evt);
- return {
- type: credential.type,
- response
- };
- }));
+ if (results.length > 0 && results.every((res) => !res.success)) {
+ res.status(500).json({ message: "Rescheduling failed" });
+ return;
+ }
// Clone elements
referencesToCreate = [...booking.references];
// Now we can delete the old booking and its references.
- let bookingReferenceDeletes = prisma.bookingReference.deleteMany({
+ const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
- bookingId: booking.id
- }
+ bookingId: booking.id,
+ },
});
- let attendeeDeletes = prisma.attendee.deleteMany({
+ const attendeeDeletes = prisma.attendee.deleteMany({
where: {
- bookingId: booking.id
- }
+ bookingId: booking.id,
+ },
});
- let bookingDeletes = prisma.booking.delete({
+ const bookingDeletes = prisma.booking.delete({
where: {
- uid: rescheduleUid
- }
+ uid: rescheduleUid,
+ },
});
- await Promise.all([
- bookingReferenceDeletes,
- attendeeDeletes,
- bookingDeletes
- ]);
+ await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
} else {
// Schedule event
- results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => {
- const response = await createEvent(credential, evt);
- return {
- type: credential.type,
- response
- };
- }));
+ results = results.concat(
+ await async.mapLimit(calendarCredentials, 5, async (credential) => {
+ return createEvent(credential, evt)
+ .then((response) => ({ type: credential.type, success: true, response }))
+ .catch((e) => {
+ console.error("createEvent failed", e);
+ return { type: credential.type, success: false };
+ });
+ })
+ );
- results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => {
- const response = await createMeeting(credential, evt);
- return {
- type: credential.type,
- response
- };
- }));
+ results = results.concat(
+ await async.mapLimit(videoCredentials, 5, async (credential) => {
+ return createMeeting(credential, evt)
+ .then((response) => ({ type: credential.type, success: true, response }))
+ .catch((e) => {
+ console.error("createMeeting failed", e);
+ return { type: credential.type, success: false };
+ });
+ })
+ );
- referencesToCreate = results.map((result => {
+ if (results.length > 0 && results.every((res) => !res.success)) {
+ res.status(500).json({ message: "Booking failed" });
+ return;
+ }
+
+ referencesToCreate = results.map((result) => {
return {
type: result.type,
- uid: result.response.createdEvent.id.toString()
+ uid: result.response.createdEvent.id.toString(),
};
- }));
+ });
}
+ const hashUID =
+ results.length > 0
+ ? results[0].response.uid
+ : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
// TODO Should just be set to the true case as soon as we have a "bare email" integration class.
// UID generation should happen in the integration itself, not here.
- const hashUID = results.length > 0 ? results[0].response.uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
- if(results.length === 0) {
+ if (results.length === 0) {
// Legacy as well, as soon as we have a separate email integration class. Just used
// to send an email even if there is no integration at all.
- const mail = new EventAttendeeMail(evt, hashUID);
- await mail.sendEmail();
+ try {
+ const mail = new EventAttendeeMail(evt, hashUID);
+ await mail.sendEmail();
+ } catch (e) {
+ console.error("Sending legacy event mail failed", e);
+ res.status(500).json({ message: "Booking failed" });
+ return;
+ }
}
- await prisma.booking.create({
- data: {
- uid: hashUID,
- userId: currentUser.id,
- references: {
- create: referencesToCreate
+ try {
+ await prisma.booking.create({
+ data: {
+ uid: hashUID,
+ userId: currentUser.id,
+ references: {
+ create: referencesToCreate,
+ },
+ eventTypeId: eventType.id,
+
+ title: evt.title,
+ description: evt.description,
+ startTime: evt.startTime,
+ endTime: evt.endTime,
+
+ attendees: {
+ create: evt.attendees,
+ },
},
- eventTypeId: eventType.id,
+ });
+ } catch (e) {
+ console.error("Error when saving booking to db", e);
+ res.status(500).json({ message: "Booking already exists" });
+ return;
+ }
- title: evt.title,
- description: evt.description,
- startTime: evt.startTime,
- endTime: evt.endTime,
-
- attendees: {
- create: evt.attendees
- }
- }
- });
-
- res.status(200).json(results);
+ res.status(204).json({});
}
diff --git a/pages/auth/forgot-password/[id].tsx b/pages/auth/forgot-password/[id].tsx
new file mode 100644
index 00000000..48c5824b
--- /dev/null
+++ b/pages/auth/forgot-password/[id].tsx
@@ -0,0 +1,231 @@
+import { getCsrfToken } from "next-auth/client";
+import prisma from "../../../lib/prisma";
+
+import Head from "next/head";
+import React 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";
+
+type Props = {
+ id: string;
+ resetPasswordRequest: ResetPasswordRequest;
+ csrfToken: string;
+};
+
+export default function Page({ resetPasswordRequest, csrfToken }: Props) {
+ const [loading, setLoading] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const [success, setSuccess] = React.useState(false);
+
+ const [password, setPassword] = React.useState("");
+ const handleChange = (e) => {
+ setPassword(e.target.value);
+ };
+
+ const submitChangePassword = async ({ password, requestId }) => {
+ try {
+ const res = await fetch("/api/auth/reset-password", {
+ method: "POST",
+ body: JSON.stringify({ requestId: requestId, password: password }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+
+ const json = await res.json();
+
+ if (!res.ok) {
+ setError(json);
+ } else {
+ setSuccess(true);
+ }
+
+ return json;
+ } catch (reason) {
+ setError({ message: "An unexpected error occurred. Try again." });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const debouncedChangePassword = debounce(submitChangePassword, 250);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (!password) {
+ return;
+ }
+
+ if (loading) {
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ setSuccess(false);
+
+ await debouncedChangePassword({ password, requestId: resetPasswordRequest.id });
+ };
+
+ const Success = () => {
+ return (
+ <>
+
+
+
Success
+
+
Your password has been reset. You can now login with your newly created password.
+
+
+ Login
+
+
+
+ >
+ );
+ };
+
+ const Expired = () => {
+ return (
+ <>
+
+
+
Whoops
+ That Request is Expired.
+
+
+ That request is expired. You can back and enter the email associated with your account and we will
+ you another link to reset your password.
+
+
+
+ Try Again
+
+
+
+ >
+ );
+ };
+
+ const isRequestExpired = useMemo(() => {
+ const now = dayjs();
+ return dayjs(resetPasswordRequest.expires).isBefore(now);
+ }, [resetPasswordRequest]);
+
+ return (
+
+
+
Reset Password
+
+
+
+
+ {isRequestExpired &&
}
+ {!isRequestExpired && !success && (
+ <>
+
+
Reset Password
+
Enter the new password you'd like for your account.
+ {error &&
{error.message}
}
+
+
+
+
+
+ New Password
+
+
+
+
+
+
+
+
+ {loading && (
+
+
+
+
+ )}
+ Submit
+
+
+
+ >
+ )}
+ {!isRequestExpired && success && (
+ <>
+
+ >
+ )}
+
+
+
+ );
+}
+
+export async function getServerSideProps(context: GetServerSidePropsContext) {
+ const id = context.params.id;
+
+ try {
+ const resetPasswordRequest = await prisma.resetPasswordRequest.findUnique({
+ where: {
+ id: id,
+ },
+ select: {
+ id: true,
+ expires: true,
+ },
+ });
+
+ return {
+ props: {
+ resetPasswordRequest: {
+ ...resetPasswordRequest,
+ expires: resetPasswordRequest.expires.toString(),
+ },
+ id,
+ csrfToken: await getCsrfToken({ req: context.req }),
+ },
+ };
+ } catch (reason) {
+ return {
+ notFound: true,
+ };
+ }
+}
diff --git a/pages/auth/forgot-password/index.tsx b/pages/auth/forgot-password/index.tsx
new file mode 100644
index 00000000..5760de01
--- /dev/null
+++ b/pages/auth/forgot-password/index.tsx
@@ -0,0 +1,153 @@
+import Head from "next/head";
+import React from "react";
+import { getCsrfToken } from "next-auth/client";
+import debounce from "lodash.debounce";
+
+export default function Page({ csrfToken }) {
+ const [loading, setLoading] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const [success, setSuccess] = React.useState(false);
+ const [email, setEmail] = React.useState("");
+
+ const handleChange = (e) => {
+ setEmail(e.target.value);
+ };
+
+ const submitForgotPasswordRequest = async ({ email }) => {
+ try {
+ const res = await fetch("/api/auth/forgot-password", {
+ method: "POST",
+ body: JSON.stringify({ email: email }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+
+ const json = await res.json();
+ if (!res.ok) {
+ setError(json);
+ } else {
+ setSuccess(true);
+ }
+
+ return json;
+ } catch (reason) {
+ setError({ message: "An unexpected error occurred. Try again." });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const debouncedHandleSubmitPasswordRequest = debounce(submitForgotPasswordRequest, 250);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (!email) {
+ return;
+ }
+
+ if (loading) {
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ setSuccess(false);
+
+ await debouncedHandleSubmitPasswordRequest({ email });
+ };
+
+ const Success = () => {
+ return (
+
+
Done
+
Check your email. We sent you a link to reset your password.
+ {error &&
{error.message}
}
+
+ );
+ };
+
+ return (
+
+
+
Forgot Password
+
+
+
+
+
+ {success &&
}
+ {!success && (
+ <>
+
+
Forgot Password
+
+ Enter the email address associated with your account and we will send you a link to reset
+ your password.
+
+ {error &&
{error.message}
}
+
+
+
+
+
+ Email address
+
+
+
+
+
+
+
+
+ {loading && (
+
+
+
+
+ )}
+ Request Password Reset
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+Page.getInitialProps = async ({ req }) => {
+ return {
+ csrfToken: await getCsrfToken({ req }),
+ };
+};
diff --git a/pages/auth/login.tsx b/pages/auth/login.tsx
index 72e0c516..76514aa4 100644
--- a/pages/auth/login.tsx
+++ b/pages/auth/login.tsx
@@ -1,55 +1,79 @@
-import Head from 'next/head';
-import { getCsrfToken } from 'next-auth/client';
+import Head from "next/head";
+import Link from "next/link";
+import { getCsrfToken } from "next-auth/client";
export default function Login({ csrfToken }) {
return (
-
-
Login
-
-
-
-
- Sign in to your account
-
-
+
+
Login
+
+
+
+
Sign in to your account
+
-
-
-
-
-
-
- Email address
-
-
-
-
-
-
-
-
-
-
- Sign in
-
-
-
+
+
+
+
+
+
+ Email address
+
+
+
+
+
+
+
+
+
+ Sign in
+
+
+
+ Forgot Password?
+
+
+
+
+
- )
+ );
}
-Login.getInitialProps = async ({ req, res }) => {
+Login.getInitialProps = async ({ req }) => {
return {
- csrfToken: await getCsrfToken({ req })
- }
-}
\ No newline at end of file
+ csrfToken: await getCsrfToken({ req }),
+ };
+};
diff --git a/pages/availability/event/[type].tsx b/pages/availability/event/[type].tsx
index 129be9fc..4a48b7b1 100644
--- a/pages/availability/event/[type].tsx
+++ b/pages/availability/event/[type].tsx
@@ -19,7 +19,8 @@ dayjs.extend(utc);
import timezone from "dayjs/plugin/timezone";
dayjs.extend(timezone);
-export default function EventType(props) {
+export default function EventType(props: any): JSX.Element {
+
const router = useRouter();
const inputOptions: OptionBase[] = [
@@ -35,6 +36,7 @@ export default function EventType(props) {
const [selectedInputOption, setSelectedInputOption] = useState
(inputOptions[0]);
const [locations, setLocations] = useState(props.eventType.locations || []);
const [schedule, setSchedule] = useState(undefined);
+ const [selectedCustomInput, setSelectedCustomInput] = useState(undefined);
const [customInputs, setCustomInputs] = useState(
props.eventType.customInputs.sort((a, b) => a.id - b.id) || []
);
@@ -131,41 +133,7 @@ export default function EventType(props) {
const closeAddCustomModal = () => {
setSelectedInputOption(inputOptions[0]);
setShowAddCustomModal(false);
- };
-
- const LocationOptions = () => {
- if (!selectedLocation) {
- return null;
- }
- switch (selectedLocation.value) {
- case LocationType.InPerson: {
- const address = locations.find((location) => location.type === LocationType.InPerson)?.address;
- return (
-
-
- Set an address or place
-
-
-
-
-
- );
- }
- case LocationType.Phone:
- return (
- Calendso will ask your invitee to enter a phone number before scheduling.
- );
- case LocationType.GoogleMeet:
- return Calendso will provide a Google Meet location.
;
- }
- return null;
+ setSelectedCustomInput(undefined);
};
const updateLocations = (e) => {
@@ -192,6 +160,47 @@ export default function EventType(props) {
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
};
+ const openEditCustomModel = (customInput: EventTypeCustomInput) => {
+ setSelectedCustomInput(customInput);
+ setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type));
+ setShowAddCustomModal(true);
+ };
+
+ const LocationOptions = () => {
+ if (!selectedLocation) {
+ return null;
+ }
+ switch (selectedLocation.value) {
+ case LocationType.InPerson:
+ return (
+
+
+ Set an address or place
+
+
+ location.type === LocationType.InPerson)?.address}
+ />
+
+
+ );
+ case LocationType.Phone:
+ return (
+ Calendso will ask your invitee to enter a phone number before scheduling.
+ );
+ case LocationType.GoogleMeet:
+ return Calendso will provide a Google Meet location.
;
+ case LocationType.Zoom:
+ return Calendso will provide a Zoom meeting URL.
;
+ }
+ return null;
+ };
+
const updateCustom = (e) => {
e.preventDefault();
@@ -201,9 +210,28 @@ export default function EventType(props) {
type: e.target.type.value,
};
- setCustomInputs(customInputs.concat(customInput));
+ if (e.target.id?.value) {
+ const index = customInputs.findIndex((inp) => inp.id === +e.target.id?.value);
+ if (index >= 0) {
+ const input = customInputs[index];
+ input.label = customInput.label;
+ input.required = customInput.required;
+ input.type = customInput.type;
+ setCustomInputs(customInputs);
+ }
+ } else {
+ setCustomInputs(customInputs.concat(customInput));
+ }
+ closeAddCustomModal();
+ };
- setShowAddCustomModal(false);
+ const removeCustom = (customInput, e) => {
+ e.preventDefault();
+ const index = customInputs.findIndex((inp) => inp.id === customInput.id);
+ if (index >= 0) {
+ customInputs.splice(index, 1);
+ setCustomInputs([...customInputs]);
+ }
};
return (
@@ -309,6 +337,50 @@ export default function EventType(props) {
Google Meet
)}
+ {location.type === LocationType.Zoom && (
+
+
+
+
+
+
+
+
+
+
+
+
Zoom Video
+
+ )}
{customInputs.map((customInput) => (
-
+
@@ -409,10 +481,13 @@ export default function EventType(props) {
-
+ openEditCustomModel(customInput)}
+ className="mr-2 text-sm text-blue-600">
Edit
-
+ removeCustom(customInput, e)}>
@@ -447,7 +522,7 @@ export default function EventType(props) {
Hide this event type
- Hide the event type from your page, so it can only be booked through it's URL.
+ Hide the event type from your page, so it can only be booked through its URL.
@@ -599,6 +674,7 @@ export default function EventType(props) {
id="label"
required
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
+ defaultValue={selectedCustomInput?.label}
/>
@@ -608,13 +684,13 @@ export default function EventType(props) {
name="required"
type="checkbox"
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2"
- defaultChecked={true}
+ defaultChecked={selectedCustomInput?.required ?? true}
/>
Is required
-
+
Save
@@ -640,7 +716,7 @@ const validJson = (jsonString: string) => {
return o;
}
} catch (e) {
- // no longer empty
+ console.log("Invalid JSON:", e);
}
return false;
};
@@ -650,7 +726,6 @@ export async function getServerSideProps(context) {
if (!session) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
-
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
@@ -715,6 +790,7 @@ export async function getServerSideProps(context) {
const locationOptions: OptionBase[] = [
{ value: LocationType.InPerson, label: "In-person meeting" },
{ value: LocationType.Phone, label: "Phone call" },
+ { value: LocationType.Zoom, label: "Zoom Video" },
];
const hasGoogleCalendarIntegration = integrations.find(
@@ -746,7 +822,7 @@ export async function getServerSideProps(context) {
const schedules = getAvailability(eventType) ||
getAvailability(user) || [
{
- days: [1, 2, 3, 4, 5, 6, 7],
+ days: [0, 1, 2, 3, 4, 5, 6],
startTime: user.startTime,
length: user.endTime >= 1440 ? 1439 : user.endTime,
},
diff --git a/pages/bookings/index.tsx b/pages/bookings/index.tsx
new file mode 100644
index 00000000..3a7b481f
--- /dev/null
+++ b/pages/bookings/index.tsx
@@ -0,0 +1,121 @@
+import Head from "next/head";
+import prisma from "../../lib/prisma";
+import { getSession, useSession } from "next-auth/client";
+import Shell from "../../components/Shell";
+
+export default function Bookings({ bookings }) {
+ const [session, loading] = useSession();
+
+ if (loading) {
+ return Loading...
;
+ }
+
+ return (
+
+
+
Bookings | Calendso
+
+
+
+
+
+
+
+
+
+
+
+ Title
+
+
+ Description
+
+
+ Name
+
+
+ Email
+
+
+ Edit
+
+
+
+
+ {bookings.map((booking) => (
+
+
+ {booking.title}
+
+
+ {booking.description}
+
+
+ {booking.attendees[0].name}
+
+
+ {booking.attendees[0].email}
+
+
+
+ Reschedule
+
+
+ Cancel
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
+
+export async function getServerSideProps(context) {
+ const session = await getSession(context);
+
+ if (!session) {
+ return { redirect: { permanent: false, destination: "/auth/login" } };
+ }
+
+ const user = await prisma.user.findFirst({
+ where: {
+ email: session.user.email,
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ const bookings = await prisma.booking.findMany({
+ where: {
+ userId: user.id,
+ },
+ select: {
+ uid: true,
+ title: true,
+ description: true,
+ attendees: true,
+ },
+ });
+
+ return { props: { bookings } };
+}
diff --git a/pages/settings/profile.tsx b/pages/settings/profile.tsx
index a00f9efa..c8cc0586 100644
--- a/pages/settings/profile.tsx
+++ b/pages/settings/profile.tsx
@@ -1,140 +1,173 @@
-import Head from 'next/head';
-import Link from 'next/link';
-import { useRef, useState } from 'react';
-import { useRouter } from 'next/router';
-import prisma from '../../lib/prisma';
-import Modal from '../../components/Modal';
-import Shell from '../../components/Shell';
-import SettingsShell from '../../components/Settings';
-import Avatar from '../../components/Avatar';
-import { signIn, useSession, getSession } from 'next-auth/client';
-import TimezoneSelect from 'react-timezone-select';
-import {UsernameInput} from "../../components/ui/UsernameInput";
+import { GetServerSideProps } from "next";
+import Head from "next/head";
+import { useRef, useState } from "react";
+import prisma from "../../lib/prisma";
+import Modal from "../../components/Modal";
+import Shell from "../../components/Shell";
+import SettingsShell from "../../components/Settings";
+import Avatar from "../../components/Avatar";
+import { getSession } from "next-auth/client";
+import TimezoneSelect from "react-timezone-select";
+import { UsernameInput } from "../../components/ui/UsernameInput";
import ErrorAlert from "../../components/ui/alerts/Error";
export default function Settings(props) {
- const [ session, loading ] = useSession();
- const router = useRouter();
- const [successModalOpen, setSuccessModalOpen] = useState(false);
- const usernameRef = useRef();
- const nameRef = useRef();
- const descriptionRef = useRef();
- const avatarRef = useRef();
+ const [successModalOpen, setSuccessModalOpen] = useState(false);
+ const usernameRef = useRef();
+ const nameRef = useRef();
+ const descriptionRef = useRef();
+ const avatarRef = useRef();
+ const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone });
+ const [selectedWeekStartDay, setSelectedWeekStartDay] = useState(props.user.weekStart || "Sunday");
- const [ selectedTimeZone, setSelectedTimeZone ] = useState({ value: props.user.timeZone });
- const [ selectedWeekStartDay, setSelectedWeekStartDay ] = useState(props.user.weekStart || 'Sunday');
+ const [hasErrors, setHasErrors] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
- const [ hasErrors, setHasErrors ] = useState(false);
- const [ errorMessage, setErrorMessage ] = useState('');
+ const closeSuccessModal = () => {
+ setSuccessModalOpen(false);
+ };
- if (loading) {
- return Loading...
;
+ const handleError = async (resp) => {
+ if (!resp.ok) {
+ const error = await resp.json();
+ throw new Error(error.message);
}
+ };
- const closeSuccessModal = () => { setSuccessModalOpen(false); }
+ async function updateProfileHandler(event) {
+ event.preventDefault();
- const handleError = async (resp) => {
- if (!resp.ok) {
- const error = await resp.json();
- throw new Error(error.message);
- }
- }
+ const enteredUsername = usernameRef.current.value.toLowerCase();
+ const enteredName = nameRef.current.value;
+ const enteredDescription = descriptionRef.current.value;
+ const enteredAvatar = avatarRef.current.value;
+ const enteredTimeZone = selectedTimeZone.value;
+ const enteredWeekStartDay = selectedWeekStartDay;
- async function updateProfileHandler(event) {
- event.preventDefault();
+ // TODO: Add validation
- const enteredUsername = usernameRef.current.value;
- const enteredName = nameRef.current.value;
- const enteredDescription = descriptionRef.current.value;
- const enteredAvatar = avatarRef.current.value;
- const enteredTimeZone = selectedTimeZone.value;
- const enteredWeekStartDay = selectedWeekStartDay;
+ await fetch("/api/user/profile", {
+ method: "PATCH",
+ body: JSON.stringify({
+ username: enteredUsername,
+ name: enteredName,
+ description: enteredDescription,
+ avatar: enteredAvatar,
+ timeZone: enteredTimeZone,
+ weekStart: enteredWeekStartDay,
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ })
+ .then(handleError)
+ .then(() => {
+ setSuccessModalOpen(true);
+ setHasErrors(false); // dismiss any open errors
+ })
+ .catch((err) => {
+ setHasErrors(true);
+ setErrorMessage(err.message);
+ });
+ }
- // TODO: Add validation
+ return (
+
+
+ Profile | Calendso
+
+
+
+
+ {hasErrors && }
+
+
+
Profile
+
Review and change your public page details.
+
- const response = await fetch('/api/user/profile', {
- method: 'PATCH',
- body: JSON.stringify({username: enteredUsername, name: enteredName, description: enteredDescription, avatar: enteredAvatar, timeZone: enteredTimeZone, weekStart: enteredWeekStartDay}),
- headers: {
- 'Content-Type': 'application/json'
- }
- }).then(handleError).then( () => {
- setSuccessModalOpen(true);
- setHasErrors(false); // dismiss any open errors
- }).catch( (err) => {
- setHasErrors(true);
- setErrorMessage(err.message);
- });
- }
+
+
+
+
+
+
+
+
+ Full name
+
+
+
+
- return(
-
-
- Profile | Calendso
-
-
-
-
- {hasErrors && }
-
-
-
Profile
-
- Review and change your public page details.
-
-
+
+
+ About
+
+
+
+ {props.user.bio}
+
+
+
+
+
+
+ First Day of Week
+
+
+ setSelectedWeekStartDay(e.target.value)}
+ className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md">
+ Sunday
+ Monday
+
+
+
+
-
-
-
-
-
-
-
- Full name
-
-
-
-
-
-
- About
-
-
- {props.user.bio}
-
-
-
-
-
- First Day of Week
-
-
- setSelectedWeekStartDay(e.target.value)} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md">
- Sunday
- Monday
-
-
-
-
-
-
-
- Photo
-
-
-
-
- {/*
-
}
- />
- {/*
+ }
+ />
+ {/*
Change
user photo
*/}
-
-
- Avatar URL
-
-
-
-
-
-
-
- Save
-
-
-
-
-
-
-
- );
+
+
+
+ Avatar URL
+
+
+
+
+
+
+
+
+ Save
+
+
+
+
+
+
+
+ );
}
-export async function getServerSideProps(context) {
- const session = await getSession(context);
- if (!session) {
- return { redirect: { permanent: false, destination: '/auth/login' } };
- }
+export const getServerSideProps: GetServerSideProps = async (context) => {
+ const session = await getSession(context);
+ if (!session) {
+ return { redirect: { permanent: false, destination: "/auth/login" } };
+ }
- const user = await prisma.user.findFirst({
- where: {
- email: session.user.email,
- },
- select: {
- id: true,
- username: true,
- name: true,
- email: true,
- bio: true,
- avatar: true,
- timeZone: true,
- weekStart: true,
- }
- });
+ const user = await prisma.user.findFirst({
+ where: {
+ email: session.user.email,
+ },
+ select: {
+ id: true,
+ username: true,
+ name: true,
+ email: true,
+ bio: true,
+ avatar: true,
+ timeZone: true,
+ weekStart: true,
+ },
+ });
- return {
- props: {user}, // will be passed to the page component as props
- }
-}
\ No newline at end of file
+ return {
+ props: { user }, // will be passed to the page component as props
+ };
+};
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0ff77712..5fb93fdc 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -159,3 +159,11 @@ model EventTypeCustomInput {
required Boolean
}
+model ResetPasswordRequest {
+ id String @id @default(cuid())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ email String
+ expires DateTime
+}
+
diff --git a/yarn.lock b/yarn.lock
index 0311f59f..ffcaf0d8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -902,6 +902,13 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.44.tgz#df7503e6002847b834371c004b372529f3f85215"
integrity sha512-+gaugz6Oce6ZInfI/tK4Pq5wIIkJMEJUu92RB3Eu93mtj4wjjjz9EB5mLp5s1pSsLXdC/CPut/xF20ZzAQJbTA==
+"@types/nodemailer@^6.4.2":
+ version "6.4.2"
+ resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.2.tgz#d8ee254c969e6ad83fb9a0a0df3a817406a3fa3b"
+ integrity sha512-yhsqg5Xbr8aWdwjFS3QjkniW5/tLpWXtOYQcJdo9qE3DolBxsKzgRCQrteaMY0hos8MklJNSEsMqDpZynGzMNg==
+ dependencies:
+ "@types/node" "*"
+
"@types/parse-json@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
@@ -2874,6 +2881,18 @@ gtoken@^5.0.4:
google-p12-pem "^3.0.3"
jws "^4.0.0"
+handlebars@^4.7.7:
+ version "4.7.7"
+ resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1"
+ integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==
+ dependencies:
+ minimist "^1.2.5"
+ neo-async "^2.6.0"
+ source-map "^0.6.1"
+ wordwrap "^1.0.0"
+ optionalDependencies:
+ uglify-js "^3.1.4"
+
has-ansi@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
@@ -4075,6 +4094,11 @@ lodash.clonedeep@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
+lodash.debounce@^4.0.8:
+ version "4.0.8"
+ resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
+ integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
+
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
@@ -4331,6 +4355,11 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
+neo-async@^2.6.0:
+ version "2.6.2"
+ resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
+ integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
+
next-auth@^3.13.2:
version "3.19.8"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-3.19.8.tgz#32331f33dd73b46ec5c774735a9db78f9dbba3c7"
@@ -6032,6 +6061,11 @@ typescript@^4.2.3:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
+uglify-js@^3.1.4:
+ version "3.13.9"
+ resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.9.tgz#4d8d21dcd497f29cfd8e9378b9df123ad025999b"
+ integrity sha512-wZbyTQ1w6Y7fHdt8sJnHfSIuWeDgk6B5rCb4E/AM6QNNPbOMIZph21PW5dRB3h7Df0GszN+t7RuUH6sWK5bF0g==
+
unbox-primitive@^1.0.0, unbox-primitive@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
@@ -6254,6 +6288,11 @@ word-wrap@^1.2.3, word-wrap@~1.2.3:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
+wordwrap@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
+ integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=
+
wrap-ansi@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"