diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts index c42301d5a4..9a24f04ffc 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts @@ -1,14 +1,12 @@ -import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; -import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output"; -import { DeleteManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/delete-managed-user.output"; import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output"; import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output"; import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; -import { UpdateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/update-managed-user.output"; +import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; import { UsersRepository } from "@/modules/users/users.repository"; @@ -21,12 +19,12 @@ import { Get, HttpCode, HttpStatus, - NotFoundException, Param, Patch, BadRequestException, Delete, Query, + NotFoundException, } from "@nestjs/common"; import { ApiTags as DocsTags } from "@nestjs/swagger"; import { User } from "@prisma/client"; @@ -38,6 +36,7 @@ import { Pagination } from "@calcom/platform-types"; path: "oauth-clients/:clientId/users", version: "2", }) +@UseGuards(OAuthClientCredentialsGuard) @DocsTags("Managed users") export class OAuthClientUsersController { private readonly logger = new Logger("UserController"); @@ -45,11 +44,11 @@ export class OAuthClientUsersController { constructor( private readonly userRepository: UsersRepository, private readonly oAuthClientUsersService: OAuthClientUsersService, - private readonly oauthRepository: OAuthClientRepository + private readonly oauthRepository: OAuthClientRepository, + private readonly tokensRepository: TokensRepository ) {} @Get("/") - @UseGuards(OAuthClientCredentialsGuard) async getManagedUsers( @Param("clientId") oAuthClientId: string, @Query() queryParams: Pagination @@ -65,12 +64,11 @@ export class OAuthClientUsersController { return { status: SUCCESS_STATUS, - data: existingUsers.map((user) => getResponseUser(user)), + data: existingUsers.map((user) => this.getResponseUser(user)), }; } @Post("/") - @UseGuards(OAuthClientCredentialsGuard) async createUser( @Param("clientId") oAuthClientId: string, @Body() body: CreateManagedUserInput @@ -96,7 +94,7 @@ export class OAuthClientUsersController { return { status: SUCCESS_STATUS, data: { - user: getResponseUser(user), + user: this.getResponseUser(user), accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, }, @@ -105,100 +103,99 @@ export class OAuthClientUsersController { @Get("/:userId") @HttpCode(HttpStatus.OK) - @UseGuards(AccessTokenGuard) async getUserById( - // @Param("clientId") is added to generate OpenAPI schema correctly: clientId is in @Controller path, and unless - // also added here as @Param, then it does not appear in OpenAPI schema. - @Param("clientId") _: string, - @GetUser("id") accessTokenUserId: number, + @Param("clientId") clientId: string, @Param("userId") userId: number ): Promise { - if (accessTokenUserId !== userId) { - throw new BadRequestException("userId parameter does not match access token"); - } - - const user = await this.userRepository.findById(userId); - if (!user) { - throw new NotFoundException(`User with ID ${userId} not found.`); - } + const user = await this.validateManagedUserOwnership(clientId, userId); return { status: SUCCESS_STATUS, - data: getResponseUser(user), + data: this.getResponseUser(user), }; } @Patch("/:userId") @HttpCode(HttpStatus.OK) - @UseGuards(AccessTokenGuard) async updateUser( - // @Param("clientId") is added to generate OpenAPI schema correctly: clientId is in @Controller path, and unless - // also added here as @Param, then it does not appear in OpenAPI schema. - @Param("clientId") oAuthClientId: string, - @GetUser("id") accessTokenUserId: number, + @Param("clientId") clientId: string, @Param("userId") userId: number, @Body() body: UpdateManagedUserInput - ): Promise { - if (accessTokenUserId !== userId) { - throw new BadRequestException("userId parameter does not match access token"); - } - + ): Promise { + await this.validateManagedUserOwnership(clientId, userId); this.logger.log(`Updating user with ID ${userId}: ${JSON.stringify(body, null, 2)}`); - const user = await this.oAuthClientUsersService.updateOAuthClientUser(oAuthClientId, userId, body); + const user = await this.oAuthClientUsersService.updateOAuthClientUser(clientId, userId, body); return { status: SUCCESS_STATUS, - data: getResponseUser(user), + data: this.getResponseUser(user), }; } @Delete("/:userId") @HttpCode(HttpStatus.OK) - @UseGuards(AccessTokenGuard) async deleteUser( - // @Param("clientId") is added to generate OpenAPI schema correctly: clientId is in @Controller path, and unless - // also added here as @Param, then it does not appear in OpenAPI schema. - @Param("clientId") _: string, - @GetUser("id") accessTokenUserId: number, + @Param("clientId") clientId: string, @Param("userId") userId: number - ): Promise { - if (accessTokenUserId !== userId) { - throw new BadRequestException("userId parameter does not match access token"); - } + ): Promise { + const user = await this.validateManagedUserOwnership(clientId, userId); + await this.userRepository.delete(userId); - this.logger.log(`Deleting user with ID: ${userId}`); - - const existingUser = await this.userRepository.findById(userId); - - if (!existingUser) { - throw new NotFoundException(`User with ID=${userId} does not exist`); - } - - if (!existingUser.isPlatformManaged) { - throw new BadRequestException(`Can't delete non managed user with ID=${userId}`); - } - - const user = await this.userRepository.delete(userId); + this.logger.warn(`Deleting user with ID: ${userId}`); return { status: SUCCESS_STATUS, - data: getResponseUser(user), + data: this.getResponseUser(user), }; } -} -function getResponseUser(user: User): ManagedUserOutput { - return { - id: user.id, - email: user.email, - username: user.username, - timeZone: user.timeZone, - weekStart: user.weekStart, - createdDate: user.createdDate, - timeFormat: user.timeFormat, - defaultScheduleId: user.defaultScheduleId, - }; + @Post("/:userId/force-refresh") + @HttpCode(HttpStatus.OK) + async forceRefresh( + @Param("userId") userId: number, + @Param("clientId") oAuthClientId: string + ): Promise { + this.logger.log(`Forcing new access tokens for managed user with ID ${userId}`); + + const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId); + + const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens( + oAuthClientId, + id, + true + ); + + return { + status: SUCCESS_STATUS, + data: { + accessToken, + refreshToken, + }, + }; + } + + private async validateManagedUserOwnership(clientId: string, userId: number): Promise { + const user = await this.userRepository.findByIdWithinPlatformScope(userId, clientId); + if (!user) { + throw new NotFoundException(`User with ID ${userId} is not part of this OAuth client.`); + } + + return user; + } + + private getResponseUser(user: User): ManagedUserOutput { + return { + id: user.id, + email: user.email, + username: user.username, + timeZone: user.timeZone, + weekStart: user.weekStart, + createdDate: user.createdDate, + timeFormat: user.timeFormat, + defaultScheduleId: user.defaultScheduleId, + }; + } } export type UserReturned = Pick; diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts index 27dd6f985e..74a1b554ae 100644 --- a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts @@ -10,13 +10,22 @@ import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth- import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { OrganizationsModule } from "@/modules/organizations/organizations.module"; import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UsersModule } from "@/modules/users/users.module"; import { Global, Module } from "@nestjs/common"; @Global() @Module({ - imports: [PrismaModule, AuthModule, UsersModule, MembershipsModule, EventTypesModule, OrganizationsModule], + imports: [ + PrismaModule, + AuthModule, + UsersModule, + TokensModule, + MembershipsModule, + EventTypesModule, + OrganizationsModule, + ], providers: [ OAuthClientRepository, OAuthClientCredentialsGuard, diff --git a/apps/api/v2/src/modules/tokens/tokens.repository.ts b/apps/api/v2/src/modules/tokens/tokens.repository.ts index 291cde6d2f..46b90a2ddc 100644 --- a/apps/api/v2/src/modules/tokens/tokens.repository.ts +++ b/apps/api/v2/src/modules/tokens/tokens.repository.ts @@ -47,7 +47,24 @@ export class TokensRepository { }); } - async createOAuthTokens(clientId: string, ownerId: number) { + async createOAuthTokens(clientId: string, ownerId: number, deleteOld?: boolean) { + if (deleteOld) { + try { + await this.dbWrite.prisma.$transaction([ + this.dbWrite.prisma.accessToken.deleteMany({ + where: { client: { id: clientId }, userId: ownerId, expiresAt: { lte: new Date() } }, + }), + this.dbWrite.prisma.refreshToken.deleteMany({ + where: { + client: { id: clientId }, + userId: ownerId, + }, + }), + ]); + } catch (err) { + // discard. + } + } const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate(); const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate(); const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([ diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index 23c943aa7f..e0239ed07b 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -52,6 +52,20 @@ export class UsersRepository { }); } + async findByIdWithinPlatformScope(userId: number, clientId: string) { + return this.dbRead.prisma.user.findFirst({ + where: { + id: userId, + isPlatformManaged: true, + platformOAuthClients: { + some: { + id: clientId, + }, + }, + }, + }); + } + async findByIdWithProfile(userId: number): Promise { return this.dbRead.prisma.user.findUnique({ where: { diff --git a/packages/platform/sdk/src/cal.ts b/packages/platform/sdk/src/cal.ts index dd5e34c500..14669427f2 100644 --- a/packages/platform/sdk/src/cal.ts +++ b/packages/platform/sdk/src/cal.ts @@ -2,6 +2,7 @@ import axios from "axios"; import axiosRetry from "axios-retry"; import { Bookings } from "./endpoints/bookings"; +import { ManagedUsers } from "./endpoints/ee/users"; import { Events } from "./endpoints/events"; import { EventTypes } from "./endpoints/events/event-types"; import { OAuthFlow } from "./endpoints/oauth-flow"; @@ -21,6 +22,7 @@ export class CalSdk { oauth: OAuthFlow; eventTypes: EventTypes; schedules: Schedules; + users: ManagedUsers; private _secrets: SdkSecrets; @@ -52,6 +54,7 @@ export class CalSdk { this.oauth = new OAuthFlow(this); this.eventTypes = new EventTypes(this); this.schedules = new Schedules(this); + this.users = new ManagedUsers(this); } private _createAxiosClientBase() { diff --git a/packages/platform/sdk/src/endpoints/ee/users/index.ts b/packages/platform/sdk/src/endpoints/ee/users/index.ts new file mode 100644 index 0000000000..a78389fee1 --- /dev/null +++ b/packages/platform/sdk/src/endpoints/ee/users/index.ts @@ -0,0 +1,54 @@ +import type { CalSdk } from "../../../cal"; +import { Endpoints } from "../../../lib/endpoints"; +import type { BasicPlatformResponse, PaginationOptions } from "../../../types"; +import { EndpointHandler } from "../../endpoint-handler"; +import type { CreateUserArgs, CreateUserResponse, User } from "./types"; + +export class ManagedUsers extends EndpointHandler { + constructor(private readonly sdk: CalSdk) { + super("users", sdk); + } + + async getManagedUsers(pagination?: PaginationOptions): Promise { + this.assertClientSecret("getManagedUsers"); + + const { data } = await this.sdk.httpCaller.get>( + Endpoints.GET_MANAGED_USERS_BY_CLIENT_ID, + { + urlParams: [this.sdk.clientId], + config: { + params: pagination, + }, + } + ); + + return data; + } + + async createManagedUser(input: CreateUserArgs): Promise { + this.assertClientSecret("createManagedUser"); + + const { data } = await this.sdk.httpCaller.post>( + Endpoints.CREATE_MANAGED_USER, + { + urlParams: [this.sdk.clientId], + body: input, + } + ); + + return data; + } + + async getManagedUser(userId: number): Promise { + this.assertClientSecret("getManagedUser"); + + const { data } = await this.sdk.httpCaller.get>( + Endpoints.GET_MANAGED_USER_BY_ID, + { + urlParams: [this.sdk.clientId, userId.toString()], + } + ); + + return data; + } +} diff --git a/packages/platform/sdk/src/endpoints/ee/users/types.ts b/packages/platform/sdk/src/endpoints/ee/users/types.ts new file mode 100644 index 0000000000..85fb7cebd0 --- /dev/null +++ b/packages/platform/sdk/src/endpoints/ee/users/types.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +export type User = { + id: number; + email: string; + username: string; +}; + +const CreateUserSchema = z.object({ + email: z.string(), + name: z.string().optional(), + timeFormat: z.number().optional(), + isWeekStart: z.string().optional(), + timeZone: z.string().optional(), +}); + +export type CreateUserArgs = z.infer; + +export type CreateUserResponse = { + user: User; + accessToken: string; + refreshToken: string; +}; diff --git a/packages/platform/sdk/src/lib/endpoints.ts b/packages/platform/sdk/src/lib/endpoints.ts index d9bf68b6d9..cbcee5a625 100644 --- a/packages/platform/sdk/src/lib/endpoints.ts +++ b/packages/platform/sdk/src/lib/endpoints.ts @@ -39,6 +39,9 @@ export enum Endpoints { GET_SUPPORTED_TIMEZONES = "GET_SUPPORTED_TIMEZONES", UPDATE_SCHEDULE_BY_ID = "UPDATE_SCHEDULE_BY_ID", DELETE_SCHEDULE_BY_ID = "DELETE_SCHEDULE_BY_ID", + GET_MANAGED_USERS_BY_CLIENT_ID = "GET_MANAGED_USERS_BY_CLIENT_ID", + CREATE_MANAGED_USER = "CREATE_MANAGED_USER", + GET_MANAGED_USER_BY_ID = "GET_MANAGED_USER_BY_ID", } const publicEndpoint = (uri: string, version = ApiVersion.NEUTRAL): EndpointDeclaration => ({ @@ -119,6 +122,27 @@ const ENDPOINTS: Record = { return `schedules/${scheduleId}`; }, }, + GET_MANAGED_USERS_BY_CLIENT_ID: { + apiVersion: ApiVersion.V2, + auth: "secret", + constructUri([clientId]) { + return `oauth-clients/${clientId}/users`; + }, + }, + CREATE_MANAGED_USER: { + apiVersion: ApiVersion.V2, + auth: "secret", + constructUri([clientId]) { + return `oauth-clients/${clientId}/users`; + }, + }, + GET_MANAGED_USER_BY_ID: { + apiVersion: ApiVersion.V2, + auth: "secret", + constructUri([clientId, userId]) { + return `oauth-clients/${clientId}/users/${userId}`; + }, + }, } as const; const isParamsRecord = (params: unknown): params is Record => { diff --git a/packages/platform/sdk/src/types.ts b/packages/platform/sdk/src/types.ts index b623785aa8..c98c8543fc 100644 --- a/packages/platform/sdk/src/types.ts +++ b/packages/platform/sdk/src/types.ts @@ -27,6 +27,11 @@ export type SdkAuthOptions = { refreshToken?: string; }; +export type PaginationOptions = { + limit?: number; + skip?: number; +}; + export * from "./endpoints/slots/types"; export * from "./endpoints/schedules/types"; export * from "./endpoints/events/types";