feat: OAuth 2.0 support for atoms (#27158)
* fix: useOAuthClient support OAuth 2.0 * fix: cannot read properties of undefined (reading NEXT_PUBLIC_IS_E2E) * fix: allow OAuth 2.0 token to connect gcal or ms calendar * fix: allow OAuth 2.0 token to save gcal or ms calendar credentials * refactor: dont set oauth id header for OAuth 2.0 * fix: calendar events not showing and emails not sent * feat: CalOAuth2Provider * chore: make OAuth 2.0 work in examples app * chore: refresh OAuth 2.0 tokens * docs: running examples app with oauth 2.0 * fix: remove sensitive console.log statements that leak secrets Remove logging of: - OAuth authorization codes (oauth2-user.ts) - Token-bearing exchange responses (oauth2-user.ts) - /me response data containing PII (oauth2-user.ts) - OAuth2 refresh response with tokens (refresh.ts) - Response payload with access tokens (_app.tsx) Addresses Cubic AI review feedback for issues with confidence >= 9/10 Co-Authored-By: unknown <> * docs: update readme * fix: implemente cubic feedback * fix: seed script import * fix: seed script pkce * fix: correct typos and SQLite capitalization in OAuth2 README (#27176) Co-authored-by: cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com> * refactor: dont return name in public oauth endpoint * docs: CalOAuthProvider * chore: add NEXT_PUBLIC_IS_E2E constant to test * docs: fix duplicated 'or' in Cal OAuth Provider documentation (#27177) Co-authored-by: cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com> * revert: is e2e constant * fix: typecheck * refactor: example app users select * update readme * chore: update oauth atoms readme * refactor: enable booking managed event types with user.username instead of profile.username * fix: EventTypeSettings when viewing round robin * test: add e2e tests for atoms-oauth2 controller Co-Authored-By: lauris@cal.com <lauris.skraucis@gmail.com> * fix: correct error message path in atoms-oauth2 e2e test Co-Authored-By: lauris@cal.com <lauris.skraucis@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
This commit is contained in:
co-authored by
unknown <>
cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com>
cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com>
lauris@cal.com <lauris.skraucis@gmail.com>
lauris@cal.com <lauris.skraucis@gmail.com>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com>
Rajiv Sahal
parent
0776bdf5fe
commit
fc602d3b03
@@ -497,6 +497,10 @@ TZ=UTC
|
||||
SEED_PLATFORM_OAUTH_CLIENT_ID=
|
||||
SEED_PLATFORM_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# test oauth2 client for `packages/platform/examples/base` (optional)
|
||||
SEED_OAUTH2_CLIENT_ID=
|
||||
SEED_OAUTH2_CLIENT_SECRET_HASHED=
|
||||
|
||||
# Trigger.dev
|
||||
ENABLE_ASYNC_TASKER="false" # set to "true" to enable
|
||||
TRIGGER_SECRET_KEY=
|
||||
|
||||
@@ -597,7 +597,7 @@ export class BookingsController_2024_04_15 {
|
||||
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
: undefined;
|
||||
this.logger.log(`createNextApiBookingRequest_2024_04_15`, {
|
||||
requestId,
|
||||
ownerId: userId,
|
||||
@@ -608,7 +608,7 @@ export class BookingsController_2024_04_15 {
|
||||
Object.assign(clone, { userId, userUuid, ...oAuthParams, platformBookingLocation });
|
||||
clone.body = {
|
||||
...clone.body,
|
||||
noEmail: !oAuthParams.arePlatformEmailsEnabled,
|
||||
noEmail: oAuthParams === undefined ? false : !oAuthParams.arePlatformEmailsEnabled,
|
||||
creationSource: CreationSource.API_V2,
|
||||
};
|
||||
if (oAuthClientId) {
|
||||
|
||||
@@ -161,7 +161,7 @@ export class CalendarsController {
|
||||
name: "calendar",
|
||||
})
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiAuthGuardOnlyAllow(["API_KEY", "ACCESS_TOKEN"])
|
||||
@ApiAuthGuardOnlyAllow(["API_KEY", "ACCESS_TOKEN", "THIRD_PARTY_ACCESS_TOKEN"])
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/:calendar/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||
import { calendar_v3 } from "@googleapis/calendar";
|
||||
import { Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
@@ -33,7 +33,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly tokensService: TokensService,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||
) {}
|
||||
|
||||
@@ -145,7 +145,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
const ownerId = await this.tokensService.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!ownerId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||
import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
@@ -27,7 +27,7 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
private readonly config: ConfigService,
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly tokensService: TokensService,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||
) {}
|
||||
|
||||
@@ -178,7 +178,7 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
const ownerId = await this.tokensService.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!ownerId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
|
||||
@@ -38,6 +38,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
|
||||
type EnabledAppType = App & {
|
||||
credential: CredentialDataWithTeamName;
|
||||
@@ -75,7 +76,8 @@ export class EventTypesAtomService {
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly eventTypeService: EventTypesService_2024_06_14,
|
||||
private readonly teamEventTypeService: TeamsEventTypesService,
|
||||
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository
|
||||
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
) {}
|
||||
|
||||
private async getTeamSlug(teamId: number): Promise<string> {
|
||||
@@ -466,26 +468,26 @@ export class EventTypesAtomService {
|
||||
}): Promise<PublicEventType> {
|
||||
const orgSlug = orgId ? await this.getTeamSlug(orgId) : null;
|
||||
|
||||
let slug: string | null = null;
|
||||
let usernameOrTeamSlug: string | null = null;
|
||||
if (isTeamEvent) {
|
||||
if (!teamId) {
|
||||
throw new BadRequestException("teamId is required for team events, please provide a valid teamId");
|
||||
}
|
||||
slug = await this.getTeamSlug(teamId);
|
||||
usernameOrTeamSlug = await this.getTeamSlug(teamId);
|
||||
} else {
|
||||
if (!username) {
|
||||
throw new BadRequestException(
|
||||
"username is required for non-team events, please provide a valid username"
|
||||
);
|
||||
}
|
||||
slug = username;
|
||||
usernameOrTeamSlug = username;
|
||||
}
|
||||
|
||||
const slugLower = slug.toLowerCase();
|
||||
usernameOrTeamSlug = usernameOrTeamSlug.toLowerCase();
|
||||
|
||||
try {
|
||||
const event = await getPublicEvent(
|
||||
slugLower,
|
||||
let event = await getPublicEvent(
|
||||
usernameOrTeamSlug,
|
||||
eventSlug,
|
||||
isTeamEvent,
|
||||
orgSlug,
|
||||
@@ -493,6 +495,24 @@ export class EventTypesAtomService {
|
||||
true
|
||||
);
|
||||
|
||||
const usernamePossiblyNotFromProfile = username && orgId && !event;
|
||||
if (usernamePossiblyNotFromProfile) {
|
||||
const user = await this.usersRepository.findByUsernameWithProfile(username);
|
||||
if (user) {
|
||||
const profile = await this.usersService.getUserMainProfile(user);
|
||||
if (profile?.username) {
|
||||
event = await getPublicEvent(
|
||||
profile.username,
|
||||
eventSlug,
|
||||
isTeamEvent,
|
||||
orgSlug,
|
||||
this.dbRead.prisma as unknown as PrismaClient,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
throw new NotFoundException(`Event type with slug ${eventSlug} not found`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
import { OAuth2ClientRepositoryFixture } from "test/fixtures/repository/oauth2-client.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { bootstrap } from "@/bootstrap";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
|
||||
describe("Atoms OAuth2 Controller Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
let moduleRef: TestingModule;
|
||||
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
|
||||
|
||||
const testClientId = `test-atoms-oauth-client-${randomString()}`;
|
||||
const testRedirectUri = "https://example.com/callback";
|
||||
|
||||
beforeAll(async () => {
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
|
||||
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
|
||||
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
|
||||
|
||||
await oAuthClientFixture.create({
|
||||
clientId: testClientId,
|
||||
name: "Test Atoms OAuth Client",
|
||||
redirectUri: testRedirectUri,
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v2/atoms/auth/oauth2/clients/:clientId", () => {
|
||||
it("should return 200 and correct client ID for existing OAuth client", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/atoms/auth/oauth2/clients/${testClientId}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe(SUCCESS_STATUS);
|
||||
expect(response.body.data.clientId).toBe(testClientId);
|
||||
expect(response.body.data.organizationId).toBeNull();
|
||||
});
|
||||
|
||||
it("should return 404 with error message for non-existing OAuth client", async () => {
|
||||
const nonExistentClientId = `non-existent-client-${randomString()}`;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/atoms/auth/oauth2/clients/${nonExistentClientId}`)
|
||||
.expect(404);
|
||||
|
||||
expect(response.body.error.message).toBe("unauthorized_client");
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oAuthClientFixture.delete(testClientId);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ErrorWithCode, getHttpStatusCode } from "@calcom/platform-libraries/errors";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Param,
|
||||
} from "@nestjs/common";
|
||||
import { ApiExcludeController, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { OAuthService } from "@/lib/services/oauth.service";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/atoms/auth/oauth2/clients/:clientId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@ApiExcludeController(true)
|
||||
@ApiTags("OAuth2")
|
||||
export class AtomsOAuth2Controller {
|
||||
private readonly logger = new Logger("AtomsOAuth2Controller");
|
||||
|
||||
constructor(private readonly oAuthService: OAuthService) {}
|
||||
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get a provider" })
|
||||
async getClient(@Param("clientId") clientId: string) {
|
||||
if (!clientId) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
try {
|
||||
const client = await this.oAuthService.getClient(clientId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
clientId: client.clientId,
|
||||
organizationId: null,
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ErrorWithCode) {
|
||||
const statusCode = getHttpStatusCode(err);
|
||||
throw new HttpException(err.message, statusCode);
|
||||
}
|
||||
this.logger.error(err);
|
||||
throw new InternalServerErrorException("Could not get oAuthClient");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { oAuthServiceModule } from "@/lib/modules/oauth.module";
|
||||
import { AtomsOAuth2Controller } from "@/modules/auth/oauth2/controllers/atoms-oauth2.controller";
|
||||
import { OAuth2Controller } from "@/modules/auth/oauth2/controllers/oauth2.controller";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [oAuthServiceModule],
|
||||
controllers: [OAuth2Controller],
|
||||
controllers: [OAuth2Controller, AtomsOAuth2Controller],
|
||||
})
|
||||
export class OAuth2Module {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import jwt from "jsonwebtoken";
|
||||
@@ -11,7 +12,22 @@ type OAuthTokenPayload = {
|
||||
|
||||
@Injectable()
|
||||
export class TokensService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
) {}
|
||||
|
||||
async getAccessTokenOwnerId(accessToken: string): Promise<number | null> {
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (ownerId) {
|
||||
return ownerId;
|
||||
}
|
||||
|
||||
const decodedToken = this.getDecodedThirdPartyAccessToken(accessToken);
|
||||
|
||||
return decodedToken?.userId ?? null;
|
||||
}
|
||||
|
||||
getDecodedThirdPartyAccessToken(token: string): OAuthTokenPayload | null {
|
||||
const encryptionKey = this.config.get<string>("CALENDSO_ENCRYPTION_KEY");
|
||||
|
||||
@@ -164,6 +164,22 @@ export class UsersRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findByUsernameWithProfile(username: string) {
|
||||
return this.dbRead.prisma.user.findFirst({
|
||||
where: { username },
|
||||
include: {
|
||||
movedToProfile: {
|
||||
include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } },
|
||||
},
|
||||
profiles: {
|
||||
include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
async findByUsername(username: string, orgSlug?: string, orgId?: number) {
|
||||
return this.dbRead.prisma.user.findFirst({
|
||||
where:
|
||||
|
||||
@@ -406,7 +406,7 @@ export const EventSetupTab = (
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && (
|
||||
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && !isPlatform && (
|
||||
<HostLocations eventTypeId={eventType.id} locationOptions={props.locationOptions} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: "Cal OAuth Provider"
|
||||
---
|
||||
|
||||
Cal OAuth Provider is used to setup Cal Atoms within your app after your users have authenticated using your Cal OAuth 2.0 client.
|
||||
|
||||
Your users will have authenticated with your Cal OAuth client and you will have access to their access tokens, which
|
||||
the Cal Atoms will use to manage their event type settings, availability, etc. within your app.
|
||||
|
||||
It is used in the root of your app, be it _app.js or _app.tsx in case of
|
||||
Next.js or App.js or App.ts in case of React. Here is an example:
|
||||
|
||||
|
||||
```js
|
||||
import "@calcom/atoms/globals.min.css";
|
||||
import { CalOAuthProvider } from '@calcom/atoms';
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
const accessToken = "user-oauth-access-token";
|
||||
|
||||
return (
|
||||
<CalOAuthProvider
|
||||
accessToken={accessToken}
|
||||
clientId={process.env.CAL_OAUTH2_CLIENT_ID ?? ""}
|
||||
options={{
|
||||
apiUrl: process.env.CAL_API_URL ?? "https://api.cal.com/v2",
|
||||
refreshUrl: process.env.REFRESH_URL
|
||||
}}
|
||||
>
|
||||
<Component {...pageProps} />
|
||||
</CalOAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApp;
|
||||
```
|
||||
|
||||
Below is a list of props that can be passed to the Cal OAuth Provider.
|
||||
|
||||
| Name | Required | Description |
|
||||
|:----------------------------|:----------|:-------------------------------------------------------------------------------------------------------|
|
||||
| clientId | Yes | Your OAuth2 client ID |
|
||||
| options | Yes | Configuration options - `apiUrl` (should be https://api.cal.com/v2) and `refreshUrl` (URL of endpoint you have to build that to which atoms will send expired access tokens and receive new one in return. Read how to set it up [here](https://cal.com/docs/platform/quickstart#4-backend%3A-setting-up-a-refresh-token-endpoint)) and `readingDirection` (defaults to "ltr" but can also pass "rtl" which will change direction of UI components) |
|
||||
| accessToken | Yes | The access token of your user for whom cal handles scheduling. |
|
||||
| autoUpdateTimezone | No | Whether to automatically update managed user timezone (default: true) |
|
||||
| language | No | Language code (default: "en") - available languages: "en", "de", "fr", "it", "nl", "pt-BR", "es" |
|
||||
| organizationId | No | ID of your organization |
|
||||
@@ -35,7 +35,7 @@ import type {
|
||||
} from "./languages";
|
||||
import { EN } from "./languages";
|
||||
|
||||
export type CalProviderProps = {
|
||||
export type BaseCalProviderProps = {
|
||||
children?: ReactNode;
|
||||
clientId: string;
|
||||
accessToken?: string;
|
||||
@@ -48,6 +48,7 @@ export type CalProviderProps = {
|
||||
version?: API_VERSIONS_ENUM;
|
||||
organizationId?: number;
|
||||
isEmbed?: boolean;
|
||||
isOAuth2?: boolean;
|
||||
} & i18nProps;
|
||||
|
||||
export function BaseCalProvider({
|
||||
@@ -64,7 +65,8 @@ export function BaseCalProvider({
|
||||
onTokenRefreshSuccess,
|
||||
onTokenRefreshError,
|
||||
isEmbed,
|
||||
}: CalProviderProps) {
|
||||
isOAuth2
|
||||
}: BaseCalProviderProps) {
|
||||
const [error, setError] = useState<string>("");
|
||||
const [stateOrgId, setOrganizationId] = useState<number>(0);
|
||||
|
||||
@@ -89,6 +91,7 @@ export function BaseCalProvider({
|
||||
|
||||
const { isInit } = useOAuthClient({
|
||||
isEmbed,
|
||||
isOAuth2,
|
||||
clientId,
|
||||
apiUrl: options.apiUrl,
|
||||
refreshUrl: options.refreshUrl,
|
||||
@@ -167,6 +170,7 @@ export function BaseCalProvider({
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
return isInit ? (
|
||||
<AtomsContext.Provider
|
||||
value={{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { VERSION_2024_06_14 } from "@calcom/platform-constants";
|
||||
|
||||
import http from "../lib/http";
|
||||
import type { BaseCalProviderProps } from "./BaseCalProvider";
|
||||
import { BaseCalProvider } from "./BaseCalProvider";
|
||||
import type { translationKeys } from "./languages";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
type CalOAuthProviderProps = Omit<BaseCalProviderProps, "isOAuth2">;
|
||||
|
||||
/**
|
||||
* Renders a CalProvider component.
|
||||
*
|
||||
* @component
|
||||
* @param {string} props.clientId - The platform oauth client ID.
|
||||
* @param {string} props.accessToken - The access token of your managed user. - Optional
|
||||
* @param {object} props.options - The options object.
|
||||
* @param {string} [options.apiUrl] - The API URL. https://api.cal.com/v2
|
||||
* @param {string} [options.refreshUrl] - The url point to your refresh endpoint. - Optional, required if accessToken is provided.
|
||||
* @param {boolean} [autoUpdateTimezone=true] - Whether to automatically update the timezone. - Optional
|
||||
* @param {function} props.onTimezoneChange - The callback function for timezone change. - Optional
|
||||
* @param {ReactNode} props.children - The child components. - Optional
|
||||
* @returns {JSX.Element} The rendered CalProvider component.
|
||||
*/
|
||||
export function CalOAuthProvider({
|
||||
clientId,
|
||||
accessToken,
|
||||
options,
|
||||
children,
|
||||
autoUpdateTimezone = true,
|
||||
labels,
|
||||
language = "en",
|
||||
onTimezoneChange,
|
||||
version = VERSION_2024_06_14,
|
||||
organizationId,
|
||||
isEmbed = false,
|
||||
}: CalOAuthProviderProps) {
|
||||
useEffect(() => {
|
||||
http.setVersionHeader(version);
|
||||
}, [version]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken) {
|
||||
queryClient.resetQueries();
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
http.setPlatformEmbedHeader(isEmbed);
|
||||
}, [isEmbed]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BaseCalProvider
|
||||
isEmbed={isEmbed}
|
||||
isOAuth2={true}
|
||||
autoUpdateTimezone={autoUpdateTimezone}
|
||||
onTimezoneChange={onTimezoneChange}
|
||||
clientId={clientId}
|
||||
accessToken={accessToken}
|
||||
options={options}
|
||||
version={version}
|
||||
labels={labels as Record<translationKeys, string>}
|
||||
language={language}
|
||||
organizationId={organizationId}>
|
||||
{children}
|
||||
</BaseCalProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useEffect } from "react";
|
||||
import http from "../lib/http";
|
||||
import type { CalProviderProps } from "./BaseCalProvider";
|
||||
import type { BaseCalProviderProps } from "./BaseCalProvider";
|
||||
import { BaseCalProvider } from "./BaseCalProvider";
|
||||
import type { translationKeys } from "./languages";
|
||||
|
||||
@@ -38,6 +38,8 @@ const queryClient: QueryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
type CalProviderProps = Omit<BaseCalProviderProps, "isOAuth2">;
|
||||
|
||||
/**
|
||||
* Renders a CalProvider component.
|
||||
*
|
||||
@@ -89,6 +91,7 @@ export function CalProvider({
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BaseCalProvider
|
||||
isEmbed={isEmbed}
|
||||
isOAuth2={false}
|
||||
autoUpdateTimezone={autoUpdateTimezone}
|
||||
onTimezoneChange={onTimezoneChange}
|
||||
onTokenRefreshStart={onTokenRefreshStart}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { CalProvider } from "./CalProvider";
|
||||
export { CalOAuthProvider } from "./CalOAuthProvider";
|
||||
|
||||
@@ -7,13 +7,19 @@ import type { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
import http from "../lib/http";
|
||||
|
||||
interface OAuthClientData {
|
||||
clientId: string;
|
||||
organizationId: number | null;
|
||||
}
|
||||
|
||||
export interface useOAuthClientProps {
|
||||
isEmbed?: boolean;
|
||||
clientId: string;
|
||||
apiUrl?: string;
|
||||
refreshUrl?: string;
|
||||
onError: (error: string) => void;
|
||||
onSuccess: (data: { client: string; organizationId: number; name: string }) => void;
|
||||
onSuccess: (data: OAuthClientData) => void;
|
||||
isOAuth2?: boolean;
|
||||
}
|
||||
export const useOAuthClient = ({
|
||||
isEmbed,
|
||||
@@ -22,9 +28,11 @@ export const useOAuthClient = ({
|
||||
refreshUrl,
|
||||
onError,
|
||||
onSuccess,
|
||||
isOAuth2 = false,
|
||||
}: useOAuthClientProps) => {
|
||||
const prevClientId = usePrevious(clientId);
|
||||
const [isInit, setIsInit] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiUrl) {
|
||||
http.setUrl(apiUrl);
|
||||
@@ -38,13 +46,16 @@ export const useOAuthClient = ({
|
||||
useEffect(() => {
|
||||
if (!isEmbed && clientId && http.getUrl() && prevClientId !== clientId) {
|
||||
try {
|
||||
const fetchUrl = isOAuth2 ? `/atoms/auth/oauth2/clients/${clientId}` : `/provider/${clientId}`;
|
||||
http
|
||||
.get<ApiResponse<{ client: string; organizationId: number; name: string }>>(`/provider/${clientId}`)
|
||||
.get<ApiResponse<OAuthClientData>>(fetchUrl)
|
||||
.then((response) => {
|
||||
if (response.data.status === SUCCESS_STATUS) {
|
||||
onSuccess(response.data.data);
|
||||
}
|
||||
http.setClientIdHeader(clientId);
|
||||
if (!isOAuth2) {
|
||||
http.setClientIdHeader(clientId);
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
if (err.response?.status === 401) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { CalProvider } from "./cal-provider";
|
||||
export { CalProvider, CalOAuthProvider } from "./cal-provider";
|
||||
export { GcalConnect } from "./connect/google/GcalConnect";
|
||||
export { AvailabilitySettingsPlatformWrapper as AvailabilitySettings } from "./availability";
|
||||
export type { AvailabilitySettingsPlatformWrapperProps as AvailabilitySettingsProps } from "./availability/wrappers/AvailabilitySettingsPlatformWrapper";
|
||||
|
||||
@@ -23,4 +23,4 @@ NEXT_PUBLIC_OAUTH2_CLIENT_ID=""
|
||||
OAUTH2_CLIENT_SECRET_PLAIN=""
|
||||
OAUTH2_REDIRECT_URI="http://localhost:4321"
|
||||
|
||||
NEXT_PUBLIC_OAUTH2_MODE=""
|
||||
NEXT_PUBLIC_OAUTH2_MODE=""
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
This readme will guide you how to run the examples app with an OAuth 2.0 client.
|
||||
|
||||
## Setup
|
||||
First, we need to create test OAuth client. You can do it manually by creating the following in the OAuthClient table:
|
||||
{
|
||||
"clientId": "1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517",
|
||||
"redirectUri": "http://localhost:4321",
|
||||
"clientSecret": "970db2cf14112013ba3a510b945294fef8737d42ee58c32031d2351692068ce7",
|
||||
"name": "atoms examples app oauth 2 client",
|
||||
"logo": null,
|
||||
"clientType": "confidential",
|
||||
"isTrusted": false,
|
||||
"createdAt": "2026-01-22 15:50:40.722",
|
||||
"purpose": "test atoms examples app with oauth 2",
|
||||
"rejectionReason": null,
|
||||
"status": "approved",
|
||||
"userId": 10,
|
||||
"websiteUrl": "http://localhost:4321"
|
||||
}
|
||||
Or you can do it automatically by:
|
||||
In the root .env set:
|
||||
SEED_OAUTH2_CLIENT_ID=1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517
|
||||
SEED_OAUTH2_CLIENT_SECRET_HASHED=970db2cf14112013ba3a510b945294fef8737d42ee58c32031d2351692068ce7
|
||||
Then run yarn db-reset in the prisma folder which will create an OAuth client for the admin@example.com user.
|
||||
|
||||
Second, we need to setup environment for the examples app. Go to packages/platform/examples/base/.env and paste the following
|
||||
NEXT_PUBLIC_OAUTH2_CLIENT_ID="1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517"
|
||||
OAUTH2_CLIENT_SECRET_PLAIN="2df0d9b1450ea95f2376fce5bc1d352e2d7a253d7e1c68a96a44745413b7dc4c"
|
||||
OAUTH2_REDIRECT_URI="http://localhost:4321"
|
||||
NEXT_PUBLIC_CALCOM_API_URL="http://localhost:5555/api/v2"
|
||||
|
||||
NEXT_PUBLIC_OAUTH2_MODE="true"
|
||||
Here we have OAUTH2_CLIENT_SECRET_PLAIN in plain because it will be used for api request when exchanging authorization code for tokens. NEXT_PUBLIC_OAUTH2_MODE tells that the examples app will run not with platform OAuth client but with OAuth 2.0 client, so in the packages/platform/examples/base/src/pages/_app.tsx we will use CalOAuthProvider and not CalProvider.
|
||||
|
||||
## Usage
|
||||
1. Before running the example app `packages/platform/examples/base` run `rm -f prisma/dev.db && yarn prisma db push` to reset its SQLite. If you don't and there are users already then an entry for the `admin@example.com` will not be created.
|
||||
2. Start web app and examples app (it is important that examples app runs on localhost:4321 because that will be redirect uri)
|
||||
3. Login as admin@example.com into cal webapp using password ADMINadmin2022!
|
||||
4. Visit this link http://localhost:3000/auth/oauth2/authorize?client_id=1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517&redirect_uri=http://localhost:4321&state=texas or if you have setup localhost:3000 to map to app.cal.local http://app.cal.local:3000/auth/oauth2/authorize?client_id=1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517&redirect_uri=http://localhost:4321&state=texas and authorize test OAuth client. You will be redirected to localhost:4321?code=abc and this route will exchange the authorization code for access and refresh tokens for the admin@example.com and store them in the examples app SQLite database.
|
||||
5. Examples app is ready to use. If you update, let's say an availability, then it will be reflected in the availability of admin@example.com in the web app running locally.
|
||||
@@ -1,13 +1,13 @@
|
||||
// pages/_app.tsx
|
||||
import type { Data } from "@/pages/api/get-managed-users";
|
||||
import "@/styles/globals.css";
|
||||
import type { AppProps } from "next/app";
|
||||
import { Poppins } from "next/font/google";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Select from "react-select";
|
||||
|
||||
import { CalProvider, BookerEmbed, Router } from "@calcom/atoms";
|
||||
import { CalProvider, CalOAuthProvider, BookerEmbed, Router as CalRouter } from "@calcom/atoms";
|
||||
import "@calcom/atoms/globals.min.css";
|
||||
|
||||
const poppins = Poppins({ subsets: ["latin"], weight: ["400", "800"] });
|
||||
@@ -22,7 +22,6 @@ function generateRandomEmail(name: string) {
|
||||
).join("");
|
||||
|
||||
const randomDomain = domain[Math.floor(Math.random() * domain.length)];
|
||||
|
||||
return `${name}-${randomLocalPart}@${randomDomain}`;
|
||||
}
|
||||
|
||||
@@ -34,33 +33,71 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
const [email, setUserEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<TUser | null>(null);
|
||||
const [options, setOptions] = useState([]);
|
||||
const [options, setOptions] = useState<any[]>([]);
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const pathname = router.pathname;
|
||||
|
||||
const oAuth2Mode = process.env.NEXT_PUBLIC_OAUTH2_MODE === "true";
|
||||
|
||||
const authorizationCode = useMemo(() => {
|
||||
const code = router.query.code;
|
||||
return typeof code === "string" ? code : null;
|
||||
}, [router.query.code]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/get-managed-users", {
|
||||
method: "get",
|
||||
}).then(async (res) => {
|
||||
fetch("/api/get-users", { method: "get" }).then(async (res) => {
|
||||
const data = await res.json();
|
||||
if (data.users.length === 1) {
|
||||
setAccessToken(data.users[0].accessToken);
|
||||
setUserEmail(data.users[0].email);
|
||||
setUsername(data.users[0].username);
|
||||
return;
|
||||
}
|
||||
setOptions(
|
||||
data.users.map((item: Data["users"][0]) => ({ ...item, value: item.id, label: item.username }))
|
||||
data.users.map((item: Data["users"][0]) => ({
|
||||
...item,
|
||||
value: item.id,
|
||||
label: item.username,
|
||||
}))
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const randomEmailOne = generateRandomEmail("keith");
|
||||
const randomEmailTwo = generateRandomEmail("somay");
|
||||
const randomEmailThree = generateRandomEmail("rajiv");
|
||||
const randomEmailFour = generateRandomEmail("morgan");
|
||||
const randomEmailFive = generateRandomEmail("lauris");
|
||||
if (!router.isReady) return;
|
||||
|
||||
if (seeding) return;
|
||||
|
||||
if (oAuth2Mode && !authorizationCode) return;
|
||||
|
||||
seeding = true;
|
||||
|
||||
if (oAuth2Mode) {
|
||||
const randomEmailOne = generateRandomEmail("keith");
|
||||
|
||||
fetch("/api/oauth2-user", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: randomEmailOne,
|
||||
authorizationCode,
|
||||
}),
|
||||
}).then(async (res) => {
|
||||
const data = await res.json();
|
||||
setAccessToken(data.accessToken);
|
||||
setUserEmail(data.email);
|
||||
setUsername(data.username);
|
||||
});
|
||||
} else {
|
||||
const randomEmailOne = generateRandomEmail("keith");
|
||||
const randomEmailTwo = generateRandomEmail("somay");
|
||||
const randomEmailThree = generateRandomEmail("rajiv");
|
||||
const randomEmailFour = generateRandomEmail("morgan");
|
||||
const randomEmailFive = generateRandomEmail("lauris");
|
||||
|
||||
if (!seeding) {
|
||||
seeding = true;
|
||||
fetch("/api/managed-user", {
|
||||
method: "POST",
|
||||
|
||||
body: JSON.stringify({
|
||||
emails: [randomEmailOne, randomEmailTwo, randomEmailThree, randomEmailFour, randomEmailFive],
|
||||
}),
|
||||
@@ -71,7 +108,8 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
setUsername(data.username);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [router.isReady, oAuth2Mode, authorizationCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUser) {
|
||||
setAccessToken(selectedUser.accessToken);
|
||||
@@ -84,27 +122,48 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
<div className={`${poppins.className} text-black`}>
|
||||
{options.length > 0 && (
|
||||
<Select
|
||||
defaultValue={options.find((opt: TUser | null) => opt?.email.includes("lauris"))}
|
||||
defaultValue={options.find((opt: TUser | null) => opt?.email?.includes("lauris"))}
|
||||
onChange={(opt: TUser | null) => setSelectedUser(opt)}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
<CalProvider
|
||||
accessToken={accessToken}
|
||||
clientId={process.env.NEXT_PUBLIC_X_CAL_ID ?? ""}
|
||||
options={{ apiUrl: process.env.NEXT_PUBLIC_CALCOM_API_URL ?? "", refreshUrl: "/api/refresh" }}>
|
||||
{email ? (
|
||||
<>
|
||||
|
||||
{oAuth2Mode ? (
|
||||
<CalOAuthProvider
|
||||
accessToken={accessToken}
|
||||
clientId={process.env.NEXT_PUBLIC_OAUTH2_CLIENT_ID}
|
||||
options={{
|
||||
apiUrl: process.env.NEXT_PUBLIC_CALCOM_API_URL ?? "",
|
||||
refreshUrl: "/api/refresh",
|
||||
}}
|
||||
>
|
||||
{email ? (
|
||||
<Component {...pageProps} calUsername={username} calEmail={email} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<main className={`flex min-h-screen flex-col items-center justify-between p-24 `}>
|
||||
) : (
|
||||
<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>{" "}
|
||||
)}
|
||||
</CalOAuthProvider>
|
||||
) : (
|
||||
<CalProvider
|
||||
accessToken={accessToken}
|
||||
clientId={process.env.NEXT_PUBLIC_X_CAL_ID ?? ""}
|
||||
options={{
|
||||
apiUrl: process.env.NEXT_PUBLIC_CALCOM_API_URL ?? "",
|
||||
refreshUrl: "/api/refresh",
|
||||
}}
|
||||
>
|
||||
{email ? (
|
||||
<Component {...pageProps} calUsername={username} calEmail={email} />
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{pathname === "/embed" && (
|
||||
<div>
|
||||
<BookerEmbed
|
||||
@@ -129,9 +188,10 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pathname === "/router" && (
|
||||
<div className="p-4">
|
||||
<Router
|
||||
<CalRouter
|
||||
formId="a63e6fce-899a-404e-8c38-e069710589c5"
|
||||
formResponsesURLParams={new URLSearchParams({ isBookingDryRun: "true", Territory: "Europe" })}
|
||||
onDisplayBookerEmbed={() => {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export type Data = {
|
||||
// example endpoint to endpoint to fetch managed users
|
||||
export default async function handler(_req: NextApiRequest, res: NextApiResponse<Data>) {
|
||||
const existingUsers = await prisma.user.findMany({ orderBy: { createdAt: "desc" } });
|
||||
if (existingUsers && existingUsers.length > 2) {
|
||||
if (existingUsers && existingUsers.length) {
|
||||
return res.status(200).json({
|
||||
users: existingUsers.map((item) => ({
|
||||
id: item.calcomUserId,
|
||||
@@ -0,0 +1,103 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
import prisma from "../../lib/prismaClient";
|
||||
|
||||
type Data = {
|
||||
email: string;
|
||||
username: 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 body = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
|
||||
const { email, authorizationCode } = body;
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
calcomUserId: true,
|
||||
email: true,
|
||||
calcomUsername: true,
|
||||
accessToken: true,
|
||||
},
|
||||
});
|
||||
if (existingUser && existingUser.calcomUserId) {
|
||||
return res.status(200).json({
|
||||
id: existingUser.calcomUserId,
|
||||
email: existingUser.email,
|
||||
username: existingUser.calcomUsername ?? "",
|
||||
accessToken: existingUser.accessToken ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
const oAuthUser = await createOAuthUser(
|
||||
authorizationCode,
|
||||
email,
|
||||
"Keith",
|
||||
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=3023&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||
);
|
||||
|
||||
|
||||
return res.status(200).json(oAuthUser);
|
||||
}
|
||||
|
||||
async function createOAuthUser(authorizationCode:string, email: string, name: string, avatarUrl: string) {
|
||||
const localUser = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
const exchangeResponse = await fetch(
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/auth/oauth2/clients/${process.env.NEXT_PUBLIC_OAUTH2_CLIENT_ID}/exchange`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
origin: "http://localhost:4321",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: authorizationCode,
|
||||
clientSecret: process.env.OAUTH2_CLIENT_SECRET_PLAIN,
|
||||
redirectUri: process.env.OAUTH2_REDIRECT_URI,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const exchangeResponseBody = await exchangeResponse.json();
|
||||
|
||||
const acccessToken = exchangeResponseBody.data.access_token;
|
||||
const refreshToken = exchangeResponseBody.data.refresh_token;
|
||||
|
||||
const me = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/me`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
Authorization: `Bearer ${acccessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const meResponseBody = await me.json();
|
||||
|
||||
await prisma.user.update({
|
||||
data: {
|
||||
refreshToken: refreshToken ?? "",
|
||||
accessToken: acccessToken ?? "",
|
||||
calcomUserId: meResponseBody.data?.id,
|
||||
calcomUsername: (meResponseBody.data?.username as string) ?? "",
|
||||
},
|
||||
where: { id: localUser.id },
|
||||
});
|
||||
|
||||
return {...meResponseBody.data, accessToken: acccessToken};
|
||||
}
|
||||
@@ -22,6 +22,38 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
|
||||
},
|
||||
});
|
||||
if (localUser?.refreshToken) {
|
||||
|
||||
if (process.env.NEXT_PUBLIC_OAUTH2_MODE === "true") {
|
||||
const oAuth2Request = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/auth/oauth2/clients/${process.env.NEXT_PUBLIC_OAUTH2_CLIENT_ID}/refresh`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refreshToken: localUser.refreshToken,
|
||||
clientSecret: process.env.OAUTH2_CLIENT_SECRET_PLAIN,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (oAuth2Request.status === 200) {
|
||||
const oAuth2Response = await oAuth2Request.json();
|
||||
const { access_token: newAccessToken, refresh_token: newRefreshToken } = oAuth2Response.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: "" });
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth/${
|
||||
@@ -53,6 +85,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
|
||||
});
|
||||
return res.status(200).json({ accessToken: newAccessToken });
|
||||
}
|
||||
|
||||
return res.status(400).json({ accessToken: "" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,28 @@ export async function createUserAndEventType({
|
||||
return theUser;
|
||||
}
|
||||
|
||||
type OAuthClientInput = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
redirectUri: string;
|
||||
websiteUrl: string;
|
||||
enablePkce: boolean;
|
||||
};
|
||||
|
||||
export async function createOAuthClientForUser(userId: number, oAuthClient: OAuthClientInput) {
|
||||
const {enablePkce, ...restOfOAuthClient} = oAuthClient;
|
||||
await prisma.oAuthClient.create({
|
||||
data: {
|
||||
userId,
|
||||
...restOfOAuthClient,
|
||||
clientType: enablePkce ? "PUBLIC" : "CONFIDENTIAL",
|
||||
},
|
||||
});
|
||||
console.log(`\t👤 Created OAuth2 client '${oAuthClient.name}' for user with id '${userId}'`);
|
||||
}
|
||||
|
||||
export async function createTeamAndAddUsers(
|
||||
teamInput: Prisma.TeamCreateInput,
|
||||
users: { id: number; username: string; role?: MembershipRole }[] = []
|
||||
|
||||
+17
-2
@@ -17,7 +17,7 @@ import type z from "zod";
|
||||
import type { teamMetadataSchema } from "../packages/prisma/zod-utils";
|
||||
import mainAppStore from "./seed-app-store";
|
||||
import mainHugeEventTypesSeed from "./seed-huge-event-types";
|
||||
import { createUserAndEventType } from "./seed-utils";
|
||||
import { createOAuthClientForUser, createUserAndEventType } from "./seed-utils";
|
||||
|
||||
type PlatformUser = {
|
||||
email: string;
|
||||
@@ -1036,7 +1036,7 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
await createUserAndEventType({
|
||||
const admin = await createUserAndEventType({
|
||||
user: {
|
||||
email: "admin@example.com",
|
||||
/** To comply with admin password requirements */
|
||||
@@ -1047,6 +1047,21 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
const clientId = process.env.SEED_OAUTH2_CLIENT_ID;
|
||||
const clientSecret = process.env.SEED_OAUTH2_CLIENT_SECRET_HASHED;
|
||||
|
||||
if (clientId && clientSecret) {
|
||||
await createOAuthClientForUser(admin.id, {
|
||||
clientId,
|
||||
clientSecret,
|
||||
name: "atoms examples app oauth 2 client",
|
||||
purpose: "test atoms examples app with oauth 2",
|
||||
redirectUri: "http://localhost:4321",
|
||||
websiteUrl: "http://localhost:4321",
|
||||
enablePkce: false,
|
||||
});
|
||||
}
|
||||
|
||||
await createPlatformAndSetupUser({
|
||||
teamInput: {
|
||||
name: "Platform Team",
|
||||
|
||||
Reference in New Issue
Block a user