integration page follow ups (#912)

### Internals

- Replace `lodash.*` packages with plain `lodash` & replace `lodash.*` imports with `lodash/` - should have no impact on bundle size and opens up for us to use all of lodash
- Update `viewer.me` to cherry-pick what we actually need on that query to avoid leaking extra context info
- Update `getIntegrations` to never include `.key`-property to avoid leaking 

### Visual

- Update calendars so `primary` is displayed last
- Update connected calendars so they are in ascending order in which you connected them
This commit is contained in:
Alex Johansson 2021-10-13 13:35:25 +02:00 committed by GitHub
parent 9e2f8de313
commit ec6b897191
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 146 additions and 147 deletions

View File

@ -11,7 +11,6 @@ NEXT_PUBLIC_LICENSE_CONSENT=''
DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public"
GOOGLE_API_CREDENTIALS='secret'
GOOGLE_REDIRECT_URL='https://localhost:3000/integrations/googlecalendar/callback'
BASE_URL='http://localhost:3000'
NEXT_PUBLIC_APP_URL='http://localhost:3000'

View File

@ -1,4 +1,4 @@
import merge from "lodash.merge";
import merge from "lodash/merge";
import { NextSeo, NextSeoProps } from "next-seo";
import React from "react";

1
environment.d.ts vendored
View File

@ -3,7 +3,6 @@ declare namespace NodeJS {
readonly CALENDSO_ENCRYPTION_KEY: string | undefined;
readonly DATABASE_URL: string | undefined;
readonly GOOGLE_API_CREDENTIALS: string | undefined;
readonly GOOGLE_REDIRECT_URL: string | undefined;
readonly BASE_URL: string | undefined;
readonly NEXT_PUBLIC_BASE_URL: string | undefined;
readonly NEXT_PUBLIC_APP_URL: string | undefined;

View File

@ -1,6 +1,6 @@
import { Credential } from "@prisma/client";
import async from "async";
import merge from "lodash.merge";
import merge from "lodash/merge";
import { v5 as uuidv5 } from "uuid";
import { CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient";

View File

@ -2,21 +2,17 @@ import React, { useState } from "react";
import { useForm } from "react-hook-form";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
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 = {
onSubmit: () => void;
};
export const ADD_APPLE_INTEGRATION_FORM_TITLE = "addAppleIntegration";
export function AddAppleIntegrationModal(props: DialogProps) {
@ -104,49 +100,3 @@ export function AddAppleIntegrationModal(props: DialogProps) {
</Dialog>
);
}
/**
* @deprecated
*/
const AddAppleIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
const onSubmit = (event) => {
event.preventDefault();
event.stopPropagation();
props.onSubmit();
};
return (
<form id={ADD_APPLE_INTEGRATION_FORM_TITLE} ref={ref} onSubmit={onSubmit}>
<div className="mb-2">
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<input
required
type="text"
name="username"
id="username"
placeholder="email@icloud.com"
className="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"
/>
</div>
<div className="mb-2">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
required
type="password"
name="password"
id="password"
placeholder="•••••••••••••"
className="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"
/>
</div>
</form>
);
});
AddAppleIntegration.displayName = "AddAppleIntegrationForm";
export default AddAppleIntegration;

View File

@ -1,9 +1,10 @@
import { Prisma } from "@prisma/client";
import _ from "lodash";
import { validJson } from "@lib/jsonUtils";
const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
select: { id: true, type: true, key: true },
select: { id: true, type: true },
});
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
@ -33,6 +34,14 @@ export const ALL_INTEGRATIONS = [
description: "Video Conferencing",
variant: "conferencing",
},
{
installed: !!process.env.DAILY_API_KEY,
type: "daily_video",
title: "Daily.co Video",
imageSrc: "integrations/daily.svg",
description: "Video Conferencing",
variant: "conferencing",
},
{
installed: true,
type: "caldav_calendar",
@ -62,23 +71,33 @@ export const ALL_INTEGRATIONS = [
variant: "payment",
},
] as const;
function getIntegrations(credentials: CredentialData[]) {
const integrations = ALL_INTEGRATIONS.map((integration) => ({
...integration,
/**
* @deprecated use `credentials.
*/
credential: credentials.find((credential) => credential.type === integration.type) || null,
credentials: credentials.filter((credential) => credential.type === integration.type) || null,
}));
function getIntegrations(userCredentials: CredentialData[]) {
const integrations = ALL_INTEGRATIONS.map((integration) => {
const credentials = userCredentials
.filter((credential) => credential.type === integration.type)
.map((credential) => _.pick(credential, ["id", "type"])); // ensure we don't leak `key` to frontend
const credential: typeof credentials[number] | null = credentials[0] || null;
return {
...integration,
/**
* @deprecated use `credentials`
*/
credential,
credentials,
};
});
return integrations;
}
export type IntegraionMeta = ReturnType<typeof getIntegrations>;
export type IntegrationMeta = ReturnType<typeof getIntegrations>;
export function hasIntegration(integrations: ReturnType<typeof getIntegrations>, type: string): boolean {
return !!integrations.find((i) => i.type === type && !!i.installed && !!i.credential);
export function hasIntegration(integrations: IntegrationMeta, type: string): boolean {
return !!integrations.find(
(i) => i.type === type && !!i.installed && (type === "daily_video" || i.credentials.length > 0)
);
}
export default getIntegrations;

View File

@ -27,8 +27,8 @@
"yarn": ">=1.19.0 < 2.0.0"
},
"dependencies": {
"@headlessui/react": "^1.4.1",
"@daily-co/daily-js": "^0.16.0",
"@headlessui/react": "^1.4.1",
"@heroicons/react": "^1.0.4",
"@hookform/resolvers": "^2.8.1",
"@jitsu/sdk-js": "^2.2.4",
@ -61,9 +61,7 @@
"ical.js": "^1.4.0",
"ics": "^2.31.0",
"jimp": "^0.16.1",
"lodash.debounce": "^4.0.8",
"lodash.merge": "^4.6.2",
"lodash.throttle": "^4.1.1",
"lodash": "^4.17.21",
"micro": "^9.3.4",
"next": "^11.1.1",
"next-auth": "^3.28.0",
@ -82,10 +80,10 @@
"react-multi-email": "^0.5.3",
"react-phone-number-input": "^3.1.25",
"react-query": "^3.23.1",
"react-router-dom": "^5.2.0",
"react-select": "^4.3.1",
"react-timezone-select": "^1.0.7",
"react-use-intercom": "1.4.0",
"react-router-dom": "^5.2.0",
"short-uuid": "^4.2.0",
"stripe": "^8.168.0",
"superjson": "1.7.5",
@ -100,8 +98,7 @@
"@types/async": "^3.2.7",
"@types/bcryptjs": "^2.4.2",
"@types/jest": "^27.0.1",
"@types/lodash.debounce": "^4.0.6",
"@types/lodash.merge": "^4.6.6",
"@types/lodash": "^4.14.175",
"@types/micro": "^7.3.6",
"@types/node": "^16.6.1",
"@types/nodemailer": "^6.4.4",

View File

@ -20,8 +20,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
// Get token from Google Calendar API
const { client_secret, client_id, redirect_uris } = JSON.parse(credentials).web;
const redirect_uri = process.env.GOOGLE_REDIRECT_URL || redirect_uris[0];
const { client_secret, client_id } = JSON.parse(credentials).web;
const redirect_uri = process.env.BASE_URL + "/api/integrations/googlecalendar/callback";
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const authUrl = oAuth2Client.generateAuthUrl({

View File

@ -25,8 +25,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
const { client_secret, client_id, redirect_uris } = JSON.parse(credentials).web;
const redirect_uri = process.env.GOOGLE_REDIRECT_URL || redirect_uris[0];
const { client_secret, client_id } = JSON.parse(credentials).web;
const redirect_uri = process.env.BASE_URL + "/api/integrations/googlecalendar/callback";
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const token = await oAuth2Client.getToken(code);
const key = token.res?.data;

View File

@ -1,6 +1,6 @@
import { ResetPasswordRequest } from "@prisma/client";
import dayjs from "dayjs";
import debounce from "lodash.debounce";
import debounce from "lodash/debounce";
import { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/client";
import Link from "next/link";

View File

@ -1,4 +1,4 @@
import debounce from "lodash.debounce";
import debounce from "lodash/debounce";
import { getCsrfToken } from "next-auth/client";
import Link from "next/link";
import React from "react";

View File

@ -1247,7 +1247,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const locationOptions: OptionTypeBase[] = [
{ value: LocationType.InPerson, label: "Link or In-person meeting" },
{ value: LocationType.Phone, label: "Phone call" },
{ value: LocationType.Zoom, label: "Zoom Video", disabled: true },
];
if (hasIntegration(integrations, "zoom_video")) {
@ -1257,8 +1256,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (hasIntegration(integrations, "google_calendar")) {
locationOptions.push({ value: LocationType.GoogleMeet, label: "Google Meet" });
}
const hasDailyIntegration = process.env.DAILY_API_KEY;
if (hasDailyIntegration) {
if (hasIntegration(integrations, "daily_video")) {
locationOptions.push({ value: LocationType.Daily, label: "Daily.co Video" });
}
const currency =

View File

@ -11,12 +11,12 @@ import classnames from "classnames";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import debounce from "lodash.debounce";
import debounce from "lodash/debounce";
import omit from "lodash/omit";
import { NextPageContext } from "next";
import { useSession } from "next-auth/client";
import Head from "next/head";
import { useRouter } from "next/router";
import { Integration } from "pages/integrations/_new";
import React, { useEffect, useRef, useState } from "react";
import TimezoneSelect from "react-timezone-select";
@ -691,7 +691,7 @@ export async function getServerSideProps(context: NextPageContext) {
},
});
integrations = getIntegrations(credentials);
integrations = getIntegrations(credentials).map((item) => omit(item, "key"));
eventTypes = await prisma.eventType.findMany({
where: {

View File

@ -1,4 +1,3 @@
import { Maybe } from "@trpc/server";
import Image from "next/image";
import { ReactNode, useEffect, useState } from "react";
import { useMutation } from "react-query";
@ -8,7 +7,7 @@ import classNames from "@lib/classNames";
import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration";
import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration";
import showToast from "@lib/notification";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { trpc } from "@lib/trpc";
import { Dialog } from "@components/Dialog";
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
@ -19,8 +18,6 @@ import Badge from "@components/ui/Badge";
import Button, { ButtonBaseProps } from "@components/ui/Button";
import Switch from "@components/ui/Switch";
type IntegrationCalendar = inferQueryOutput<"viewer.integrations">["calendar"]["items"][number];
function pluralize(opts: { num: number; plural: string; singular: string }) {
if (opts.num === 0) {
return opts.singular;
@ -47,10 +44,7 @@ function SubHeadingTitleWithConnections(props: { title: ReactNode; numConnection
);
}
function ConnectIntegration(props: {
type: IntegrationCalendar["type"];
render: (renderProps: ButtonBaseProps) => JSX.Element;
}) {
function ConnectIntegration(props: { type: string; render: (renderProps: ButtonBaseProps) => JSX.Element }) {
const { type } = props;
const [isLoading, setIsLoading] = useState(false);
const mutation = useMutation(async () => {
@ -154,14 +148,15 @@ function DisconnectIntegration(props: {
function ConnectOrDisconnectIntegrationButton(props: {
//
credential: Maybe<{ id: number }>;
type: IntegrationCalendar["type"];
credentialIds: number[];
type: string;
installed: boolean;
}) {
if (props.credential) {
const [credentialId] = props.credentialIds;
if (credentialId) {
return (
<DisconnectIntegration
id={props.credential.id}
id={credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn">
Disconnect
@ -177,6 +172,14 @@ function ConnectOrDisconnectIntegrationButton(props: {
</div>
);
}
/** We don't need to "Connect", just show that it's installed */
if (props.type === "daily_video") {
return (
<div className="px-3 py-2 truncate">
<h3 className="text-sm font-medium text-gray-700">Installed</h3>
</div>
);
}
return (
<ConnectIntegration type={props.type} render={(btnProps) => <Button {...btnProps}>Connect</Button>} />
);
@ -193,7 +196,7 @@ function IntegrationListItem(props: {
<ListItem expanded={!!props.children} className={classNames("flex-col")}>
<div className={classNames("flex flex-1 space-x-2 w-full p-4 items-center")}>
<Image width={40} height={40} src={`/${props.imageSrc}`} alt={props.title} />
<div className="pl-2 flex-grow truncate">
<div className="flex-grow pl-2 truncate">
<ListItemTitle component="h3">{props.title}</ListItemTitle>
<ListItemText component="p">{props.description}</ListItemText>
</div>
@ -205,7 +208,7 @@ function IntegrationListItem(props: {
}
export function CalendarSwitch(props: {
type: IntegrationCalendar["type"];
type: string;
externalId: string;
title: string;
defaultSelected: boolean;
@ -353,7 +356,7 @@ export default function IntegrationsPage() {
)}
/>
}>
<ul className="space-y-2 p-4">
<ul className="p-4 space-y-2">
{item.calendars.map((cal) => (
<CalendarSwitch
key={cal.externalId}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -38,6 +38,9 @@ async function getUserFromSession({ session, req }: { session: Maybe<Session>; r
type: true,
key: true,
},
orderBy: {
id: "asc",
},
},
selectedCalendars: {
select: {

View File

@ -1,12 +1,13 @@
import { BookingStatus, Prisma } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import _ from "lodash";
import { getErrorFromUnknown } from "pages/_error";
import { z } from "zod";
import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername";
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
import getIntegrations, { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
import slugify from "@lib/slugify";
import { getCalendarAdapterOrNull } from "../../lib/calendarClient";
@ -20,7 +21,34 @@ const checkUsername =
export const viewerRouter = createProtectedRouter()
.query("me", {
resolve({ ctx }) {
return ctx.user;
const {
// pick only the part we want to expose in the API
id,
name,
username,
email,
startTime,
endTime,
bufferTime,
locale,
avatar,
createdDate,
completedOnboarding,
} = ctx.user;
const me = {
id,
name,
username,
email,
startTime,
endTime,
bufferTime,
locale,
avatar,
createdDate,
completedOnboarding,
};
return me;
},
})
.query("bookings", {
@ -98,11 +126,17 @@ export const viewerRouter = createProtectedRouter()
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);
function countActive(items: { credentialIds: unknown[] }[]) {
return items.reduce((acc, item) => acc + item.credentialIds.length, 0);
}
const integrations = ALL_INTEGRATIONS.map((integration) => ({
...integration,
credentialIds: credentials
.filter((credential) => credential.type === integration.type)
.map((credential) => credential.id),
}));
// `flatMap()` these work like `.filter()` but infers the types correctly
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] : []));
@ -126,25 +160,24 @@ export const viewerRouter = createProtectedRouter()
const connectedCalendars = await Promise.all(
calendarCredentials.map(async (item) => {
const { adapter, integration, credential } = item;
const credentialId = credential.id;
try {
const _calendars = await adapter.listCalendars();
const calendars = _calendars.map((cal) => ({
...cal,
isSelected: !!user.selectedCalendars.find((selected) => selected.externalId === cal.externalId),
}));
const cals = await adapter.listCalendars();
const calendars = _(cals)
.map((cal) => ({
...cal,
isSelected: user.selectedCalendars.some((selected) => selected.externalId === cal.externalId),
}))
.sortBy(["primary"])
.value();
const primary = calendars.find((item) => item.primary) ?? calendars[0];
if (!primary) {
return {
integration,
credentialId: credential.id,
error: {
message: "No primary calendar found",
},
};
throw new Error("No primary calendar found");
}
return {
integration,
credentialId: credential.id,
credentialId,
primary,
calendars,
};
@ -152,6 +185,7 @@ export const viewerRouter = createProtectedRouter()
const error = getErrorFromUnknown(_error);
return {
integration,
credentialId,
error: {
message: error.message,
},

View File

@ -1790,26 +1790,14 @@
version "7.0.9"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
"@types/lodash.debounce@^4.0.6":
version "4.0.6"
resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz#c5a2326cd3efc46566c47e4c0aa248dc0ee57d60"
dependencies:
"@types/lodash" "*"
"@types/lodash.merge@^4.6.6":
version "4.6.6"
resolved "https://registry.yarnpkg.com/@types/lodash.merge/-/lodash.merge-4.6.6.tgz#b84b403c1d31bc42d51772d1cd5557fa008cd3d6"
dependencies:
"@types/lodash" "*"
"@types/lodash@*", "@types/lodash@^4.14.165":
version "4.14.175"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.175.tgz#b78dfa959192b01fae0ad90e166478769b215f45"
"@types/lodash@4.14.168":
version "4.14.168"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008"
"@types/lodash@^4.14.165", "@types/lodash@^4.14.175":
version "4.14.175"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.175.tgz#b78dfa959192b01fae0ad90e166478769b215f45"
"@types/micro@^7.3.6":
version "7.3.6"
resolved "https://registry.yarnpkg.com/@types/micro/-/micro-7.3.6.tgz#7d68eb5a780ac4761e3b80687b4ee7328ebc3f2e"
@ -5712,10 +5700,6 @@ lodash.clonedeep@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
lodash.flattendeep@^4.4.0:
version "4.4.0"
resolved "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2"
@ -5757,10 +5741,6 @@ lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
lodash.throttle@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
lodash.topath@^4.5.2:
version "4.5.2"
resolved "https://registry.yarnpkg.com/lodash.topath/-/lodash.topath-4.5.2.tgz#3616351f3bba61994a0931989660bd03254fd009"