diff --git a/.env.example b/.env.example index 97c782de65..971dc8a620 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts index d43feb75f5..7e4b7c2539 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts @@ -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) { diff --git a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts index 6739d40aec..5e32ebf533 100644 --- a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts @@ -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) diff --git a/apps/api/v2/src/ee/calendars/services/gcal.service.ts b/apps/api/v2/src/ee/calendars/services/gcal.service.ts index 2f8466a6d6..9f4fe14583 100644 --- a/apps/api/v2/src/ee/calendars/services/gcal.service.ts +++ b/apps/api/v2/src/ee/calendars/services/gcal.service.ts @@ -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."); diff --git a/apps/api/v2/src/ee/calendars/services/outlook.service.ts b/apps/api/v2/src/ee/calendars/services/outlook.service.ts index 8899cd9a1d..3007a8542b 100644 --- a/apps/api/v2/src/ee/calendars/services/outlook.service.ts +++ b/apps/api/v2/src/ee/calendars/services/outlook.service.ts @@ -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."); diff --git a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts index 375b65c89e..6b0fa894f1 100644 --- a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts +++ b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts @@ -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 { @@ -466,26 +468,26 @@ export class EventTypesAtomService { }): Promise { 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`); } diff --git a/apps/api/v2/src/modules/auth/oauth2/controllers/atoms-oauth2.controller.e2e-spec.ts b/apps/api/v2/src/modules/auth/oauth2/controllers/atoms-oauth2.controller.e2e-spec.ts new file mode 100644 index 0000000000..4d4996c85c --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/controllers/atoms-oauth2.controller.e2e-spec.ts @@ -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(); + }); +}); diff --git a/apps/api/v2/src/modules/auth/oauth2/controllers/atoms-oauth2.controller.ts b/apps/api/v2/src/modules/auth/oauth2/controllers/atoms-oauth2.controller.ts new file mode 100644 index 0000000000..dccc6197b6 --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/controllers/atoms-oauth2.controller.ts @@ -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"); + } + } +} diff --git a/apps/api/v2/src/modules/auth/oauth2/oauth2.module.ts b/apps/api/v2/src/modules/auth/oauth2/oauth2.module.ts index ed2907e3c6..3d86a23b98 100644 --- a/apps/api/v2/src/modules/auth/oauth2/oauth2.module.ts +++ b/apps/api/v2/src/modules/auth/oauth2/oauth2.module.ts @@ -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 {} diff --git a/apps/api/v2/src/modules/tokens/tokens.service.ts b/apps/api/v2/src/modules/tokens/tokens.service.ts index c384721250..20ceda9f3c 100644 --- a/apps/api/v2/src/modules/tokens/tokens.service.ts +++ b/apps/api/v2/src/modules/tokens/tokens.service.ts @@ -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 { + 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("CALENDSO_ENCRYPTION_KEY"); diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index a454350062..719602ccae 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -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: diff --git a/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx b/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx index 66c3e9bca2..5d98535f35 100644 --- a/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx +++ b/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx @@ -406,7 +406,7 @@ export const EventSetupTab = ( - {eventType.schedulingType === SchedulingType.ROUND_ROBIN && ( + {eventType.schedulingType === SchedulingType.ROUND_ROBIN && !isPlatform && ( )} diff --git a/docs/platform/atoms/cal-oauth-provider.mdx b/docs/platform/atoms/cal-oauth-provider.mdx new file mode 100644 index 0000000000..9cfca510be --- /dev/null +++ b/docs/platform/atoms/cal-oauth-provider.mdx @@ -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 ( + + + + ); +} + +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 | \ No newline at end of file diff --git a/packages/platform/atoms/cal-provider/BaseCalProvider.tsx b/packages/platform/atoms/cal-provider/BaseCalProvider.tsx index ca2267d3e9..6c1c00fbd5 100644 --- a/packages/platform/atoms/cal-provider/BaseCalProvider.tsx +++ b/packages/platform/atoms/cal-provider/BaseCalProvider.tsx @@ -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(""); const [stateOrgId, setOrganizationId] = useState(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 ? ( ; + +/** + * 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 ( + + } + language={language} + organizationId={organizationId}> + {children} + + + ); +} diff --git a/packages/platform/atoms/cal-provider/CalProvider.tsx b/packages/platform/atoms/cal-provider/CalProvider.tsx index 7be1e26a3d..312b86088a 100644 --- a/packages/platform/atoms/cal-provider/CalProvider.tsx +++ b/packages/platform/atoms/cal-provider/CalProvider.tsx @@ -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; + /** * Renders a CalProvider component. * @@ -89,6 +91,7 @@ export function CalProvider({ 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(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>(`/provider/${clientId}`) + .get>(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) { diff --git a/packages/platform/atoms/index.ts b/packages/platform/atoms/index.ts index 3fa67f4325..d1ac7ff4f9 100644 --- a/packages/platform/atoms/index.ts +++ b/packages/platform/atoms/index.ts @@ -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"; diff --git a/packages/platform/examples/base/.env.example b/packages/platform/examples/base/.env.example index c28fad8c29..58d0fd5971 100644 --- a/packages/platform/examples/base/.env.example +++ b/packages/platform/examples/base/.env.example @@ -23,4 +23,4 @@ NEXT_PUBLIC_OAUTH2_CLIENT_ID="" OAUTH2_CLIENT_SECRET_PLAIN="" OAUTH2_REDIRECT_URI="http://localhost:4321" -NEXT_PUBLIC_OAUTH2_MODE="" \ No newline at end of file +NEXT_PUBLIC_OAUTH2_MODE="" diff --git a/packages/platform/examples/base/README-OAUTH2.MD b/packages/platform/examples/base/README-OAUTH2.MD new file mode 100644 index 0000000000..39aecbb46f --- /dev/null +++ b/packages/platform/examples/base/README-OAUTH2.MD @@ -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. \ No newline at end of file diff --git a/packages/platform/examples/base/src/pages/_app.tsx b/packages/platform/examples/base/src/pages/_app.tsx index a052a510e3..5b8d35fb8e 100644 --- a/packages/platform/examples/base/src/pages/_app.tsx +++ b/packages/platform/examples/base/src/pages/_app.tsx @@ -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(null); - const [options, setOptions] = useState([]); + const [options, setOptions] = useState([]); 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) {
{options.length > 0 && (