feat: SDK managed users + PLA fixes (#14495)
* feat: SDK managed users + PLA fixes * chore: include lock
This commit is contained in:
9 files changed
+219
-73
No files matched your search
+68
-71
@@ -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: {
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user