Basic atoms in barebone example platform apps (#13006)

* example app

* example app

* dev move

* fix: more entry points

* fixup! fix: more entry points

* refactor: v2 API (#12913)

* Use Boolean only instead of git add src/modules/auth/guard/organization-roles/organization-roles.guard.ts

* move tests next to files they test

* replace .. in import paths with absolute path

* camelCase instead of snake_case for access and refresh token variables

* user sanitize function Typescript friendly

* restructure oAuth clients folder: example for other folders

* restructure bookings module

* organize modules in auth, endpoints, repositories, services

* organize auth module

* organize repositories

* organize inputs

* rename OAuthClientGuard to OAuthClientCredentialsGuard

* add error messages

* add error messages

* clientId as param in oauth-flow & schema mapping

* camelCase instead of snake_case for clientId and clientSecret

* access token guard as passport strategy

* folder structure as features

* get rid of index files

* feat: endpoint for deleting oAuth users & oAuth users returned data (#12912)

* feat: delete oAuth users

* check if access token matches userId in parameter

* driveby: return only user id and email in oauth users endpoints

* Connect CalProvider and GCal

* Connect CalProvider and GCal

* return response interceptor to handle failed requests

* handle failed requests using axios intercepter

* cal provider refresh tokens, external gcal

* external gcal

* cal provider refresh and retries

* remove console.log

* refactor

* ignore built atoms css

* remove change to token repo

* refactor

* refactor

* downdgrade vite of unrelated packages

* move gcal endpoints to platform

* gcal service

* refactor: use atoms provider

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: Ryukemeister <sahalrajiv-extc@atharvacoe.ac.in>
This commit is contained in:
Morgan 2024-01-08 10:00:41 +02:00 committed by GitHub
parent 158ac7d7c4
commit f6c9447410
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 1197 additions and 329 deletions

View File

@ -8,14 +8,13 @@ export class AppLoggerMiddleware implements NestMiddleware {
private logger = new Logger("HTTP");
use(request: Request, response: Response, next: NextFunction): void {
const { ip, method, path: url } = request;
const { ip, method, protocol, originalUrl, path: url } = request;
const userAgent = request.get("user-agent") || "";
response.on("close", () => {
const { statusCode } = response;
const contentLength = response.get("content-length");
this.logger.log(`${method} ${url} ${statusCode} ${contentLength} - ${userAgent} ${ip}`);
this.logger.log(`${method} ${originalUrl} ${statusCode} ${contentLength} - ${userAgent} ${ip}`);
});
next();
}

View File

@ -14,7 +14,7 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
describe("OAuth Gcal App Endpoints", () => {
describe("Platform Gcal Endpoints", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
@ -44,7 +44,7 @@ describe("OAuth Gcal App Endpoints", () => {
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.createOAuthManagedUser("managed-user-e2e@gmail.com", oAuthClient.id);
user = await userRepositoryFixture.createOAuthManagedUser("gcal-connect@gmail.com", oAuthClient.id);
const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id);
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
@ -73,67 +73,66 @@ describe("OAuth Gcal App Endpoints", () => {
expect(user).toBeDefined();
});
it(`/GET/apps/gcal/oauth/redirect: it should respond 401 with invalid access token`, async () => {
it(`/GET/platform/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/apps/gcal/oauth/redirect`)
.get(`/api/v2/platform/gcal/oauth/auth-url`)
.set("Authorization", `Bearer invalid_access_token`)
.expect(401);
});
it(`/GET/apps/gcal/oauth/redirect: it should redirect to google oauth with valid access token `, async () => {
it(`/GET/platform/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/apps/gcal/oauth/redirect`)
.get(`/api/v2/platform/gcal/oauth/auth-url`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("origin", "http://localhost:5555")
.expect(301);
const redirectUrl = response.get("location");
expect(redirectUrl).toBeDefined();
expect(redirectUrl).toContain("https://accounts.google.com/o/oauth2/v2/auth");
.expect(200);
const data = response.body.data;
expect(data.authUrl).toBeDefined();
});
it(`/GET/apps/gcal/oauth/save: without oauth code`, async () => {
it(`/GET/platform/gcal/oauth/save: without oauth code`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/apps/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3Dhttp://localhost:5555&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/api/v2/platform/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3Dhttp://localhost:5555&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});
it(`/GET/apps/gcal/oauth/save: without access token`, async () => {
it(`/GET/platform/gcal/oauth/save: without access token`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/apps/gcal/oauth/save?state=origin%3Dhttp://localhost:5555&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/api/v2/platform/gcal/oauth/save?state=origin%3Dhttp://localhost:5555&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});
it(`/GET/apps/gcal/oauth/save: without origin`, async () => {
it(`/GET/platform/gcal/oauth/save: without origin`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/apps/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/api/v2/platform/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});
it(`/GET/apps/gcal/oauth/check with access token`, async () => {
it(`/GET/platform/gcal/check with access token`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/apps/gcal/oauth/check`)
.get(`/api/v2/platform/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400);
});
it(`/GET/apps/gcal/oauth/check without access token`, async () => {
await request(app.getHttpServer()).get(`/api/v2/apps/gcal/oauth/check`).expect(401);
it(`/GET/platform/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/api/v2/platform/gcal/check`).expect(401);
});
it(`/GET/apps/gcal/oauth/check with access token but no credentials`, async () => {
it(`/GET/platform/gcal/check with access token but no credentials`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/apps/gcal/oauth/check`)
.get(`/api/v2/platform/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400);
});
it(`/GET/apps/gcal/oauth/check with access token and gcal credentials`, async () => {
it(`/GET/platform/gcal/check with access token and gcal credentials`, async () => {
gcalCredentials = await credentialsRepositoryFixture.create(
"google_calendar",
{},
@ -141,7 +140,7 @@ describe("OAuth Gcal App Endpoints", () => {
"google-calendar"
);
await request(app.getHttpServer())
.get(`/api/v2/apps/gcal/oauth/check`)
.get(`/api/v2/platform/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(200);
});

View File

@ -1,4 +1,5 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { GcalService } from "@/modules/apps/services/gcal.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
@ -11,19 +12,19 @@ import {
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Query,
Redirect,
Req,
UnauthorizedException,
UseGuards,
Headers,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Request } from "express";
import { google } from "googleapis";
import { z } from "zod";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GOOGLE_CALENDAR_ID, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiRedirectResponseType, ApiResponse } from "@calcom/platform-types";
const CALENDAR_SCOPES = [
@ -32,36 +33,32 @@ const CALENDAR_SCOPES = [
];
@Controller({
path: "apps/gcal",
path: "platform/gcal",
version: "2",
})
export class GoogleCalendarOAuthController {
private readonly logger = new Logger("Apps: Gcal Controller");
export class GcalController {
private readonly logger = new Logger("Platform Gcal Provider");
constructor(
private readonly appRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly config: ConfigService
private readonly config: ConfigService,
private readonly gcalService: GcalService
) {}
@Get("/oauth/redirect")
@Redirect(undefined, 301)
private redirectUri = `${this.config.get("api.url")}/platform/gcal/oauth/save`;
@Get("/oauth/auth-url")
@HttpCode(HttpStatus.OK)
@UseGuards(AccessTokenGuard)
async redirect(@Req() req: Request): Promise<ApiRedirectResponseType> {
const app = await this.appRepository.getAppBySlug("google-calendar");
if (!app) {
throw new NotFoundException();
}
const { client_id, client_secret } = z
.object({ client_id: z.string(), client_secret: z.string() })
.parse(app.keys);
const redirect_uri = `${this.config.get("api.url")}/apps/gcal/oauth/save`;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const accessToken = req.get("Authorization")?.replace("Bearer ", "");
async redirect(
@Headers("Authorization") authorization: string,
@Req() req: Request
): Promise<ApiResponse<{ authUrl: string }>> {
const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri);
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
@ -69,7 +66,7 @@ export class GoogleCalendarOAuthController {
prompt: "consent",
state: `accessToken=${accessToken}&origin=${origin}`,
});
return { url: authUrl };
return { status: SUCCESS_STATUS, data: { authUrl } };
}
@Get("/oauth/save")
@ -79,7 +76,14 @@ export class GoogleCalendarOAuthController {
const stateParams = new URLSearchParams(state);
const { accessToken, origin } = z
.object({ accessToken: z.string(), origin: z.string() })
.parse(stateParams);
.parse({ accessToken: stateParams.get("accessToken"), origin: stateParams.get("origin") });
// User chose not to authorize your app or didn't authorize your app
// redirect directly without oauth code
if (!code) {
return { url: origin };
}
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
@ -88,24 +92,13 @@ export class GoogleCalendarOAuthController {
throw new UnauthorizedException("Invalid Access token.");
}
const app = await this.appRepository.getAppBySlug("google-calendar");
if (!app) {
throw new NotFoundException();
}
const { client_id, client_secret } = z
.object({ client_id: z.string(), client_secret: z.string() })
.parse(app.keys);
const redirect_uri = `${this.config.get("api.url")}/apps/gcal/oauth/save`;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri);
const token = await oAuth2Client.getToken(parsedCode);
const key = token.res?.data;
const credential = await this.credentialRepository.createAppCredential(
"google_calendar",
GOOGLE_CALENDAR_TYPE,
key,
ownerId,
"google-calendar"
ownerId
);
oAuth2Client.setCredentials(key);
@ -124,14 +117,14 @@ export class GoogleCalendarOAuthController {
primaryCal.id,
credential.id,
ownerId,
"google_calendar"
GOOGLE_CALENDAR_ID
);
}
return { url: origin };
}
@Get("/oauth/check")
@Get("/check")
@HttpCode(HttpStatus.OK)
@UseGuards(AccessTokenGuard)
async check(@GetUser("id") userId: number): Promise<ApiResponse> {

View File

@ -0,0 +1,17 @@
import { GcalController } from "@/ee/gcal/gcal.controller";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { GcalService } from "@/modules/apps/services/gcal.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@Module({
imports: [PrismaModule, TokensModule, OAuthClientModule],
providers: [AppsRepository, ConfigService, CredentialsRepository, SelectedCalendarsRepository, GcalService],
controllers: [GcalController],
})
export class GcalModule {}

View File

@ -0,0 +1,14 @@
import { GcalModule } from "@/ee/gcal/gcal.module";
import { ProviderModule } from "@/ee/provider/provider.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@Module({
imports: [GcalModule, ProviderModule],
})
export class PlatformEndpointsModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
configure(_consumer: MiddlewareConsumer) {
// TODO: apply ratelimits
}
}

View File

@ -0,0 +1,68 @@
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { UserReturned } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import {
BadRequestException,
Controller,
Get,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "platform/provider",
version: "2",
})
export class CalProviderController {
private readonly logger = new Logger("Platform Provider Controller");
constructor(
private readonly tokensRepository: TokensRepository,
private readonly oauthClientRepository: OAuthClientRepository
) {}
@Get("/:clientId")
@HttpCode(HttpStatus.OK)
async verifyClientId(@Param("clientId") clientId: string): Promise<ApiResponse> {
if (!clientId) {
throw new NotFoundException();
}
const oAuthClient = await this.oauthClientRepository.getOAuthClient(clientId);
if (!oAuthClient) throw new UnauthorizedException();
return {
status: SUCCESS_STATUS,
};
}
@Get("/:clientId/access-token")
@HttpCode(HttpStatus.OK)
@UseGuards(AccessTokenGuard)
async verifyAccessToken(
@Param("clientId") clientId: string,
@GetUser() user: UserReturned
): Promise<ApiResponse> {
if (!clientId) {
throw new BadRequestException();
}
if (!user) {
throw new UnauthorizedException();
}
return {
status: SUCCESS_STATUS,
};
}
}

View File

@ -0,0 +1,13 @@
import { CalProviderController } from "@/ee/provider/provider.controller";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, TokensModule, OAuthClientModule],
providers: [CredentialsRepository],
controllers: [CalProviderController],
})
export class ProviderModule {}

View File

@ -1,15 +1,20 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/common";
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { Response } from "@calcom/platform-types";
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
private readonly logger = new Logger("HttpExceptionFilter");
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
const statusCode = exception.getStatus();
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
exception,
});
response.status(statusCode).json({
status: ERROR_STATUS,
timestamp: new Date().toISOString(),

View File

@ -1,5 +1,4 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { GoogleCalendarOAuthController } from "@/modules/apps/controllers/gcal-oauth/gcal-oauth.controller";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
@ -10,7 +9,6 @@ import { ConfigService } from "@nestjs/config";
@Module({
imports: [PrismaModule, TokensModule],
providers: [AppsRepository, ConfigService, CredentialsRepository, SelectedCalendarsRepository],
controllers: [GoogleCalendarOAuthController],
exports: [],
})
export class AppsModule {}

View File

@ -0,0 +1,27 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { google } from "googleapis";
import { z } from "zod";
@Injectable()
export class GcalService {
private logger = new Logger("GcalService");
constructor(private readonly appsRepository: AppsRepository) {}
async getOAuthClient(redirectUri: string) {
this.logger.log("Getting Google Calendar OAuth Client");
const app = await this.appsRepository.getAppBySlug("google-calendar");
if (!app) {
throw new NotFoundException();
}
const { client_id, client_secret } = z
.object({ client_id: z.string(), client_secret: z.string() })
.parse(app.keys);
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
return oAuth2Client;
}
}

View File

@ -3,17 +3,19 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
@Injectable()
export class CredentialsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
createAppCredential(type: string, key: Prisma.InputJsonValue, userId: number, appId: string) {
createAppCredential(type: keyof typeof APPS_TYPE_ID_MAPPING, key: Prisma.InputJsonValue, userId: number) {
return this.dbWrite.prisma.credential.create({
data: {
type,
key,
userId,
appId,
appId: APPS_TYPE_ID_MAPPING[type],
},
});
}

View File

@ -1,11 +1,11 @@
import { AppsModule } from "@/modules/apps/apps.module";
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
import { BookingModule } from "@/modules/bookings/booking.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@Module({
imports: [BookingModule, OAuthClientModule, AppsModule],
imports: [BookingModule, OAuthClientModule, PlatformEndpointsModule],
})
export class EndpointsModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@ -1,5 +1,6 @@
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { Request } from "express";
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@ -8,16 +9,15 @@ export class OAuthClientCredentialsGuard implements CanActivate {
constructor(private readonly oauthRepository: OAuthClientRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const { headers, params } = request;
const request = context.switchToHttp().getRequest<Request>();
const { params } = request;
const oauthClientId = params.clientId;
const oauthClientSecret = headers[X_CAL_SECRET_KEY];
const oauthClientSecret = request.get(X_CAL_SECRET_KEY);
if (!oauthClientId) {
throw new UnauthorizedException("Missing client ID");
}
if (!oauthClientSecret) {
throw new UnauthorizedException("Missing client secret");
}

View File

@ -1,7 +1,6 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
@Injectable()
export class SelectedCalendarsRepository {

View File

@ -40,9 +40,8 @@ export class TokensRepository {
}
async createOAuthTokens(clientId: string, ownerId: number) {
const accessExpiry = DateTime.now().plus({ days: 1 }).startOf("day").toJSDate();
const accessExpiry = DateTime.now().plus({ minute: 1 }).startOf("minute").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.create({
data: {
@ -94,7 +93,7 @@ export class TokensRepository {
}
async refreshOAuthTokens(clientId: string, refreshTokenSecret: string, tokenUserId: number) {
const accessExpiry = DateTime.now().plus({ days: 1 }).startOf("day").toJSDate();
const accessExpiry = DateTime.now().plus({ minute: 1 }).startOf("minute").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@ -65,7 +65,7 @@ export const OAuthClients = () => {
return (
<OAuthClientCard
name={client.name}
redirect_uris={client.redirect_uris}
redirectUris={client.redirectUris}
permissions={client.permissions}
key={index}
lastItem={data.length === index + 1}

View File

@ -10,7 +10,8 @@
"packages/features/*",
"packages/app-store/*",
"packages/app-store/ee/*",
"packages/platform/*"
"packages/platform/*",
"packages/platform/examples/base"
],
"scripts": {
"app-store-cli": "yarn workspace @calcom/app-store-cli",

View File

@ -10,7 +10,7 @@ module.exports = {
"../../packages/app-store/**/*{components,pages}/**/*.{js,ts,jsx,tsx}",
"../../packages/features/**/*.{js,ts,jsx,tsx}",
"../../packages/ui/**/*.{js,ts,jsx,tsx}",
"../../packages/atoms/**/*.{js,ts,jsx,tsx}",
"../../packages/platform/atoms/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
theme: {

1
packages/platform/atoms/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
globals.min.css

View File

@ -0,0 +1,53 @@
import type { ReactNode } from "react";
import { useState } from "react";
import { AtomsContext } from "../hooks/useAtomsContext";
import { useOAuthClient } from "../hooks/useOAuthClient";
import { useOAuthFlow } from "../hooks/useOAuthFlow";
import http from "../lib/http";
type CalProviderProps = {
children?: ReactNode;
clientId: string;
accessToken: string;
options: { refreshUrl?: string; apiUrl: string };
};
export function CalProvider({ clientId, accessToken, options, children }: CalProviderProps) {
const [error, setError] = useState<string>("");
const { isInit } = useOAuthClient({
clientId,
apiUrl: options.apiUrl,
refreshUrl: options.refreshUrl,
onError: setError,
});
const { isRefreshing, currentAccessToken } = useOAuthFlow({
accessToken,
refreshUrl: options.refreshUrl,
onError: setError,
clientId,
});
return isInit ? (
<AtomsContext.Provider
value={{
clientId,
accessToken: currentAccessToken,
options,
error,
getClient: () => http,
isRefreshing: isRefreshing,
isInit: isInit,
isValidClient: Boolean(!error && clientId && isInit),
isAuth: Boolean(
isInit && !error && clientId && !isRefreshing && currentAccessToken && http.getAuthorizationHeader()
),
}}>
{children}
</AtomsContext.Provider>
) : (
<>{children}</>
);
}

View File

@ -1,2 +0,0 @@
export const NO_KEY_VALUE = "no key value";
export const INVALID_API_KEY = "invalid api key";

View File

@ -1,2 +0,0 @@
export { CalProvider } from "./index";
export * from "../types";

View File

@ -0,0 +1 @@
export { CalProvider } from "./CalProvider";

View File

@ -1,47 +0,0 @@
import type { ReactNode } from "react";
import { createContext, useContext, useState, useCallback, useEffect } from "react";
import { NO_KEY_VALUE, INVALID_API_KEY } from "./errors";
type CalProviderProps = {
apiKey: string;
children: ReactNode;
};
const ApiKeyContext = createContext({ key: "", error: "" });
export const useApiKey = () => useContext(ApiKeyContext);
export function CalProvider({ apiKey, children }: CalProviderProps) {
const [key, setKey] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const verifyApiKey = useCallback(
async (key: string) => {
try {
// here we'll call the /me endpoint in v2 to get user profile
const response = await fetch(`/v2/me?apiKey=${key}`);
if (response.ok) {
setKey(apiKey);
}
} catch (error) {
console.error(error);
setErrorMessage(INVALID_API_KEY);
}
},
[apiKey]
);
useEffect(() => {
if (apiKey.length === 0) {
setErrorMessage(NO_KEY_VALUE);
} else {
verifyApiKey(apiKey);
}
}, [verifyApiKey, apiKey]);
return (
<ApiKeyContext.Provider value={{ key: key, error: errorMessage }}>{children}</ApiKeyContext.Provider>
);
}

View File

@ -0,0 +1,2 @@
export { CalProvider } from "./cal-provider";
export { GcalConnect } from "./gcal-connect";

View File

@ -1,79 +0,0 @@
import { Loader2 } from "lucide-react";
import { useState, useCallback } from "react";
import * as React from "react";
import { Button } from "../src/components/ui/button";
import { cn } from "../src/lib/utils";
import type { AtomsGlobalConfigProps } from "../types";
interface ConnectButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
buttonText?: string;
icon?: JSX.Element;
onSuccess?: () => void;
onError?: () => void;
}
export function ConnectButton({
buttonText,
onClick,
onSuccess,
onError,
className,
icon,
}: ConnectButtonProps & AtomsGlobalConfigProps) {
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [errMsg, setErrMsg] = useState<string>("");
const handleSubmit = useCallback(
async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
setIsProcessing(true);
try {
if (onClick) {
await onClick(e);
}
// if user wants to handle onSuccess inside onClick then it makes no sense to have a separate handler
// otherwise only if the user explicitly passes an onSuccess handler this gets triggered
if (onSuccess) {
await onSuccess();
}
} catch (error: any) {
setIsProcessing(false);
if (onError) {
await onError();
}
setErrMsg(error?.message);
}
setIsProcessing(false);
},
[onClick, onSuccess, onError]
);
return (
<div>
{/* TODO: Button needs a fix width in order to not resize at loading time */}
<Button
className={cn(
"bg-default text-default dark:text-muted dark:bg-muted relative inline-flex h-9 items-center whitespace-nowrap rounded-md px-4 py-2.5 text-sm font-medium !shadow-none transition-colors disabled:cursor-not-allowed",
className
)}
type="button"
disabled={isProcessing}
onClick={handleSubmit}>
{isProcessing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<>
{!!icon && icon}
{buttonText || "Install App"}
</>
)}
</Button>
{!!errMsg && <span>{errMsg}</span>}
</div>
);
}

View File

@ -1,2 +0,0 @@
export { ConnectToCal } from "./index";
export * from "../types";

View File

@ -1,29 +0,0 @@
import { useApiKey } from "../cal-provider";
import { ConnectButton } from "../connect-to-cal-button/Button";
// This atom will initiate the oAuth connection process to the users of the platform
// the user would be redirected to grant oAuth permission page after the user has clicked on Connect Atom
// they will have to login/signup and then will be redirected to the permission page where they can see required permissions for the oAuth clients and can choose to deny or accept
export function ConnectToCal() {
const { key } = useApiKey();
const handleClick = () => {
// TODO: the url to redirect should include a client_id and redirect_uri
window.location.href = `https://app.cal.com/auth/login?client_id=%${key}&redirect_uri=`;
};
if (key === "no_key") {
return <>You havent entered a key</>;
}
if (key === "invalid_key") {
return <>This is not a valid key, please enter a valid key</>;
}
return (
<>
<ConnectButton onClick={handleClick}>Connect to Cal.com</ConnectButton>
</>
);
}

View File

@ -0,0 +1,37 @@
import { cn } from "@/lib/utils";
import type { FC } from "react";
import { Button } from "@calcom/ui";
import { CalendarDays } from "@calcom/ui/components/icon";
import { useAtomsContext } from "../hooks/useAtomsContext";
import { useGcal } from "../hooks/useGcal";
interface GcalConnectProps {
className?: string;
label?: string;
alreadyConnectedLabel?: string;
}
export const GcalConnect: FC<GcalConnectProps> = ({
label = "Connect Google Calendar",
alreadyConnectedLabel = "Connected Google Calendar",
className,
}) => {
const { isAuth } = useAtomsContext();
const { allowConnect, checked, redirectToGcalOAuth } = useGcal({ isAuth });
if (!isAuth || !checked) return <></>;
return (
<Button
StartIcon={CalendarDays}
color="primary"
disabled={!allowConnect}
className={cn("", className)}
onClick={() => redirectToGcalOAuth()}>
{allowConnect ? label : alreadyConnectedLabel}
</Button>
);
};

View File

@ -0,0 +1 @@
export { GcalConnect } from "./GcalConnect";

View File

@ -1,83 +1,166 @@
/*
* @NOTE: This file is only imported when building the component's CSS file
* When using this component in any Cal project, the globals are automatically imported
* in that project.
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "../ui/styles/shared-globals.css";
@import "/packages/ui/styles/shared-globals.css";
@import "/apps/web/styles/globals.css";
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 222.2 47.4% 11.2%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
/* background */
--cal-bg-emphasis: #e5e7eb;
--cal-bg: white;
--cal-bg-subtle: #f3f4f6;
--cal-bg-muted: #f9fafb;
--cal-bg-inverted: #111827;
/* background -> components*/
--cal-bg-info: #dee9fc;
--cal-bg-success: #e2fbe8;
--cal-bg-attention: #fceed8;
--cal-bg-error: #f9e3e2;
--cal-bg-dark-error: #752522;
/* Borders */
--cal-border-emphasis: #9ca3af;
--cal-border: #d1d5db;
--cal-border-subtle: #e5e7eb;
--cal-border-booker: #e5e7eb;
--cal-border-muted: #f3f4f6;
--cal-border-error: #aa2e26;
/* Content/Text */
--cal-text-emphasis: #111827;
--cal-text: #374151;
--cal-text-subtle: #6b7280;
--cal-text-muted: #9ca3af;
--cal-text-inverted: white;
/* Content/Text -> components */
--cal-text-info: #253985;
--cal-text-success: #285231;
--cal-text-attention: #73321b;
--cal-text-error: #752522;
/* Brand shinanigans
-> These will be computed for the users theme at runtime.
*/
--cal-brand: #111827;
--cal-brand-emphasis: #101010;
--cal-brand-text: white;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%;
--radius: 0.5rem;
--cal-bg-emphasis: #2b2b2b;
--cal-bg: #101010;
--cal-bg-subtle: #2b2b2b;
--cal-bg-muted: #1c1c1c;
--cal-bg-inverted: #f3f4f6;
/* background -> components*/
--cal-bg-info: #263fa9;
--cal-bg-success: #306339;
--cal-bg-attention: #8e3b1f;
--cal-bg-error: #8c2822;
--cal-bg-dark-error: #752522;
/* Borders */
--cal-border-emphasis: #575757;
--cal-border: #444444;
--cal-border-subtle: #2b2b2b;
--cal-border-booker: #2b2b2b;
--cal-border-muted: #1c1c1c;
--cal-border-error: #aa2e26;
/* Content/Text */
--cal-text-emphasis: #f3f4f6;
--cal-text: #d6d6d6;
--cal-text-subtle: #a5a5a5;
--cal-text-muted: #575757;
--cal-text-inverted: #101010;
/* Content/Text -> components */
--cal-text-info: #dee9fc;
--cal-text-success: #e2fbe8;
--cal-text-attention: #fceed8;
--cal-text-error: #f9e3e2;
/* Brand shenanigans
-> These will be computed for the users theme at runtime.
*/
--cal-brand: white;
--cal-brand-emphasis: #e1e1e1;
--cal-brand-text: black;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
}

View File

@ -0,0 +1,5 @@
import { createContext, useContext } from "react";
export const ApiKeyContext = createContext({ key: "", error: "" });
export const useApiKey = () => useContext(ApiKeyContext);

View File

@ -0,0 +1,33 @@
import { createContext, useContext } from "react";
import type http from "../lib/http";
export interface IAtomsContextOptions {
refreshUrl?: string;
apiUrl: string;
}
export interface IAtomsContext {
clientId: string;
accessToken?: string;
options: IAtomsContextOptions;
error?: string;
getClient: () => typeof http | void;
refreshToken?: string;
isRefreshing?: boolean;
isAuth: boolean;
isValidClient: boolean;
isInit: boolean;
}
export const AtomsContext = createContext({
clientId: "",
accessToken: "",
options: { refreshUrl: "", apiUrl: "" },
error: "",
getClient: () => {
return;
},
} as IAtomsContext);
export const useAtomsContext = () => useContext(AtomsContext);

View File

@ -0,0 +1,35 @@
import { useState, useEffect } from "react";
import http from "../lib/http";
export interface useGcalProps {
isAuth: boolean;
}
export const useGcal = ({ isAuth }: useGcalProps) => {
const [allowConnect, setAllowConnect] = useState<boolean>(false);
const [checked, setChecked] = useState<boolean>(false);
const redirectToGcalOAuth = () => {
http
?.get("/platform/gcal/oauth/auth-url")
.then(({ data: responseBody }) => {
if (responseBody.data?.authUrl) {
window.location.href = responseBody.data.authUrl;
}
})
.catch(console.error);
};
useEffect(() => {
if (isAuth) {
http
?.get("/platform/gcal/check")
.then(() => setAllowConnect(false))
.catch(() => setAllowConnect(true))
.finally(() => setChecked(true));
}
}, [isAuth]);
return { allowConnect, checked, redirectToGcalOAuth };
};

View File

@ -0,0 +1,44 @@
import type { AxiosError } from "axios";
import { useState, useEffect } from "react";
import { usePrevious } from "react-use";
import type { ApiResponse } from "@calcom/platform-types";
import http from "../lib/http";
export interface useOAuthClientProps {
clientId: string;
apiUrl?: string;
refreshUrl?: string;
onError: (error: string) => void;
}
export const useOAuthClient = ({ clientId, apiUrl, refreshUrl, onError }: useOAuthClientProps) => {
const prevClientId = usePrevious(clientId);
const [isInit, setIsInit] = useState<boolean>(false);
useEffect(() => {
if (apiUrl && http.getUrl() !== apiUrl) {
http.setUrl(apiUrl);
setIsInit(true);
}
if (refreshUrl && http.getRefreshUrl() !== refreshUrl) {
http.setRefreshUrl(refreshUrl);
}
}, [apiUrl, refreshUrl]);
useEffect(() => {
if (clientId && http.getUrl() && prevClientId !== clientId) {
try {
http.get<ApiResponse>(`/platform/provider/${clientId}`).catch((err: AxiosError) => {
if (err.response?.status === 401) {
onError("Invalid oAuth Client.");
}
});
} catch (err) {
console.error(err);
}
}
}, [clientId, onError, prevClientId]);
return { isInit };
};

View File

@ -0,0 +1,74 @@
import type { AxiosError, AxiosRequestConfig } from "axios";
import { useEffect, useState } from "react";
import usePrevious from "react-use/lib/usePrevious";
import type { ApiResponse } from "@calcom/platform-types";
import http from "../lib/http";
export interface useOAuthProps {
accessToken?: string;
refreshUrl?: string;
onError?: (error: string) => void;
clientId: string;
}
export const useOAuthFlow = ({ accessToken, refreshUrl, clientId, onError }: useOAuthProps) => {
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [clientAccessToken, setClientAccessToken] = useState<string>("");
const prevAccessToken = usePrevious(accessToken);
useEffect(() => {
const interceptorId =
clientAccessToken && http.getAuthorizationHeader()
? http.responseInterceptor.use(undefined, async (err: AxiosError) => {
const originalRequest = err.config as AxiosRequestConfig & { _retry?: boolean };
if (refreshUrl && err.response?.status === 498 && !isRefreshing) {
setIsRefreshing(true);
originalRequest._retry = true;
const refreshedToken = await http.refreshTokens(refreshUrl);
if (refreshedToken) setClientAccessToken(refreshedToken);
else onError?.("Invalid Refresh Token.");
setIsRefreshing(false);
if (!originalRequest._retry) {
return http.instance(originalRequest);
}
}
})
: "";
return () => {
if (interceptorId) {
http.responseInterceptor.eject(interceptorId);
}
};
}, [clientAccessToken, isRefreshing, refreshUrl, onError]);
useEffect(() => {
if (accessToken && http.getUrl() && prevAccessToken !== accessToken) {
http.setAuthorizationHeader(accessToken);
try {
http
.get<ApiResponse>(`/platform/provider/${clientId}/access-token`)
.catch(async (err: AxiosError) => {
if (err.response?.status === 401) onError?.("Invalid Access Token.");
if (err.response?.status === 498 && refreshUrl) {
setIsRefreshing(true);
const refreshedToken = await http.refreshTokens(refreshUrl);
if (refreshedToken) setClientAccessToken(refreshedToken);
else onError?.("Invalid Refresh Token.");
setIsRefreshing(false);
}
})
.finally(() => {
setClientAccessToken(accessToken);
});
} catch (err) {}
}
}, [accessToken, clientId, refreshUrl, prevAccessToken, onError]);
return { isRefreshing, currentAccessToken: clientAccessToken };
};

View File

@ -1,3 +1,2 @@
export { Booker } from "./booker/Booker";
export { CalProvider } from "./cal-provider/index";
export { ConnectToCal } from "./connect-to-cal-button/index";
export { CalProvider } from "./cal-provider/CalProvider";

View File

@ -0,0 +1,55 @@
import axios from "axios";
// Immediately Invoked Function Expression to create simple singleton class like
const http = (function () {
const instance = axios.create({
timeout: 10000,
headers: {},
});
let refreshUrl = "";
return {
instance: instance,
get: instance.get,
post: instance.post,
put: instance.put,
delete: instance.delete,
responseInterceptor: instance.interceptors.response,
setRefreshUrl: (url: string) => {
refreshUrl = url;
},
getRefreshUrl: () => {
return refreshUrl;
},
setUrl: (url: string) => {
instance.defaults.baseURL = url;
},
getUrl: () => {
return instance.defaults.baseURL;
},
setAuthorizationHeader: (accessToken: string) => {
instance.defaults.headers.common["Authorization"] = `Bearer ${accessToken}`;
},
getAuthorizationHeader: () => {
return instance.defaults.headers.common?.["Authorization"]?.toString() ?? "";
},
refreshTokens: async (refreshUrl: string): Promise<string> => {
const response = await fetch(`${refreshUrl}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: http.getAuthorizationHeader(),
},
});
const res = await response.json();
if (res.accessToken) {
http.setAuthorizationHeader(res.accessToken);
return res.accessToken;
}
return "";
},
};
})();
export default http;

View File

@ -7,7 +7,9 @@
"authors": "Cal.com, Inc.",
"version": "0.0.0",
"scripts": {
"build": "node build.mjs"
"build": "node build.mjs",
"vite-dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
"vite-build": "yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.0.1",
@ -17,17 +19,34 @@
"@vitejs/plugin-react": "^2.2.0",
"rollup-plugin-node-builtins": "^2.1.2",
"typescript": "^4.9.4",
"vite": "^4.1.2"
"vite": "^5.0.10"
},
"files": [
"dist"
],
"main": "index.ts",
"module": "index.ts",
"exports": {
".": {
"import": "./index.ts",
"require": "./index.ts"
},
"./components": {
"import": "./dist/cal-atoms.js",
"require": "./dist/cal-atoms.umd.cjs"
},
"./dist/globals.min.css": "./globals.min.css",
"./dist/index.ts": "./index.ts"
},
"main": "./index.ts",
"types": "./index.ts",
"dependencies": {
"@calcom/ui": "*",
"@radix-ui/react-slot": "^1.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"lucide-react": "^0.293.0",
"react-use": "^17.4.2",
"tailwind-merge": "^2.0.0",
"tailwindcss": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
}
}

View File

@ -1,25 +1,26 @@
const base = require("@calcom/config/tailwind-preset");
/** @type {import('tailwindcss').Config} */
module.exports = {
...base,
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
"./bookings/**/*.tsx",
...base.content,
"../../../packages/ui/**/*.{js,ts,jsx,tsx,mdx}",
"../../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
"./**/*.tsx",
],
plugins: [...base.plugins, require("tailwindcss-animate")],
theme: {
...base.theme,
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
...base.theme.container,
},
extend: {
...base.theme.extend,
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
@ -54,27 +55,30 @@ module.exports = {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
...base.theme.extend.colors,
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
lg: `var(--radius)`,
md: `calc(var(--radius) - 2px)`,
sm: "calc(var(--radius) - 4px)",
...base.theme.extend.borderRadius,
},
keyframes: {
"accordion-down": {
from: { height: 0 },
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
to: { height: "0" },
},
...base.theme.keyframes,
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
...base.theme.animation,
},
},
},
plugins: [require("tailwindcss-animate")],
};

View File

@ -7,7 +7,7 @@ export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: [resolve(__dirname, "booker/export.ts")],
entry: [resolve(__dirname, "components.ts")],
name: "CalAtoms",
fileName: "cal-atoms",
},
@ -23,9 +23,9 @@ export default defineConfig({
},
resolve: {
alias: {
fs: resolve("../../node_modules/rollup-plugin-node-builtins"),
path: resolve("../../node_modules/rollup-plugin-node-builtins"),
os: resolve("../../node_modules/rollup-plugin-node-builtins"),
fs: resolve("../../../node_modules/rollup-plugin-node-builtins"),
path: resolve("../../../node_modules/rollup-plugin-node-builtins"),
os: resolve("../../../node_modules/rollup-plugin-node-builtins"),
"@": path.resolve(__dirname, "./src"),
},
},

View File

@ -0,0 +1,6 @@
export const GOOGLE_CALENDAR_TYPE = "google_calendar";
export const GOOGLE_CALENDAR_ID = "google-calendar";
export const APPS_TYPE_ID_MAPPING = {
[GOOGLE_CALENDAR_TYPE]: GOOGLE_CALENDAR_ID,
} as const;

View File

@ -1,2 +1,3 @@
export * from "./permissions";
export * from "./api";
export * from "./apps";

View File

@ -51,7 +51,7 @@ export const PERMISSIONS_GROUPED_MAP = {
APPS: {
read: APPS_READ,
write: APPS_WRITE,
key: "apps",
label: "Apps",
key: "app",
label: "App",
},
} as const;

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.yarn
dev.db

View File

@ -0,0 +1,40 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ["@calcom/platform-constants"],
webpack: (config, { webpack, buildId }) => {
config.resolve.fallback = {
...config.resolve.fallback, // if you miss it, all the other options in fallback, specified
};
return config;
},
};
module.exports = nextConfig;

View File

@ -0,0 +1,30 @@
{
"name": "@calcom/base",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "PORT=4321 next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@calcom/platform-atoms": "*",
"@prisma/client": "5.4.2",
"next": "14.0.4",
"prisma": "^5.7.1",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.0.4",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,25 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
// prisma/schema.prisma
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
calcomUserId Int? @unique
refreshToken String? @unique
accessToken String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@ -0,0 +1,16 @@
import { PrismaClient } from "@prisma/client";
const prismaClientSingleton = () => {
return new PrismaClient();
};
declare global {
// eslint-disable-next-line no-var
var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
}
const prisma = global.prisma ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma;

View File

@ -0,0 +1,60 @@
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { useEffect, useState } from "react";
import { CalProvider } from "@calcom/platform-atoms/components";
import "@calcom/platform-atoms/dist/globals.min.css";
function generateRandomEmail() {
const localPartLength = 10;
const domain = ["example.com", "example.net", "example.org"];
const randomLocalPart = Array.from({ length: localPartLength }, () =>
String.fromCharCode(Math.floor(Math.random() * 26) + 97)
).join("");
const randomDomain = domain[Math.floor(Math.random() * domain.length)];
return `${randomLocalPart}@${randomDomain}`;
}
export default function App({ Component, pageProps }: AppProps) {
const [accessToken, setAccessToken] = useState("");
const [email, setUserEmail] = useState("");
useEffect(() => {
const randomEmail = generateRandomEmail();
fetch("/api/managed-user", {
method: "POST",
body: JSON.stringify({ email: randomEmail }),
}).then(async (res) => {
const data = await res.json();
setAccessToken(data.accessToken);
setUserEmail(data.email);
});
}, []);
return (
<div>
<CalProvider
accessToken={accessToken}
// eslint-disable-next-line turbo/no-undeclared-env-vars
clientId={process.env.NEXT_PUBLIC_X_CAL_ID ?? ""}
// eslint-disable-next-line turbo/no-undeclared-env-vars
options={{ apiUrl: process.env.NEXT_PUBLIC_CALCOM_API_URL ?? "", refreshUrl: "/api/refresh" }}>
{email ? (
<>
<p className="m-12 text-lg">{email}</p>
<Component {...pageProps} />
</>
) : (
<>
<main className={`flex min-h-screen flex-col items-center justify-between p-24 `}>
<div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex" />
</main>
</>
)}
</CalProvider>{" "}
</div>
);
}

View File

@ -0,0 +1,13 @@
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="en" dir="ltr">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}

View File

@ -0,0 +1,60 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import prisma from "../../lib/prismaClient";
type Data = {
email: string;
id: number;
accessToken: string;
};
// example endpoint to create a managed cal.com user
export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
const { email } = JSON.parse(req.body);
const existingUser = await prisma.user.findFirst({ orderBy: { createdAt: "desc" } });
if (existingUser && existingUser.calcomUserId) {
return res.status(200).json({
id: existingUser.calcomUserId,
email: existingUser.email,
accessToken: existingUser.accessToken ?? "",
});
}
const localUser = await prisma.user.create({
data: {
email,
},
});
const response = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
},
body: JSON.stringify({
email,
}),
}
);
const body = await response.json();
await prisma.user.update({
data: {
refreshToken: (body.data.refreshToken as string) ?? "",
accessToken: (body.data.accessToken as string) ?? "",
calcomUserId: body.data.user.id,
},
where: { id: localUser.id },
});
return res.status(200).json({
id: body?.data?.user?.id,
email: (body.data.user.email as string) ?? "",
accessToken: (body.data.accessToken as string) ?? "",
});
}

View File

@ -0,0 +1,61 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import prisma from "../../lib/prismaClient";
type Data = {
accessToken: string;
};
// example endpoint called by the client to refresh the access token of cal.com managed user
export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
const authHeader = req.headers.authorization;
const accessToken = authHeader?.split("Bearer ")[1];
if (accessToken) {
const localUser = await prisma.user.findUnique({
where: {
accessToken: accessToken as string,
},
});
if (localUser?.refreshToken) {
const response = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth/${
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.NEXT_PUBLIC_X_CAL_ID ?? ""
}/refresh`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
},
body: JSON.stringify({
refreshToken: localUser.refreshToken,
}),
}
);
if (response.status === 200) {
const resp = await response.json();
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = resp.data;
await prisma.user.update({
data: {
refreshToken: (newRefreshToken as string) ?? "",
accessToken: (newAccessToken as string) ?? "",
},
where: { id: localUser.id },
});
return res.status(200).json({ accessToken: newAccessToken });
}
return res.status(400).json({ accessToken: "" });
}
}
return res.status(404).json({ accessToken: "" });
}

View File

@ -0,0 +1,15 @@
import { Inter } from "next/font/google";
import { GcalConnect } from "@calcom/platform-atoms/components";
const inter = Inter({ subsets: ["latin"] });
export default function Home() {
return (
<main className={`flex min-h-screen flex-col items-center justify-between p-24 ${inter.className}`}>
<div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex">
<GcalConnect className="bg-orange-600 hover:bg-orange-700" />
</div>
</main>
);
}

View File

@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
}

View File

@ -0,0 +1,19 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
},
},
plugins: [],
};
export default config;

View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}