diff --git a/apps/api/v2/src/app.logger.middleware.ts b/apps/api/v2/src/app.logger.middleware.ts index 89e1b144870f35..80ce4cef3e3f26 100644 --- a/apps/api/v2/src/app.logger.middleware.ts +++ b/apps/api/v2/src/app.logger.middleware.ts @@ -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(); } diff --git a/apps/api/v2/src/modules/apps/controllers/gcal-oauth/gcal-oauth.controller.e2e-spec.ts b/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts similarity index 68% rename from apps/api/v2/src/modules/apps/controllers/gcal-oauth/gcal-oauth.controller.e2e-spec.ts rename to apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts index 73b984b137ea1a..611e4511ab1075 100644 --- a/apps/api/v2/src/modules/apps/controllers/gcal-oauth/gcal-oauth.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts @@ -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); }); diff --git a/apps/api/v2/src/modules/apps/controllers/gcal-oauth/gcal-oauth.controller.ts b/apps/api/v2/src/ee/gcal/gcal.controller.ts similarity index 69% rename from apps/api/v2/src/modules/apps/controllers/gcal-oauth/gcal-oauth.controller.ts rename to apps/api/v2/src/ee/gcal/gcal.controller.ts index 74f20e53c9322c..a5f88e0bae64ed 100644 --- a/apps/api/v2/src/modules/apps/controllers/gcal-oauth/gcal-oauth.controller.ts +++ b/apps/api/v2/src/ee/gcal/gcal.controller.ts @@ -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) - @UseGuards(AccessTokenGuard) - async redirect(@Req() req: Request): Promise { - const app = await this.appRepository.getAppBySlug("google-calendar"); - - if (!app) { - throw new NotFoundException(); - } + private redirectUri = `${this.config.get("api.url")}/platform/gcal/oauth/save`; - 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 ", ""); + @Get("/oauth/auth-url") + @HttpCode(HttpStatus.OK) + @UseGuards(AccessTokenGuard) + async redirect( + @Headers("Authorization") authorization: string, + @Req() req: Request + ): Promise> { + 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 { diff --git a/apps/api/v2/src/ee/gcal/gcal.module.ts b/apps/api/v2/src/ee/gcal/gcal.module.ts new file mode 100644 index 00000000000000..238fb90ca29c7d --- /dev/null +++ b/apps/api/v2/src/ee/gcal/gcal.module.ts @@ -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 {} diff --git a/apps/api/v2/src/ee/platform-endpoints-module.ts b/apps/api/v2/src/ee/platform-endpoints-module.ts new file mode 100644 index 00000000000000..191772ec5947bd --- /dev/null +++ b/apps/api/v2/src/ee/platform-endpoints-module.ts @@ -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 + } +} diff --git a/apps/api/v2/src/ee/provider/provider.controller.ts b/apps/api/v2/src/ee/provider/provider.controller.ts new file mode 100644 index 00000000000000..6400c3b1909555 --- /dev/null +++ b/apps/api/v2/src/ee/provider/provider.controller.ts @@ -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 { + 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 { + if (!clientId) { + throw new BadRequestException(); + } + + if (!user) { + throw new UnauthorizedException(); + } + + return { + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/ee/provider/provider.module.ts b/apps/api/v2/src/ee/provider/provider.module.ts new file mode 100644 index 00000000000000..d96be50d3a6fbb --- /dev/null +++ b/apps/api/v2/src/ee/provider/provider.module.ts @@ -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 {} diff --git a/apps/api/v2/src/filters/http-exception.filter.ts b/apps/api/v2/src/filters/http-exception.filter.ts index 45122d0bb55985..3cd808a8e29040 100644 --- a/apps/api/v2/src/filters/http-exception.filter.ts +++ b/apps/api/v2/src/filters/http-exception.filter.ts @@ -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 { + private readonly logger = new Logger("HttpExceptionFilter"); + catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); 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(), diff --git a/apps/api/v2/src/modules/apps/apps.module.ts b/apps/api/v2/src/modules/apps/apps.module.ts index c5df72dfa3d003..587b18ce8d91cf 100644 --- a/apps/api/v2/src/modules/apps/apps.module.ts +++ b/apps/api/v2/src/modules/apps/apps.module.ts @@ -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 {} diff --git a/apps/api/v2/src/modules/apps/services/gcal.service.ts b/apps/api/v2/src/modules/apps/services/gcal.service.ts new file mode 100644 index 00000000000000..45b3406363091c --- /dev/null +++ b/apps/api/v2/src/modules/apps/services/gcal.service.ts @@ -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; + } +} diff --git a/apps/api/v2/src/modules/credentials/credentials.repository.ts b/apps/api/v2/src/modules/credentials/credentials.repository.ts index eddeb3f85f1cab..372207a91fb53f 100644 --- a/apps/api/v2/src/modules/credentials/credentials.repository.ts +++ b/apps/api/v2/src/modules/credentials/credentials.repository.ts @@ -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], }, }); } diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts index c68afcd1ef5c66..dbdc4c3207016e 100644 --- a/apps/api/v2/src/modules/endpoints.module.ts +++ b/apps/api/v2/src/modules/endpoints.module.ts @@ -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 diff --git a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts index 8ba87286d94b1e..e58cdcba5c7fb5 100644 --- a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts +++ b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts @@ -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 { - const request = context.switchToHttp().getRequest(); - const { headers, params } = request; + const request = context.switchToHttp().getRequest(); + 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"); } diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts index 09935893104afd..a4a1bb5e7b44d2 100644 --- a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts @@ -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 { diff --git a/apps/api/v2/src/modules/tokens/tokens.repository.ts b/apps/api/v2/src/modules/tokens/tokens.repository.ts index 6cfe89d553e8f7..ddb560b7476fca 100644 --- a/apps/api/v2/src/modules/tokens/tokens.repository.ts +++ b/apps/api/v2/src/modules/tokens/tokens.repository.ts @@ -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 diff --git a/apps/web/pages/settings/organizations/platform/oauth-clients/index.tsx b/apps/web/pages/settings/organizations/platform/oauth-clients/index.tsx index 1053cb03298814..6a664895651d86 100644 --- a/apps/web/pages/settings/organizations/platform/oauth-clients/index.tsx +++ b/apps/web/pages/settings/organizations/platform/oauth-clients/index.tsx @@ -65,7 +65,7 @@ export const OAuthClients = () => { return ( (""); + + 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 ? ( + http, + isRefreshing: isRefreshing, + isInit: isInit, + isValidClient: Boolean(!error && clientId && isInit), + isAuth: Boolean( + isInit && !error && clientId && !isRefreshing && currentAccessToken && http.getAuthorizationHeader() + ), + }}> + {children} + + ) : ( + <>{children} + ); +} diff --git a/packages/platform/atoms/cal-provider/errors.ts b/packages/platform/atoms/cal-provider/errors.ts deleted file mode 100644 index 565ec204b32956..00000000000000 --- a/packages/platform/atoms/cal-provider/errors.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const NO_KEY_VALUE = "no key value"; -export const INVALID_API_KEY = "invalid api key"; diff --git a/packages/platform/atoms/cal-provider/export.ts b/packages/platform/atoms/cal-provider/export.ts deleted file mode 100644 index 4a4e04887cf6db..00000000000000 --- a/packages/platform/atoms/cal-provider/export.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CalProvider } from "./index"; -export * from "../types"; diff --git a/packages/platform/atoms/cal-provider/index.ts b/packages/platform/atoms/cal-provider/index.ts new file mode 100644 index 00000000000000..30f88a25d2c67e --- /dev/null +++ b/packages/platform/atoms/cal-provider/index.ts @@ -0,0 +1 @@ +export { CalProvider } from "./CalProvider"; diff --git a/packages/platform/atoms/cal-provider/index.tsx b/packages/platform/atoms/cal-provider/index.tsx deleted file mode 100644 index cbde97a62f5a24..00000000000000 --- a/packages/platform/atoms/cal-provider/index.tsx +++ /dev/null @@ -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 ( - {children} - ); -} diff --git a/packages/platform/atoms/components.ts b/packages/platform/atoms/components.ts new file mode 100644 index 00000000000000..bf84f50c369ed9 --- /dev/null +++ b/packages/platform/atoms/components.ts @@ -0,0 +1,2 @@ +export { CalProvider } from "./cal-provider"; +export { GcalConnect } from "./gcal-connect"; diff --git a/packages/platform/atoms/connect-to-cal-button/Button.tsx b/packages/platform/atoms/connect-to-cal-button/Button.tsx deleted file mode 100644 index 8c0f72ed8af29a..00000000000000 --- a/packages/platform/atoms/connect-to-cal-button/Button.tsx +++ /dev/null @@ -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 { - buttonText?: string; - icon?: JSX.Element; - onSuccess?: () => void; - onError?: () => void; -} - -export function ConnectButton({ - buttonText, - onClick, - onSuccess, - onError, - className, - icon, -}: ConnectButtonProps & AtomsGlobalConfigProps) { - const [isProcessing, setIsProcessing] = useState(false); - const [errMsg, setErrMsg] = useState(""); - - const handleSubmit = useCallback( - async (e: React.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 ( -
- {/* TODO: Button needs a fix width in order to not resize at loading time */} - - {!!errMsg && {errMsg}} -
- ); -} diff --git a/packages/platform/atoms/connect-to-cal-button/export.ts b/packages/platform/atoms/connect-to-cal-button/export.ts deleted file mode 100644 index 2e13cdc6bc6942..00000000000000 --- a/packages/platform/atoms/connect-to-cal-button/export.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ConnectToCal } from "./index"; -export * from "../types"; diff --git a/packages/platform/atoms/connect-to-cal-button/index.tsx b/packages/platform/atoms/connect-to-cal-button/index.tsx deleted file mode 100644 index 73010b4c5009e0..00000000000000 --- a/packages/platform/atoms/connect-to-cal-button/index.tsx +++ /dev/null @@ -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 ( - <> - Connect to Cal.com - - ); -} diff --git a/packages/platform/atoms/gcal-connect/GcalConnect.tsx b/packages/platform/atoms/gcal-connect/GcalConnect.tsx new file mode 100644 index 00000000000000..d3bc5e2569b47c --- /dev/null +++ b/packages/platform/atoms/gcal-connect/GcalConnect.tsx @@ -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 = ({ + label = "Connect Google Calendar", + alreadyConnectedLabel = "Connected Google Calendar", + className, +}) => { + const { isAuth } = useAtomsContext(); + + const { allowConnect, checked, redirectToGcalOAuth } = useGcal({ isAuth }); + + if (!isAuth || !checked) return <>; + + return ( + + ); +}; diff --git a/packages/platform/atoms/gcal-connect/index.ts b/packages/platform/atoms/gcal-connect/index.ts new file mode 100644 index 00000000000000..490a111c941d63 --- /dev/null +++ b/packages/platform/atoms/gcal-connect/index.ts @@ -0,0 +1 @@ +export { GcalConnect } from "./GcalConnect"; diff --git a/packages/platform/atoms/globals.css b/packages/platform/atoms/globals.css index 05d36de0585439..bbb799615005cb 100644 --- a/packages/platform/atoms/globals.css +++ b/packages/platform/atoms/globals.css @@ -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%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - + --popover-foreground: 222.2 47.4% 11.2%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + + --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%; - - --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: 0 100% 50%; --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.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: 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%; - + --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% 11.2%; - - --secondary: 217.2 32.6% 17.5%; + --primary-foreground: 222.2 47.4% 1.2%; + + --secondary: 222.2 47.4% 11.2%; --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%; + + --destructive: 0 63% 31%; --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: 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; } -} \ No newline at end of file +} diff --git a/packages/platform/atoms/hooks/useApiKeys.ts b/packages/platform/atoms/hooks/useApiKeys.ts new file mode 100644 index 00000000000000..f3288a4e30f193 --- /dev/null +++ b/packages/platform/atoms/hooks/useApiKeys.ts @@ -0,0 +1,5 @@ +import { createContext, useContext } from "react"; + +export const ApiKeyContext = createContext({ key: "", error: "" }); + +export const useApiKey = () => useContext(ApiKeyContext); diff --git a/packages/platform/atoms/hooks/useAtomsContext.ts b/packages/platform/atoms/hooks/useAtomsContext.ts new file mode 100644 index 00000000000000..6f76957d71a9b6 --- /dev/null +++ b/packages/platform/atoms/hooks/useAtomsContext.ts @@ -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); diff --git a/packages/platform/atoms/hooks/useGcal.ts b/packages/platform/atoms/hooks/useGcal.ts new file mode 100644 index 00000000000000..fc7a8b5b8d7be3 --- /dev/null +++ b/packages/platform/atoms/hooks/useGcal.ts @@ -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(false); + const [checked, setChecked] = useState(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 }; +}; diff --git a/packages/platform/atoms/hooks/useOAuthClient.ts b/packages/platform/atoms/hooks/useOAuthClient.ts new file mode 100644 index 00000000000000..b13537707854a6 --- /dev/null +++ b/packages/platform/atoms/hooks/useOAuthClient.ts @@ -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(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(`/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 }; +}; diff --git a/packages/platform/atoms/hooks/useOAuthFlow.ts b/packages/platform/atoms/hooks/useOAuthFlow.ts new file mode 100644 index 00000000000000..c96a41487f59f7 --- /dev/null +++ b/packages/platform/atoms/hooks/useOAuthFlow.ts @@ -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(false); + const [clientAccessToken, setClientAccessToken] = useState(""); + 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(`/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 }; +}; diff --git a/packages/platform/atoms/index.ts b/packages/platform/atoms/index.ts index 33b056cd3ca3c1..2fc0f3f0ecb16c 100644 --- a/packages/platform/atoms/index.ts +++ b/packages/platform/atoms/index.ts @@ -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"; diff --git a/packages/platform/atoms/lib/http.ts b/packages/platform/atoms/lib/http.ts new file mode 100644 index 00000000000000..c05ff1b6039b89 --- /dev/null +++ b/packages/platform/atoms/lib/http.ts @@ -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 => { + 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; diff --git a/packages/platform/atoms/package.json b/packages/platform/atoms/package.json index e1e7e731cc949b..e8b498281b1e5e 100644 --- a/packages/platform/atoms/package.json +++ b/packages/platform/atoms/package.json @@ -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" } } diff --git a/packages/platform/atoms/tailwind.config.cjs b/packages/platform/atoms/tailwind.config.cjs index 3121f1d65358e9..5dc4efd4137dae 100644 --- a/packages/platform/atoms/tailwind.config.cjs +++ b/packages/platform/atoms/tailwind.config.cjs @@ -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")], }; diff --git a/packages/platform/atoms/vite.config.ts b/packages/platform/atoms/vite.config.ts index d920cf995d2fc5..fe1cb02c3fb676 100644 --- a/packages/platform/atoms/vite.config.ts +++ b/packages/platform/atoms/vite.config.ts @@ -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"), }, }, diff --git a/packages/platform/constants/apps.ts b/packages/platform/constants/apps.ts new file mode 100644 index 00000000000000..d86ac6d7976aec --- /dev/null +++ b/packages/platform/constants/apps.ts @@ -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; diff --git a/packages/platform/constants/index.ts b/packages/platform/constants/index.ts index ae73ce8760874b..89dff9d4ed5242 100644 --- a/packages/platform/constants/index.ts +++ b/packages/platform/constants/index.ts @@ -1,2 +1,3 @@ export * from "./permissions"; export * from "./api"; +export * from "./apps"; diff --git a/packages/platform/constants/permissions.ts b/packages/platform/constants/permissions.ts index 1323f30df6475f..a26526cb3d0bda 100644 --- a/packages/platform/constants/permissions.ts +++ b/packages/platform/constants/permissions.ts @@ -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; diff --git a/packages/platform/examples/base/.eslintrc.js b/packages/platform/examples/base/.eslintrc.js new file mode 100644 index 00000000000000..f053ebf7976e37 --- /dev/null +++ b/packages/platform/examples/base/.eslintrc.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/packages/platform/examples/base/.gitignore b/packages/platform/examples/base/.gitignore new file mode 100644 index 00000000000000..f303df6b8cfd5a --- /dev/null +++ b/packages/platform/examples/base/.gitignore @@ -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 \ No newline at end of file diff --git a/packages/platform/examples/base/README.md b/packages/platform/examples/base/README.md new file mode 100644 index 00000000000000..a75ac5248816fa --- /dev/null +++ b/packages/platform/examples/base/README.md @@ -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. diff --git a/packages/platform/examples/base/next.config.js b/packages/platform/examples/base/next.config.js new file mode 100644 index 00000000000000..4df77be7f2a508 --- /dev/null +++ b/packages/platform/examples/base/next.config.js @@ -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; diff --git a/packages/platform/examples/base/package.json b/packages/platform/examples/base/package.json new file mode 100644 index 00000000000000..455d80cd713beb --- /dev/null +++ b/packages/platform/examples/base/package.json @@ -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" + } +} diff --git a/packages/platform/examples/base/postcss.config.js b/packages/platform/examples/base/postcss.config.js new file mode 100644 index 00000000000000..12a703d900da81 --- /dev/null +++ b/packages/platform/examples/base/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/platform/examples/base/prisma/schema.prisma b/packages/platform/examples/base/prisma/schema.prisma new file mode 100644 index 00000000000000..6e32a683b2ef6e --- /dev/null +++ b/packages/platform/examples/base/prisma/schema.prisma @@ -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()) + +} \ No newline at end of file diff --git a/packages/platform/examples/base/public/favicon.ico b/packages/platform/examples/base/public/favicon.ico new file mode 100644 index 00000000000000..718d6fea4835ec Binary files /dev/null and b/packages/platform/examples/base/public/favicon.ico differ diff --git a/packages/platform/examples/base/public/next.svg b/packages/platform/examples/base/public/next.svg new file mode 100644 index 00000000000000..5174b28c565c28 --- /dev/null +++ b/packages/platform/examples/base/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/platform/examples/base/public/vercel.svg b/packages/platform/examples/base/public/vercel.svg new file mode 100644 index 00000000000000..d2f84222734f27 --- /dev/null +++ b/packages/platform/examples/base/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/platform/examples/base/src/lib/prismaClient.ts b/packages/platform/examples/base/src/lib/prismaClient.ts new file mode 100644 index 00000000000000..b994052651db0d --- /dev/null +++ b/packages/platform/examples/base/src/lib/prismaClient.ts @@ -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; +} + +const prisma = global.prisma ?? prismaClientSingleton(); + +export default prisma; + +if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma; diff --git a/packages/platform/examples/base/src/pages/_app.tsx b/packages/platform/examples/base/src/pages/_app.tsx new file mode 100644 index 00000000000000..dfdfb1d9064097 --- /dev/null +++ b/packages/platform/examples/base/src/pages/_app.tsx @@ -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 ( +
+ + {email ? ( + <> +

{email}

+ + + ) : ( + <> +
+
+
+ + )} +
{" "} +
+ ); +} diff --git a/packages/platform/examples/base/src/pages/_document.tsx b/packages/platform/examples/base/src/pages/_document.tsx new file mode 100644 index 00000000000000..9e36ba17c3f517 --- /dev/null +++ b/packages/platform/examples/base/src/pages/_document.tsx @@ -0,0 +1,13 @@ +import { Html, Head, Main, NextScript } from "next/document"; + +export default function Document() { + return ( + + + +
+ + + + ); +} diff --git a/packages/platform/examples/base/src/pages/api/managed-user.ts b/packages/platform/examples/base/src/pages/api/managed-user.ts new file mode 100644 index 00000000000000..b17d2a1cffd2e9 --- /dev/null +++ b/packages/platform/examples/base/src/pages/api/managed-user.ts @@ -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) { + 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) ?? "", + }); +} diff --git a/packages/platform/examples/base/src/pages/api/refresh.ts b/packages/platform/examples/base/src/pages/api/refresh.ts new file mode 100644 index 00000000000000..5c66cb7144edd0 --- /dev/null +++ b/packages/platform/examples/base/src/pages/api/refresh.ts @@ -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) { + 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: "" }); +} diff --git a/packages/platform/examples/base/src/pages/index.tsx b/packages/platform/examples/base/src/pages/index.tsx new file mode 100644 index 00000000000000..3508546f808eae --- /dev/null +++ b/packages/platform/examples/base/src/pages/index.tsx @@ -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 ( +
+
+ +
+
+ ); +} diff --git a/packages/platform/examples/base/src/styles/globals.css b/packages/platform/examples/base/src/styles/globals.css new file mode 100644 index 00000000000000..71f619b0a454a4 --- /dev/null +++ b/packages/platform/examples/base/src/styles/globals.css @@ -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)); +} diff --git a/packages/platform/examples/base/tailwind.config.ts b/packages/platform/examples/base/tailwind.config.ts new file mode 100644 index 00000000000000..adf06a6fd920e2 --- /dev/null +++ b/packages/platform/examples/base/tailwind.config.ts @@ -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; diff --git a/packages/platform/examples/base/tsconfig.json b/packages/platform/examples/base/tsconfig.json new file mode 100644 index 00000000000000..3206756d81ace9 --- /dev/null +++ b/packages/platform/examples/base/tsconfig.json @@ -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"] +}