`/integrations` facelift (#858)
This commit is contained in:
parent
7dc4a55319
commit
c3dc18643e
|
@ -11,6 +11,8 @@ NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||||
DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public"
|
DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public"
|
||||||
|
|
||||||
GOOGLE_API_CREDENTIALS='secret'
|
GOOGLE_API_CREDENTIALS='secret'
|
||||||
|
GOOGLE_REDIRECT_URL='https://localhost:3000/integrations/googlecalendar/callback'
|
||||||
|
|
||||||
BASE_URL='http://localhost:3000'
|
BASE_URL='http://localhost:3000'
|
||||||
NEXT_PUBLIC_APP_URL='http://localhost:3000'
|
NEXT_PUBLIC_APP_URL='http://localhost:3000'
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import React from "react";
|
import React, { ReactNode } from "react";
|
||||||
|
|
||||||
type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]>;
|
export type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]>;
|
||||||
export function Dialog(props: DialogProps) {
|
export function Dialog(props: DialogProps) {
|
||||||
const { children, ...other } = props;
|
const { children, ...other } = props;
|
||||||
return (
|
return (
|
||||||
|
@ -35,9 +35,15 @@ export function DialogHeader({ title, subtitle }: DialogHeaderProps) {
|
||||||
<h3 className="font-cal text-gray-900 text-lg font-bold leading-6" id="modal-title">
|
<h3 className="font-cal text-gray-900 text-lg font-bold leading-6" id="modal-title">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<div>
|
<div className="text-gray-400 text-sm">{subtitle}</div>
|
||||||
<p className="text-gray-400 text-sm">{subtitle}</p>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogFooter(props: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mt-5 flex space-x-2 justify-end">{props.children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { createElement } from "react";
|
||||||
|
|
||||||
|
import classNames from "@lib/classNames";
|
||||||
|
|
||||||
|
export function List(props: JSX.IntrinsicElements["ul"]) {
|
||||||
|
return (
|
||||||
|
<ul {...props} className={classNames("overflow-hidden rounded-sm sm:mx-0", props.className)}>
|
||||||
|
{props.children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListItemProps = { expanded?: boolean } & ({ href?: never } & JSX.IntrinsicElements["li"]);
|
||||||
|
|
||||||
|
export function ListItem(props: ListItemProps) {
|
||||||
|
const { href, expanded, ...passThroughProps } = props;
|
||||||
|
|
||||||
|
const elementType = href ? "a" : "li";
|
||||||
|
|
||||||
|
const element = createElement(
|
||||||
|
elementType,
|
||||||
|
{
|
||||||
|
...passThroughProps,
|
||||||
|
className: classNames(
|
||||||
|
"items-center bg-white min-w-0 flex-1 flex border-gray-200",
|
||||||
|
expanded ? "my-2 border" : "border -mb-px last:mb-0",
|
||||||
|
props.className,
|
||||||
|
(props.onClick || href) && "hover:bg-neutral-50"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props.children
|
||||||
|
);
|
||||||
|
|
||||||
|
return href ? (
|
||||||
|
<Link passHref href={href}>
|
||||||
|
{element}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
element
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListItemTitle<TComponent extends keyof JSX.IntrinsicElements = "span">(
|
||||||
|
props: JSX.IntrinsicElements[TComponent] & { component?: TComponent }
|
||||||
|
) {
|
||||||
|
const { component = "span", ...passThroughProps } = props;
|
||||||
|
|
||||||
|
return createElement(
|
||||||
|
component,
|
||||||
|
{
|
||||||
|
...passThroughProps,
|
||||||
|
className: classNames("text-sm font-medium text-neutral-900 truncate", props.className),
|
||||||
|
},
|
||||||
|
props.children
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListItemText<TComponent extends keyof JSX.IntrinsicElements = "span">(
|
||||||
|
props: JSX.IntrinsicElements[TComponent] & { component?: TComponent }
|
||||||
|
) {
|
||||||
|
const { component = "span", ...passThroughProps } = props;
|
||||||
|
|
||||||
|
return createElement(
|
||||||
|
component,
|
||||||
|
{
|
||||||
|
...passThroughProps,
|
||||||
|
className: classNames("text-sm text-gray-500", props.className),
|
||||||
|
},
|
||||||
|
props.children
|
||||||
|
);
|
||||||
|
}
|
|
@ -19,6 +19,7 @@ import LicenseBanner from "@ee/components/LicenseBanner";
|
||||||
import HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic";
|
import HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
import { shouldShowOnboarding } from "@lib/getting-started";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
|
@ -29,10 +30,7 @@ import Logo from "./Logo";
|
||||||
|
|
||||||
function useMeQuery() {
|
function useMeQuery() {
|
||||||
const [session] = useSession();
|
const [session] = useSession();
|
||||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
const meQuery = trpc.useQuery(["viewer.me"]);
|
||||||
// refetch max once per 5s
|
|
||||||
staleTime: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// refetch if sesion changes
|
// refetch if sesion changes
|
||||||
|
@ -59,6 +57,26 @@ function useRedirectToLoginIfUnauthenticated() {
|
||||||
}, [loading, session, router]);
|
}, [loading, session, router]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ShellSubHeading(props: {
|
||||||
|
title: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={classNames("block sm:flex justify-between mb-3", props.className)}>
|
||||||
|
<div>
|
||||||
|
{/* TODO should be Roboto */}
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 flex items-center content-center space-x-2">
|
||||||
|
{props.title}
|
||||||
|
</h2>
|
||||||
|
{props.subtitle && <p className="text-sm text-neutral-500 mr-4">{props.subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{props.actions && <div className="mb-4 flex-shrink-0">{props.actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Shell(props: {
|
export default function Shell(props: {
|
||||||
centered?: boolean;
|
centered?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
@ -74,6 +92,15 @@ export default function Shell(props: {
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const query = useMeQuery();
|
const query = useMeQuery();
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function redirectToOnboardingIfNeeded() {
|
||||||
|
if (query.data && shouldShowOnboarding(query.data)) {
|
||||||
|
router.push("/getting-started");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[query.data, router]
|
||||||
|
);
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
name: "Event Types",
|
name: "Event Types",
|
||||||
|
@ -209,7 +236,6 @@ export default function Shell(props: {
|
||||||
<div className="mb-4 flex-shrink-0">{props.CTA}</div>
|
<div className="mb-4 flex-shrink-0">{props.CTA}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 sm:px-6 md:px-8">{props.children}</div>
|
<div className="px-4 sm:px-6 md:px-8">{props.children}</div>
|
||||||
|
|
||||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||||
<nav className="bottom-nav md:hidden flex fixed bottom-0 bg-white w-full shadow">
|
<nav className="bottom-nav md:hidden flex fixed bottom-0 bg-white w-full shadow">
|
||||||
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
|
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
|
||||||
|
@ -239,7 +265,6 @@ export default function Shell(props: {
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* add padding to content for mobile navigation*/}
|
{/* add padding to content for mobile navigation*/}
|
||||||
<div className="block md:hidden pt-12" />
|
<div className="block md:hidden pt-12" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { useId } from "@radix-ui/react-id";
|
||||||
|
import { forwardRef, ReactNode } from "react";
|
||||||
|
import { FormProvider, UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
|
import classNames from "@lib/classNames";
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, JSX.IntrinsicElements["input"]>(function Input(props, ref) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(
|
||||||
|
"mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Label(props: JSX.IntrinsicElements["label"]) {
|
||||||
|
return (
|
||||||
|
<label {...props} className={classNames("block text-sm font-medium text-gray-700", props.className)}>
|
||||||
|
{props.children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextField = forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
{
|
||||||
|
label: ReactNode;
|
||||||
|
} & React.ComponentProps<typeof Input> & {
|
||||||
|
labelProps?: React.ComponentProps<typeof Label>;
|
||||||
|
}
|
||||||
|
>(function TextField(props, ref) {
|
||||||
|
const id = useId();
|
||||||
|
const { label, ...passThroughToInput } = props;
|
||||||
|
|
||||||
|
// TODO: use `useForm()` from RHF and get error state here too!
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={id} {...props.labelProps}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<Input id={id} {...passThroughToInput} ref={ref} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const Form = forwardRef<HTMLFormElement, { form: UseFormReturn<any> } & JSX.IntrinsicElements["form"]>(
|
||||||
|
function Form(props, ref) {
|
||||||
|
const { form, ...passThrough } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form ref={ref} {...passThrough}>
|
||||||
|
{props.children}
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
|
@ -5,6 +5,7 @@ import { ReactNode } from "react";
|
||||||
export interface AlertProps {
|
export interface AlertProps {
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
message?: ReactNode;
|
message?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
severity: "success" | "warning" | "error";
|
severity: "success" | "warning" | "error";
|
||||||
}
|
}
|
||||||
|
@ -36,6 +37,7 @@ export function Alert(props: AlertProps) {
|
||||||
<h3 className="text-sm font-medium">{props.title}</h3>
|
<h3 className="text-sm font-medium">{props.title}</h3>
|
||||||
<div className="text-sm">{props.message}</div>
|
<div className="text-sm">{props.message}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{props.actions && <div className="text-sm">{props.actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -13,7 +13,7 @@ export const Badge = function Badge(props: BadgeProps) {
|
||||||
<span
|
<span
|
||||||
{...passThroughProps}
|
{...passThroughProps}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"font-bold px-2 py-0.5 inline-block rounded-sm",
|
"font-bold px-2 py-0.5 inline-block rounded-sm text-xs",
|
||||||
variant === "default" && "bg-yellow-100 text-yellow-800",
|
variant === "default" && "bg-yellow-100 text-yellow-800",
|
||||||
variant === "success" && "bg-green-100 text-green-800",
|
variant === "success" && "bg-green-100 text-green-800",
|
||||||
variant === "gray" && "bg-gray-200 text-gray-800",
|
variant === "gray" && "bg-gray-200 text-gray-800",
|
||||||
|
|
|
@ -4,7 +4,7 @@ import React, { forwardRef } from "react";
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||||
|
|
||||||
export type ButtonProps = {
|
export type ButtonBaseProps = {
|
||||||
color?: "primary" | "secondary" | "minimal" | "warn";
|
color?: "primary" | "secondary" | "minimal" | "warn";
|
||||||
size?: "base" | "sm" | "lg" | "fab" | "icon";
|
size?: "base" | "sm" | "lg" | "fab" | "icon";
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
@ -13,10 +13,12 @@ export type ButtonProps = {
|
||||||
StartIcon?: SVGComponent;
|
StartIcon?: SVGComponent;
|
||||||
EndIcon?: SVGComponent;
|
EndIcon?: SVGComponent;
|
||||||
shallow?: boolean;
|
shallow?: boolean;
|
||||||
} & (
|
};
|
||||||
| (Omit<JSX.IntrinsicElements["a"], "href"> & { href: LinkProps["href"] })
|
export type ButtonProps = ButtonBaseProps &
|
||||||
| (JSX.IntrinsicElements["button"] & { href?: never })
|
(
|
||||||
);
|
| (Omit<JSX.IntrinsicElements["a"], "href"> & { href: LinkProps["href"] })
|
||||||
|
| (JSX.IntrinsicElements["button"] & { href?: never })
|
||||||
|
);
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonProps>(function Button(
|
export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonProps>(function Button(
|
||||||
props: ButtonProps,
|
props: ButtonProps,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useId } from "@radix-ui/react-id";
|
||||||
import * as Label from "@radix-ui/react-label";
|
import * as Label from "@radix-ui/react-label";
|
||||||
import * as PrimitiveSwitch from "@radix-ui/react-switch";
|
import * as PrimitiveSwitch from "@radix-ui/react-switch";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
@ -16,7 +17,7 @@ export default function Switch(props) {
|
||||||
}
|
}
|
||||||
setChecked(change);
|
setChecked(change);
|
||||||
};
|
};
|
||||||
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-[20px]">
|
<div className="flex items-center h-[20px]">
|
||||||
<PrimitiveSwitch.Root
|
<PrimitiveSwitch.Root
|
||||||
|
@ -25,6 +26,7 @@ export default function Switch(props) {
|
||||||
onCheckedChange={onPrimitiveCheckedChange}
|
onCheckedChange={onPrimitiveCheckedChange}
|
||||||
{...primitiveProps}>
|
{...primitiveProps}>
|
||||||
<PrimitiveSwitch.Thumb
|
<PrimitiveSwitch.Thumb
|
||||||
|
id={id}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"bg-white w-[16px] h-[16px] block transition-transform",
|
"bg-white w-[16px] h-[16px] block transition-transform",
|
||||||
checked ? "translate-x-[16px]" : "translate-x-0"
|
checked ? "translate-x-[16px]" : "translate-x-0"
|
||||||
|
@ -32,7 +34,9 @@ export default function Switch(props) {
|
||||||
/>
|
/>
|
||||||
</PrimitiveSwitch.Root>
|
</PrimitiveSwitch.Root>
|
||||||
{label && (
|
{label && (
|
||||||
<Label.Root className="text-neutral-700 text-sm align-text-top ml-3 font-medium cursor-pointer">
|
<Label.Root
|
||||||
|
htmlFor={id}
|
||||||
|
className="text-neutral-700 text-sm align-text-top ml-3 font-medium cursor-pointer">
|
||||||
{label}
|
{label}
|
||||||
</Label.Root>
|
</Label.Root>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -3,6 +3,7 @@ declare namespace NodeJS {
|
||||||
readonly CALENDSO_ENCRYPTION_KEY: string | undefined;
|
readonly CALENDSO_ENCRYPTION_KEY: string | undefined;
|
||||||
readonly DATABASE_URL: string | undefined;
|
readonly DATABASE_URL: string | undefined;
|
||||||
readonly GOOGLE_API_CREDENTIALS: string | undefined;
|
readonly GOOGLE_API_CREDENTIALS: string | undefined;
|
||||||
|
readonly GOOGLE_REDIRECT_URL: string | undefined;
|
||||||
readonly BASE_URL: string | undefined;
|
readonly BASE_URL: string | undefined;
|
||||||
readonly NEXT_PUBLIC_BASE_URL: string | undefined;
|
readonly NEXT_PUBLIC_BASE_URL: string | undefined;
|
||||||
readonly NEXT_PUBLIC_APP_URL: string | undefined;
|
readonly NEXT_PUBLIC_APP_URL: string | undefined;
|
||||||
|
|
|
@ -517,8 +517,26 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// factory
|
function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter | null {
|
||||||
const calendars = (withCredentials): CalendarApiAdapter[] =>
|
switch (credential.type) {
|
||||||
|
case "google_calendar":
|
||||||
|
return GoogleCalendar(credential);
|
||||||
|
case "office365_calendar":
|
||||||
|
return MicrosoftOffice365Calendar(credential);
|
||||||
|
case "caldav_calendar":
|
||||||
|
// FIXME types wrong & type casting should not be needed
|
||||||
|
return new CalDavCalendar(credential) as never as CalendarApiAdapter;
|
||||||
|
case "apple_calendar":
|
||||||
|
// FIXME types wrong & type casting should not be needed
|
||||||
|
return new AppleCalendar(credential) as never as CalendarApiAdapter;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
const calendars = (withCredentials: Credential[]): CalendarApiAdapter[] =>
|
||||||
withCredentials
|
withCredentials
|
||||||
.map((cred) => {
|
.map((cred) => {
|
||||||
switch (cred.type) {
|
switch (cred.type) {
|
||||||
|
@ -534,7 +552,7 @@ const calendars = (withCredentials): CalendarApiAdapter[] =>
|
||||||
return; // unknown credential, could be legacy? In any case, ignore
|
return; // unknown credential, could be legacy? In any case, ignore
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.flatMap((item) => (item ? [item as CalendarApiAdapter] : []));
|
||||||
|
|
||||||
const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) =>
|
const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
|
@ -543,6 +561,11 @@ const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalenda
|
||||||
return results.reduce((acc, availability) => acc.concat(availability), []);
|
return results.reduce((acc, availability) => acc.concat(availability), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param withCredentials
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
const listCalendars = (withCredentials) =>
|
const listCalendars = (withCredentials) =>
|
||||||
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
|
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
|
||||||
results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null)
|
results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null)
|
||||||
|
@ -650,4 +673,11 @@ const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, listCalendars };
|
export {
|
||||||
|
getBusyCalendarTimes,
|
||||||
|
createEvent,
|
||||||
|
updateEvent,
|
||||||
|
deleteEvent,
|
||||||
|
listCalendars,
|
||||||
|
getCalendarAdapterOrNull,
|
||||||
|
};
|
||||||
|
|
|
@ -255,10 +255,11 @@ export class AppleCalendar implements CalendarApiAdapter {
|
||||||
.filter((calendar) => {
|
.filter((calendar) => {
|
||||||
return calendar.components?.includes("VEVENT");
|
return calendar.components?.includes("VEVENT");
|
||||||
})
|
})
|
||||||
.map((calendar) => ({
|
.map((calendar, index) => ({
|
||||||
externalId: calendar.url,
|
externalId: calendar.url,
|
||||||
name: calendar.displayName ?? "",
|
name: calendar.displayName ?? "",
|
||||||
primary: false,
|
// FIXME Find a better way to set the primary calendar
|
||||||
|
primary: index === 0,
|
||||||
integration: this.integrationName,
|
integration: this.integrationName,
|
||||||
}));
|
}));
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
|
|
@ -1,4 +1,17 @@
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DialogHeader,
|
||||||
|
DialogProps,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogClose,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@components/Dialog";
|
||||||
|
import { Form, TextField } from "@components/form/fields";
|
||||||
|
import { Alert } from "@components/ui/Alert";
|
||||||
|
import Button from "@components/ui/Button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
|
@ -6,6 +19,95 @@ type Props = {
|
||||||
|
|
||||||
export const ADD_APPLE_INTEGRATION_FORM_TITLE = "addAppleIntegration";
|
export const ADD_APPLE_INTEGRATION_FORM_TITLE = "addAppleIntegration";
|
||||||
|
|
||||||
|
export function AddAppleIntegrationModal(props: DialogProps) {
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader
|
||||||
|
title="Connect to Apple Server"
|
||||||
|
subtitle={
|
||||||
|
<>
|
||||||
|
Generate an app specific password to use with Cal.com at{" "}
|
||||||
|
<a
|
||||||
|
className="text-indigo-400"
|
||||||
|
href="https://appleid.apple.com/account/manage"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">
|
||||||
|
https://appleid.apple.com/account/manage
|
||||||
|
</a>
|
||||||
|
. Your credentials will be stored and encrypted.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onSubmit={form.handleSubmit(async (values) => {
|
||||||
|
setErrorMessage("");
|
||||||
|
const res = await fetch("/api/integrations/apple/add", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setErrorMessage(json?.message || "Something went wrong");
|
||||||
|
} else {
|
||||||
|
props.onOpenChange?.(false);
|
||||||
|
}
|
||||||
|
})}>
|
||||||
|
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
{...form.register("username")}
|
||||||
|
label="Username"
|
||||||
|
placeholder="rickroll"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
{...form.register("password")}
|
||||||
|
label="Password"
|
||||||
|
placeholder="•••••••••••••"
|
||||||
|
autoComplete="password"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose
|
||||||
|
onClick={() => {
|
||||||
|
props.onOpenChange?.(false);
|
||||||
|
}}
|
||||||
|
asChild>
|
||||||
|
<Button type="button" color="secondary" tabIndex={-1}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
const AddAppleIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
|
const AddAppleIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
|
||||||
const onSubmit = (event) => {
|
const onSubmit = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
|
@ -79,6 +79,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
|
||||||
const { error, value: iCalString } = await createEvent({
|
const { error, value: iCalString } = await createEvent({
|
||||||
uid,
|
uid,
|
||||||
startInputType: "utc",
|
startInputType: "utc",
|
||||||
|
// FIXME types
|
||||||
start: this.convertDate(event.startTime),
|
start: this.convertDate(event.startTime),
|
||||||
duration: this.getDuration(event.startTime, event.endTime),
|
duration: this.getDuration(event.startTime, event.endTime),
|
||||||
title: event.title,
|
title: event.title,
|
||||||
|
@ -137,6 +138,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
|
||||||
const { error, value: iCalString } = await createEvent({
|
const { error, value: iCalString } = await createEvent({
|
||||||
uid,
|
uid,
|
||||||
startInputType: "utc",
|
startInputType: "utc",
|
||||||
|
// FIXME - types wrong
|
||||||
start: this.convertDate(event.startTime),
|
start: this.convertDate(event.startTime),
|
||||||
duration: this.getDuration(event.startTime, event.endTime),
|
duration: this.getDuration(event.startTime, event.endTime),
|
||||||
title: event.title,
|
title: event.title,
|
||||||
|
@ -203,6 +205,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME - types wrong
|
||||||
async getAvailability(
|
async getAvailability(
|
||||||
dateFrom: string,
|
dateFrom: string,
|
||||||
dateTo: string,
|
dateTo: string,
|
||||||
|
@ -258,10 +261,11 @@ export class CalDavCalendar implements CalendarApiAdapter {
|
||||||
.filter((calendar) => {
|
.filter((calendar) => {
|
||||||
return calendar.components?.includes("VEVENT");
|
return calendar.components?.includes("VEVENT");
|
||||||
})
|
})
|
||||||
.map((calendar) => ({
|
.map((calendar, index) => ({
|
||||||
externalId: calendar.url,
|
externalId: calendar.url,
|
||||||
name: calendar.displayName ?? "",
|
name: calendar.displayName ?? "",
|
||||||
primary: false,
|
// FIXME Find a better way to set the primary calendar
|
||||||
|
primary: index === 0,
|
||||||
integration: this.integrationName,
|
integration: this.integrationName,
|
||||||
}));
|
}));
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
|
|
@ -1,4 +1,17 @@
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DialogHeader,
|
||||||
|
DialogProps,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogClose,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@components/Dialog";
|
||||||
|
import { Form, TextField } from "@components/form/fields";
|
||||||
|
import { Alert } from "@components/ui/Alert";
|
||||||
|
import Button from "@components/ui/Button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
|
@ -11,8 +24,93 @@ export type AddCalDavIntegrationRequest = {
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function AddCalDavIntegrationModal(props: DialogProps) {
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
url: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader
|
||||||
|
title="Connect to CalDav Server"
|
||||||
|
subtitle="Your credentials will be stored and encrypted."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onSubmit={form.handleSubmit(async (values) => {
|
||||||
|
setErrorMessage("");
|
||||||
|
const res = await fetch("/api/integrations/caldav/add", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setErrorMessage(json?.message || "Something went wrong");
|
||||||
|
} else {
|
||||||
|
props.onOpenChange?.(false);
|
||||||
|
}
|
||||||
|
})}>
|
||||||
|
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
{...form.register("url")}
|
||||||
|
label="Calendar URL"
|
||||||
|
placeholder="https://example.com/calendar"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
{...form.register("username")}
|
||||||
|
label="Username"
|
||||||
|
placeholder="rickroll"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
{...form.register("password")}
|
||||||
|
label="Password"
|
||||||
|
placeholder="•••••••••••••"
|
||||||
|
autoComplete="password"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose
|
||||||
|
onClick={() => {
|
||||||
|
props.onOpenChange?.(false);
|
||||||
|
}}
|
||||||
|
asChild>
|
||||||
|
<Button type="button" color="secondary" tabIndex={-1}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
const AddCalDavIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
|
const AddCalDavIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
|
||||||
const onSubmit = (event) => {
|
const onSubmit = (event: any) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
|
|
@ -8,65 +8,75 @@ const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
|
||||||
|
|
||||||
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
|
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
|
||||||
|
|
||||||
|
export const ALL_INTEGRATIONS = [
|
||||||
|
{
|
||||||
|
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
|
||||||
|
type: "google_calendar",
|
||||||
|
title: "Google Calendar",
|
||||||
|
imageSrc: "integrations/google-calendar.svg",
|
||||||
|
description: "For personal and business calendars",
|
||||||
|
variant: "calendar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
|
||||||
|
type: "office365_calendar",
|
||||||
|
title: "Office 365 / Outlook.com Calendar",
|
||||||
|
imageSrc: "integrations/outlook.svg",
|
||||||
|
description: "For personal and business calendars",
|
||||||
|
variant: "calendar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET),
|
||||||
|
type: "zoom_video",
|
||||||
|
title: "Zoom",
|
||||||
|
imageSrc: "integrations/zoom.svg",
|
||||||
|
description: "Video Conferencing",
|
||||||
|
variant: "conferencing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
installed: true,
|
||||||
|
type: "caldav_calendar",
|
||||||
|
title: "CalDav Server",
|
||||||
|
imageSrc: "integrations/caldav.svg",
|
||||||
|
description: "For personal and business calendars",
|
||||||
|
variant: "calendar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
installed: true,
|
||||||
|
type: "apple_calendar",
|
||||||
|
title: "Apple Calendar",
|
||||||
|
imageSrc: "integrations/apple-calendar.svg",
|
||||||
|
description: "For personal and business calendars",
|
||||||
|
variant: "calendar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
installed: !!(
|
||||||
|
process.env.STRIPE_CLIENT_ID &&
|
||||||
|
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
|
||||||
|
process.env.STRIPE_PRIVATE_KEY
|
||||||
|
),
|
||||||
|
type: "stripe_payment",
|
||||||
|
title: "Stripe",
|
||||||
|
imageSrc: "integrations/stripe.svg",
|
||||||
|
description: "Receive payments",
|
||||||
|
variant: "payment",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
function getIntegrations(credentials: CredentialData[]) {
|
function getIntegrations(credentials: CredentialData[]) {
|
||||||
const integrations = [
|
const integrations = ALL_INTEGRATIONS.map((integration) => ({
|
||||||
{
|
...integration,
|
||||||
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
|
/**
|
||||||
credential: credentials.find((integration) => integration.type === "google_calendar") || null,
|
* @deprecated use `credentials.
|
||||||
type: "google_calendar",
|
*/
|
||||||
title: "Google Calendar",
|
credential: credentials.find((credential) => credential.type === integration.type) || null,
|
||||||
imageSrc: "integrations/google-calendar.svg",
|
credentials: credentials.filter((credential) => credential.type === integration.type) || null,
|
||||||
description: "For personal and business calendars",
|
}));
|
||||||
},
|
|
||||||
{
|
|
||||||
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
|
|
||||||
type: "office365_calendar",
|
|
||||||
credential: credentials.find((integration) => integration.type === "office365_calendar") || null,
|
|
||||||
title: "Office 365 / Outlook.com Calendar",
|
|
||||||
imageSrc: "integrations/outlook.svg",
|
|
||||||
description: "For personal and business calendars",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET),
|
|
||||||
type: "zoom_video",
|
|
||||||
credential: credentials.find((integration) => integration.type === "zoom_video") || null,
|
|
||||||
title: "Zoom",
|
|
||||||
imageSrc: "integrations/zoom.svg",
|
|
||||||
description: "Video Conferencing",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
installed: true,
|
|
||||||
type: "caldav_calendar",
|
|
||||||
credential: credentials.find((integration) => integration.type === "caldav_calendar") || null,
|
|
||||||
title: "CalDav Server",
|
|
||||||
imageSrc: "integrations/caldav.svg",
|
|
||||||
description: "For personal and business calendars",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
installed: true,
|
|
||||||
type: "apple_calendar",
|
|
||||||
credential: credentials.find((integration) => integration.type === "apple_calendar") || null,
|
|
||||||
title: "Apple Calendar",
|
|
||||||
imageSrc: "integrations/apple-calendar.svg",
|
|
||||||
description: "For personal and business calendars",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
installed: !!(
|
|
||||||
process.env.STRIPE_CLIENT_ID &&
|
|
||||||
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
|
|
||||||
process.env.STRIPE_PRIVATE_KEY
|
|
||||||
),
|
|
||||||
type: "stripe_payment",
|
|
||||||
credential: credentials.find((integration) => integration.type === "stripe_payment") || null,
|
|
||||||
title: "Stripe",
|
|
||||||
imageSrc: "integrations/stripe.svg",
|
|
||||||
description: "Receive payments",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return integrations;
|
return integrations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IntegraionMeta = ReturnType<typeof getIntegrations>;
|
||||||
|
|
||||||
export function hasIntegration(integrations: ReturnType<typeof getIntegrations>, type: string): boolean {
|
export function hasIntegration(integrations: ReturnType<typeof getIntegrations>, type: string): boolean {
|
||||||
return !!integrations.find((i) => i.type === type && !!i.installed && !!i.credential);
|
return !!integrations.find((i) => i.type === type && !!i.installed && !!i.credential);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,12 +8,12 @@ import prisma from "../../../lib/prisma";
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req: req });
|
||||||
|
|
||||||
if (!session) {
|
if (!session?.user?.id) {
|
||||||
res.status(401).json({ message: "Not authenticated" });
|
res.status(401).json({ message: "Not authenticated" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUser = await prisma.user.findFirst({
|
const currentUser = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
},
|
},
|
||||||
|
@ -24,17 +24,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
res.status(401).json({ message: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method == "POST") {
|
if (req.method == "POST") {
|
||||||
await prisma.selectedCalendar.create({
|
await prisma.selectedCalendar.upsert({
|
||||||
data: {
|
where: {
|
||||||
user: {
|
userId_integration_externalId: {
|
||||||
connect: {
|
userId: currentUser.id,
|
||||||
id: currentUser.id,
|
integration: req.body.integration,
|
||||||
},
|
externalId: req.body.externalId,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: currentUser.id,
|
||||||
integration: req.body.integration,
|
integration: req.body.integration,
|
||||||
externalId: req.body.externalId,
|
externalId: req.body.externalId,
|
||||||
},
|
},
|
||||||
|
// already exists
|
||||||
|
update: {},
|
||||||
});
|
});
|
||||||
res.status(200).json({ message: "Calendar Selection Saved" });
|
res.status(200).json({ message: "Calendar Selection Saved" });
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
|
|
||||||
// Get token from Google Calendar API
|
// Get token from Google Calendar API
|
||||||
const { client_secret, client_id, redirect_uris } = JSON.parse(credentials).web;
|
const { client_secret, client_id, redirect_uris } = JSON.parse(credentials).web;
|
||||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
const redirect_uri = process.env.GOOGLE_REDIRECT_URL || redirect_uris[0];
|
||||||
|
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||||
|
|
||||||
const authUrl = oAuth2Client.generateAuthUrl({
|
const authUrl = oAuth2Client.generateAuthUrl({
|
||||||
access_type: "offline",
|
access_type: "offline",
|
||||||
|
|
|
@ -2,10 +2,9 @@ import { google } from "googleapis";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
import prisma from "../../../../lib/prisma";
|
const credentials = process.env.GOOGLE_API_CREDENTIALS;
|
||||||
|
|
||||||
const credentials = process.env.GOOGLE_API_CREDENTIALS!;
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const { code } = req.query;
|
const { code } = req.query;
|
||||||
|
@ -13,7 +12,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
// Check that user is authenticated
|
// Check that user is authenticated
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req: req });
|
||||||
|
|
||||||
if (!session) {
|
if (!session?.user?.id) {
|
||||||
res.status(401).json({ message: "You must be logged in to do this" });
|
res.status(401).json({ message: "You must be logged in to do this" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -21,9 +20,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
res.status(400).json({ message: "`code` must be a string" });
|
res.status(400).json({ message: "`code` must be a string" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(400).json({ message: "There are no Google Credentials installed." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { client_secret, client_id, redirect_uris } = JSON.parse(credentials).web;
|
const { client_secret, client_id, redirect_uris } = JSON.parse(credentials).web;
|
||||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
const redirect_uri = process.env.GOOGLE_REDIRECT_URL || redirect_uris[0];
|
||||||
|
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||||
const token = await oAuth2Client.getToken(code);
|
const token = await oAuth2Client.getToken(code);
|
||||||
const key = token.res?.data;
|
const key = token.res?.data;
|
||||||
await prisma.credential.create({
|
await prisma.credential.create({
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { NextPageContext } from "next";
|
||||||
import { useSession } from "next-auth/client";
|
import { useSession } from "next-auth/client";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Integration } from "pages/integrations";
|
import { Integration } from "pages/integrations/_new";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import TimezoneSelect from "react-timezone-select";
|
import TimezoneSelect from "react-timezone-select";
|
||||||
|
|
||||||
|
|
|
@ -1,103 +1,9 @@
|
||||||
import { useSession } from "next-auth/client";
|
function RedirectPage() {
|
||||||
import { useRouter } from "next/router";
|
return null;
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
|
||||||
import { getIntegrationName, getIntegrationType } from "@lib/integrations";
|
|
||||||
import prisma from "@lib/prisma";
|
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
|
||||||
import Shell from "@components/Shell";
|
|
||||||
|
|
||||||
export default function Integration(props) {
|
|
||||||
const router = useRouter();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const [session, loading] = useSession();
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Loader />;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteIntegrationHandler(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
/*eslint-disable */
|
|
||||||
const response = await fetch("/api/integrations", {
|
|
||||||
method: "DELETE",
|
|
||||||
body: JSON.stringify({ id: props.integration.id }),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
/*eslint-enable */
|
|
||||||
|
|
||||||
router.push("/integrations");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Shell
|
|
||||||
heading={`${getIntegrationName(props.integration.type)} App`}
|
|
||||||
subtitle="Manage and delete this app.">
|
|
||||||
<div className="block sm:grid grid-cols-3 gap-4">
|
|
||||||
<div className="col-span-2 bg-white border border-gray-200 mb-6 overflow-hidden rounded-sm">
|
|
||||||
<div className="px-4 py-5 sm:px-6">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Integration Details</h3>
|
|
||||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
|
||||||
Information about your {getIntegrationName(props.integration.type)} App.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
|
|
||||||
<dl className="grid gap-y-8">
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500">App name</dt>
|
|
||||||
<dd className="mt-1 text-sm text-gray-900">{getIntegrationName(props.integration.type)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500">App Category</dt>
|
|
||||||
<dd className="mt-1 text-sm text-gray-900">{getIntegrationType(props.integration.type)}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="bg-white border border-gray-200 mb-6 rounded-sm">
|
|
||||||
<div className="px-4 py-5 sm:p-6">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Delete this app</h3>
|
|
||||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
|
||||||
<p>Once you delete this app, it will be permanently removed.</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5">
|
|
||||||
<button
|
|
||||||
onClick={deleteIntegrationHandler}
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-sm text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm">
|
|
||||||
Delete App
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Shell>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
export async function getServerSideProps() {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
return { redirect: { permanent: false, destination: "/integrations" } };
|
||||||
const session = await getSession(context);
|
|
||||||
|
|
||||||
const integration = await prisma.credential.findFirst({
|
|
||||||
where: {
|
|
||||||
id: parseInt(context.query.integration),
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
type: true,
|
|
||||||
key: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
props: { session, integration },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default RedirectPage;
|
||||||
|
|
|
@ -1,510 +1,410 @@
|
||||||
import { InformationCircleIcon } from "@heroicons/react/outline";
|
import { Maybe } from "@trpc/server";
|
||||||
import { CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon } from "@heroicons/react/solid";
|
import Image from "next/image";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import { useSession } from "next-auth/client";
|
import { useMutation } from "react-query";
|
||||||
import Link from "next/link";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
import { ONBOARDING_NEXT_REDIRECT, shouldShowOnboarding } from "@lib/getting-started";
|
import classNames from "@lib/classNames";
|
||||||
import AddAppleIntegration, {
|
import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration";
|
||||||
ADD_APPLE_INTEGRATION_FORM_TITLE,
|
import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration";
|
||||||
} from "@lib/integrations/Apple/components/AddAppleIntegration";
|
import showToast from "@lib/notification";
|
||||||
import AddCalDavIntegration, {
|
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
ADD_CALDAV_INTEGRATION_FORM_TITLE,
|
|
||||||
} from "@lib/integrations/CalDav/components/AddCalDavIntegration";
|
|
||||||
import getIntegrations from "@lib/integrations/getIntegrations";
|
|
||||||
import prisma from "@lib/prisma";
|
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
|
||||||
|
|
||||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog";
|
import { Dialog } from "@components/Dialog";
|
||||||
import Loader from "@components/Loader";
|
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
|
||||||
import Shell from "@components/Shell";
|
import Shell, { ShellSubHeading } from "@components/Shell";
|
||||||
import Button from "@components/ui/Button";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
|
import { Alert } from "@components/ui/Alert";
|
||||||
|
import Badge from "@components/ui/Badge";
|
||||||
|
import Button, { ButtonBaseProps } from "@components/ui/Button";
|
||||||
import Switch from "@components/ui/Switch";
|
import Switch from "@components/ui/Switch";
|
||||||
|
|
||||||
export default function Home({ integrations }: inferSSRProps<typeof getServerSideProps>) {
|
type IntegrationCalendar = inferQueryOutput<"viewer.integrations">["calendar"]["items"][number];
|
||||||
const [, loading] = useSession();
|
|
||||||
|
|
||||||
const [selectableCalendars, setSelectableCalendars] = useState([]);
|
function pluralize(opts: { num: number; plural: string; singular: string }) {
|
||||||
const addCalDavIntegrationRef = useRef<HTMLFormElement>(null);
|
if (opts.num === 0) {
|
||||||
const [isAddCalDavIntegrationDialogOpen, setIsAddCalDavIntegrationDialogOpen] = useState(false);
|
return opts.singular;
|
||||||
const [addCalDavError, setAddCalDavError] = useState<{ message: string } | null>(null);
|
}
|
||||||
|
return opts.singular;
|
||||||
|
}
|
||||||
|
|
||||||
const addAppleIntegrationRef = useRef<HTMLFormElement>(null);
|
function SubHeadingTitleWithConnections(props: { title: ReactNode; numConnections?: number }) {
|
||||||
const [isAddAppleIntegrationDialogOpen, setIsAddAppleIntegrationDialogOpen] = useState(false);
|
const num = props.numConnections;
|
||||||
const [addAppleError, setAddAppleError] = useState<{ message: string } | null>(null);
|
return (
|
||||||
|
<>
|
||||||
|
<span>{props.title}</span>
|
||||||
|
{num ? (
|
||||||
|
<Badge variant="success">
|
||||||
|
{num}{" "}
|
||||||
|
{pluralize({
|
||||||
|
num,
|
||||||
|
singular: "connection",
|
||||||
|
plural: "connections",
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(loadCalendars, [integrations]);
|
function ConnectIntegration(props: {
|
||||||
|
type: IntegrationCalendar["type"];
|
||||||
|
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||||
|
}) {
|
||||||
|
const { type } = props;
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const mutation = useMutation(async () => {
|
||||||
|
const res = await fetch("/api/integrations/" + type.replace("_", "") + "/add");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Something went wrong");
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
window.location.href = json.url;
|
||||||
|
setIsLoading(true);
|
||||||
|
});
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
function loadCalendars() {
|
// refetch intergrations when modal closes
|
||||||
fetch("api/availability/calendar")
|
const utils = trpc.useContext();
|
||||||
.then((response) => response.json())
|
useEffect(() => {
|
||||||
.then((data) => {
|
utils.invalidateQueries(["viewer.integrations"]);
|
||||||
setSelectableCalendars(data);
|
}, [isModalOpen, utils]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.render({
|
||||||
|
onClick() {
|
||||||
|
if (["caldav_calendar", "apple_calendar"].includes(type)) {
|
||||||
|
// special handlers
|
||||||
|
setIsModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate();
|
||||||
|
},
|
||||||
|
loading: mutation.isLoading || isLoading,
|
||||||
|
disabled: isModalOpen,
|
||||||
|
})}
|
||||||
|
{type === "caldav_calendar" && (
|
||||||
|
<AddCalDavIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "apple_calendar" && (
|
||||||
|
<AddAppleIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DisconnectIntegration(props: {
|
||||||
|
/**
|
||||||
|
* Integration credential id
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||||
|
}) {
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const mutation = useMutation(
|
||||||
|
async () => {
|
||||||
|
const res = await fetch("/api/integrations", {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({ id: props.id }),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
if (!res.ok) {
|
||||||
|
throw new Error("Something went wrong");
|
||||||
function integrationHandler(type) {
|
}
|
||||||
if (type === "caldav_calendar") {
|
},
|
||||||
setAddCalDavError(null);
|
{
|
||||||
setIsAddCalDavIntegrationDialogOpen(true);
|
async onSettled() {
|
||||||
return;
|
await utils.invalidateQueries(["viewer.integrations"]);
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "apple_calendar") {
|
|
||||||
setAddAppleError(null);
|
|
||||||
setIsAddAppleIntegrationDialogOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch("/api/integrations/" + type.replace("_", "") + "/add")
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => (window.location.href = data.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddCalDavIntegration = async ({ url, username, password }) => {
|
|
||||||
const requestBody = JSON.stringify({
|
|
||||||
url,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await fetch("/api/integrations/caldav/add", {
|
|
||||||
method: "POST",
|
|
||||||
body: requestBody,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
});
|
onSuccess() {
|
||||||
};
|
setModalOpen(false);
|
||||||
|
|
||||||
const handleAddAppleIntegration = async ({ username, password }) => {
|
|
||||||
const requestBody = JSON.stringify({
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await fetch("/api/integrations/apple/add", {
|
|
||||||
method: "POST",
|
|
||||||
body: requestBody,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
};
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
|
<ConfirmationDialogContent
|
||||||
|
variety="danger"
|
||||||
|
title="Disconnect Integration"
|
||||||
|
confirmBtnText="Yes, delete integration"
|
||||||
|
cancelBtnText="Cancel"
|
||||||
|
onConfirm={() => {
|
||||||
|
mutation.mutate();
|
||||||
|
}}>
|
||||||
|
Are you sure you want to disconnect this integration?
|
||||||
|
</ConfirmationDialogContent>
|
||||||
|
</Dialog>
|
||||||
|
{props.render({
|
||||||
|
onClick() {
|
||||||
|
setModalOpen(true);
|
||||||
|
},
|
||||||
|
disabled: modalOpen,
|
||||||
|
loading: mutation.isLoading,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function calendarSelectionHandler(calendar) {
|
function ConnectOrDisconnectIntegrationButton(props: {
|
||||||
return (selected) => {
|
//
|
||||||
const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId);
|
credential: Maybe<{ id: number }>;
|
||||||
selectableCalendars[i].selected = selected;
|
type: IntegrationCalendar["type"];
|
||||||
if (selected) {
|
installed: boolean;
|
||||||
fetch("api/availability/calendar", {
|
}) {
|
||||||
|
if (props.credential) {
|
||||||
|
return (
|
||||||
|
<DisconnectIntegration
|
||||||
|
id={props.credential.id}
|
||||||
|
render={(btnProps) => (
|
||||||
|
<Button {...btnProps} color="warn">
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!props.installed) {
|
||||||
|
return <Alert severity="warning" title="Not installed" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ConnectIntegration type={props.type} render={(btnProps) => <Button {...btnProps}>Connect</Button>} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegrationListItem(props: {
|
||||||
|
imageSrc: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ListItem expanded={!!props.children} className={classNames("flex-col")}>
|
||||||
|
<div className={classNames("flex flex-1 space-x-2 w-full p-4")}>
|
||||||
|
<div>
|
||||||
|
<Image width={40} height={40} src={`/${props.imageSrc}`} alt={props.title} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow">
|
||||||
|
<ListItemTitle component="h3">{props.title}</ListItemTitle>
|
||||||
|
<ListItemText component="p">{props.description}</ListItemText>
|
||||||
|
</div>
|
||||||
|
<div>{props.actions}</div>
|
||||||
|
</div>
|
||||||
|
{props.children && <div className="w-full border-t border-gray-200">{props.children}</div>}
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarSwitch(props: {
|
||||||
|
type: IntegrationCalendar["type"];
|
||||||
|
externalId: string;
|
||||||
|
title: string;
|
||||||
|
defaultSelected: boolean;
|
||||||
|
}) {
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
|
||||||
|
const mutation = useMutation<
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
{
|
||||||
|
isOn: boolean;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
async ({ isOn }) => {
|
||||||
|
const body = {
|
||||||
|
integration: props.type,
|
||||||
|
externalId: props.externalId,
|
||||||
|
};
|
||||||
|
if (isOn) {
|
||||||
|
const res = await fetch("/api/availability/calendar", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(selectableCalendars[i]),
|
body: JSON.stringify(body),
|
||||||
}).then((response) => response.json());
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Something went wrong");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fetch("api/availability/calendar", {
|
const res = await fetch("/api/availability/calendar", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(selectableCalendars[i]),
|
body: JSON.stringify(body),
|
||||||
}).then((response) => response.json());
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Something went wrong");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
}
|
{
|
||||||
|
async onSettled() {
|
||||||
function getCalendarIntegrationImage(integrationType: string) {
|
await utils.invalidateQueries(["viewer.integrations"]);
|
||||||
switch (integrationType) {
|
},
|
||||||
case "google_calendar":
|
onError() {
|
||||||
return "integrations/google-calendar.svg";
|
showToast(`Something went wrong when toggling "${props.title}""`, "error");
|
||||||
case "office365_calendar":
|
},
|
||||||
return "integrations/outlook.svg";
|
|
||||||
case "caldav_calendar":
|
|
||||||
return "integrations/caldav.svg";
|
|
||||||
case "apple_calendar":
|
|
||||||
return "integrations/apple-calendar.svg";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function onCloseSelectCalendar() {
|
|
||||||
setSelectableCalendars([...selectableCalendars]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConnectNewAppDialog = () => (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger className="px-4 py-2 mt-6 text-sm font-medium text-white border border-transparent rounded-sm shadow-sm bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
|
||||||
<PlusIcon className="inline w-5 h-5 mr-1" />
|
|
||||||
Connect a new App
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader title="Connect a new App" subtitle="Integrate your account with other services." />
|
|
||||||
<div className="my-4">
|
|
||||||
<ul className="divide-y divide-gray-200">
|
|
||||||
{integrations
|
|
||||||
.filter((integration) => integration.installed)
|
|
||||||
.map((integration) => {
|
|
||||||
return (
|
|
||||||
<li key={integration.type} className="flex py-4">
|
|
||||||
<div className="w-1/12 pt-2 mr-4">
|
|
||||||
<img className="w-8 h-8 mr-2" src={integration.imageSrc} alt={integration.title} />
|
|
||||||
</div>
|
|
||||||
<div className="w-10/12">
|
|
||||||
<h2 className="font-medium text-gray-800 font-cal">{integration.title}</h2>
|
|
||||||
<p className="text-sm text-gray-400">{integration.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-2/12 pt-2 text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => integrationHandler(integration.type)}
|
|
||||||
className="font-medium text-neutral-900 hover:text-neutral-500">
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="gap-2 mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button color="secondary">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
|
return (
|
||||||
const SelectCalendarDialog = () => (
|
<Switch
|
||||||
<Dialog onOpenChange={(open) => !open && onCloseSelectCalendar()}>
|
key={props.externalId}
|
||||||
<DialogTrigger className="px-4 py-2 mt-6 text-sm font-medium text-white border border-transparent rounded-sm shadow-sm bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
name="enabled"
|
||||||
Select calendars
|
label={props.title}
|
||||||
</DialogTrigger>
|
defaultChecked={props.defaultSelected}
|
||||||
|
onCheckedChange={(isOn: boolean) => {
|
||||||
<DialogContent>
|
mutation.mutate({ isOn });
|
||||||
<DialogHeader
|
}}
|
||||||
title="Select calendars"
|
/>
|
||||||
subtitle="If no entry is selected, all calendars will be checked"
|
|
||||||
/>
|
|
||||||
<div className="my-4">
|
|
||||||
<ul className="overflow-y-auto divide-y divide-gray-200 max-h-96">
|
|
||||||
{selectableCalendars.map((calendar) => (
|
|
||||||
<li key={calendar.name} className="flex py-4">
|
|
||||||
<div className="w-1/12 pt-2 mr-4">
|
|
||||||
<img
|
|
||||||
className="w-8 h-8 mr-2"
|
|
||||||
src={getCalendarIntegrationImage(calendar.integration)}
|
|
||||||
alt={calendar.integration}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-10/12 pt-3">
|
|
||||||
<h2 className="font-medium text-gray-800">{calendar.name}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="w-2/12 pt-3 text-right">
|
|
||||||
<Switch
|
|
||||||
defaultChecked={calendar.selected}
|
|
||||||
onCheckedChange={calendarSelectionHandler(calendar)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="gap-2 mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button color="secondary">Confirm</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddCalDavIntegrationSaveButtonPress = async () => {
|
export default function IntegrationsPage() {
|
||||||
const form = addCalDavIntegrationRef.current.elements;
|
const query = trpc.useQuery(["viewer.integrations"]);
|
||||||
const url = form.url.value;
|
|
||||||
const password = form.password.value;
|
|
||||||
const username = form.username.value;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setAddCalDavError(null);
|
|
||||||
const addCalDavIntegrationResponse = await handleAddCalDavIntegration({ username, password, url });
|
|
||||||
if (addCalDavIntegrationResponse.ok) {
|
|
||||||
setIsAddCalDavIntegrationDialogOpen(false);
|
|
||||||
} else {
|
|
||||||
const j = await addCalDavIntegrationResponse.json();
|
|
||||||
setAddCalDavError({ message: j.message });
|
|
||||||
}
|
|
||||||
} catch (reason) {
|
|
||||||
console.error(reason);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddAppleIntegrationSaveButtonPress = async () => {
|
|
||||||
const form = addAppleIntegrationRef.current.elements;
|
|
||||||
const password = form.password.value;
|
|
||||||
const username = form.username.value;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setAddAppleError(null);
|
|
||||||
const addAppleIntegrationResponse = await handleAddAppleIntegration({ username, password });
|
|
||||||
if (addAppleIntegrationResponse.ok) {
|
|
||||||
setIsAddAppleIntegrationDialogOpen(false);
|
|
||||||
} else {
|
|
||||||
const j = await addAppleIntegrationResponse.json();
|
|
||||||
setAddAppleError({ message: j.message });
|
|
||||||
}
|
|
||||||
} catch (reason) {
|
|
||||||
console.error(reason);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ConnectCalDavServerDialog = useCallback(() => {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={isAddCalDavIntegrationDialogOpen}
|
|
||||||
onOpenChange={(isOpen) => setIsAddCalDavIntegrationDialogOpen(isOpen)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader
|
|
||||||
title="Connect to CalDav Server"
|
|
||||||
subtitle="Your credentials will be stored and encrypted."
|
|
||||||
/>
|
|
||||||
<div className="my-4">
|
|
||||||
{addCalDavError && (
|
|
||||||
<p className="text-sm text-red-700">
|
|
||||||
<span className="font-bold">Error: </span>
|
|
||||||
{addCalDavError.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<AddCalDavIntegration
|
|
||||||
ref={addCalDavIntegrationRef}
|
|
||||||
onSubmit={handleAddCalDavIntegrationSaveButtonPress}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="gap-2 mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form={ADD_CALDAV_INTEGRATION_FORM_TITLE}
|
|
||||||
className="flex justify-center px-4 py-2 text-sm font-medium text-white border border-transparent rounded-sm shadow-sm bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<DialogClose
|
|
||||||
onClick={() => {
|
|
||||||
setIsAddCalDavIntegrationDialogOpen(false);
|
|
||||||
}}
|
|
||||||
asChild>
|
|
||||||
<Button color="secondary">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}, [isAddCalDavIntegrationDialogOpen, addCalDavError]);
|
|
||||||
|
|
||||||
const ConnectAppleServerDialog = useCallback(() => {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={isAddAppleIntegrationDialogOpen}
|
|
||||||
onOpenChange={(isOpen) => setIsAddAppleIntegrationDialogOpen(isOpen)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader
|
|
||||||
title="Connect to Apple Server"
|
|
||||||
subtitle={
|
|
||||||
<p>
|
|
||||||
Generate an app specific password to use with Cal.com at{" "}
|
|
||||||
<a
|
|
||||||
className="text-indigo-400"
|
|
||||||
href="https://appleid.apple.com/account/manage"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer">
|
|
||||||
https://appleid.apple.com/account/manage
|
|
||||||
</a>
|
|
||||||
. Your credentials will be stored and encrypted.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="my-4">
|
|
||||||
{addAppleError && (
|
|
||||||
<p className="text-sm text-red-700">
|
|
||||||
<span className="font-bold">Error: </span>
|
|
||||||
{addAppleError.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<AddAppleIntegration
|
|
||||||
ref={addAppleIntegrationRef}
|
|
||||||
onSubmit={handleAddAppleIntegrationSaveButtonPress}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="gap-2 mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
form={ADD_APPLE_INTEGRATION_FORM_TITLE}
|
|
||||||
className="flex justify-center px-4 py-2 text-sm font-medium text-white border border-transparent rounded-sm shadow-sm bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<DialogClose
|
|
||||||
onClick={() => {
|
|
||||||
setIsAddAppleIntegrationDialogOpen(false);
|
|
||||||
}}
|
|
||||||
asChild>
|
|
||||||
<Button color="secondary">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}, [isAddAppleIntegrationDialogOpen, addAppleError]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Loader />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Shell heading="Integrations" subtitle="Connect your favourite apps.">
|
||||||
<Shell heading="Integrations" subtitle="Connect your favourite apps." CTA={<ConnectNewAppDialog />}>
|
<QueryCell
|
||||||
<div className="mb-8 overflow-hidden bg-white border border-gray-200 rounded-sm">
|
query={query}
|
||||||
{integrations.filter((ig) => ig.credential).length !== 0 ? (
|
success={({ data }) => {
|
||||||
<ul className="divide-y divide-gray-200">
|
return (
|
||||||
{integrations
|
<>
|
||||||
.filter((ig) => ig.credential)
|
<ShellSubHeading
|
||||||
.map((ig) => (
|
title={
|
||||||
<li key={ig.credential.id}>
|
<SubHeadingTitleWithConnections
|
||||||
<Link href={"/integrations/" + ig.credential.id}>
|
title="Conferencing"
|
||||||
<a className="block hover:bg-gray-50">
|
numConnections={data.conferencing.numActive}
|
||||||
<div className="flex items-center px-4 py-4 sm:px-6">
|
/>
|
||||||
<div className="flex items-center flex-1 min-w-0">
|
}
|
||||||
<div className="flex-shrink-0">
|
/>
|
||||||
<img className="w-10 h-10 mr-2" src={ig.imageSrc} alt={ig.title} />
|
<List>
|
||||||
</div>
|
{data.conferencing.items.map((item) => (
|
||||||
<div className="flex-1 min-w-0 px-4 md:grid md:grid-cols-2 md:gap-4">
|
<IntegrationListItem
|
||||||
<div>
|
key={item.title}
|
||||||
<p className="text-sm font-medium truncate text-neutral-900">{ig.title}</p>
|
{...item}
|
||||||
<p className="flex items-center text-sm text-gray-500">
|
actions={<ConnectOrDisconnectIntegrationButton {...item} />}
|
||||||
{ig.type.endsWith("_calendar") && (
|
/>
|
||||||
<span className="truncate">Calendar Integration</span>
|
|
||||||
)}
|
|
||||||
{ig.type.endsWith("_video") && (
|
|
||||||
<span className="truncate">Video Conferencing</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:block">
|
|
||||||
{ig.credential.key && (
|
|
||||||
<p className="flex items-center mt-2 text-gray-500 text">
|
|
||||||
<CheckCircleIcon className="flex-shrink-0 mr-1.5 h-5 w-5 text-green-400" />
|
|
||||||
Connected
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!ig.credential.key && (
|
|
||||||
<p className="flex items-center mt-3 text-gray-500 text">
|
|
||||||
<XCircleIcon className="flex-shrink-0 mr-1.5 h-5 w-5 text-yellow-400" />
|
|
||||||
Not connected
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<ChevronRightIcon className="w-5 h-5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</List>
|
||||||
) : (
|
|
||||||
<div className="bg-white rounded-sm shadow">
|
<ShellSubHeading
|
||||||
<div className="flex">
|
className="mt-6"
|
||||||
<div className="pl-8 py-9">
|
title={
|
||||||
<InformationCircleIcon className="w-16 text-neutral-900" />
|
<SubHeadingTitleWithConnections title="Payment" numConnections={data.payment.numActive} />
|
||||||
</div>
|
}
|
||||||
<div className="py-5 sm:p-6">
|
/>
|
||||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
<List>
|
||||||
You don't have any apps connected.
|
{data.payment.items.map((item) => (
|
||||||
</h3>
|
<IntegrationListItem
|
||||||
<div className="mt-2 text-sm text-gray-500">
|
key={item.title}
|
||||||
<p>
|
{...item}
|
||||||
You currently do not have any apps connected. Connect your first app to get started.
|
actions={<ConnectOrDisconnectIntegrationButton {...item} />}
|
||||||
</p>
|
/>
|
||||||
</div>
|
))}
|
||||||
<ConnectNewAppDialog />
|
</List>
|
||||||
</div>
|
|
||||||
</div>
|
<ShellSubHeading
|
||||||
</div>
|
className="mt-6"
|
||||||
)}
|
title={
|
||||||
</div>
|
<SubHeadingTitleWithConnections
|
||||||
<div className="mb-8 bg-white border border-gray-200 rounded-sm">
|
title="Calendars"
|
||||||
<div className="px-4 py-5 sm:p-6">
|
numConnections={data.calendar.numActive}
|
||||||
<h3 className="text-lg font-medium leading-6 text-gray-900 font-cal">Select calendars</h3>
|
/>
|
||||||
<div className="max-w-xl mt-2 text-sm text-gray-500">
|
}
|
||||||
<p>Select which calendars are checked for availability to prevent double bookings.</p>
|
subtitle={
|
||||||
</div>
|
<>
|
||||||
<SelectCalendarDialog />
|
Configure how your links integrate with your calendars.
|
||||||
</div>
|
<br />
|
||||||
</div>
|
You can override these settings on a per event basis.
|
||||||
<div className="border border-gray-200 rounded-sm">
|
</>
|
||||||
<div className="px-4 py-5 sm:p-6">
|
}
|
||||||
<h3 className="text-lg font-medium leading-6 text-gray-900 font-cal">Launch your own App</h3>
|
/>
|
||||||
<div className="max-w-xl mt-2 text-sm text-gray-500">
|
|
||||||
<p>If you want to add your own App here, get in touch with us.</p>
|
{data.connectedCalendars.length > 0 && (
|
||||||
</div>
|
<>
|
||||||
<div className="mt-5">
|
<List>
|
||||||
<a href="mailto:apps@cal.com" className="btn btn-white">
|
{data.connectedCalendars.map((item, index) => (
|
||||||
Contact us
|
<li key={index}>
|
||||||
</a>
|
{item.calendars ? (
|
||||||
</div>
|
<IntegrationListItem
|
||||||
</div>
|
{...item.integration}
|
||||||
</div>
|
description={item.primary.externalId}
|
||||||
<ConnectCalDavServerDialog />
|
actions={
|
||||||
<ConnectAppleServerDialog />
|
<DisconnectIntegration
|
||||||
</Shell>
|
id={item.credentialId}
|
||||||
</div>
|
render={(btnProps) => (
|
||||||
|
<Button {...btnProps} color="warn">
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}>
|
||||||
|
<ul className="space-y-2 p-4">
|
||||||
|
{item.calendars.map((cal) => (
|
||||||
|
<CalendarSwitch
|
||||||
|
key={cal.externalId}
|
||||||
|
externalId={cal.externalId}
|
||||||
|
title={cal.name}
|
||||||
|
type={item.integration.type}
|
||||||
|
defaultSelected={cal.isSelected}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</IntegrationListItem>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
title="Something went wrong"
|
||||||
|
message={item.error.message}
|
||||||
|
actions={
|
||||||
|
<DisconnectIntegration
|
||||||
|
id={item.credentialId}
|
||||||
|
render={(btnProps) => (
|
||||||
|
<Button {...btnProps} color="warn">
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<h2 className="font-bold text-gray-900 flex items-center content-center mb-2 mt-4">
|
||||||
|
Connect an additional calendar
|
||||||
|
</h2>
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<List>
|
||||||
|
{data.calendar.items.map((item) => (
|
||||||
|
<IntegrationListItem
|
||||||
|
key={item.title}
|
||||||
|
{...item}
|
||||||
|
actions={
|
||||||
|
<ConnectIntegration
|
||||||
|
type={item.type}
|
||||||
|
render={(btnProps) => <Button {...btnProps}>Connect</Button>}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|
||||||
const session = await getSession(context);
|
|
||||||
if (!session?.user?.email) {
|
|
||||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
|
||||||
}
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
email: session.user.email,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
credentials: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
type: true,
|
|
||||||
key: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
completedOnboarding: true,
|
|
||||||
createdDate: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user)
|
|
||||||
return {
|
|
||||||
redirect: { permanent: false, destination: "/auth/login" },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
shouldShowOnboarding({ completedOnboarding: user.completedOnboarding, createdDate: user.createdDate })
|
|
||||||
) {
|
|
||||||
return ONBOARDING_NEXT_REDIRECT;
|
|
||||||
}
|
|
||||||
|
|
||||||
const integrations = getIntegrations(user.credentials);
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: { session, integrations },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import Head from "next/head";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Alert, AlertProps } from "@components/ui/Alert";
|
import { Alert, AlertProps } from "@components/ui/Alert";
|
||||||
|
|
||||||
export default function AlertPage() {
|
import { sandboxPage } from ".";
|
||||||
|
|
||||||
|
const page = sandboxPage(function AlertPage() {
|
||||||
const list: AlertProps[] = [
|
const list: AlertProps[] = [
|
||||||
{ title: "Something went wrong", severity: "error" },
|
{ title: "Something went wrong", severity: "error" },
|
||||||
{ title: "Something went kinda wrong", severity: "warning" },
|
{ title: "Something went kinda wrong", severity: "warning" },
|
||||||
|
@ -23,9 +24,6 @@ export default function AlertPage() {
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
|
||||||
<meta name="googlebot" content="noindex" />
|
|
||||||
</Head>
|
|
||||||
<div className="p-4 bg-gray-200">
|
<div className="p-4 bg-gray-200">
|
||||||
<h1>Alert component</h1>
|
<h1>Alert component</h1>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
@ -52,4 +50,7 @@ export default function AlertPage() {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default page.default;
|
||||||
|
export const getStaticProps = page.getStaticProps;
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Badge, BadgeProps } from "@components/ui/Badge";
|
||||||
|
|
||||||
|
import { sandboxPage } from ".";
|
||||||
|
|
||||||
|
const page = sandboxPage(function BadgePage() {
|
||||||
|
const list: BadgeProps[] = [
|
||||||
|
//
|
||||||
|
{ variant: "success" },
|
||||||
|
{ variant: "gray" },
|
||||||
|
{ variant: "success" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="p-4 bg-gray-200">
|
||||||
|
<h1>Badge component</h1>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{list.map((props, index) => (
|
||||||
|
<div key={index} className="p-2 m-2 bg-white">
|
||||||
|
<h3>
|
||||||
|
<code>
|
||||||
|
{JSON.stringify(
|
||||||
|
props,
|
||||||
|
(key, value) => {
|
||||||
|
if (key.includes("Icon")) {
|
||||||
|
return "..";
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
</code>
|
||||||
|
</h3>
|
||||||
|
<Badge {...(props as any)}>Badge text</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default page.default;
|
||||||
|
export const getStaticProps = page.getStaticProps;
|
|
@ -1,11 +1,12 @@
|
||||||
import { PlusIcon } from "@heroicons/react/solid";
|
import { PlusIcon } from "@heroicons/react/solid";
|
||||||
import Head from "next/head";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Button, ButtonProps } from "@components/ui/Button";
|
import { Button, ButtonBaseProps } from "@components/ui/Button";
|
||||||
|
|
||||||
export default function ButtonPage() {
|
import { sandboxPage } from ".";
|
||||||
const list: ButtonProps[] = [
|
|
||||||
|
const page = sandboxPage(function ButtonPage() {
|
||||||
|
const list: ButtonBaseProps[] = [
|
||||||
// primary
|
// primary
|
||||||
{ color: "primary" },
|
{ color: "primary" },
|
||||||
{ color: "primary", disabled: true },
|
{ color: "primary", disabled: true },
|
||||||
|
@ -26,18 +27,15 @@ export default function ButtonPage() {
|
||||||
{ color: "primary", size: "base" },
|
{ color: "primary", size: "base" },
|
||||||
{ color: "primary", size: "lg" },
|
{ color: "primary", size: "lg" },
|
||||||
|
|
||||||
// href
|
// // href
|
||||||
{ href: "/staging" },
|
// { href: "/staging" },
|
||||||
{ href: "/staging", disabled: true },
|
// { href: "/staging", disabled: true },
|
||||||
|
|
||||||
{ StartIcon: PlusIcon },
|
{ StartIcon: PlusIcon },
|
||||||
{ EndIcon: PlusIcon },
|
{ EndIcon: PlusIcon },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
|
||||||
<meta name="googlebot" content="noindex" />
|
|
||||||
</Head>
|
|
||||||
<div className="p-4 bg-gray-200">
|
<div className="p-4 bg-gray-200">
|
||||||
<h1>Button component</h1>
|
<h1>Button component</h1>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
@ -57,11 +55,14 @@ export default function ButtonPage() {
|
||||||
)}
|
)}
|
||||||
</code>
|
</code>
|
||||||
</h3>
|
</h3>
|
||||||
<Button {...props}>Button text</Button>
|
<Button {...(props as any)}>Button text</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default page.default;
|
||||||
|
export const getStaticProps = page.getStaticProps;
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
import { List, ListItem } from "@components/List";
|
||||||
|
import Button from "@components/ui/Button";
|
||||||
|
|
||||||
|
import { sandboxPage } from ".";
|
||||||
|
|
||||||
|
const page = sandboxPage(() => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
Unstyled -{" "}
|
||||||
|
<Button size="sm" color="minimal" onClick={() => setExpanded((state) => !state)}>
|
||||||
|
Toggle expanded
|
||||||
|
</Button>
|
||||||
|
<List>
|
||||||
|
<ListItem expanded={expanded} className="transition-all">
|
||||||
|
An item
|
||||||
|
</ListItem>
|
||||||
|
<ListItem expanded={expanded} className="transition-all">
|
||||||
|
An item
|
||||||
|
</ListItem>
|
||||||
|
<ListItem expanded={expanded} className="transition-all">
|
||||||
|
An item
|
||||||
|
</ListItem>
|
||||||
|
<ListItem expanded={expanded} className="transition-all">
|
||||||
|
An item
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
One expanded
|
||||||
|
<List>
|
||||||
|
<ListItem>An item</ListItem>
|
||||||
|
<ListItem expanded>Spaced</ListItem>
|
||||||
|
<ListItem>An item</ListItem>
|
||||||
|
<ListItem>An item</ListItem>
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default page.default;
|
||||||
|
export const getStaticProps = page.getStaticProps;
|
|
@ -0,0 +1,22 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { Label, Input, TextField } from "@components/form/fields";
|
||||||
|
|
||||||
|
import { sandboxPage } from ".";
|
||||||
|
|
||||||
|
const page = sandboxPage(() => (
|
||||||
|
<div className="p-4 space-y-6">
|
||||||
|
<div>
|
||||||
|
<Label>Label</Label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input placeholder="Input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<TextField label="TextField" placeholder="it has an input baked in" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
export default page.default;
|
||||||
|
export const getStaticProps = page.getStaticProps;
|
|
@ -0,0 +1,73 @@
|
||||||
|
import fs from "fs";
|
||||||
|
import { NextPage } from "next";
|
||||||
|
import Head from "next/head";
|
||||||
|
import Link from "next/link";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
|
async function _getStaticProps() {
|
||||||
|
const dir = path.join(process.cwd(), "pages", "sandbox");
|
||||||
|
|
||||||
|
const pages = fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((file) => !file.startsWith("."))
|
||||||
|
.map((file) => {
|
||||||
|
const parts = file.split(".");
|
||||||
|
// remove extension
|
||||||
|
parts.pop();
|
||||||
|
return parts.join(".");
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
pages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
type PageProps = inferSSRProps<typeof _getStaticProps>;
|
||||||
|
|
||||||
|
const SandboxPage: NextPage<PageProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<meta name="googlebot" content="noindex" />
|
||||||
|
</Head>
|
||||||
|
<nav>
|
||||||
|
<ul className="flex justify-between flex-col md:flex-row">
|
||||||
|
{props.pages.map((pathname) => (
|
||||||
|
<li key={pathname}>
|
||||||
|
<Link href={"/sandbox/" + pathname + "#main"}>
|
||||||
|
<a className="font-mono px-4">{pathname}</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main id="main" className="bg-gray-100">
|
||||||
|
{props.children}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export function sandboxPage(Component: NextPage) {
|
||||||
|
const Wrapper: NextPage<PageProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SandboxPage {...props}>
|
||||||
|
<Component />
|
||||||
|
</SandboxPage>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
default: Wrapper,
|
||||||
|
getStaticProps: _getStaticProps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = sandboxPage(() => {
|
||||||
|
return <p className="text-center text-2xl my-20">Click a component above</p>;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default page.default;
|
||||||
|
export const getStaticProps = page.getStaticProps;
|
|
@ -7,7 +7,14 @@ import { hashPassword } from "../lib/auth";
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function createUserAndEventType(opts: {
|
async function createUserAndEventType(opts: {
|
||||||
user: { email: string; password: string; username: string; plan: UserPlan; name: string };
|
user: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
username: string;
|
||||||
|
plan: UserPlan;
|
||||||
|
name: string;
|
||||||
|
completedOnboarding?: boolean;
|
||||||
|
};
|
||||||
eventTypes: Array<
|
eventTypes: Array<
|
||||||
Prisma.EventTypeCreateInput & {
|
Prisma.EventTypeCreateInput & {
|
||||||
_bookings?: Prisma.BookingCreateInput[];
|
_bookings?: Prisma.BookingCreateInput[];
|
||||||
|
@ -18,7 +25,7 @@ async function createUserAndEventType(opts: {
|
||||||
...opts.user,
|
...opts.user,
|
||||||
password: await hashPassword(opts.user.password),
|
password: await hashPassword(opts.user.password),
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
completedOnboarding: true,
|
completedOnboarding: opts.user.completedOnboarding ?? true,
|
||||||
};
|
};
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
where: { email: opts.user.email },
|
where: { email: opts.user.email },
|
||||||
|
@ -97,24 +104,14 @@ async function createUserAndEventType(opts: {
|
||||||
async function main() {
|
async function main() {
|
||||||
await createUserAndEventType({
|
await createUserAndEventType({
|
||||||
user: {
|
user: {
|
||||||
email: "free@example.com",
|
email: "onboarding@example.com",
|
||||||
password: "free",
|
password: "onboarding",
|
||||||
username: "free",
|
username: "onboarding",
|
||||||
name: "Free Example",
|
name: "onboarding",
|
||||||
plan: "FREE",
|
plan: "TRIAL",
|
||||||
|
completedOnboarding: false,
|
||||||
},
|
},
|
||||||
eventTypes: [
|
eventTypes: [],
|
||||||
{
|
|
||||||
title: "30min",
|
|
||||||
slug: "30min",
|
|
||||||
length: 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "60min",
|
|
||||||
slug: "60min",
|
|
||||||
length: 30,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await createUserAndEventType({
|
await createUserAndEventType({
|
||||||
|
@ -199,6 +196,28 @@ async function main() {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await createUserAndEventType({
|
||||||
|
user: {
|
||||||
|
email: "free@example.com",
|
||||||
|
password: "free",
|
||||||
|
username: "free",
|
||||||
|
name: "Free Example",
|
||||||
|
plan: "FREE",
|
||||||
|
},
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
title: "30min",
|
||||||
|
slug: "30min",
|
||||||
|
length: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "60min",
|
||||||
|
slug: "60min",
|
||||||
|
length: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,20 @@ async function getUserFromSession({ session, req }: { session: Maybe<Session>; r
|
||||||
createdDate: true,
|
createdDate: true,
|
||||||
hideBranding: true,
|
hideBranding: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
|
credentials: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
key: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selectedCalendars: {
|
||||||
|
select: {
|
||||||
|
externalId: true,
|
||||||
|
integration: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
completedOnboarding: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
import { Prisma, BookingStatus } from "@prisma/client";
|
import { BookingStatus, Prisma } from "@prisma/client";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { getErrorFromUnknown } from "pages/_error";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername";
|
import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername";
|
||||||
|
|
||||||
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
||||||
|
import getIntegrations, { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
|
||||||
import slugify from "@lib/slugify";
|
import slugify from "@lib/slugify";
|
||||||
|
|
||||||
|
import { getCalendarAdapterOrNull } from "../../lib/calendarClient";
|
||||||
import { createProtectedRouter } from "../createRouter";
|
import { createProtectedRouter } from "../createRouter";
|
||||||
import { resizeBase64Image } from "../lib/resizeBase64Image";
|
import { resizeBase64Image } from "../lib/resizeBase64Image";
|
||||||
|
|
||||||
|
@ -91,6 +94,88 @@ export const viewerRouter = createProtectedRouter()
|
||||||
return bookings;
|
return bookings;
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
.query("integrations", {
|
||||||
|
async resolve({ ctx }) {
|
||||||
|
const { user } = ctx;
|
||||||
|
const { credentials } = user;
|
||||||
|
const integrations = getIntegrations(credentials);
|
||||||
|
|
||||||
|
function countActive(items: { credentials: unknown[] }[]) {
|
||||||
|
return items.reduce((acc, item) => acc + item.credentials.length, 0);
|
||||||
|
}
|
||||||
|
const conferencing = integrations.flatMap((item) => (item.variant === "conferencing" ? [item] : []));
|
||||||
|
const payment = integrations.flatMap((item) => (item.variant === "payment" ? [item] : []));
|
||||||
|
const calendar = integrations.flatMap((item) => (item.variant === "calendar" ? [item] : []));
|
||||||
|
|
||||||
|
// get user's credentials + their connected integrations
|
||||||
|
const calendarCredentials = user.credentials
|
||||||
|
.filter((credential) => credential.type.endsWith("_calendar"))
|
||||||
|
.flatMap((credential) => {
|
||||||
|
const integration = ALL_INTEGRATIONS.find((integration) => integration.type === credential.type);
|
||||||
|
|
||||||
|
const adapter = getCalendarAdapterOrNull({
|
||||||
|
...credential,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
return integration && adapter && integration.variant === "calendar"
|
||||||
|
? [{ integration, credential, adapter }]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// get all the connected integrations' calendars (from third party)
|
||||||
|
const connectedCalendars = await Promise.all(
|
||||||
|
calendarCredentials.map(async (item) => {
|
||||||
|
const { adapter, integration, credential } = item;
|
||||||
|
try {
|
||||||
|
const _calendars = await adapter.listCalendars();
|
||||||
|
const calendars = _calendars.map((cal) => ({
|
||||||
|
...cal,
|
||||||
|
isSelected: !!user.selectedCalendars.find((selected) => selected.externalId === cal.externalId),
|
||||||
|
}));
|
||||||
|
const primary = calendars.find((item) => item.primary) ?? calendars[0];
|
||||||
|
if (!primary) {
|
||||||
|
return {
|
||||||
|
integration,
|
||||||
|
credentialId: credential.id,
|
||||||
|
error: {
|
||||||
|
message: "No primary calendar found",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
integration,
|
||||||
|
credentialId: credential.id,
|
||||||
|
primary,
|
||||||
|
calendars,
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
const error = getErrorFromUnknown(_error);
|
||||||
|
return {
|
||||||
|
integration,
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
conferencing: {
|
||||||
|
items: conferencing,
|
||||||
|
numActive: countActive(conferencing),
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
items: calendar,
|
||||||
|
numActive: countActive(calendar),
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
items: payment,
|
||||||
|
numActive: countActive(payment),
|
||||||
|
},
|
||||||
|
connectedCalendars,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
})
|
||||||
.mutation("updateProfile", {
|
.mutation("updateProfile", {
|
||||||
input: z.object({
|
input: z.object({
|
||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
|
|
Loading…
Reference in New Issue
Block a user