feat: SDK managed users + PLA fixes (#14495)

* feat: SDK managed users + PLA fixes

* chore: include lock
This commit is contained in:
Erik authored and GitHub committed 2024-04-10 19:53:44 +00:00
1 parent 68c4ad2bf9
commit 67725f0cba
9 files changed
+219 -73

No files matched your search

@@ -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<GetManagedUserOutput> {
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<UpdateManagedUserOutput> {
if (accessTokenUserId !== userId) {
throw new BadRequestException("userId parameter does not match access token");
}
): Promise<GetManagedUserOutput> {
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<DeleteManagedUserOutput> {
if (accessTokenUserId !== userId) {
throw new BadRequestException("userId parameter does not match access token");
}
): Promise<GetManagedUserOutput> {
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<KeysResponseDto> {
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<User> {
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<User, "id" | "email" | "username">;
@@ -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,
@@ -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([
@@ -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<UserWithProfile | null> {
return this.dbRead.prisma.user.findUnique({
where: {
+3
View File
@@ -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() {
@@ -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<User> {
this.assertClientSecret("getManagedUsers");
const { data } = await this.sdk.httpCaller.get<BasicPlatformResponse<User>>(
Endpoints.GET_MANAGED_USERS_BY_CLIENT_ID,
{
urlParams: [this.sdk.clientId],
config: {
params: pagination,
},
}
);
return data;
}
async createManagedUser(input: CreateUserArgs): Promise<CreateUserResponse> {
this.assertClientSecret("createManagedUser");
const { data } = await this.sdk.httpCaller.post<BasicPlatformResponse<CreateUserResponse>>(
Endpoints.CREATE_MANAGED_USER,
{
urlParams: [this.sdk.clientId],
body: input,
}
);
return data;
}
async getManagedUser(userId: number): Promise<User> {
this.assertClientSecret("getManagedUser");
const { data } = await this.sdk.httpCaller.get<BasicPlatformResponse<User>>(
Endpoints.GET_MANAGED_USER_BY_ID,
{
urlParams: [this.sdk.clientId, userId.toString()],
}
);
return data;
}
}
@@ -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<typeof CreateUserSchema>;
export type CreateUserResponse = {
user: User;
accessToken: string;
refreshToken: string;
};
@@ -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<Endpoints, EndpointDeclaration> = {
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<string, string> => {
+5
View File
@@ -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";