feat: add guards to stripe teams controller (#20540)
* feat: add guards to stripe teams controller * remove logs and comments * fix return type status * refactor: move PlatformSubscription to a dedicated module * reroute to `organizations/stripe/save` for teams * fix: type errors * feat: fixed it for conferencing apps * feat: Add error handling and fallback URL support in Stripe callback * Refactor OAuth callback handling and move token validation to service layer * Add documentation for OAuth callback proxying in conferencing and stripe controllers * Move OAuthCallbackState type from organizations to stripe service module
This commit is contained in:
@@ -46,6 +46,7 @@
|
||||
"@golevelup/ts-jest": "^0.4.0",
|
||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||
"@nestjs/axios": "^4.0.0",
|
||||
"@nestjs/bull": "^10.1.1",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
|
||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
|
||||
@Module({
|
||||
@@ -29,28 +23,8 @@ import { PassportModule } from "@nestjs/passport";
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
ProfilesModule,
|
||||
forwardRef(() => OrganizationsModule),
|
||||
],
|
||||
providers: [
|
||||
NextAuthGuard,
|
||||
NextAuthStrategy,
|
||||
ApiAuthGuard,
|
||||
ApiAuthStrategy,
|
||||
OAuthFlowService,
|
||||
IsTeamInOrg,
|
||||
RolesGuard,
|
||||
IsOrgGuard,
|
||||
IsAdminAPIEnabledGuard,
|
||||
PlatformPlanGuard,
|
||||
],
|
||||
exports: [
|
||||
NextAuthGuard,
|
||||
ApiAuthGuard,
|
||||
IsTeamInOrg,
|
||||
RolesGuard,
|
||||
IsOrgGuard,
|
||||
IsAdminAPIEnabledGuard,
|
||||
PlatformPlanGuard,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
exports: [NextAuthGuard, ApiAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -6,15 +6,15 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule, forwardRef(() => OrganizationsModule)],
|
||||
imports: [PrismaModule, ConfigModule, HttpModule],
|
||||
providers: [
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
@@ -32,10 +31,10 @@ import {
|
||||
Delete,
|
||||
Headers,
|
||||
Redirect,
|
||||
UnauthorizedException,
|
||||
Req,
|
||||
HttpException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
@@ -60,9 +59,9 @@ export class ConferencingController {
|
||||
private readonly logger = new Logger("ConferencingController");
|
||||
|
||||
constructor(
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
private readonly config: ConfigService,
|
||||
private readonly httpService: HttpService
|
||||
) {}
|
||||
|
||||
@Post("/:app/connect")
|
||||
@@ -120,6 +119,14 @@ export class ConferencingController {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles saving conferencing app credentials.
|
||||
* If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint;
|
||||
* otherwise, credentials are saved at the user level.
|
||||
*
|
||||
* Proxying ensures that permission checks—such as whether the user is allowed to install conferencing app for a team or organization—
|
||||
* are enforced via controller route guards, avoiding duplication of this logic within the service layer.
|
||||
*/
|
||||
@Get("/:app/oauth/callback")
|
||||
@UseGuards()
|
||||
@Redirect(undefined, 301)
|
||||
@@ -143,23 +150,28 @@ export class ConferencingController {
|
||||
|
||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||
try {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||
if (error) {
|
||||
throw new BadRequestException(error_description);
|
||||
}
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
if (decodedCallbackState.orgId) {
|
||||
return this.organizationsConferencingService.connectTeamOauthApps({
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
decodedCallbackState,
|
||||
});
|
||||
} else {
|
||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState);
|
||||
|
||||
if (decodedCallbackState.teamId && decodedCallbackState.orgId) {
|
||||
const apiUrl = this.config.get("api.url");
|
||||
const url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/teams/${decodedCallbackState.teamId}/conferencing/${app}/oauth/callback`;
|
||||
const params: Record<string, string | undefined> = { state, code, error, error_description };
|
||||
const headers = {
|
||||
Authorization: `Bearer ${decodedCallbackState.accessToken}`,
|
||||
};
|
||||
try {
|
||||
const response = await this.httpService.axiosRef.get(url, { params, headers });
|
||||
const redirectUrl = response.data?.url || decodedCallbackState.onErrorReturnTo || "";
|
||||
return { url: redirectUrl };
|
||||
} catch (err) {
|
||||
const fallbackUrl = decodedCallbackState.onErrorReturnTo || "";
|
||||
return { url: fallbackUrl };
|
||||
}
|
||||
}
|
||||
|
||||
return this.conferencingService.connectOauthApps(app, code, decodedCallbackState);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException || error instanceof Error) {
|
||||
this.logger.error(error.message);
|
||||
|
||||
@@ -3,9 +3,15 @@ import { ConferencingRepository } from "@/modules/conferencing/repositories/conf
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
@@ -25,6 +31,7 @@ export class ConferencingService {
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly zoomVideoService: ZoomVideoService,
|
||||
private readonly office365VideoService: Office365VideoService
|
||||
@@ -47,10 +54,13 @@ export class ConferencingService {
|
||||
async connectOauthApps(
|
||||
app: string,
|
||||
code: string,
|
||||
userId: number,
|
||||
decodedCallbackState: OAuthCallbackState,
|
||||
teamId?: number
|
||||
) {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId);
|
||||
|
||||
+43
-1
@@ -22,6 +22,7 @@ import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferenci
|
||||
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -36,10 +37,13 @@ import {
|
||||
Headers,
|
||||
Req,
|
||||
ParseIntPipe,
|
||||
BadRequestException,
|
||||
Redirect,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
|
||||
@@ -60,7 +64,8 @@ export type OAuthCallbackState = {
|
||||
export class OrganizationsConferencingController {
|
||||
constructor(
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -221,4 +226,41 @@ export class OrganizationsConferencingController {
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing/:app/oauth/callback")
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save conferencing app OAuth credentials" })
|
||||
async saveTeamOauthCredentials(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Query("error") error: string | undefined,
|
||||
@Query("error_description") error_description: string | undefined,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<{ url: string }> {
|
||||
if (!state) {
|
||||
throw new BadRequestException("Missing `state` query param");
|
||||
}
|
||||
|
||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||
try {
|
||||
return await this.organizationsConferencingService.connectTeamOauthApps({
|
||||
decodedCallbackState,
|
||||
code,
|
||||
app,
|
||||
teamId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return {
|
||||
url: decodedCallbackState.onErrorReturnTo ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule, ConferencingModule],
|
||||
providers: [
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
GoogleMeetService,
|
||||
UsersRepository,
|
||||
TeamsRepository,
|
||||
OrganizationsConferencingService,
|
||||
ZoomVideoService,
|
||||
Office365VideoService,
|
||||
CredentialsRepository,
|
||||
TokensRepository,
|
||||
AppsRepository,
|
||||
OrganizationsRepository,
|
||||
StripeService,
|
||||
MembershipsRepository,
|
||||
RedisService,
|
||||
OrganizationsTeamsRepository,
|
||||
],
|
||||
exports: [OrganizationsConferencingService],
|
||||
controllers: [OrganizationsConferencingController],
|
||||
})
|
||||
export class OrganizationsConferencingModule {}
|
||||
+5
-163
@@ -1,10 +1,3 @@
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { PlatformPlanType } from "@/modules/billing/types";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { DefaultConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
@@ -13,12 +6,7 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { GOOGLE_MEET } from "@calcom/platform-constants";
|
||||
@@ -35,129 +23,9 @@ export class OrganizationsConferencingService {
|
||||
private teamsRepository: TeamsRepository,
|
||||
private usersRepository: UsersRepository,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly platformPlanGuard: PlatformPlanGuard,
|
||||
private readonly isAdminAPIEnabledGuard: IsAdminAPIEnabledGuard,
|
||||
private readonly isOrgGuard: IsOrgGuard,
|
||||
private readonly isTeamInOrg: IsTeamInOrg,
|
||||
private readonly rolesGuard: RolesGuard
|
||||
private readonly conferencingService: ConferencingService
|
||||
) {}
|
||||
|
||||
async verifyAccess({
|
||||
user,
|
||||
orgId,
|
||||
teamId,
|
||||
requiredRole,
|
||||
minimumPlan,
|
||||
}: {
|
||||
user: UserWithProfile;
|
||||
orgId: string;
|
||||
teamId?: string;
|
||||
requiredRole?: string;
|
||||
minimumPlan?: PlatformPlanType;
|
||||
}): Promise<{ orgId: number; teamId: number | null }> {
|
||||
const userWithAdminFlag = {
|
||||
...user,
|
||||
isSystemAdmin: user.role === "ADMIN",
|
||||
};
|
||||
|
||||
if (!orgId) {
|
||||
throw new BadRequestException("Organization ID is required");
|
||||
}
|
||||
|
||||
await this.ensureOrganizationAccess(orgId);
|
||||
await this.ensureAdminAPIEnabled(orgId);
|
||||
|
||||
// Only validate team if a teamId is provided
|
||||
if (teamId && teamId.trim() !== "") {
|
||||
await this.ensureTeamBelongsToOrg(orgId, teamId);
|
||||
}
|
||||
|
||||
await this.ensureUserRoleAccess(userWithAdminFlag, orgId, teamId, requiredRole);
|
||||
await this.ensurePlatformPlanAccess({ teamId, orgId, user: userWithAdminFlag, minimumPlan });
|
||||
|
||||
const parsedOrgId = parseInt(orgId, 10);
|
||||
// For organization-level operations, teamId might be empty or null
|
||||
const parsedTeamId = teamId && teamId.trim() !== "" ? parseInt(teamId, 10) : null;
|
||||
|
||||
return {
|
||||
orgId: parsedOrgId,
|
||||
teamId: parsedTeamId,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureOrganizationAccess(orgId: string): Promise<void> {
|
||||
const { canAccess } = await this.isOrgGuard.checkOrgAccess(orgId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Organization validation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAdminAPIEnabled(orgId: string): Promise<void> {
|
||||
const { canAccess } = await this.isAdminAPIEnabledGuard.checkAdminAPIEnabled(orgId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Admin API is not enabled for this organization.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureTeamBelongsToOrg(orgId: string, teamId: string): Promise<void> {
|
||||
const { canAccess } = await this.isTeamInOrg.checkIfTeamIsInOrg(orgId, teamId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Team is not part of the organization.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUserRoleAccess(
|
||||
user: ApiAuthGuardUser,
|
||||
orgId: string,
|
||||
teamId?: string,
|
||||
requiredRole?: string
|
||||
): Promise<void> {
|
||||
if (!requiredRole) return;
|
||||
|
||||
// For team-level roles, we need both orgId and teamId
|
||||
if (requiredRole.startsWith("TEAM_") && (!teamId || teamId.trim() === "")) {
|
||||
throw new ForbiddenException("Team ID is required for team-level role access");
|
||||
}
|
||||
|
||||
// Use empty string for teamId if it's an org-level operation
|
||||
const effectiveTeamId = teamId && teamId.trim() !== "" ? teamId : "";
|
||||
|
||||
const { canAccess } = await this.rolesGuard.checkUserRoleAccess(
|
||||
user,
|
||||
orgId,
|
||||
effectiveTeamId,
|
||||
requiredRole
|
||||
);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("User does not have the required role.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
}: {
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
user: ApiAuthGuardUser;
|
||||
minimumPlan?: PlatformPlanType;
|
||||
}): Promise<void> {
|
||||
if (!minimumPlan) return;
|
||||
|
||||
const { canAccess } = await this.platformPlanGuard.checkPlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
});
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("User's organization does not meet the required subscription plan.");
|
||||
}
|
||||
}
|
||||
|
||||
async connectTeamNonOauthApps({ teamId, app }: { teamId: number; app: string }): Promise<any> {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
@@ -171,40 +39,14 @@ export class OrganizationsConferencingService {
|
||||
decodedCallbackState,
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
teamId,
|
||||
}: {
|
||||
userId: number;
|
||||
app: string;
|
||||
decodedCallbackState: OAuthCallbackState;
|
||||
code: string;
|
||||
teamId: number;
|
||||
}) {
|
||||
const user = await this.usersRepository.findByIdWithProfile(userId);
|
||||
const { orgId, teamId } = decodedCallbackState;
|
||||
|
||||
if (!orgId) {
|
||||
throw new BadRequestException("orgId required");
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException("user not found");
|
||||
}
|
||||
|
||||
// Determine if this is a team-level or organization-level operation
|
||||
const isTeamLevel = !!teamId;
|
||||
const requiredRole = isTeamLevel ? "TEAM_ADMIN" : "ORG_ADMIN";
|
||||
|
||||
// Verify access with appropriate parameters
|
||||
const { orgId: validatedOrgId, teamId: validatedTeamId } = await this.verifyAccess({
|
||||
user,
|
||||
orgId,
|
||||
teamId,
|
||||
requiredRole,
|
||||
minimumPlan: "ESSENTIALS",
|
||||
});
|
||||
|
||||
const entityId = isTeamLevel && validatedTeamId !== null ? validatedTeamId : validatedOrgId;
|
||||
|
||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState, entityId);
|
||||
return this.conferencingService.connectOauthApps(app, code, decodedCallbackState, teamId);
|
||||
}
|
||||
|
||||
async getConferencingApps({ teamId }: { teamId: number }) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { EmailModule } from "@/modules/email/email.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
@@ -14,6 +19,7 @@ import { OrganizationAttributeOptionRepository } from "@/modules/organizations/a
|
||||
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
||||
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
|
||||
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
|
||||
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
|
||||
@@ -31,6 +37,8 @@ import { OrganizationsOrganizationsModule } from "@/modules/organizations/organi
|
||||
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
||||
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
|
||||
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
|
||||
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
|
||||
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||
import { OrganizationsTeamsController } from "@/modules/organizations/teams/index/organizations-teams.controller";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
|
||||
@@ -50,13 +58,15 @@ import { OrganizationsWebhooksRepository } from "@/modules/organizations/webhook
|
||||
import { OrganizationsWebhooksService } from "@/modules/organizations/webhooks/services/organizations-webhooks.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -69,11 +79,11 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
EventTypesModule_2024_06_14,
|
||||
TeamsEventTypesModule,
|
||||
TeamsModule,
|
||||
forwardRef(() => ConferencingModule),
|
||||
forwardRef(() => AuthModule),
|
||||
OrganizationsDelegationCredentialModule,
|
||||
OrganizationsOrganizationsModule,
|
||||
OrganizationsStripeModule,
|
||||
OrganizationsTeamsRoutingFormsModule,
|
||||
OrganizationsConferencingModule,
|
||||
],
|
||||
providers: [
|
||||
OrganizationsRepository,
|
||||
@@ -108,6 +118,16 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
OrgUsersOOOService,
|
||||
OrgUsersOOORepository,
|
||||
OrganizationsConferencingService,
|
||||
OrganizationsStripeService,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
RedisService,
|
||||
ConferencingRepository,
|
||||
GoogleMeetService,
|
||||
ConferencingService,
|
||||
ZoomVideoService,
|
||||
Office365VideoService,
|
||||
TokensRepository,
|
||||
],
|
||||
exports: [
|
||||
OrganizationsService,
|
||||
@@ -129,6 +149,7 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
WebhooksService,
|
||||
OrganizationsEventTypesService,
|
||||
OrganizationsConferencingService,
|
||||
OrganizationsStripeService,
|
||||
],
|
||||
controllers: [
|
||||
OrganizationsTeamsController,
|
||||
@@ -142,7 +163,6 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
OrganizationsWebhooksController,
|
||||
OrganizationsTeamsSchedulesController,
|
||||
OrganizationsUsersOOOController,
|
||||
OrganizationsConferencingController,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||
import {
|
||||
StripConnectOutputDto,
|
||||
StripConnectOutputResponseDto,
|
||||
StripCredentialsCheckOutputResponseDto,
|
||||
StripCredentialsSaveOutputResponseDto,
|
||||
} from "@/modules/stripe/outputs/stripe.output";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Param,
|
||||
Headers,
|
||||
Req,
|
||||
ParseIntPipe,
|
||||
Redirect,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
};
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/stripe",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Organizations/Teams Stripe")
|
||||
export class OrganizationsStripeController {
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly organizationsStripeService: OrganizationsStripeService,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get stripe connect URL for a team" })
|
||||
async getTeamStripeConnectUrl(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId") teamId: string,
|
||||
@Param("orgId") orgId: string,
|
||||
@Query("returnTo") returnTo?: string,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string
|
||||
): Promise<StripConnectOutputResponseDto> {
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
const state: OAuthCallbackState = {
|
||||
onErrorReturnTo: !!onErrorReturnTo ? onErrorReturnTo : origin,
|
||||
fromApp: false,
|
||||
returnTo: !!returnTo ? returnTo : origin,
|
||||
accessToken,
|
||||
teamId,
|
||||
orgId,
|
||||
};
|
||||
|
||||
const stripeRedirectUrl = await this.organizationsStripeService.getStripeTeamRedirectUrl({
|
||||
state,
|
||||
userEmail: user.email,
|
||||
userName: user.name,
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(StripConnectOutputDto, { authUrl: stripeRedirectUrl }, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/check")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Check team stripe connection" })
|
||||
async checkTeamStripeConnection(
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.organizationsStripeService.checkIfTeamStripeAccountConnected(teamId);
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/save")
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save stripe credentials" })
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Query("error") error: string | undefined,
|
||||
@Query("error_description") error_description: string | undefined,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<StripCredentialsSaveOutputResponseDto> {
|
||||
if (!state) {
|
||||
throw new BadRequestException("Missing `state` query param");
|
||||
}
|
||||
|
||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||
try {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||
|
||||
// user cancels flow
|
||||
if (error === "access_denied") {
|
||||
return { url: getOnErrorReturnToValueFromQueryState(state) };
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new BadRequestException(stringify({ error, error_description }));
|
||||
}
|
||||
|
||||
return await this.organizationsStripeService.saveStripeAccount(
|
||||
decodedCallbackState,
|
||||
code,
|
||||
teamId,
|
||||
userId
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return {
|
||||
url: decodedCallbackState.onErrorReturnTo ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsStripeController } from "@/modules/organizations/stripe/organizations-stripe.controller";
|
||||
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [StripeModule, PrismaModule],
|
||||
exports: [OrganizationsStripeService],
|
||||
providers: [
|
||||
OrganizationsStripeService,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
RedisService,
|
||||
OrganizationsRepository,
|
||||
MembershipsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
TokensRepository,
|
||||
],
|
||||
controllers: [OrganizationsStripeController],
|
||||
})
|
||||
export class OrganizationsStripeModule {}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OAuthCallbackState, StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
|
||||
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
|
||||
import { Logger, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { ApiResponseWithoutData } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsStripeService {
|
||||
private logger = new Logger("OrganizationsStripeService");
|
||||
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly appsRepository: AppsRepository
|
||||
) {}
|
||||
|
||||
async getStripeTeamRedirectUrl({
|
||||
state,
|
||||
userEmail,
|
||||
userName,
|
||||
}: {
|
||||
state: OAuthCallbackState;
|
||||
userEmail?: string;
|
||||
userName?: string | null;
|
||||
}): Promise<string> {
|
||||
return await this.stripeService.getStripeRedirectUrl(state, userEmail, userName);
|
||||
}
|
||||
|
||||
async saveStripeAccount(
|
||||
state: OAuthCallbackState,
|
||||
code: string,
|
||||
teamId: number,
|
||||
userId?: number
|
||||
): Promise<{ url: string }> {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
const response = await stripeInstance.oauth.token({
|
||||
grant_type: "authorization_code",
|
||||
code: code?.toString(),
|
||||
});
|
||||
|
||||
const data: StripeData = { ...response, default_currency: "" };
|
||||
if (response["stripe_user_id"]) {
|
||||
const account = await stripeInstance.accounts.retrieve(response["stripe_user_id"]);
|
||||
data["default_currency"] = account.default_currency;
|
||||
}
|
||||
|
||||
const existingCredentials = await this.credentialRepository.findAllCredentialsByTypeAndTeamId(
|
||||
"stripe_payment",
|
||||
teamId
|
||||
);
|
||||
|
||||
const credentialIdsToDelete = existingCredentials.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createTeamAppCredential(
|
||||
"stripe_payment",
|
||||
data as unknown as Prisma.InputJsonObject,
|
||||
teamId,
|
||||
"stripe"
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
async checkIfTeamStripeAccountConnected(teamId: number): Promise<ApiResponseWithoutData> {
|
||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId(
|
||||
"stripe_payment",
|
||||
teamId
|
||||
);
|
||||
|
||||
return await this.stripeService.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
StripCredentialsCheckOutputResponseDto,
|
||||
StripCredentialsSaveOutputResponseDto,
|
||||
} from "@/modules/stripe/outputs/stripe.output";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { OAuthCallbackState, StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import {
|
||||
Controller,
|
||||
Query,
|
||||
@@ -22,8 +24,8 @@ import {
|
||||
Req,
|
||||
BadRequestException,
|
||||
Headers,
|
||||
Param,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiTags as DocsTags, ApiOperation, ApiHeader } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
@@ -37,7 +39,12 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
})
|
||||
@DocsTags("Stripe")
|
||||
export class StripeController {
|
||||
constructor(private readonly stripeService: StripeService) {}
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly config: ConfigService
|
||||
) {}
|
||||
|
||||
@Get("/connect")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -48,26 +55,20 @@ export class StripeController {
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Query("redir") redir?: string | null,
|
||||
@Query("errorRedir") errorRedir?: string | null,
|
||||
@Query("teamId") teamId?: string | null
|
||||
@Query("returnTo") returnTo?: string | null,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string | null
|
||||
): Promise<StripConnectOutputResponseDto> {
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
const state = {
|
||||
onErrorReturnTo: !!errorRedir ? errorRedir : origin,
|
||||
const state: OAuthCallbackState = {
|
||||
onErrorReturnTo: !!onErrorReturnTo ? onErrorReturnTo : origin,
|
||||
fromApp: false,
|
||||
returnTo: !!redir ? redir : origin,
|
||||
returnTo: !!returnTo ? returnTo : origin,
|
||||
accessToken,
|
||||
teamId: Number(teamId) ?? null,
|
||||
};
|
||||
|
||||
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
|
||||
JSON.stringify(state),
|
||||
user.email,
|
||||
user.name
|
||||
);
|
||||
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(state, user.email, user.name);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -79,24 +80,71 @@ export class StripeController {
|
||||
@UseGuards()
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save stripe credentials" })
|
||||
/**
|
||||
* Handles saving Stripe credentials.
|
||||
* If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint;
|
||||
* otherwise, credentials are saved at the user level.
|
||||
*
|
||||
* Proxying ensures that permission checks—such as whether the user is allowed to install Stripe for a team or organization—
|
||||
* are enforced via controller route guards, avoiding duplication of this logic within the service layer.
|
||||
*/
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Query("error") error: string | undefined,
|
||||
@Query("error_description") error_description: string | undefined
|
||||
): Promise<StripCredentialsSaveOutputResponseDto> {
|
||||
const accessToken = JSON.parse(state).accessToken;
|
||||
|
||||
// user cancels flow
|
||||
if (error === "access_denied") {
|
||||
return { url: getOnErrorReturnToValueFromQueryState(state) };
|
||||
if (!state) {
|
||||
throw new BadRequestException("Missing `state` query param");
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new BadRequestException(stringify({ error, error_description }));
|
||||
}
|
||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||
try {
|
||||
// If teamId is present, proxy to team endpoint
|
||||
if (decodedCallbackState.teamId && decodedCallbackState.orgId) {
|
||||
let url = "";
|
||||
const apiUrl = this.config.get("api.url");
|
||||
url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/teams/${decodedCallbackState.teamId}/stripe/save`;
|
||||
|
||||
return await this.stripeService.saveStripeAccount(state, code, accessToken);
|
||||
const params: Record<string, string | undefined> = { state, code, error, error_description };
|
||||
const headers = {
|
||||
Authorization: `Bearer ${decodedCallbackState.accessToken}`,
|
||||
};
|
||||
try {
|
||||
const response = await this.httpService.axiosRef.get(url, { params, headers });
|
||||
const redirectUrl = response.data?.url || decodedCallbackState.onErrorReturnTo || "";
|
||||
return { url: redirectUrl };
|
||||
} catch (err) {
|
||||
const fallbackUrl = decodedCallbackState.onErrorReturnTo || "";
|
||||
return { url: fallbackUrl };
|
||||
}
|
||||
}
|
||||
|
||||
// user-level fallback
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||
|
||||
// user cancels flow
|
||||
if (error === "access_denied") {
|
||||
return { url: getOnErrorReturnToValueFromQueryState(state) };
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new BadRequestException(stringify({ error, error_description }));
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("Invalid Access token.");
|
||||
}
|
||||
|
||||
return await this.stripeService.saveStripeAccount(decodedCallbackState, code, userId);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return {
|
||||
url: decodedCallbackState.onErrorReturnTo ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/check")
|
||||
@@ -107,15 +155,4 @@ export class StripeController {
|
||||
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
|
||||
}
|
||||
|
||||
@Get("/check/:teamId")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Check team stripe connection" })
|
||||
async checkTeamStripeConnection(
|
||||
@Param("teamId") teamId: string
|
||||
): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export class StripConnectOutputResponseDto {
|
||||
|
||||
export class StripCredentialsCheckOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS })
|
||||
status!: typeof SUCCESS_STATUS;
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
}
|
||||
|
||||
export class StripCredentialsSaveOutputResponseDto {
|
||||
|
||||
@@ -6,11 +6,12 @@ import { StripeController } from "@/modules/stripe/controllers/stripe.controller
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, PrismaModule, UsersModule],
|
||||
imports: [ConfigModule, PrismaModule, UsersModule, HttpModule],
|
||||
exports: [StripeService],
|
||||
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository],
|
||||
controllers: [StripeController],
|
||||
|
||||
@@ -2,10 +2,8 @@ import { AppConfig } from "@/config/type";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
|
||||
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Injectable,
|
||||
@@ -25,12 +23,13 @@ import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
|
||||
|
||||
import stringify = require("qs-stringify");
|
||||
|
||||
type IntegrationOAuthCallbackState = {
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
returnTo: string;
|
||||
onErrorReturnTo: string;
|
||||
fromApp: boolean;
|
||||
teamId?: number | null;
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -45,8 +44,7 @@ export class StripeService {
|
||||
configService: ConfigService<AppConfig>,
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly membershipRepository: MembershipsRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {
|
||||
@@ -59,7 +57,7 @@ export class StripeService {
|
||||
return this.stripe;
|
||||
}
|
||||
|
||||
async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) {
|
||||
async getStripeRedirectUrl(state: OAuthCallbackState, userEmail?: string, userName?: string | null) {
|
||||
const { client_id } = await this.getStripeAppKeys();
|
||||
|
||||
const stripeConnectParams: Stripe.OAuthAuthorizeUrlParams = {
|
||||
@@ -73,7 +71,7 @@ export class StripeService {
|
||||
country: process.env.NEXT_PUBLIC_IS_E2E ? "US" : undefined,
|
||||
},
|
||||
redirect_uri: this.redirectUri,
|
||||
state: state,
|
||||
state: JSON.stringify(state),
|
||||
};
|
||||
|
||||
const params = z.record(z.any()).parse(stripeConnectParams);
|
||||
@@ -99,10 +97,7 @@ export class StripeService {
|
||||
return { client_id, client_secret };
|
||||
}
|
||||
|
||||
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
const oAuthCallbackState: IntegrationOAuthCallbackState = JSON.parse(state);
|
||||
|
||||
async saveStripeAccount(state: OAuthCallbackState, code: string, userId: number): Promise<{ url: string }> {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
@@ -118,17 +113,14 @@ export class StripeService {
|
||||
data["default_currency"] = account.default_currency;
|
||||
}
|
||||
|
||||
if (oAuthCallbackState.teamId) {
|
||||
await this.checkIfUserHasAdminAccessToTeam(oAuthCallbackState.teamId, userId);
|
||||
const existingCredentials = await this.credentialsRepository.findAllCredentialsByTypeAndUserId(
|
||||
"stripe_payment",
|
||||
userId
|
||||
);
|
||||
|
||||
await this.appsRepository.createTeamAppCredential(
|
||||
"stripe_payment",
|
||||
data as unknown as Prisma.InputJsonObject,
|
||||
oAuthCallbackState.teamId,
|
||||
"stripe"
|
||||
);
|
||||
|
||||
return { url: getReturnToValueFromQueryState(state) };
|
||||
const credentialIdsToDelete = existingCredentials.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
@@ -138,11 +130,11 @@ export class StripeService {
|
||||
"stripe"
|
||||
);
|
||||
|
||||
return { url: getReturnToValueFromQueryState(state) };
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
const stripeCredentials = await this.credentialsRepository.findCredentialByTypeAndUserId(
|
||||
"stripe_payment",
|
||||
userId
|
||||
);
|
||||
@@ -150,24 +142,6 @@ export class StripeService {
|
||||
return await this.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
|
||||
async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId(
|
||||
"stripe_payment",
|
||||
teamId
|
||||
);
|
||||
|
||||
return await this.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
|
||||
async checkIfUserHasAdminAccessToTeam(teamId: number, userId: number) {
|
||||
const teamMembership = await this.membershipRepository.findMembershipByTeamId(teamId, userId);
|
||||
const hasAdminAccessToTeam = teamMembership?.role === "ADMIN" || teamMembership?.role === "OWNER";
|
||||
|
||||
if (!hasAdminAccessToTeam) {
|
||||
throw new BadRequestException("You must be team owner or admin to do this");
|
||||
}
|
||||
}
|
||||
|
||||
async validateStripeCredentials(
|
||||
credentials?: Credential | null
|
||||
): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
|
||||
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
||||
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
|
||||
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service";
|
||||
@@ -14,7 +12,7 @@ import { TeamsEventTypesService } from "@/modules/teams/event-types/services/tea
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,8 +22,7 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
EventTypesModule_2024_06_14,
|
||||
UsersModule,
|
||||
TeamsModule,
|
||||
forwardRef(() => ConferencingModule),
|
||||
forwardRef(() => AuthModule),
|
||||
OrganizationsConferencingModule,
|
||||
],
|
||||
providers: [
|
||||
TeamsEventTypesRepository,
|
||||
@@ -34,7 +31,6 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
OrganizationsTeamsRepository,
|
||||
OutputTeamEventTypesResponsePipe,
|
||||
OutputOrganizationsEventTypesService,
|
||||
OrganizationsConferencingService,
|
||||
],
|
||||
exports: [TeamsEventTypesRepository, TeamsEventTypesService],
|
||||
controllers: [TeamsEventTypesController],
|
||||
|
||||
@@ -3577,6 +3577,62 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/oauth/callback": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsConferencingController_saveTeamOauthCredentials",
|
||||
"summary": "Save conferencing app OAuth credentials",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "teamId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "orgId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "app",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Orgs / Teams / Conferencing"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/event-types": {
|
||||
"post": {
|
||||
"operationId": "OrganizationsEventTypesController_createTeamEventType",
|
||||
@@ -9249,6 +9305,147 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsStripeController_getTeamStripeConnectUrl",
|
||||
"summary": "Get stripe connect URL for a team",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"required": true,
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "teamId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "orgId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "returnTo",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "onErrorReturnTo",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StripConnectOutputResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Organizations/Teams Stripe"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/stripe/check": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsStripeController_checkTeamStripeConnection",
|
||||
"summary": "Check team stripe connection",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "teamId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Organizations/Teams Stripe"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/stripe/save": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsStripeController_save",
|
||||
"summary": "Save stripe credentials",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "teamId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StripCredentialsSaveOutputResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Organizations/Teams Stripe"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/routing-forms/{routingFormId}/calculate-slots": {
|
||||
"post": {
|
||||
"operationId": "RoutingFormsController_calculateSlotsBasedOnRoutingFormResponse",
|
||||
@@ -10234,46 +10431,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/stripe/check/{teamId}": {
|
||||
"get": {
|
||||
"operationId": "StripeController_checkTeamStripeConnection",
|
||||
"summary": "Check team stripe connection",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "teamId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Stripe"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/teams": {
|
||||
"post": {
|
||||
"operationId": "TeamsController_createTeam",
|
||||
@@ -19156,7 +19313,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"type": "object",
|
||||
"example": "success"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,11 +28,7 @@ import {
|
||||
} from "@calcom/features/bookings/lib/SystemField";
|
||||
import { getCalendarLinks, CalendarLinkType } from "@calcom/lib/bookings/getCalendarLinks";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import {
|
||||
formatToLocalizedDate,
|
||||
formatToLocalizedTime,
|
||||
formatToLocalizedTimezone,
|
||||
} from "@calcom/lib/dayjs";
|
||||
import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs";
|
||||
import type { nameObjectSchema } from "@calcom/lib/event";
|
||||
import { getEventName } from "@calcom/lib/event";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
|
||||
@@ -3409,6 +3409,60 @@
|
||||
"tags": ["Orgs / Teams / Conferencing"]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/oauth/callback": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsConferencingController_saveTeamOauthCredentials",
|
||||
"summary": "Save conferencing app OAuth credentials",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "teamId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "orgId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "app",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": ["Orgs / Teams / Conferencing"]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/event-types": {
|
||||
"post": {
|
||||
"operationId": "OrganizationsEventTypesController_createTeamEventType",
|
||||
@@ -8808,6 +8862,141 @@
|
||||
"tags": ["OAuth Clients"]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsStripeController_getTeamStripeConnectUrl",
|
||||
"summary": "Get stripe connect URL for a team",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"required": true,
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "teamId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "orgId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "returnTo",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "onErrorReturnTo",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StripConnectOutputResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["Organizations/Teams Stripe"]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/stripe/check": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsStripeController_checkTeamStripeConnection",
|
||||
"summary": "Check team stripe connection",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "teamId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["Organizations/Teams Stripe"]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/stripe/save": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsStripeController_save",
|
||||
"summary": "Save stripe credentials",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "teamId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StripCredentialsSaveOutputResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["Organizations/Teams Stripe"]
|
||||
}
|
||||
},
|
||||
"/v2/routing-forms/{routingFormId}/calculate-slots": {
|
||||
"post": {
|
||||
"operationId": "RoutingFormsController_calculateSlotsBasedOnRoutingFormResponse",
|
||||
@@ -9756,44 +9945,6 @@
|
||||
"tags": ["Stripe"]
|
||||
}
|
||||
},
|
||||
"/v2/stripe/check/{teamId}": {
|
||||
"get": {
|
||||
"operationId": "StripeController_checkTeamStripeConnection",
|
||||
"summary": "Check team stripe connection",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "teamId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["Stripe"]
|
||||
}
|
||||
},
|
||||
"/v2/teams": {
|
||||
"post": {
|
||||
"operationId": "TeamsController_createTeam",
|
||||
@@ -17567,7 +17718,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"type": "object",
|
||||
"example": "success"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,7 +139,7 @@ export const AppleConnect: FC<Partial<Omit<OAuthConnectProps, "redir">>> = ({
|
||||
form.reset();
|
||||
setIsDialogOpen(false);
|
||||
toast({
|
||||
description: "Calendar credentials added successfully",
|
||||
description: "Calendar credentials added successfully",
|
||||
});
|
||||
onSuccess?.();
|
||||
} else {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ButtonColor } from "@calcom/ui/components/button";
|
||||
import type { IconName } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { OnCheckErrorType, UseCheckProps } from "../../hooks/connect/useCheck";
|
||||
import { useTeamCheck, useCheck } from "../../hooks/stripe/useCheck";
|
||||
import { useCheck } from "../../hooks/stripe/useCheck";
|
||||
import { useConnect } from "../../hooks/stripe/useConnect";
|
||||
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
@@ -46,24 +46,16 @@ export const StripeConnect: FC<Partial<StripeConnectProps>> = ({
|
||||
let displayedLabel = label || t("stripe_connect_atom_label");
|
||||
|
||||
const { connect } = useConnect(redir, errorRedir, teamId);
|
||||
const { allowConnect, checked } = useCheck({
|
||||
const { allowConnect, checked, isLoading } = useCheck({
|
||||
onCheckError,
|
||||
onCheckSuccess,
|
||||
initialData,
|
||||
});
|
||||
const { allowConnect: allowConnectTeam, checked: checkedTeam } = useTeamCheck({
|
||||
teamId,
|
||||
onCheckError,
|
||||
onCheckSuccess,
|
||||
initialData,
|
||||
});
|
||||
|
||||
const isChecking = teamId ? !checkedTeam : !checked;
|
||||
const isAllowConnect = teamId ? allowConnectTeam : allowConnect;
|
||||
const isDisabled = !checked || !allowConnect;
|
||||
|
||||
const isDisabled = isChecking || !isAllowConnect;
|
||||
|
||||
if (isChecking) {
|
||||
if (!checked) {
|
||||
displayedLabel = loadingLabel || t("stripe_connect_atom_loading_label");
|
||||
} else if (!allowConnect) {
|
||||
displayedLabel = alreadyConnectedLabel || t("stripe_connect_atom_already_connected_label");
|
||||
@@ -79,7 +71,7 @@ export const StripeConnect: FC<Partial<StripeConnectProps>> = ({
|
||||
"",
|
||||
"md:rounded-md",
|
||||
className,
|
||||
isChecking && "animate-pulse",
|
||||
isLoading && "animate-pulse bg-gray-200",
|
||||
isDisabled && "cursor-not-allowed",
|
||||
!isDisabled && "cursor-pointer"
|
||||
)}
|
||||
|
||||
@@ -9,19 +9,15 @@ import useAppsData from "@calcom/lib/hooks/useAppsData";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
|
||||
import { StripeConnect } from "../../connect/stripe/StripeConnect";
|
||||
import { useCheck, useTeamCheck } from "../../hooks/stripe/useCheck";
|
||||
import { useCheck } from "../../hooks/stripe/useCheck";
|
||||
import { useAtomsEventTypeById } from "../hooks/useAtomEventTypeAppIntegration";
|
||||
|
||||
const EventPaymentsTabPlatformWrapper = ({ eventType }: { eventType: EventTypeSetupProps["eventType"] }) => {
|
||||
const { allowConnect, checked } = useCheck({});
|
||||
const { allowConnect: allowConnectTeam, checked: checkedTeam } = useTeamCheck({ teamId: eventType.teamId });
|
||||
const { allowConnect, checked } = useCheck({ teamId: eventType.teamId });
|
||||
|
||||
const isAllowConnect = eventType.teamId ? allowConnectTeam : allowConnect;
|
||||
const isChecking = eventType.teamId ? !checkedTeam : !checked;
|
||||
const isStripeConnected = !checked || !allowConnect;
|
||||
|
||||
const isStripeConnected = isChecking || !isAllowConnect;
|
||||
|
||||
if (isChecking) return <div>Checking...</div>;
|
||||
if (!checked) return <div>Checking...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { CALENDARS } from "@calcom/platform-constants";
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@@ -17,23 +16,33 @@ export interface UseCheckProps {
|
||||
checked: boolean;
|
||||
};
|
||||
};
|
||||
teamId?: number | null;
|
||||
}
|
||||
const stripeQueryKey = ["get-stripe-check"];
|
||||
const stripeTeamQueryKey = ["get-stripe-team-check"];
|
||||
const stripeTeamQueryKey = "get-stripe-check";
|
||||
|
||||
export type OnCheckErrorType = (err: ApiErrorResponse) => void;
|
||||
export const getQueryKey = (calendar: (typeof CALENDARS)[number]) => [`get-${calendar}-check`];
|
||||
|
||||
export const useCheck = ({ onCheckError, initialData, onCheckSuccess }: UseCheckProps) => {
|
||||
const { isInit, accessToken } = useAtomsContext();
|
||||
export const useCheck = ({ teamId, onCheckError, initialData, onCheckSuccess }: UseCheckProps) => {
|
||||
const { isInit, accessToken, organizationId } = useAtomsContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: check, refetch } = useQuery({
|
||||
queryKey: stripeQueryKey,
|
||||
// Determine the appropriate endpoint based on whether teamId is provided
|
||||
let pathname = "/stripe/check";
|
||||
|
||||
if (teamId && organizationId) {
|
||||
pathname = `/organizations/${organizationId}/teams/${teamId}/stripe/check`;
|
||||
}
|
||||
|
||||
const {
|
||||
data: check,
|
||||
refetch,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryKey: [stripeTeamQueryKey, teamId, organizationId],
|
||||
enabled: isInit && !!accessToken,
|
||||
queryFn: () => {
|
||||
return http
|
||||
?.get<ApiResponse<{ checked: boolean; allowConnect: boolean }>>(`/stripe/check`)
|
||||
?.get<ApiResponse<{ checked: boolean; allowConnect: boolean }>>(pathname)
|
||||
.then(({ data: responseBody }) => {
|
||||
if (responseBody.status === SUCCESS_STATUS) {
|
||||
onCheckSuccess?.();
|
||||
@@ -53,54 +62,12 @@ export const useCheck = ({ onCheckError, initialData, onCheckSuccess }: UseCheck
|
||||
allowConnect: check?.data?.allowConnect ?? false,
|
||||
checked: check?.data?.checked ?? false,
|
||||
refetch: () => {
|
||||
queryClient.setQueryData(stripeQueryKey, {
|
||||
status: SUCCESS_STATUS,
|
||||
data: { allowConnect: false, checked: false },
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useTeamCheck = ({
|
||||
teamId,
|
||||
onCheckError,
|
||||
initialData,
|
||||
onCheckSuccess,
|
||||
}: UseCheckProps & { teamId?: number | null }) => {
|
||||
const { isInit, accessToken } = useAtomsContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: check, refetch } = useQuery({
|
||||
queryKey: stripeTeamQueryKey,
|
||||
enabled: isInit && !!accessToken && !!teamId,
|
||||
queryFn: () => {
|
||||
return http
|
||||
?.get<ApiResponse<{ checked: boolean; allowConnect: boolean }>>(`/stripe/check/${teamId}`)
|
||||
.then(({ data: responseBody }) => {
|
||||
if (responseBody.status === SUCCESS_STATUS) {
|
||||
onCheckSuccess?.();
|
||||
return { status: SUCCESS_STATUS, data: { allowConnect: false, checked: true } };
|
||||
}
|
||||
onCheckError?.(responseBody);
|
||||
return { status: ERROR_STATUS, data: { allowConnect: true, checked: true } };
|
||||
})
|
||||
.catch((err) => {
|
||||
onCheckError?.(err);
|
||||
return { status: ERROR_STATUS, data: { allowConnect: true, checked: true } };
|
||||
});
|
||||
},
|
||||
initialData,
|
||||
});
|
||||
return {
|
||||
allowConnect: check?.data?.allowConnect ?? false,
|
||||
checked: check?.data?.checked ?? false,
|
||||
refetch: () => {
|
||||
queryClient.setQueryData(stripeQueryKey, {
|
||||
queryClient.setQueryData([stripeTeamQueryKey, teamId, organizationId], {
|
||||
status: SUCCESS_STATUS,
|
||||
data: { allowConnect: false, checked: false },
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,34 +4,43 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import type { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
import http from "../../lib/http";
|
||||
import { useAtomsContext } from "../useAtomsContext";
|
||||
|
||||
export const useGetRedirectUrl = (redir?: string, errorRedir?: string, teamId?: number | null) => {
|
||||
const authUrl = useQuery({
|
||||
queryKey: ["get-stripe-connect-redirect-uri"],
|
||||
export const useGetRedirectUrl = (returnTo?: string, onErrorReturnTo?: string, teamId?: number | null) => {
|
||||
const { organizationId } = useAtomsContext();
|
||||
|
||||
// Determine the appropriate endpoint based on whether teamId is provided
|
||||
let pathname = "/stripe/connect";
|
||||
|
||||
if (teamId && organizationId) {
|
||||
pathname = `/organizations/${organizationId}/teams/${teamId}/stripe/connect`;
|
||||
}
|
||||
|
||||
// Add query parameters
|
||||
const queryParams = new URLSearchParams();
|
||||
if (returnTo) queryParams.append("returnTo", returnTo);
|
||||
if (onErrorReturnTo) queryParams.append("onErrorReturnTo", onErrorReturnTo);
|
||||
|
||||
const fullPath = queryParams.toString() ? `${pathname}?${queryParams.toString()}` : pathname;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["get-stripe-connect-redirect-uri", teamId, organizationId],
|
||||
staleTime: Infinity,
|
||||
enabled: false,
|
||||
queryFn: () => {
|
||||
return http
|
||||
?.get<ApiResponse<{ authUrl: string }>>(
|
||||
`/stripe/connect${redir ? `?redir=${encodeURIComponent(redir)}` : "?redir="}${
|
||||
errorRedir ? `&errorRedir=${encodeURIComponent(errorRedir)}` : ""
|
||||
}${teamId ? `&teamId=${teamId}` : ""}`
|
||||
)
|
||||
.then(({ data: responseBody }) => {
|
||||
if (responseBody.status === SUCCESS_STATUS) {
|
||||
return responseBody.data.authUrl;
|
||||
}
|
||||
if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message);
|
||||
return "";
|
||||
});
|
||||
return http?.get<ApiResponse<{ authUrl: string }>>(fullPath).then(({ data: responseBody }) => {
|
||||
if (responseBody.status === SUCCESS_STATUS) {
|
||||
return responseBody.data.authUrl;
|
||||
}
|
||||
if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message);
|
||||
return "";
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return authUrl;
|
||||
};
|
||||
|
||||
export const useConnect = (redir?: string, errorRedir?: string, teamId?: number | null) => {
|
||||
const { refetch } = useGetRedirectUrl(redir, errorRedir, teamId);
|
||||
export const useConnect = (returnTo?: string, onErrorReturnTo?: string, teamId?: number | null) => {
|
||||
const { refetch } = useGetRedirectUrl(returnTo, onErrorReturnTo, teamId);
|
||||
|
||||
const connect = async () => {
|
||||
const redirectUri = await refetch();
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function ConferencingApps(props: { calUsername: string; calEmail:
|
||||
className={`flex min-h-screen flex-col ${inter.className} main text-default flex min-h-full w-full flex-col items-center overflow-visible`}>
|
||||
<Navbar username={props.calUsername} />
|
||||
<div className="my-8">
|
||||
<ConferencingAppsSettings returnTo={callbackUri} onErrorReturnTo={callbackUri} teamId={35} />
|
||||
<ConferencingAppsSettings returnTo={callbackUri} onErrorReturnTo={callbackUri} teamId={20} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -13,11 +13,9 @@ import type {
|
||||
} from "zod";
|
||||
|
||||
import { appDataSchemas } from "@calcom/app-store/apps.schemas.generated";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
|
||||
import type { FieldType as FormBuilderFieldType } from "@calcom/features/form-builder/schema";
|
||||
import { fieldsSchema as formBuilderFieldsSchema } from "@calcom/features/form-builder/schema";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||
import { emailSchema as emailRegexSchema, emailRegex } from "@calcom/lib/emailSchema";
|
||||
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
||||
import { zodAttributesQueryValue } from "@calcom/lib/raqb/zod";
|
||||
|
||||
@@ -2305,6 +2305,7 @@ __metadata:
|
||||
"@golevelup/ts-jest": ^0.4.0
|
||||
"@microsoft/microsoft-graph-types-beta": ^0.42.0-preview
|
||||
"@nest-lab/throttler-storage-redis": 1.0.0
|
||||
"@nestjs/axios": ^4.0.0
|
||||
"@nestjs/bull": ^10.1.1
|
||||
"@nestjs/cli": ^10.0.0
|
||||
"@nestjs/common": ^10.0.0
|
||||
@@ -7681,6 +7682,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@nestjs/axios@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "@nestjs/axios@npm:4.0.0"
|
||||
peerDependencies:
|
||||
"@nestjs/common": ^10.0.0 || ^11.0.0
|
||||
axios: ^1.3.1
|
||||
rxjs: ^7.0.0
|
||||
checksum: ab05bc772a7ad45e3ea79ea3acd04e6971d17495387d543b3e17355d0f4164982c20ed830eab48e8c7373546259b50ff770708c176832716f4fd126142d18ca7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@nestjs/bull-shared@npm:^10.1.1":
|
||||
version: 10.1.1
|
||||
resolution: "@nestjs/bull-shared@npm:10.1.1"
|
||||
|
||||
Reference in New Issue
Block a user