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:
parent
158ac7d7c4
commit
f6c9447410
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -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> {
|
|
@ -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 {}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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(),
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
globals.min.css
|
|
@ -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}</>
|
||||
);
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
export const NO_KEY_VALUE = "no key value";
|
||||
export const INVALID_API_KEY = "invalid api key";
|
|
@ -1,2 +0,0 @@
|
|||
export { CalProvider } from "./index";
|
||||
export * from "../types";
|
|
@ -0,0 +1 @@
|
|||
export { CalProvider } from "./CalProvider";
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export { CalProvider } from "./cal-provider";
|
||||
export { GcalConnect } from "./gcal-connect";
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
export { ConnectToCal } from "./index";
|
||||
export * from "../types";
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { GcalConnect } from "./GcalConnect";
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import { createContext, useContext } from "react";
|
||||
|
||||
export const ApiKeyContext = createContext({ key: "", error: "" });
|
||||
|
||||
export const useApiKey = () => useContext(ApiKeyContext);
|
|
@ -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);
|
|
@ -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 };
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")],
|
||||
};
|
||||
|
|
|
@ -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"),
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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;
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./permissions";
|
||||
export * from "./api";
|
||||
export * from "./apps";
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
module.exports = {};
|
|
@ -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
|
|
@ -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.
|
|
@ -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;
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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) ?? "",
|
||||
});
|
||||
}
|
|
@ -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: "" });
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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));
|
||||
}
|
|
@ -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;
|
|
@ -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"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user