HSB-462 feat: infra token module and sh apis (#4191)
* feat: infra token module added * feat: infra token guard added * feat: token prefix removed * feat: get pending invites api added * docs: swagger doc added for get user invites api * feat: delete user invitation api added * feat: get users api added * feat: update user api added * feat: update admin status api added * feat: create invitation api added * chore: swagger doc update for create user invite * feat: interceptor added to track last used on * feat: change db schema * chore: readonly tag added * feat: get user by id api added * fix: return type of a function * feat: controller name change * chore: improve token extractino * chore: added email validation logic --------- Co-authored-by: Balu Babu <balub997@gmail.com>
This commit is contained in:
@@ -35,11 +35,14 @@
|
||||
"@nestjs/passport": "10.0.2",
|
||||
"@nestjs/platform-express": "10.2.7",
|
||||
"@nestjs/schedule": "4.0.1",
|
||||
"@nestjs/swagger": "7.4.0",
|
||||
"@nestjs/terminus": "10.2.3",
|
||||
"@nestjs/throttler": "5.0.1",
|
||||
"@prisma/client": "5.8.1",
|
||||
"argon2": "0.30.3",
|
||||
"bcrypt": "5.1.0",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.14.1",
|
||||
"cookie": "0.5.0",
|
||||
"cookie-parser": "1.4.6",
|
||||
"cron": "3.1.6",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "InfraToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"creatorUid" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expiresOn" TIMESTAMP(3),
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "InfraToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InfraToken_token_key" ON "InfraToken"("token");
|
||||
@@ -232,3 +232,13 @@ model PersonalAccessToken {
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model InfraToken {
|
||||
id String @id @default(cuid())
|
||||
creatorUid String
|
||||
label String
|
||||
token String @unique @default(uuid())
|
||||
expiresOn DateTime? @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @default(now()) @db.Timestamp(3)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { CreateAccessTokenDto } from './dto/create-access-token.dto';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { isValidLength } from 'src/utils';
|
||||
import { calculateExpirationDate, isValidLength } from 'src/utils';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import {
|
||||
ACCESS_TOKEN_EXPIRY_INVALID,
|
||||
@@ -20,17 +20,6 @@ export class AccessTokenService {
|
||||
VALID_TOKEN_DURATIONS = [7, 30, 60, 90];
|
||||
TOKEN_PREFIX = 'pat-';
|
||||
|
||||
/**
|
||||
* Calculate the expiration date of the token
|
||||
*
|
||||
* @param expiresOn Number of days the token is valid for
|
||||
* @returns Date object of the expiration date
|
||||
*/
|
||||
private calculateExpirationDate(expiresOn: null | number) {
|
||||
if (expiresOn === null) return null;
|
||||
return new Date(Date.now() + expiresOn * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the expiration date of the token
|
||||
*
|
||||
@@ -97,9 +86,7 @@ export class AccessTokenService {
|
||||
data: {
|
||||
userUid: user.uid,
|
||||
label: createAccessTokenDto.label,
|
||||
expiresOn: this.calculateExpirationDate(
|
||||
createAccessTokenDto.expiryInDays,
|
||||
),
|
||||
expiresOn: calculateExpirationDate(createAccessTokenDto.expiryInDays),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -161,6 +161,13 @@ export class AdminService {
|
||||
* @returns an Either of boolean or error string
|
||||
*/
|
||||
async revokeUserInvitations(inviteeEmails: string[]) {
|
||||
const areAllEmailsValid = inviteeEmails.every((email) =>
|
||||
validateEmail(email),
|
||||
);
|
||||
if (!areAllEmailsValid) {
|
||||
return E.left(INVALID_EMAIL);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.prisma.invitedUsers.deleteMany({
|
||||
where: {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AccessTokenModule } from './access-token/access-token.module';
|
||||
import { UserLastActiveOnInterceptor } from './interceptors/user-last-active-on.interceptor';
|
||||
import { InfraTokenModule } from './infra-token/infra-token.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -105,6 +106,7 @@ import { UserLastActiveOnInterceptor } from './interceptors/user-last-active-on.
|
||||
ScheduleModule.forRoot(),
|
||||
HealthModule,
|
||||
AccessTokenModule,
|
||||
InfraTokenModule,
|
||||
],
|
||||
providers: [
|
||||
GQLComplexityPlugin,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
** Decorator to fetch refresh_token from cookie
|
||||
*/
|
||||
export const BearerToken = createParamDecorator(
|
||||
(data: unknown, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
// authorization token will be "Bearer <token>"
|
||||
const authorization = request.headers['authorization'];
|
||||
// Remove "Bearer " and return the token only
|
||||
return authorization.split(' ')[1];
|
||||
},
|
||||
);
|
||||
@@ -810,3 +810,46 @@ export const ACCESS_TOKEN_INVALID = 'TOKEN_INVALID';
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKENS_INVALID_DATA_ID = 'INVALID_ID';
|
||||
|
||||
/**
|
||||
* The provided label for the infra-token is short (less than 3 characters)
|
||||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_LABEL_SHORT = 'infra_token/label_too_short';
|
||||
|
||||
/**
|
||||
* The provided expiryInDays value is not valid
|
||||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_EXPIRY_INVALID = 'infra_token/expiry_days_invalid';
|
||||
|
||||
/**
|
||||
* The provided Infra Token ID is invalid
|
||||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_NOT_FOUND = 'infra_token/infra_token_not_found';
|
||||
|
||||
/**
|
||||
* Authorization missing in header (Check 'Authorization' Header)
|
||||
* (InfraTokenGuard)
|
||||
*/
|
||||
export const INFRA_TOKEN_HEADER_MISSING =
|
||||
'infra_token/authorization_token_missing';
|
||||
|
||||
/**
|
||||
* Infra Token is invalid
|
||||
* (InfraTokenGuard)
|
||||
*/
|
||||
export const INFRA_TOKEN_INVALID_TOKEN = 'infra_token/invalid_token';
|
||||
|
||||
/**
|
||||
* Infra Token is expired
|
||||
* (InfraTokenGuard)
|
||||
*/
|
||||
export const INFRA_TOKEN_EXPIRED = 'infra_token/expired';
|
||||
|
||||
/**
|
||||
* Token creator not found
|
||||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_CREATOR_NOT_FOUND = 'infra_token/creator_not_found';
|
||||
|
||||
@@ -29,6 +29,7 @@ import { UserHistoryUserResolver } from './user-history/user.resolver';
|
||||
import { UserSettingsUserResolver } from './user-settings/user.resolver';
|
||||
import { InfraResolver } from './admin/infra.resolver';
|
||||
import { InfraConfigResolver } from './infra-config/infra-config.resolver';
|
||||
import { InfraTokenResolver } from './infra-token/infra-token.resolver';
|
||||
|
||||
/**
|
||||
* All the resolvers present in the application.
|
||||
@@ -60,6 +61,7 @@ const RESOLVERS = [
|
||||
UserSettingsResolver,
|
||||
UserSettingsUserResolver,
|
||||
InfraConfigResolver,
|
||||
InfraTokenResolver,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
47
packages/hoppscotch-backend/src/guards/infra-token.guard.ts
Normal file
47
packages/hoppscotch-backend/src/guards/infra-token.guard.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { DateTime } from 'luxon';
|
||||
import {
|
||||
INFRA_TOKEN_EXPIRED,
|
||||
INFRA_TOKEN_HEADER_MISSING,
|
||||
INFRA_TOKEN_INVALID_TOKEN,
|
||||
} from 'src/errors';
|
||||
|
||||
@Injectable()
|
||||
export class InfraTokenGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authorization = request.headers['authorization'];
|
||||
|
||||
if (!authorization)
|
||||
throw new UnauthorizedException(INFRA_TOKEN_HEADER_MISSING);
|
||||
|
||||
if (!authorization.startsWith('Bearer '))
|
||||
throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN);
|
||||
|
||||
const token = authorization.split(' ')[1];
|
||||
|
||||
if (!token) throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN);
|
||||
|
||||
const infraToken = await this.prisma.infraToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (infraToken === null)
|
||||
throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN);
|
||||
|
||||
const currentTime = DateTime.now().toISO();
|
||||
if (currentTime > infraToken.expiresOn.toISOString()) {
|
||||
throw new UnauthorizedException(INFRA_TOKEN_EXPIRED);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { AdminService } from 'src/admin/admin.service';
|
||||
import { InfraTokenGuard } from 'src/guards/infra-token.guard';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
import {
|
||||
DeleteUserInvitationRequest,
|
||||
DeleteUserInvitationResponse,
|
||||
ExceptionResponse,
|
||||
GetUserInvitationResponse,
|
||||
GetUsersRequestQuery,
|
||||
GetUserResponse,
|
||||
UpdateUserRequest,
|
||||
UpdateUserAdminStatusRequest,
|
||||
UpdateUserAdminStatusResponse,
|
||||
CreateUserInvitationRequest,
|
||||
CreateUserInvitationResponse,
|
||||
} from './request-response.dto';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import {
|
||||
INFRA_TOKEN_CREATOR_NOT_FOUND,
|
||||
USER_NOT_FOUND,
|
||||
USERS_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { InfraTokenService } from './infra-token.service';
|
||||
import { InfraTokenInterceptor } from 'src/interceptors/infra-token.interceptor';
|
||||
import { BearerToken } from 'src/decorators/bearer-token.decorator';
|
||||
|
||||
@ApiTags('User Management API')
|
||||
@ApiSecurity('infra-token')
|
||||
@UseGuards(ThrottlerBehindProxyGuard, InfraTokenGuard)
|
||||
@UseInterceptors(InfraTokenInterceptor)
|
||||
@Controller({ path: 'infra', version: '1' })
|
||||
export class InfraTokensController {
|
||||
constructor(
|
||||
private readonly infraTokenService: InfraTokenService,
|
||||
private readonly adminService: AdminService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
@Post('user-invitations')
|
||||
@ApiCreatedResponse({
|
||||
description: 'Create a user invitation',
|
||||
type: CreateUserInvitationResponse,
|
||||
})
|
||||
@ApiBadRequestResponse({ type: ExceptionResponse })
|
||||
@ApiNotFoundResponse({ type: ExceptionResponse })
|
||||
async createUserInvitation(
|
||||
@BearerToken() token: string,
|
||||
@Body() dto: CreateUserInvitationRequest,
|
||||
) {
|
||||
const createdInvitations =
|
||||
await this.infraTokenService.createUserInvitation(token, dto);
|
||||
|
||||
if (E.isLeft(createdInvitations)) {
|
||||
const statusCode =
|
||||
(createdInvitations.left as string) === INFRA_TOKEN_CREATOR_NOT_FOUND
|
||||
? HttpStatus.NOT_FOUND
|
||||
: HttpStatus.BAD_REQUEST;
|
||||
|
||||
throwHTTPErr({ message: createdInvitations.left, statusCode });
|
||||
}
|
||||
|
||||
return plainToInstance(
|
||||
CreateUserInvitationResponse,
|
||||
{ invitationLink: process.env.VITE_BASE_URL },
|
||||
{
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@Get('user-invitations')
|
||||
@ApiOkResponse({
|
||||
description: 'Get pending user invitations',
|
||||
type: [GetUserInvitationResponse],
|
||||
})
|
||||
async getPendingUserInvitation(
|
||||
@Query() paginationQuery: OffsetPaginationArgs,
|
||||
) {
|
||||
const pendingInvitedUsers = await this.adminService.fetchInvitedUsers(
|
||||
paginationQuery,
|
||||
);
|
||||
|
||||
return plainToInstance(GetUserInvitationResponse, pendingInvitedUsers, {
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('user-invitations')
|
||||
@ApiOkResponse({
|
||||
description: 'Delete a pending user invitation',
|
||||
type: DeleteUserInvitationResponse,
|
||||
})
|
||||
@ApiBadRequestResponse({ type: ExceptionResponse })
|
||||
async deleteUserInvitation(@Body() dto: DeleteUserInvitationRequest) {
|
||||
const isDeleted = await this.adminService.revokeUserInvitations(
|
||||
dto.inviteeEmails,
|
||||
);
|
||||
|
||||
if (E.isLeft(isDeleted)) {
|
||||
throwHTTPErr({
|
||||
message: isDeleted.left,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
|
||||
return plainToInstance(
|
||||
DeleteUserInvitationResponse,
|
||||
{ message: isDeleted.right },
|
||||
{
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
@ApiOkResponse({
|
||||
description: 'Get users list',
|
||||
type: [GetUserResponse],
|
||||
})
|
||||
async getUsers(@Query() query: GetUsersRequestQuery) {
|
||||
const users = await this.userService.fetchAllUsersV2(query.searchString, {
|
||||
take: query.take,
|
||||
skip: query.skip,
|
||||
});
|
||||
|
||||
return plainToInstance(GetUserResponse, users, {
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('users/:uid')
|
||||
@ApiOkResponse({
|
||||
description: 'Get user details',
|
||||
type: GetUserResponse,
|
||||
})
|
||||
@ApiNotFoundResponse({ type: ExceptionResponse })
|
||||
async getUser(@Param('uid') uid: string) {
|
||||
const user = await this.userService.findUserById(uid);
|
||||
|
||||
if (O.isNone(user)) {
|
||||
throwHTTPErr({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
|
||||
return plainToInstance(GetUserResponse, user.value, {
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('users/:uid')
|
||||
@ApiOkResponse({
|
||||
description: 'Update user display name',
|
||||
type: GetUserResponse,
|
||||
})
|
||||
@ApiBadRequestResponse({ type: ExceptionResponse })
|
||||
@ApiNotFoundResponse({ type: ExceptionResponse })
|
||||
async updateUser(@Param('uid') uid: string, @Body() body: UpdateUserRequest) {
|
||||
const updatedUser = await this.userService.updateUserDisplayName(
|
||||
uid,
|
||||
body.displayName,
|
||||
);
|
||||
|
||||
if (E.isLeft(updatedUser)) {
|
||||
const statusCode =
|
||||
(updatedUser.left as string) === USER_NOT_FOUND
|
||||
? HttpStatus.NOT_FOUND
|
||||
: HttpStatus.BAD_REQUEST;
|
||||
|
||||
throwHTTPErr({ message: updatedUser.left, statusCode });
|
||||
}
|
||||
|
||||
return plainToInstance(GetUserResponse, updatedUser.right, {
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('users/:uid/admin-status')
|
||||
@ApiOkResponse({
|
||||
description: 'Update user admin status',
|
||||
type: UpdateUserAdminStatusResponse,
|
||||
})
|
||||
@ApiBadRequestResponse({ type: ExceptionResponse })
|
||||
@ApiNotFoundResponse({ type: ExceptionResponse })
|
||||
async updateUserAdminStatus(
|
||||
@Param('uid') uid: string,
|
||||
@Body() body: UpdateUserAdminStatusRequest,
|
||||
) {
|
||||
let updatedUser;
|
||||
|
||||
if (body.isAdmin) {
|
||||
updatedUser = await this.adminService.makeUsersAdmin([uid]);
|
||||
} else {
|
||||
updatedUser = await this.adminService.demoteUsersByAdmin([uid]);
|
||||
}
|
||||
|
||||
if (E.isLeft(updatedUser)) {
|
||||
const statusCode =
|
||||
(updatedUser.left as string) === USERS_NOT_FOUND
|
||||
? HttpStatus.NOT_FOUND
|
||||
: HttpStatus.BAD_REQUEST;
|
||||
|
||||
throwHTTPErr({ message: updatedUser.left as string, statusCode });
|
||||
}
|
||||
|
||||
return plainToInstance(
|
||||
UpdateUserAdminStatusResponse,
|
||||
{ message: updatedUser.right },
|
||||
{
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class InfraToken {
|
||||
@Field(() => ID, {
|
||||
description: 'ID of the infra token',
|
||||
})
|
||||
id: string;
|
||||
|
||||
@Field(() => String, {
|
||||
description: 'Label of the infra token',
|
||||
})
|
||||
label: string;
|
||||
|
||||
@Field(() => Date, {
|
||||
description: 'Date when the infra token was created',
|
||||
})
|
||||
createdOn: Date;
|
||||
|
||||
@Field(() => Date, {
|
||||
description: 'Date when the infra token expires',
|
||||
nullable: true,
|
||||
})
|
||||
expiresOn: Date;
|
||||
|
||||
@Field(() => Date, {
|
||||
description: 'Date when the infra token was last used',
|
||||
})
|
||||
lastUsedOn: Date;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class CreateInfraTokenResponse {
|
||||
@Field(() => String, {
|
||||
description: 'The infra token',
|
||||
})
|
||||
token: string;
|
||||
|
||||
@Field(() => InfraToken, {
|
||||
description: 'Infra token info',
|
||||
})
|
||||
info: InfraToken;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { InfraTokenResolver } from './infra-token.resolver';
|
||||
import { InfraTokenService } from './infra-token.service';
|
||||
import { InfraTokensController } from './infra-token.controller';
|
||||
import { AdminModule } from 'src/admin/admin.module';
|
||||
import { UserModule } from 'src/user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AdminModule, UserModule],
|
||||
controllers: [InfraTokensController],
|
||||
providers: [InfraTokenResolver, InfraTokenService],
|
||||
})
|
||||
export class InfraTokenModule {}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { CreateInfraTokenResponse, InfraToken } from './infra-token.model';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||
import { InfraTokenService } from './infra-token.service';
|
||||
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
|
||||
import { GqlAdminGuard } from 'src/admin/guards/gql-admin.guard';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
import { GqlAdmin } from 'src/admin/decorators/gql-admin.decorator';
|
||||
import { Admin } from 'src/admin/admin.model';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { throwErr } from 'src/utils';
|
||||
|
||||
@UseGuards(GqlThrottlerGuard)
|
||||
@Resolver(() => InfraToken)
|
||||
export class InfraTokenResolver {
|
||||
constructor(private readonly infraTokenService: InfraTokenService) {}
|
||||
|
||||
/* Query */
|
||||
|
||||
@Query(() => [InfraToken], {
|
||||
description: 'Get list of infra tokens',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
infraTokens(@Args() args: OffsetPaginationArgs) {
|
||||
return this.infraTokenService.getAll(args.take, args.skip);
|
||||
}
|
||||
|
||||
/* Mutations */
|
||||
|
||||
@Mutation(() => CreateInfraTokenResponse, {
|
||||
description: 'Create a new infra token',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async createInfraToken(
|
||||
@GqlAdmin() admin: Admin,
|
||||
@Args({ name: 'label', description: 'Label of the token' }) label: string,
|
||||
@Args({
|
||||
name: 'expiryInDays',
|
||||
description: 'Number of days the token is valid for',
|
||||
nullable: true,
|
||||
})
|
||||
expiryInDays: number,
|
||||
) {
|
||||
const infraToken = await this.infraTokenService.create(
|
||||
label,
|
||||
expiryInDays,
|
||||
admin,
|
||||
);
|
||||
|
||||
if (E.isLeft(infraToken)) throwErr(infraToken.left);
|
||||
return infraToken.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Revoke an infra token',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async revokeInfraToken(
|
||||
@Args({ name: 'id', type: () => ID, description: 'ID of the infra token' })
|
||||
id: string,
|
||||
) {
|
||||
const res = await this.infraTokenService.revoke(id);
|
||||
|
||||
if (E.isLeft(res)) throwErr(res.left);
|
||||
return res.right;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InfraToken as dbInfraToken } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { CreateInfraTokenResponse, InfraToken } from './infra-token.model';
|
||||
import { calculateExpirationDate, isValidLength } from 'src/utils';
|
||||
import { Admin } from 'src/admin/admin.model';
|
||||
import {
|
||||
INFRA_TOKEN_CREATOR_NOT_FOUND,
|
||||
INFRA_TOKEN_EXPIRY_INVALID,
|
||||
INFRA_TOKEN_LABEL_SHORT,
|
||||
INFRA_TOKEN_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { CreateUserInvitationRequest } from './request-response.dto';
|
||||
import { AdminService } from 'src/admin/admin.service';
|
||||
|
||||
@Injectable()
|
||||
export class InfraTokenService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly adminService: AdminService,
|
||||
) {}
|
||||
|
||||
TITLE_LENGTH = 3;
|
||||
VALID_TOKEN_DURATIONS = [7, 30, 60, 90];
|
||||
|
||||
/**
|
||||
* Validate the expiration date of the token
|
||||
*
|
||||
* @param expiresOn Number of days the token is valid for
|
||||
* @returns Boolean indicating if the expiration date is valid
|
||||
*/
|
||||
private validateExpirationDate(expiresOn: null | number) {
|
||||
if (expiresOn === null || this.VALID_TOKEN_DURATIONS.includes(expiresOn))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typecast a database InfraToken to a InfraToken model
|
||||
* @param dbInfraToken database InfraToken
|
||||
* @returns InfraToken model
|
||||
*/
|
||||
private cast(dbInfraToken: dbInfraToken): InfraToken {
|
||||
return {
|
||||
id: dbInfraToken.id,
|
||||
label: dbInfraToken.label,
|
||||
createdOn: dbInfraToken.createdOn,
|
||||
expiresOn: dbInfraToken.expiresOn,
|
||||
lastUsedOn: dbInfraToken.updatedOn,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all infra tokens with pagination
|
||||
* @param take take for pagination
|
||||
* @param skip skip for pagination
|
||||
* @returns List of InfraToken models
|
||||
*/
|
||||
async getAll(take = 10, skip = 0) {
|
||||
const infraTokens = await this.prisma.infraToken.findMany({
|
||||
take,
|
||||
skip,
|
||||
orderBy: { createdOn: 'desc' },
|
||||
});
|
||||
|
||||
return infraTokens.map((token) => this.cast(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new infra token
|
||||
* @param label label of the token
|
||||
* @param expiryInDays expiry duration of the token
|
||||
* @param admin admin who created the token
|
||||
* @returns Either of error message or CreateInfraTokenResponse
|
||||
*/
|
||||
async create(label: string, expiryInDays: number, admin: Admin) {
|
||||
if (!isValidLength(label, this.TITLE_LENGTH)) {
|
||||
return E.left(INFRA_TOKEN_LABEL_SHORT);
|
||||
}
|
||||
|
||||
if (!this.validateExpirationDate(expiryInDays ?? null)) {
|
||||
return E.left(INFRA_TOKEN_EXPIRY_INVALID);
|
||||
}
|
||||
|
||||
const createdInfraToken = await this.prisma.infraToken.create({
|
||||
data: {
|
||||
creatorUid: admin.uid,
|
||||
label,
|
||||
expiresOn: calculateExpirationDate(expiryInDays ?? null) ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const res: CreateInfraTokenResponse = {
|
||||
token: createdInfraToken.token,
|
||||
info: this.cast(createdInfraToken),
|
||||
};
|
||||
|
||||
return E.right(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an infra token
|
||||
* @param id ID of the infra token
|
||||
* @returns Either of error or true
|
||||
*/
|
||||
async revoke(id: string) {
|
||||
try {
|
||||
await this.prisma.infraToken.delete({
|
||||
where: { id },
|
||||
});
|
||||
} catch (error) {
|
||||
return E.left(INFRA_TOKEN_NOT_FOUND);
|
||||
}
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last used on of an infra token
|
||||
* @param token token to update
|
||||
* @returns Either of error or InfraToken
|
||||
*/
|
||||
async updateLastUsedOn(token: string) {
|
||||
try {
|
||||
const infraToken = await this.prisma.infraToken.update({
|
||||
where: { token },
|
||||
data: { updatedOn: new Date() },
|
||||
});
|
||||
return E.right(this.cast(infraToken));
|
||||
} catch (error) {
|
||||
return E.left(INFRA_TOKEN_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a user invitation using an infra token
|
||||
* @param token token used to create the invitation
|
||||
* @param dto CreateUserInvitationRequest
|
||||
* @returns Either of error or InvitedUser
|
||||
*/
|
||||
async createUserInvitation(token: string, dto: CreateUserInvitationRequest) {
|
||||
const infraToken = await this.prisma.infraToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
const tokenCreator = await this.prisma.user.findUnique({
|
||||
where: { uid: infraToken.creatorUid },
|
||||
});
|
||||
if (!tokenCreator) return E.left(INFRA_TOKEN_CREATOR_NOT_FOUND);
|
||||
|
||||
const invitedUser = await this.adminService.inviteUserToSignInViaEmail(
|
||||
tokenCreator.uid,
|
||||
tokenCreator.email,
|
||||
dto.inviteeEmail,
|
||||
);
|
||||
if (E.isLeft(invitedUser)) return E.left(invitedUser.left);
|
||||
|
||||
return E.right(invitedUser);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Expose, Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
|
||||
// POST v1/infra/user-invitations
|
||||
export class CreateUserInvitationRequest {
|
||||
@Type(() => String)
|
||||
@IsNotEmpty()
|
||||
@ApiProperty()
|
||||
inviteeEmail: string;
|
||||
}
|
||||
export class CreateUserInvitationResponse {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
invitationLink: string;
|
||||
}
|
||||
|
||||
// GET v1/infra/user-invitations
|
||||
export class GetUserInvitationResponse {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
inviteeEmail: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
invitedOn: Date;
|
||||
}
|
||||
|
||||
// DELETE v1/infra/user-invitations
|
||||
export class DeleteUserInvitationRequest {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@Type(() => String)
|
||||
@IsNotEmpty()
|
||||
@ApiProperty()
|
||||
inviteeEmails: string[];
|
||||
}
|
||||
export class DeleteUserInvitationResponse {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
message: string;
|
||||
}
|
||||
|
||||
// POST v1/infra/users
|
||||
export class GetUsersRequestQuery extends OffsetPaginationArgs {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@ApiPropertyOptional()
|
||||
searchString: string;
|
||||
}
|
||||
export class GetUserResponse {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
uid: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
displayName: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
photoURL: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
// PATCH v1/infra/users/:uid
|
||||
export class UpdateUserRequest {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@ApiPropertyOptional()
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
// PATCH v1/infra/users/:uid/admin-status
|
||||
export class UpdateUserAdminStatusRequest {
|
||||
@IsBoolean()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty()
|
||||
isAdmin: boolean;
|
||||
}
|
||||
export class UpdateUserAdminStatusResponse {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Used for Swagger doc only, in codebase throwHTTPErr function is used to throw errors
|
||||
export class ExceptionResponse {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
message: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
statusCode: number;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { INFRA_TOKEN_NOT_FOUND } from 'src/errors';
|
||||
import { InfraTokenService } from 'src/infra-token/infra-token.service';
|
||||
|
||||
@Injectable()
|
||||
export class InfraTokenInterceptor implements NestInterceptor {
|
||||
constructor(private readonly infraTokenService: InfraTokenService) {}
|
||||
|
||||
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new BadRequestException(INFRA_TOKEN_NOT_FOUND);
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
this.infraTokenService.updateLastUsedOn(token);
|
||||
|
||||
return handler.handle();
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,40 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { json } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import { VersioningType } from '@nestjs/common';
|
||||
import { ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import * as session from 'express-session';
|
||||
import { emitGQLSchemaFile } from './gql-schema';
|
||||
import { checkEnvironmentAuthProvider } from './utils';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { InfraTokensController } from './infra-token/infra-token.controller';
|
||||
import { InfraTokenModule } from './infra-token/infra-token.module';
|
||||
|
||||
function setupSwagger(app) {
|
||||
const swaggerDocPath = '/api-docs';
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Hoppscotch API Documentation')
|
||||
.setDescription('APIs for external integration')
|
||||
.addApiKey(
|
||||
{
|
||||
type: 'apiKey',
|
||||
name: 'Authorization',
|
||||
in: 'header',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'Bearer',
|
||||
},
|
||||
'infra-token',
|
||||
)
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config, {
|
||||
include: [InfraTokenModule],
|
||||
});
|
||||
SwaggerModule.setup(swaggerDocPath, app, document, {
|
||||
swaggerOptions: { persistAuthorization: true, ignoreGlobalPrefix: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
@@ -53,6 +82,14 @@ async function bootstrap() {
|
||||
type: VersioningType.URI,
|
||||
});
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await setupSwagger(app);
|
||||
|
||||
await app.listen(configService.get('PORT') || 3170);
|
||||
|
||||
// Graceful shutdown
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ArgsType, Field, ID, InputType } from '@nestjs/graphql';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
@InputType()
|
||||
@@ -21,6 +24,10 @@ export class PaginationArgs {
|
||||
@ArgsType()
|
||||
@InputType()
|
||||
export class OffsetPaginationArgs {
|
||||
@IsOptional()
|
||||
@IsNotEmpty()
|
||||
@Type(() => Number)
|
||||
@ApiPropertyOptional()
|
||||
@Field({
|
||||
nullable: true,
|
||||
defaultValue: 0,
|
||||
@@ -28,6 +35,10 @@ export class OffsetPaginationArgs {
|
||||
})
|
||||
skip: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNotEmpty()
|
||||
@Type(() => Number)
|
||||
@ApiPropertyOptional()
|
||||
@Field({
|
||||
nullable: true,
|
||||
defaultValue: 10,
|
||||
|
||||
@@ -286,3 +286,14 @@ export function escapeSqlLikeString(str: string) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the expiration date of the token
|
||||
*
|
||||
* @param expiresOn Number of days the token is valid for
|
||||
* @returns Date object of the expiration date
|
||||
*/
|
||||
export function calculateExpirationDate(expiresOn: null | number) {
|
||||
if (expiresOn === null) return null;
|
||||
return new Date(Date.now() + expiresOn * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user