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:
Mir Arif Hasan
2024-07-29 13:06:18 +06:00
committed by GitHub
parent c88ea5c8b2
commit 783d911f8d
21 changed files with 1075 additions and 126 deletions

View File

@@ -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",

View File

@@ -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");

View File

@@ -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)
}

View File

@@ -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),
},
});

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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];
},
);

View File

@@ -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';

View File

@@ -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,
];
/**

View 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;
}
}

View File

@@ -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,
},
);
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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);
}