Merge branch 'main' into feature/saml-login

# Conflicts:
#	package.json
feature/saml-login
Deepak Prabhakara 2021-12-07 21:23:14 +00:00
commit 4680222f5e
18 changed files with 539 additions and 278 deletions

View File

@ -8,7 +8,7 @@
NEXT_PUBLIC_LICENSE_CONSENT=''
# DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>/<db-name>'
DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public"
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
GOOGLE_API_CREDENTIALS='secret'
@ -22,12 +22,12 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Enable SAML login using https://github.com/boxyhq/jackson
SAML_LOGIN_URL=
SAML_API_URL=
SAML_TENANT_ID=
SAML_PRODUCT_ID=
JACKSON_API_KEYS=
SAML_ADMINS=
SAML_LOGIN_URL='http://localhost:5000'
SAML_API_URL='http://localhost:6000'
SAML_TENANT_ID='Cal.com'
SAML_PRODUCT_ID='Cal.com'
JACKSON_API_KEYS='secret'
SAML_ADMINS='onboarding@example.com'
# @see: https://github.com/calendso/calendso/issues/263
# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL

257
@types/ical.d.ts vendored Normal file
View File

@ -0,0 +1,257 @@
// SPDX-FileCopyrightText: © 2019 EteSync Authors
// SPDX-License-Identifier: GPL-3.0-only
// https://github.com/mozilla-comm/ical.js/issues/367#issuecomment-568493517
declare module "ical.js" {
function parse(input: string): any[];
export class helpers {
public updateTimezones(vcal: Component): Component;
}
class Component {
public fromString(str: string): Component;
public name: string;
constructor(jCal: any[] | string, parent?: Component);
public toJSON(): any[];
public getFirstSubcomponent(name?: string): Component | null;
public getAllSubcomponents(name?: string): Component[];
public getFirstPropertyValue<T = any>(name?: string): T;
public getFirstProperty(name?: string): Property;
public getAllProperties(name?: string): Property[];
public addProperty(property: Property): Property;
public addPropertyWithValue(name: string, value: string | number | Record<string, unknown>): Property;
public hasProperty(name?: string): boolean;
public updatePropertyWithValue(name: string, value: string | number | Record<string, unknown>): Property;
public removeAllProperties(name?: string): boolean;
public addSubcomponent(component: Component): Component;
}
export class Event {
public uid: string;
public summary: string;
public startDate: Time;
public endDate: Time;
public description: string;
public location: string;
public attendees: Property[];
/**
* The sequence value for this event. Used for scheduling.
*
* @type {number}
* @memberof Event
*/
public sequence: number;
/**
* The duration. This can be the result directly from the property, or the
* duration calculated from start date and end date. Setting the property
* will remove any `dtend` properties.
*
* @type {Duration}
* @memberof Event
*/
public duration: Duration;
/**
* The organizer value as an uri. In most cases this is a mailto: uri,
* but it can also be something else, like urn:uuid:...
*/
public organizer: string;
/** The sequence value for this event. Used for scheduling */
public sequence: number;
/** The recurrence id for this event */
public recurrenceId: Time;
public component: Component;
public constructor(
component?: Component | null,
options?: { strictExceptions: boolean; exepctions: Array<Component | Event> }
);
public isRecurring(): boolean;
public iterator(startTime?: Time): RecurExpansion;
}
export class Property {
public name: string;
public type: string;
constructor(jCal: any[] | string, parent?: Component);
public getFirstValue<T = any>(): T;
public getValues<T = any>(): T[];
public setParameter(name: string, value: string | string[]): void;
public setValue(value: string | Record<string, unknown>): void;
public setValues(values: (string | Record<string, unknown>)[]): void;
public toJSON(): any;
}
interface TimeJsonData {
year?: number;
month?: number;
day?: number;
hour?: number;
minute?: number;
second?: number;
isDate?: boolean;
}
export class Time {
public fromString(str: string): Time;
public fromJSDate(aDate: Date | null, useUTC: boolean): Time;
public fromData(aData: TimeJsonData): Time;
public now(): Time;
public isDate: boolean;
public timezone: string;
public zone: Timezone;
public year: number;
public month: number;
public day: number;
public hour: number;
public minute: number;
public second: number;
constructor(data?: TimeJsonData);
public compare(aOther: Time): number;
public clone(): Time;
public convertToZone(zone: Timezone): Time;
public adjust(
aExtraDays: number,
aExtraHours: number,
aExtraMinutes: number,
aExtraSeconds: number,
aTimeopt?: Time
): void;
public addDuration(aDuration: Duration): void;
public subtractDateTz(aDate: Time): Duration;
public toUnixTime(): number;
public toJSDate(): Date;
public toJSON(): TimeJsonData;
public get icaltype(): "date" | "date-time";
}
export class Duration {
public weeks: number;
public days: number;
public hours: number;
public minutes: number;
public seconds: number;
public isNegative: boolean;
public icalclass: string;
public icaltype: string;
}
export class RecurExpansion {
public complete: boolean;
public dtstart: Time;
public last: Time;
public next(): Time;
public fromData(options);
public toJSON();
constructor(options: {
/** Start time of the event */
dtstart: Time;
/** Component for expansion, required if not resuming. */
component?: Component;
});
}
export class Timezone {
public utcTimezone: Timezone;
public localTimezone: Timezone;
public convert_time(tt: Time, fromZone: Timezone, toZone: Timezone): Time;
public tzid: string;
public component: Component;
constructor(
data:
| Component
| {
component: string | Component;
tzid?: string;
location?: string;
tznames?: string;
latitude?: number;
longitude?: number;
}
);
}
export class TimezoneService {
public get(tzid: string): Timezone | null;
public has(tzid: string): boolean;
public register(tzid: string, zone: Timezone | Component);
public remove(tzid: string): Timezone | null;
}
export type FrequencyValues =
| "YEARLY"
| "MONTHLY"
| "WEEKLY"
| "DAILY"
| "HOURLY"
| "MINUTELY"
| "SECONDLY";
export enum WeekDay {
SU = 1,
MO,
TU,
WE,
TH,
FR,
SA,
}
export class RecurData {
public freq?: FrequencyValues;
public interval?: number;
public wkst?: WeekDay;
public until?: Time;
public count?: number;
public bysecond?: number[] | number;
public byminute?: number[] | number;
public byhour?: number[] | number;
public byday?: string[] | string;
public bymonthday?: number[] | number;
public byyearday?: number[] | number;
public byweekno?: number[] | number;
public bymonth?: number[] | number;
public bysetpos?: number[] | number;
}
export class RecurIterator {
public next(): Time;
}
export class Recur {
constructor(data?: RecurData);
public until: Time | null;
public freq: FrequencyValues;
public count: number | null;
public clone(): Recur;
public toJSON(): Omit<RecurData, "until"> & { until?: string };
public iterator(startTime?: Time): RecurIterator;
public isByCount(): boolean;
}
}

View File

@ -125,9 +125,9 @@ export default function ImageUploader({
</div>
</div>
<div className="mb-4">
<div className="flex flex-col items-center justify-center p-8 mt-6 cropper bg-gray-50">
<div className="flex flex-col items-center justify-center p-8 mt-6 cropper">
{!result && (
<div className="flex items-center justify-start w-20 h-20 bg-gray-500 rounded-full max-h-20">
<div className="flex items-center justify-start w-20 h-20 bg-gray-50 rounded-full max-h-20">
{!imageSrc && (
<p className="w-full text-sm text-center text-white sm:text-xs">
{t("no_target", { target })}

View File

@ -1,32 +1,26 @@
import dayjs from "dayjs";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getIntegrationName } from "@lib/integrations";
import { CalendarEvent } from "./calendarClient";
import { CalendarEvent, Person } from "./calendarClient";
import { BASE_URL } from "./config/constants";
const translator = short();
// The odd indentation in this file is necessary because otherwise the leading tabs will be applied into the event description.
export const getWhat = (calEvent: CalendarEvent) => {
return `
${calEvent.language("what")}
${calEvent.language("what")}:
${calEvent.type}
`;
};
export const getWhen = (calEvent: CalendarEvent) => {
const inviteeStart = dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone);
const inviteeEnd = dayjs(calEvent.endTime).tz(calEvent.attendees[0].timeZone);
return `
${calEvent.language("when")}
${calEvent.language(inviteeStart.format("dddd").toLowerCase())}, ${calEvent.language(
inviteeStart.format("MMMM").toLowerCase()
)} ${inviteeStart.format("D")}, ${inviteeStart.format("YYYY")} | ${inviteeStart.format(
"h:mma"
)} - ${inviteeEnd.format("h:mma")} (${calEvent.attendees[0].timeZone})
${calEvent.language("invitee_timezone")}:
${calEvent.attendees[0].timeZone}
`;
};
@ -46,14 +40,14 @@ ${calEvent.organizer.email}
`;
return `
${calEvent.language("who")}
${calEvent.language("who")}:
${organizer + attendees}
`;
};
export const getAdditionalNotes = (calEvent: CalendarEvent) => {
return `
${calEvent.language("additional_notes")}
${calEvent.language("additional_notes")}:
${calEvent.description}
`;
};
@ -92,13 +86,28 @@ export const getCancelLink = (calEvent: CalendarEvent): string => {
return BASE_URL + "/cancel/" + getUid(calEvent);
};
export const getRichDescription = (calEvent: CalendarEvent) => {
export const getRichDescription = (calEvent: CalendarEvent, attendee?: Person) => {
// Only the original attendee can make changes to the event
// Guests cannot
if (attendee && attendee === calEvent.attendees[0]) {
return `
${getWhat(calEvent)}
${getWhen(calEvent)}
${getWho(calEvent)}
${calEvent.language("where")}:
${getLocation(calEvent)}
${getAdditionalNotes(calEvent)}
`.trim();
}
return `
${getWhat(calEvent)}
${getWhen(calEvent)}
${getWho(calEvent)}
${getLocation(calEvent)}
${getAdditionalNotes(calEvent)}
${getManageLink(calEvent)}
`;
${getWhat(calEvent)}
${getWhen(calEvent)}
${getWho(calEvent)}
${calEvent.language("where")}:
${getLocation(calEvent)}
${getAdditionalNotes(calEvent)}
${getManageLink(calEvent)}
`.trim();
};

View File

@ -19,6 +19,8 @@ import {
import logger from "@lib/logger";
import { VideoCallData } from "@lib/videoClient";
import { Ensure } from "./types/utils";
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
export type Person = { name: string; email: string; timeZone: string };
@ -61,7 +63,7 @@ export interface CalendarEvent {
paymentInfo?: PaymentInfo | null;
}
export interface IntegrationCalendar extends Partial<SelectedCalendar> {
export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, "externalId"> {
primary?: boolean;
name?: string;
}
@ -89,11 +91,9 @@ function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter |
case "office365_calendar":
return Office365CalendarApiAdapter(credential);
case "caldav_calendar":
// FIXME types wrong & type casting should not be needed
return new CalDavCalendar(credential) as never as CalendarApiAdapter;
return new CalDavCalendar(credential);
case "apple_calendar":
// FIXME types wrong & type casting should not be needed
return new AppleCalendar(credential) as never as CalendarApiAdapter;
return new AppleCalendar(credential);
}
return null;
}

View File

@ -45,7 +45,7 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<
// on very low ratios, the quality of the resize becomes awful. For this reason the resizeRatio is limited to 0.75
if (resizeRatio <= 0.75) {
// With a smaller image, thus improved ratio. Keep doing this until the resizeRatio > 0.75.
return getCroppedImg(canvas.toDataURL("image/jpeg"), {
return getCroppedImg(canvas.toDataURL("image/png"), {
width: canvas.width,
height: canvas.height,
x: 0,
@ -53,5 +53,5 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<
});
}
return canvas.toDataURL("image/jpeg");
return canvas.toDataURL("image/png");
}

View File

@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
import { createEvent, DateArray } from "ics";
import nodemailer from "nodemailer";
import { getCancelLink } from "@lib/CalEventParser";
import { getCancelLink, getRichDescription } from "@lib/CalEventParser";
import { CalendarEvent, Person } from "@lib/calendarClient";
import { getErrorFromUnknown } from "@lib/errors";
import { getIntegrationName } from "@lib/integrations";
@ -113,30 +113,12 @@ export default class AttendeeScheduledEmail {
}
protected getTextBody(): string {
// Only the original attendee can make changes to the event
// Guests cannot
if (this.attendee === this.calEvent.attendees[0]) {
return `
${this.calEvent.language("your_event_has_been_scheduled")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.calEvent.language("need_to_reschedule_or_cancel")}
${getCancelLink(this.calEvent)}
`.replace(/(<([^>]+)>)/gi, "");
}
return `
${this.calEvent.language("your_event_has_been_scheduled")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
`.replace(/(<([^>]+)>)/gi, "");
${getRichDescription(this.calEvent)}
`.trim();
}
protected printNodeMailerError(error: Error): void {
@ -364,12 +346,12 @@ ${this.getAdditionalNotes()}
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("where")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${
hangoutLink &&
`<a href="${hangoutLink}" target="_blank" alt="${this.calEvent.language(
"meeting_url"
)}"><img src="${linkIcon()}" width="12px"></img></a>`
}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${providerName} ${
hangoutLink &&
`<a href="${hangoutLink}" target="_blank" alt="${this.calEvent.language(
"meeting_url"
)}"><img src="${linkIcon()}" width="12px"></img></a>`
}</p>
<div style="color: #494949; font-weight: 400; line-height: 24px;"><a href="${hangoutLink}" alt="${this.calEvent.language(
"meeting_url"
)}" style="color: #3E3E3E" target="_blank">${hangoutLink}</a></div>

View File

@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
import { createEvent, DateArray } from "ics";
import nodemailer from "nodemailer";
import { getCancelLink } from "@lib/CalEventParser";
import { getCancelLink, getRichDescription } from "@lib/CalEventParser";
import { CalendarEvent, Person } from "@lib/calendarClient";
import { getErrorFromUnknown } from "@lib/errors";
import { getIntegrationName } from "@lib/integrations";
@ -123,14 +123,9 @@ export default class OrganizerScheduledEmail {
return `
${this.calEvent.language("new_event_scheduled")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.calEvent.language("need_to_reschedule_or_cancel")}
${getCancelLink(this.calEvent)}
`.replace(/(<([^>]+)>)/gi, "");
${getRichDescription(this.calEvent)}
`.trim();
}
protected printNodeMailerError(error: Error): void {
@ -352,12 +347,12 @@ ${getCancelLink(this.calEvent)}
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("where")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${
hangoutLink &&
`<a href="${hangoutLink}" target="_blank" alt="${this.calEvent.language(
"meeting_url"
)}"><img src="${linkIcon()}" width="12px"></img></a>`
}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${providerName} ${
hangoutLink &&
`<a href="${hangoutLink}" target="_blank" alt="${this.calEvent.language(
"meeting_url"
)}"><img src="${linkIcon()}" width="12px"></img></a>`
}</p>
<div style="color: #494949; font-weight: 400; line-height: 24px;"><a href="${hangoutLink}" alt="${this.calEvent.language(
"meeting_url"
)}" style="color: #3E3E3E" target="_blank">${hangoutLink}</a></div>

View File

@ -24,8 +24,6 @@ dayjs.extend(utc);
const log = logger.getChildLogger({ prefix: ["[[lib] apple calendar"] });
type EventBusyDate = Record<"start" | "end", Date>;
export class AppleCalendar implements CalendarApiAdapter {
private url: string;
private credentials: Record<string, string>;
@ -34,7 +32,7 @@ export class AppleCalendar implements CalendarApiAdapter {
constructor(credential: Credential) {
const decryptedCredential = JSON.parse(
symmetricDecrypt(credential.key, process.env.CALENDSO_ENCRYPTION_KEY)
symmetricDecrypt(credential.key as string, process.env.CALENDSO_ENCRYPTION_KEY!)
);
const username = decryptedCredential.username;
const password = decryptedCredential.password;
@ -52,12 +50,12 @@ export class AppleCalendar implements CalendarApiAdapter {
});
}
convertDate(date: string): number[] {
convertDate(date: string): [number, number, number] {
return dayjs(date)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v));
.map((v, i) => (i === 1 ? v + 1 : v)) as [number, number, number];
}
getDuration(start: string, end: string): DurationObject {
@ -70,11 +68,11 @@ export class AppleCalendar implements CalendarApiAdapter {
return attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" }));
}
async createEvent(event: CalendarEvent): Promise<Record<string, unknown>> {
async createEvent(event: CalendarEvent) {
try {
const calendars = await this.listCalendars();
const uid = uuidv4();
const { error, value: iCalString } = await createEvent({
const { error, value: iCalString } = createEvent({
uid,
startInputType: "utc",
start: this.convertDate(event.startTime),
@ -86,15 +84,9 @@ export class AppleCalendar implements CalendarApiAdapter {
attendees: this.getAttendees(event.attendees),
});
if (error) {
log.debug("Error creating iCalString");
return {};
}
if (error) throw new Error("Error creating iCalString");
if (!iCalString) {
log.debug("Error creating iCalString");
return {};
}
if (!iCalString) throw new Error("Error creating iCalString");
await Promise.all(
calendars.map((calendar) => {
@ -112,6 +104,9 @@ export class AppleCalendar implements CalendarApiAdapter {
return {
uid,
id: uid,
type: "apple_calendar",
password: "",
url: "",
};
} catch (reason) {
console.error(reason);
@ -132,7 +127,7 @@ export class AppleCalendar implements CalendarApiAdapter {
}
}
const { error, value: iCalString } = await createEvent({
const { error, value: iCalString } = createEvent({
uid,
startInputType: "utc",
start: this.convertDate(event.startTime),
@ -201,11 +196,7 @@ export class AppleCalendar implements CalendarApiAdapter {
}
}
async getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
async getAvailability(dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[]) {
try {
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
@ -229,8 +220,8 @@ export class AppleCalendar implements CalendarApiAdapter {
ids.map(async (calId) => {
return (await this.getEvents(calId, dateFrom, dateTo)).map((event) => {
return {
start: event.startDate,
end: event.endDate,
start: event.startDate.toISOString(),
end: event.endDate.toISOString(),
};
});
})
@ -272,8 +263,8 @@ export class AppleCalendar implements CalendarApiAdapter {
calId: string,
dateFrom: string | null,
dateTo: string | null,
objectUrls: string[] | null
): Promise<unknown[]> {
objectUrls?: string[] | null
) {
try {
const objects = await fetchCalendarObjects({
calendar: {
@ -290,54 +281,48 @@ export class AppleCalendar implements CalendarApiAdapter {
headers: this.headers,
});
const events =
objects &&
objects?.length > 0 &&
objects
.map((object) => {
if (object?.data) {
const jcalData = ICAL.parse(object.data);
const vcalendar = new ICAL.Component(jcalData);
const vevent = vcalendar.getFirstSubcomponent("vevent");
const event = new ICAL.Event(vevent);
const events = objects
.filter((e) => !!e.data)
.map((object) => {
const jcalData = ICAL.parse(object.data);
const vcalendar = new ICAL.Component(jcalData);
const vevent = vcalendar.getFirstSubcomponent("vevent");
const event = new ICAL.Event(vevent);
const calendarTimezone = vcalendar.getFirstSubcomponent("vtimezone")
? vcalendar.getFirstSubcomponent("vtimezone").getFirstPropertyValue("tzid")
: "";
const calendarTimezone =
vcalendar.getFirstSubcomponent("vtimezone")?.getFirstPropertyValue("tzid") || "";
const startDate = calendarTimezone
? dayjs(event.startDate).tz(calendarTimezone)
: new Date(event.startDate.toUnixTime() * 1000);
const endDate = calendarTimezone
? dayjs(event.endDate).tz(calendarTimezone)
: new Date(event.endDate.toUnixTime() * 1000);
const startDate = calendarTimezone
? dayjs(event.startDate.toJSDate()).tz(calendarTimezone)
: new Date(event.startDate.toUnixTime() * 1000);
const endDate = calendarTimezone
? dayjs(event.endDate.toJSDate()).tz(calendarTimezone)
: new Date(event.endDate.toUnixTime() * 1000);
return {
uid: event.uid,
etag: object.etag,
url: object.url,
summary: event.summary,
description: event.description,
location: event.location,
sequence: event.sequence,
startDate,
endDate,
duration: {
weeks: event.duration.weeks,
days: event.duration.days,
hours: event.duration.hours,
minutes: event.duration.minutes,
seconds: event.duration.seconds,
isNegative: event.duration.isNegative,
},
organizer: event.organizer,
attendees: event.attendees.map((a) => a.getValues()),
recurrenceId: event.recurrenceId,
timezone: calendarTimezone,
};
}
})
.filter((e) => e != null);
return {
uid: event.uid,
etag: object.etag,
url: object.url,
summary: event.summary,
description: event.description,
location: event.location,
sequence: event.sequence,
startDate,
endDate,
duration: {
weeks: event.duration.weeks,
days: event.duration.days,
hours: event.duration.hours,
minutes: event.duration.minutes,
seconds: event.duration.seconds,
isNegative: event.duration.isNegative,
},
organizer: event.organizer,
attendees: event.attendees.map((a) => a.getValues()),
recurrenceId: event.recurrenceId,
timezone: calendarTimezone,
};
});
return events;
} catch (reason) {

View File

@ -24,8 +24,6 @@ dayjs.extend(utc);
const log = logger.getChildLogger({ prefix: ["[lib] caldav"] });
type EventBusyDate = Record<"start" | "end", Date>;
export class CalDavCalendar implements CalendarApiAdapter {
private url: string;
private credentials: Record<string, string>;
@ -34,7 +32,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
constructor(credential: Credential) {
const decryptedCredential = JSON.parse(
symmetricDecrypt(credential.key, process.env.CALENDSO_ENCRYPTION_KEY)
symmetricDecrypt(credential.key as string, process.env.CALENDSO_ENCRYPTION_KEY!)
);
const username = decryptedCredential.username;
const url = decryptedCredential.url;
@ -53,12 +51,12 @@ export class CalDavCalendar implements CalendarApiAdapter {
});
}
convertDate(date: string): number[] {
convertDate(date: string): [number, number, number] {
return dayjs(date)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v));
.map((v, i) => (i === 1 ? v + 1 : v)) as [number, number, number];
}
getDuration(start: string, end: string): DurationObject {
@ -71,15 +69,14 @@ export class CalDavCalendar implements CalendarApiAdapter {
return attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" }));
}
async createEvent(event: CalendarEvent): Promise<Record<string, unknown>> {
async createEvent(event: CalendarEvent) {
try {
const calendars = await this.listCalendars();
const uid = uuidv4();
const { error, value: iCalString } = await createEvent({
const { error, value: iCalString } = createEvent({
uid,
startInputType: "utc",
// FIXME types
start: this.convertDate(event.startTime),
duration: this.getDuration(event.startTime, event.endTime),
title: event.title,
@ -89,15 +86,9 @@ export class CalDavCalendar implements CalendarApiAdapter {
attendees: this.getAttendees(event.attendees),
});
if (error) {
log.debug("Error creating iCalString");
return {};
}
if (error) throw new Error("Error creating iCalString");
if (!iCalString) {
log.debug("Error creating iCalString");
return {};
}
if (!iCalString) throw new Error("Error creating iCalString");
await Promise.all(
calendars.map((calendar) => {
@ -115,6 +106,9 @@ export class CalDavCalendar implements CalendarApiAdapter {
return {
uid,
id: uid,
type: "caldav_calendar",
password: "",
url: "",
};
} catch (reason) {
log.error(reason);
@ -138,7 +132,6 @@ export class CalDavCalendar implements CalendarApiAdapter {
const { error, value: iCalString } = await createEvent({
uid,
startInputType: "utc",
// FIXME - types wrong
start: this.convertDate(event.startTime),
duration: this.getDuration(event.startTime, event.endTime),
title: event.title,
@ -205,12 +198,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
}
}
// FIXME - types wrong
async getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
async getAvailability(dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[]) {
try {
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
@ -235,8 +223,8 @@ export class CalDavCalendar implements CalendarApiAdapter {
ids.map(async (calId) => {
return (await this.getEvents(calId, dateFrom, dateTo)).map((event) => {
return {
start: event.startDate,
end: event.endDate,
start: event.startDate.toISOString(),
end: event.endDate.toISOString(),
};
});
})
@ -274,7 +262,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
}
}
async getEvents(calId: string, dateFrom: string | null, dateTo: string | null): Promise<unknown[]> {
async getEvents(calId: string, dateFrom: string | null, dateTo: string | null) {
try {
const objects = await fetchCalendarObjects({
calendar: {
@ -295,50 +283,47 @@ export class CalDavCalendar implements CalendarApiAdapter {
}
const events = objects
.filter((e) => !!e.data)
.map((object) => {
if (object?.data) {
const jcalData = ICAL.parse(object.data);
const vcalendar = new ICAL.Component(jcalData);
const vevent = vcalendar.getFirstSubcomponent("vevent");
const event = new ICAL.Event(vevent);
const jcalData = ICAL.parse(object.data);
const vcalendar = new ICAL.Component(jcalData);
const vevent = vcalendar.getFirstSubcomponent("vevent");
const event = new ICAL.Event(vevent);
const calendarTimezone = vcalendar.getFirstSubcomponent("vtimezone")
? vcalendar.getFirstSubcomponent("vtimezone").getFirstPropertyValue("tzid")
: "";
const calendarTimezone =
vcalendar.getFirstSubcomponent("vtimezone")?.getFirstPropertyValue("tzid") || "";
const startDate = calendarTimezone
? dayjs(event.startDate).tz(calendarTimezone)
: new Date(event.startDate.toUnixTime() * 1000);
const endDate = calendarTimezone
? dayjs(event.endDate).tz(calendarTimezone)
: new Date(event.endDate.toUnixTime() * 1000);
const startDate = calendarTimezone
? dayjs(event.startDate.toJSDate()).tz(calendarTimezone)
: new Date(event.startDate.toUnixTime() * 1000);
const endDate = calendarTimezone
? dayjs(event.endDate.toJSDate()).tz(calendarTimezone)
: new Date(event.endDate.toUnixTime() * 1000);
return {
uid: event.uid,
etag: object.etag,
url: object.url,
summary: event.summary,
description: event.description,
location: event.location,
sequence: event.sequence,
startDate,
endDate,
duration: {
weeks: event.duration.weeks,
days: event.duration.days,
hours: event.duration.hours,
minutes: event.duration.minutes,
seconds: event.duration.seconds,
isNegative: event.duration.isNegative,
},
organizer: event.organizer,
attendees: event.attendees.map((a) => a.getValues()),
recurrenceId: event.recurrenceId,
timezone: calendarTimezone,
};
}
})
.filter((e) => e != null);
return {
uid: event.uid,
etag: object.etag,
url: object.url,
summary: event.summary,
description: event.description,
location: event.location,
sequence: event.sequence,
startDate,
endDate,
duration: {
weeks: event.duration.weeks,
days: event.duration.days,
hours: event.duration.hours,
minutes: event.duration.minutes,
seconds: event.duration.seconds,
isNegative: event.duration.isNegative,
},
organizer: event.organizer,
attendees: event.attendees.map((a) => a.getValues()),
recurrenceId: event.recurrenceId,
timezone: calendarTimezone,
};
});
return events;
} catch (reason) {

View File

@ -7,7 +7,7 @@ import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "@lib/cal
import prisma from "@lib/prisma";
export interface ConferenceData {
createRequest: calendar_v3.Schema$CreateConferenceRequest;
createRequest?: calendar_v3.Schema$CreateConferenceRequest;
}
const googleAuth = (credential: Credential) => {
@ -91,7 +91,19 @@ export const GoogleCalendarApiAdapter = (credential: Credential): CalendarApiAda
if (err) {
reject(err);
}
resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"]));
let result: Prisma.PromiseReturnType<CalendarApiAdapter["getAvailability"]> = [];
if (apires?.data.calendars) {
result = Object.values(apires.data.calendars).reduce((c, i) => {
i.busy?.forEach((busyTime) => {
c.push({
start: busyTime.start || "",
end: busyTime.end || "",
});
});
return c;
}, [] as typeof result);
}
resolve(result);
}
);
})
@ -146,7 +158,14 @@ export const GoogleCalendarApiAdapter = (credential: Credential): CalendarApiAda
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event.data);
return resolve({
...event.data,
id: event.data.id || "",
hangoutLink: event.data.hangoutLink || "",
type: "google_calendar",
password: "",
url: "",
});
}
);
})

View File

@ -13,8 +13,12 @@
"db-seed": "yarn ts-node scripts/seed.ts",
"db-nuke": "docker-compose down --volumes --remove-orphans",
"db-saml-nuke": "docker-compose -p cal-saml -f docker-compose-saml.yml down --volumes --remove-orphans",
"dx": "cross-env BASE_URL=http://localhost:3000 JWT_SECRET=secret DATABASE_URL=postgresql://postgres:@localhost:5450/calendso run-s db-up db-migrate db-seed dev",
"dx-saml": "cross-env BASE_URL=http://localhost:3000 SAML_LOGIN_URL=http://localhost:5000 SAML_API_URL=http://localhost:6000 SAML_ADMINS=onboarding@example.com JWT_SECRET=secret DATABASE_URL=postgresql://postgres:@localhost:5450/calendso run-s db-up db-migrate db-seed dev",
"db-setup": "run-s db-up db-migrate db-seed",
"db-reset": "run-s db-nuke db-setup",
"db-saml-setup": "run-s db-saml-up db-migrate db-seed",
"db-saml-reset": "run-s db-nuke db-saml-setup",
"dx": "env-cmd run-s db-setup dev",
"dx-saml": "env-cmd run-s db-saml-setup dev",
"test": "jest",
"test-playwright": "jest --config jest.playwright.config.js",
"test-codegen": "yarn playwright codegen http://localhost:3000",
@ -95,8 +99,8 @@
"react-use-intercom": "1.4.0",
"short-uuid": "^4.2.0",
"stripe": "^8.191.0",
"tsdav": "^1.1.5",
"superjson": "1.8.0",
"tsdav": "^1.1.5",
"tslog": "^3.2.1",
"uuid": "^8.3.2",
"zod": "^3.8.2"
@ -121,7 +125,7 @@
"@typescript-eslint/parser": "^4.33.0",
"autoprefixer": "^10.3.1",
"babel-jest": "^27.3.1",
"cross-env": "^7.0.3",
"env-cmd": "10.1.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.4.0",

View File

@ -23,7 +23,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
if (nameCollisions > 0) {
return res.status(409).json({ errorCode: "TeamNameCollision", message: "Team name already take." });
return res
.status(409)
.json({ errorCode: "TeamNameCollision", message: "Team username already taken." });
}
const createTeam = await prisma.team.create({

View File

@ -13,6 +13,7 @@ import Shell from "@components/Shell";
import EditTeam from "@components/team/EditTeam";
import TeamList from "@components/team/TeamList";
import TeamListItem from "@components/team/TeamListItem";
import { Alert } from "@components/ui/Alert";
import Button from "@components/ui/Button";
export default function Teams() {
@ -24,6 +25,8 @@ export default function Teams() {
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const [editTeamEnabled, setEditTeamEnabled] = useState(false);
const [teamToEdit, setTeamToEdit] = useState<Team | null>();
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const handleErrors = async (resp: Response) => {
@ -48,6 +51,11 @@ export default function Teams() {
loadData();
}, []);
useEffect(() => {
setHasErrors(false);
setErrorMessage("");
}, [showCreateTeamModal]);
if (loading) {
return <Loader />;
}
@ -60,10 +68,16 @@ export default function Teams() {
headers: {
"Content-Type": "application/json",
},
}).then(() => {
loadData();
setShowCreateTeamModal(false);
});
})
.then(handleErrors)
.then(() => {
loadData();
setShowCreateTeamModal(false);
})
.catch((err) => {
setHasErrors(true);
setErrorMessage(err.message);
});
};
const editTeam = (team: Team) => {
@ -162,6 +176,7 @@ export default function Teams() {
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
{t("name")}
</label>
{hasErrors && <Alert className="mt-1 mb-2" severity="error" message={errorMessage} />}
<input
ref={nameRef}
type="text"

View File

@ -17,7 +17,6 @@ import Team from "@components/team/screens/Team";
import Avatar from "@components/ui/Avatar";
import AvatarGroup from "@components/ui/AvatarGroup";
import Button from "@components/ui/Button";
import Text from "@components/ui/Text";
function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
const { isReady } = useTheme();
@ -61,38 +60,43 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
isReady && (
<div>
<HeadSeo title={teamName} description={teamName} />
<div className="pt-24 pb-12 px-4">
<div className="mb-8 text-center">
<Avatar alt={teamName} imageSrc={team.logo} className="mx-auto w-20 h-20 rounded-full mb-4" />
<Text variant="headline">{teamName}</Text>
</div>
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{!showMembers.isOn && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl">
{eventTypes}
<div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200 dark:border-gray-900" />
</div>
<div className="relative flex justify-center">
<span className="px-2 bg-gray-100 text-sm text-gray-500 dark:bg-brand dark:text-gray-500">
{t("or")}
</span>
</div>
</div>
<aside className="text-center dark:text-white mt-8">
<Button
color="secondary"
EndIcon={ArrowRightIcon}
href={`/team/${team.slug}?members=1`}
shallow={true}>
{t("book_a_team_member")}
</Button>
</aside>
<div className="h-screen bg-neutral-50 dark:bg-black">
<main className="max-w-3xl px-4 py-24 mx-auto">
<div className="mb-8 text-center">
<Avatar alt={teamName} imageSrc={team.logo} className="mx-auto w-20 h-20 rounded-full mb-4" />
<h1 className="mb-1 text-3xl font-bold font-cal text-neutral-900 dark:text-white">
{teamName}
</h1>
<p className="text-neutral-500 dark:text-white">{team.bio}</p>
</div>
)}
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{!showMembers.isOn && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl">
{eventTypes}
<div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200 dark:border-gray-900" />
</div>
<div className="relative flex justify-center">
<span className="px-2 bg-gray-100 text-sm text-gray-500 dark:bg-brand dark:text-gray-500">
{t("or")}
</span>
</div>
</div>
<aside className="text-center dark:text-white mt-8">
<Button
color="secondary"
EndIcon={ArrowRightIcon}
href={`/team/${team.slug}?members=1`}
shallow={true}>
{t("book_a_team_member")}
</Button>
</aside>
</div>
)}
</main>
</div>
</div>
)
@ -116,6 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
name: true,
slug: true,
logo: true,
bio: true,
members: {
select: {
user: {

View File

@ -1,4 +1,3 @@
import dayjs from "dayjs";
import { kont } from "kont";
import { loginProvider } from "./lib/loginProvider";
@ -35,14 +34,11 @@ describe("webhooks", () => {
// page contains the url
await expect(page).toHaveSelector(`text='${webhookReceiver.url}'`);
// --- go to tomorrow in the pro user's "30min"-event
const tomorrow = dayjs().add(1, "day");
const tomorrowFormatted = tomorrow.format("YYYY-MM-DDZZ");
await page.goto(`http://localhost:3000/pro/30min?date=${encodeURIComponent(tomorrowFormatted)}`);
// click first time available
await page.click("[data-testid=time]");
// --- Book the first available day next month in the pro user's "30min"-event
await page.goto(`http://localhost:3000/pro/30min`);
await page.click('[data-testid="incrementMonth"]');
await page.click('[data-testid="day"]');
await page.click('[data-testid="time"]');
// --- fill form
await page.fill('[name="name"]', "Test Testson");

View File

@ -43,6 +43,7 @@
},
"include": [
"next-env.d.ts",
"@types/*.d.ts",
"**/*.ts",
"**/*.tsx"
],

View File

@ -3173,6 +3173,11 @@ commander@^3.0.2:
resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e"
integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==
commander@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
commander@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae"
@ -3294,13 +3299,6 @@ create-require@^1.1.0:
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cross-env@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf"
integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==
dependencies:
cross-spawn "^7.0.1"
cross-fetch@3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39"
@ -3319,7 +3317,7 @@ cross-spawn@^6.0.5:
shebang-command "^1.2.0"
which "^1.2.9"
cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
@ -3728,6 +3726,14 @@ enquirer@^2.3.5, enquirer@^2.3.6:
dependencies:
ansi-colors "^4.1.1"
env-cmd@10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/env-cmd/-/env-cmd-10.1.0.tgz#c7f5d3b550c9519f137fdac4dd8fb6866a8c8c4b"
integrity sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==
dependencies:
commander "^4.0.0"
cross-spawn "^7.0.0"
errno@^0.1.1, errno@~0.1.1:
version "0.1.8"
resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f"