feat: install gcal + gvideo by default for google signups (#17773)

* feat: install gcal + gvideo by default for google signups

* add upsertSelectedCalendar

* fix type check
This commit is contained in:
Benny Joo
2024-11-20 23:54:11 +00:00
committed by GitHub
parent 57bbba3d95
commit fcd06c80e3
13 changed files with 249 additions and 170 deletions
+1 -1
View File
@@ -119,7 +119,7 @@ NEXT_PUBLIC_FRESHCHAT_HOST=
# Google OAuth credentials
# To enable Login with Google you need to:
# 1. Set `GOOGLE_API_CREDENTIALS` above
# 1. Set `GOOGLE_API_CREDENTIALS` below
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
+2 -3
View File
@@ -1,11 +1,10 @@
import { google } from "googleapis";
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { GOOGLE_CALENDAR_SCOPES, SCOPE_USERINFO_PROFILE, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
import { SCOPES } from "../lib/constants";
import { getGoogleAppKeys } from "../lib/getGoogleAppKeys";
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
@@ -16,7 +15,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: SCOPES,
scope: [SCOPE_USERINFO_PROFILE, ...GOOGLE_CALENDAR_SCOPES],
// A refresh token is only returned the first time the user
// consents to providing access. For illustration purposes,
// setting the prompt to 'consent' will force this consent
@@ -2,18 +2,21 @@ import { google } from "googleapis";
import type { NextApiRequest, NextApiResponse } from "next";
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import {
GOOGLE_CALENDAR_SCOPES,
SCOPE_USERINFO_PROFILE,
WEBAPP_URL,
WEBAPP_URL_FOR_OAUTH,
} from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { getAllCalendars, updateProfilePhoto } from "@calcom/lib/google";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { GoogleRepository } from "@calcom/lib/server/repository/google";
import { GoogleService } from "@calcom/lib/server/service/google";
import { Prisma } from "@calcom/prisma/client";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import { REQUIRED_SCOPES, SCOPE_USERINFO_PROFILE } from "../lib/constants";
import { getGoogleAppKeys } from "../lib/getGoogleAppKeys";
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
@@ -47,7 +50,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const key = token.tokens;
const grantedScopes = token.tokens.scope?.split(" ") ?? [];
// Check if we have granted all required permissions
const hasMissingRequiredScopes = REQUIRED_SCOPES.some((scope) => !grantedScopes.includes(scope));
const hasMissingRequiredScopes = GOOGLE_CALENDAR_SCOPES.some((scope) => !grantedScopes.includes(scope));
if (hasMissingRequiredScopes) {
if (!state?.fromApp) {
throw new HttpError({
@@ -72,19 +75,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
auth: oAuth2Client,
});
const cals = await getAllCalendars(calendar);
const primaryCal = cals.find((cal) => cal.primary) ?? cals[0];
// Only attempt to update the user's profile photo if the user has granted the required scope
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
await updateProfilePhoto(oAuth2Client, req.session.user.id);
}
const gcalCredential = await GoogleRepository.createGoogleCalendarCredential({
key,
userId: req.session.user.id,
});
const primaryCal = await GoogleService.getPrimaryCalendar(calendar);
// If we still don't have a primary calendar skip creating the selected calendar.
// It can be toggled on later.
@@ -96,6 +87,16 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
return;
}
// Only attempt to update the user's profile photo if the user has granted the required scope
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
await GoogleService.updateProfilePhoto(oAuth2Client, req.session.user.id);
}
const gcalCredential = await GoogleService.createGoogleCalendarCredential({
key,
userId: req.session.user.id,
});
const selectedCalendarWhereUnique = {
userId: req.session.user.id,
externalId: primaryCal.id,
@@ -105,7 +106,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
// Wrapping in a try/catch to reduce chance of race conditions-
// also this improves performance for most of the happy-paths.
try {
await GoogleRepository.upsertSelectedCalendar({
await GoogleService.upsertSelectedCalendar({
credentialId: gcalCredential.id,
externalId: selectedCalendarWhereUnique.externalId,
userId: selectedCalendarWhereUnique.userId,
@@ -145,7 +146,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
return;
}
const existingGoogleMeetCredential = await GoogleRepository.findGoogleMeetCredential({
const existingGoogleMeetCredential = await GoogleService.findGoogleMeetCredential({
userId: req.session.user.id,
});
@@ -159,7 +160,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
}
// Create a new google meet credential
await GoogleRepository.createGoogleMeetsCredential({ userId: req.session.user.id });
await GoogleService.createGoogleMeetsCredential({ userId: req.session.user.id });
res.redirect(
getSafeRedirectUrl(`${WEBAPP_URL}/apps/installed/conferencing?hl=google-meet`) ??
getInstalledAppPath({ variant: "conferencing", slug: "google-meet" })
@@ -18,10 +18,9 @@ import {
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import { getAllCalendars } from "@calcom/lib/google";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { GoogleRepository } from "@calcom/lib/server/repository/google";
import { GoogleService } from "@calcom/lib/server/service/google";
import prisma from "@calcom/prisma";
import type {
Calendar,
@@ -543,7 +542,7 @@ export default class GoogleCalendarService implements Calendar {
}
async function getCalIds() {
if (selectedCalendarIds.length !== 0) return selectedCalendarIds;
const cals = await getAllCalendars(calendar, ["id"]);
const cals = await GoogleService.getAllCalendars(calendar, ["id"]);
if (!cals.length) return [];
return cals.reduce((c, cal) => (cal.id ? [...c, cal.id] : c), [] as string[]);
}
@@ -607,7 +606,7 @@ export default class GoogleCalendarService implements Calendar {
status: 200,
statusText: "OK",
data: {
items: await getAllCalendars(calendar),
items: await GoogleService.getAllCalendars(calendar),
},
})
);
@@ -652,7 +651,7 @@ export default class GoogleCalendarService implements Calendar {
},
});
const response = res.data;
await GoogleRepository.upsertSelectedCalendar({
await GoogleService.upsertSelectedCalendar({
userId: this.credential.userId!,
externalId: calendarId,
credentialId: this.credential.id,
@@ -686,7 +685,7 @@ export default class GoogleCalendarService implements Calendar {
.catch((err) => {
console.warn(JSON.stringify(err));
});
await GoogleRepository.upsertSelectedCalendar({
await GoogleService.upsertSelectedCalendar({
userId: this.credential.userId!,
externalId: calendarId,
credentialId: this.credential.id,
@@ -1,7 +0,0 @@
export const SCOPE_USERINFO_PROFILE = "https://www.googleapis.com/auth/userinfo.profile";
export const SCOPE_CALENDAR_READONLY = "https://www.googleapis.com/auth/calendar.readonly";
export const SCOPE_CALENDAR_EVENT = "https://www.googleapis.com/auth/calendar.events";
export const REQUIRED_SCOPES = [SCOPE_CALENDAR_READONLY, SCOPE_CALENDAR_EVENT];
export const SCOPES = [...REQUIRED_SCOPES, SCOPE_USERINFO_PROFILE];
@@ -1,29 +0,0 @@
import type { calendar_v3 } from "googleapis";
import logger from "@calcom/lib/logger";
export async function getAllCalendars(
calendar: calendar_v3.Calendar,
fields: string[] = ["id", "summary", "primary", "accessRole"]
): Promise<calendar_v3.Schema$CalendarListEntry[]> {
let allCalendars: calendar_v3.Schema$CalendarListEntry[] = [];
let pageToken: string | undefined;
try {
do {
const response: any = await calendar.calendarList.list({
fields: `items(${fields.join(",")}),nextPageToken`,
pageToken,
maxResults: 250, // 250 is max
});
allCalendars = [...allCalendars, ...(response.data.items ?? [])];
pageToken = response.data.nextPageToken;
} while (pageToken);
return allCalendars;
} catch (error) {
logger.error("Error fetching all Google Calendars", { error });
throw error;
}
}
@@ -1,5 +1,6 @@
import type { Membership, Team, UserPermissionRole } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { google } from "googleapis";
import type { AuthOptions, Session, User } from "next-auth";
import type { JWT } from "next-auth/jwt";
import { encode } from "next-auth/jwt";
@@ -15,7 +16,12 @@ import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/Imperso
import { getOrgFullOrigin, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { HOSTED_CAL_FEATURES, IS_CALCOM } from "@calcom/lib/constants";
import {
GOOGLE_CALENDAR_SCOPES,
GOOGLE_OAUTH_SCOPES,
HOSTED_CAL_FEATURES,
IS_CALCOM,
} from "@calcom/lib/constants";
import { ENABLE_PROFILE_SWITCHER, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto";
import { defaultCookies } from "@calcom/lib/default-cookies";
@@ -25,6 +31,7 @@ import { randomString } from "@calcom/lib/random";
import { safeStringify } from "@calcom/lib/safeStringify";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { GoogleService } from "@calcom/lib/server/service/google";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
@@ -251,6 +258,13 @@ if (IS_GOOGLE_LOGIN_ENABLED) {
clientId: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
authorization: {
params: {
scope: [...GOOGLE_OAUTH_SCOPES, ...GOOGLE_CALENDAR_SCOPES].join(" "),
access_type: "offline",
prompt: "consent",
},
},
})
);
}
@@ -608,6 +622,55 @@ export const getOptions = ({
return await autoMergeIdentities();
}
const grantedScopes = account.scope?.split(" ") ?? [];
if (
account.provider === "google" &&
!(await GoogleService.findFirstGoogleCalendarCredential({
userId: user.id as number,
})) &&
GOOGLE_CALENDAR_SCOPES.every((scope) => grantedScopes.includes(scope))
) {
// Installing Google Calendar by default
const credentialkey = {
access_token: account.access_token,
refresh_token: account.refresh_token,
id_token: account.id_token,
token_type: account.token_type,
expires_at: account.expires_at,
};
const gcalCredential = await GoogleService.createGoogleCalendarCredential({
userId: user.id as number,
key: credentialkey,
});
if (
!(await GoogleService.findGoogleMeetCredential({
userId: user.id as number,
}))
) {
await GoogleService.createGoogleMeetsCredential({
userId: user.id as number,
});
}
const oAuth2Client = new google.auth.OAuth2(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
oAuth2Client.setCredentials(credentialkey);
const calendar = google.calendar({
version: "v3",
auth: oAuth2Client,
});
const primaryCal = await GoogleService.getPrimaryCalendar(calendar);
if (primaryCal?.id) {
await GoogleService.createSelectedCalendar({
credentialId: gcalCredential.id,
userId: user.id as number,
externalId: primaryCal.id,
});
}
await GoogleService.updateProfilePhoto(oAuth2Client, user.id as number);
}
return {
...token,
id: existingUser.id,
@@ -632,7 +695,6 @@ export const getOptions = ({
log.debug("callbacks:session - Session callback called", safeStringify({ session, token, user }));
const licenseKeyService = await LicenseKeySingleton.getInstance();
const hasValidLicense = await licenseKeyService.checkLicense();
const profileId = token.profileId;
const calendsoSession: Session = {
...session,
+7
View File
@@ -192,3 +192,10 @@ export const RECORDING_IN_PROGRESS_ICON = IS_PRODUCTION
? `${WEBAPP_URL}/stop-recording.svg`
: `https://app.cal.com/stop-recording.svg`;
export const SCOPE_USERINFO_PROFILE = "https://www.googleapis.com/auth/userinfo.profile";
export const SCOPE_USERINFO_EMAIL = "https://www.googleapis.com/auth/userinfo.email";
export const GOOGLE_OAUTH_SCOPES = [SCOPE_USERINFO_PROFILE, SCOPE_USERINFO_EMAIL];
export const GOOGLE_CALENDAR_SCOPES = [
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.readonly",
];
-45
View File
@@ -1,45 +0,0 @@
import type { OAuth2Client } from "google-auth-library";
import type { calendar_v3 } from "googleapis";
import { google } from "googleapis";
import logger from "@calcom/lib/logger";
import { UserRepository } from "./server/repository/user";
export async function getAllCalendars(
calendar: calendar_v3.Calendar,
fields: string[] = ["id", "summary", "primary", "accessRole"]
): Promise<calendar_v3.Schema$CalendarListEntry[]> {
let allCalendars: calendar_v3.Schema$CalendarListEntry[] = [];
let pageToken: string | undefined;
try {
do {
const response: any = await calendar.calendarList.list({
fields: `items(${fields.join(",")}),nextPageToken`,
pageToken,
maxResults: 250, // 250 is max
});
allCalendars = [...allCalendars, ...(response.data.items ?? [])];
pageToken = response.data.nextPageToken;
} while (pageToken);
return allCalendars;
} catch (error) {
logger.error("Error fetching all Google Calendars", { error });
throw error;
}
}
export async function updateProfilePhoto(oAuth2Client: OAuth2Client, userId: number) {
try {
const oauth2 = google.oauth2({ version: "v2", auth: oAuth2Client });
const userDetails = await oauth2.userinfo.get();
if (userDetails.data?.picture) {
await UserRepository.updateAvatar({ id: userId, avatarUrl: userDetails.data.picture });
}
} catch (error) {
logger.error("Error updating avatarUrl from google calendar connect", error);
}
}
@@ -30,6 +30,15 @@ export class CredentialRepository {
});
}
static async findFirstByAppIdAndUserId({ appId, userId }: { appId: string; userId: number }) {
return await prisma.credential.findFirst({
where: {
appId,
userId,
},
});
}
static async findFirstByUserIdAndType({ userId, type }: { userId: number; type: string }) {
return await prisma.credential.findFirst({ where: { userId, type } });
}
-48
View File
@@ -1,48 +0,0 @@
import type { Prisma } from "@prisma/client";
import type { Credentials } from "google-auth-library";
import { CredentialRepository } from "./credential";
import { SelectedCalendarRepository } from "./selectedCalendar";
export class GoogleRepository {
static async createGoogleCalendarCredential({ userId, key }: { userId: number; key: Credentials }) {
return await CredentialRepository.create({
type: "google_calendar",
key,
userId,
appId: "google-calendar",
});
}
static async createGoogleMeetsCredential({ userId }: { userId: number }) {
return await CredentialRepository.create({
type: "google_video",
key: {},
userId,
appId: "google-meet",
});
}
static async createSelectedCalendar(data: { credentialId: number; userId: number; externalId: string }) {
return await SelectedCalendarRepository.create({
...data,
integration: "google_calendar",
});
}
static async upsertSelectedCalendar(
data: Omit<Prisma.SelectedCalendarUncheckedCreateInput, "integration">
) {
return await SelectedCalendarRepository.upsert({
...data,
integration: "google_calendar",
});
}
static async findGoogleMeetCredential({ userId }: { userId: number }) {
return await CredentialRepository.findFirstByUserIdAndType({
userId,
type: "google_video",
});
}
}
@@ -3,15 +3,8 @@ import type { Prisma } from "@prisma/client";
import { prisma } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
type SelectedCalendarCreateInput = {
credentialId: number;
userId: number;
externalId: string;
integration: string;
};
export class SelectedCalendarRepository {
static async create(data: SelectedCalendarCreateInput) {
static async create(data: Prisma.SelectedCalendarUncheckedCreateInput) {
return await prisma.selectedCalendar.create({
data: {
...data,
+138
View File
@@ -0,0 +1,138 @@
import type { Prisma } from "@prisma/client";
import type { Credentials } from "google-auth-library";
import type { OAuth2Client } from "google-auth-library";
import type { calendar_v3 } from "googleapis";
import { google } from "googleapis";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "../repository/credential";
import { SelectedCalendarRepository } from "../repository/selectedCalendar";
import { UserRepository } from "../repository/user";
export class GoogleService {
static async createGoogleCalendarCredential({ userId, key }: { userId: number; key: Credentials }) {
return await CredentialRepository.create({
type: "google_calendar",
key,
userId,
appId: "google-calendar",
});
}
static async createGoogleMeetsCredential({ userId }: { userId: number }) {
return await CredentialRepository.create({
type: "google_video",
key: {},
userId,
appId: "google-meet",
});
}
static async createSelectedCalendar(
data: Omit<Prisma.SelectedCalendarUncheckedCreateInput, "integration">
) {
return await SelectedCalendarRepository.create({
...data,
integration: "google_calendar",
});
}
static async upsertSelectedCalendar(
data: Omit<Prisma.SelectedCalendarUncheckedCreateInput, "integration">
) {
return await SelectedCalendarRepository.upsert({
...data,
integration: "google_calendar",
});
}
static async findGoogleMeetCredential({ userId }: { userId: number }) {
return await CredentialRepository.findFirstByUserIdAndType({
userId,
type: "google_video",
});
}
static async findFirstGoogleCalendarCredential({ userId }: { userId: number }) {
return await CredentialRepository.findFirstByAppIdAndUserId({
appId: "google-calendar",
userId,
});
}
static async getAllCalendars(
calendar: calendar_v3.Calendar,
fields: string[] = ["id", "summary", "primary", "accessRole"]
): Promise<calendar_v3.Schema$CalendarListEntry[]> {
let allCalendars: calendar_v3.Schema$CalendarListEntry[] = [];
let pageToken: string | undefined;
try {
do {
const response: any = await calendar.calendarList.list({
fields: `items(${fields.join(",")}),nextPageToken`,
pageToken,
maxResults: 250, // 250 is max
});
allCalendars = [...allCalendars, ...(response.data.items ?? [])];
pageToken = response.data.nextPageToken;
} while (pageToken);
return allCalendars;
} catch (error) {
logger.error("Error fetching all Google Calendars", { error });
throw error;
}
}
static async getPrimaryCalendar(
calendar: calendar_v3.Calendar,
fields: string[] = ["id", "summary", "primary", "accessRole"]
): Promise<calendar_v3.Schema$CalendarListEntry | null> {
let pageToken: string | undefined;
let firstCalendar: calendar_v3.Schema$CalendarListEntry | undefined;
try {
do {
const response: any = await calendar.calendarList.list({
fields: `items(${fields.join(",")}),nextPageToken`,
pageToken,
maxResults: 250, // 250 is max
});
const cals = response.data.items ?? [];
const primaryCal = cals.find((cal: calendar_v3.Schema$CalendarListEntry) => cal.primary);
if (primaryCal) {
return primaryCal;
}
// Store the first calendar in case no primary is found
if (cals.length > 0 && !firstCalendar) {
firstCalendar = cals[0];
}
pageToken = response.data.nextPageToken;
} while (pageToken);
// should not be reached because Google Cal always has a primary cal
return firstCalendar ?? null;
} catch (error) {
logger.error("Error in `getPrimaryCalendar`", { error });
throw error;
}
}
static async updateProfilePhoto(oAuth2Client: OAuth2Client, userId: number) {
try {
const oauth2 = google.oauth2({ version: "v2", auth: oAuth2Client });
const userDetails = await oauth2.userinfo.get();
if (userDetails.data?.picture) {
await UserRepository.updateAvatar({ id: userId, avatarUrl: userDetails.data.picture });
}
} catch (error) {
logger.error("Error updating avatarUrl from google calendar connect", error);
}
}
}