Compare commits
3 Commits
2024.3.3
...
feat/pat-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79a6e8e1c6 | ||
|
|
4bd23a8f4c | ||
|
|
f4f3fdf2d5 |
@@ -118,11 +118,11 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
|
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
|
||||||
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
# - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
volumes:
|
volumes:
|
||||||
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
|
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
|
||||||
# - ./packages/hoppscotch-backend/:/usr/src/app
|
- ./packages/hoppscotch-backend/:/usr/src/app
|
||||||
- /usr/src/app/node_modules/
|
- /usr/src/app/node_modules/
|
||||||
depends_on:
|
depends_on:
|
||||||
hoppscotch-db:
|
hoppscotch-db:
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "lastLoggedOn" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PersonalAccessToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userUid" 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,
|
||||||
|
|
||||||
|
CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PersonalAccessToken_token_key" ON "PersonalAccessToken"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -41,31 +41,31 @@ model TeamInvitation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TeamCollection {
|
model TeamCollection {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
parentID String?
|
parentID String?
|
||||||
data Json?
|
data Json?
|
||||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||||
requests TeamRequest[]
|
requests TeamRequest[]
|
||||||
teamID String
|
teamID String
|
||||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||||
title String
|
title String
|
||||||
orderIndex Int
|
orderIndex Int
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
model TeamRequest {
|
model TeamRequest {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
collectionID String
|
collectionID String
|
||||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||||
teamID String
|
teamID String
|
||||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||||
title String
|
title String
|
||||||
request Json
|
request Json
|
||||||
orderIndex Int
|
orderIndex Int
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Shortcode {
|
model Shortcode {
|
||||||
@@ -89,24 +89,26 @@ model TeamEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
uid String @id @default(cuid())
|
uid String @id @default(cuid())
|
||||||
displayName String?
|
displayName String?
|
||||||
email String? @unique
|
email String? @unique
|
||||||
photoURL String?
|
photoURL String?
|
||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
refreshToken String?
|
refreshToken String?
|
||||||
providerAccounts Account[]
|
providerAccounts Account[]
|
||||||
VerificationToken VerificationToken[]
|
VerificationToken VerificationToken[]
|
||||||
settings UserSettings?
|
settings UserSettings?
|
||||||
UserHistory UserHistory[]
|
UserHistory UserHistory[]
|
||||||
UserEnvironments UserEnvironment[]
|
UserEnvironments UserEnvironment[]
|
||||||
userCollections UserCollection[]
|
userCollections UserCollection[]
|
||||||
userRequests UserRequest[]
|
userRequests UserRequest[]
|
||||||
currentRESTSession Json?
|
currentRESTSession Json?
|
||||||
currentGQLSession Json?
|
currentGQLSession Json?
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
lastLoggedOn DateTime?
|
||||||
invitedUsers InvitedUsers[]
|
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||||
shortcodes Shortcode[]
|
invitedUsers InvitedUsers[]
|
||||||
|
shortcodes Shortcode[]
|
||||||
|
personalAccessTokens PersonalAccessToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
@@ -218,3 +220,14 @@ model InfraConfig {
|
|||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PersonalAccessToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userUid String
|
||||||
|
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||||
|
label String
|
||||||
|
token String @unique @default(uuid())
|
||||||
|
expiresOn DateTime? @db.Timestamp(3)
|
||||||
|
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||||
|
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AccessTokenService } from './access-token.service';
|
||||||
|
import { CreateAccessTokenDto } from './dto/create-access-token.dto';
|
||||||
|
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||||
|
import * as E from 'fp-ts/Either';
|
||||||
|
import { throwHTTPErr } from 'src/utils';
|
||||||
|
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||||
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
|
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||||
|
import { PATAuthGuard } from 'src/guards/rest-pat-auth.guard';
|
||||||
|
import { AccessTokenInterceptor } from 'src/interceptors/access-token.interceptor';
|
||||||
|
import { TeamEnvironmentsService } from 'src/team-environments/team-environments.service';
|
||||||
|
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
|
||||||
|
import { ACCESS_TOKENS_INVALID_DATA_ID } from 'src/errors';
|
||||||
|
import { createCLIErrorResponse } from './helper';
|
||||||
|
|
||||||
|
@UseGuards(ThrottlerBehindProxyGuard)
|
||||||
|
@Controller({ path: 'access-tokens', version: '1' })
|
||||||
|
export class AccessTokenController {
|
||||||
|
constructor(
|
||||||
|
private readonly accessTokenService: AccessTokenService,
|
||||||
|
private readonly teamCollectionService: TeamCollectionService,
|
||||||
|
private readonly teamEnvironmentsService: TeamEnvironmentsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('create')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async createPAT(
|
||||||
|
@GqlUser() user: AuthUser,
|
||||||
|
@Body() createAccessTokenDto: CreateAccessTokenDto,
|
||||||
|
) {
|
||||||
|
const result = await this.accessTokenService.createPAT(
|
||||||
|
createAccessTokenDto,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
if (E.isLeft(result)) throwHTTPErr(result.left);
|
||||||
|
return result.right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('revoke')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async deletePAT(@Query('id') id: string) {
|
||||||
|
const result = await this.accessTokenService.deletePAT(id);
|
||||||
|
|
||||||
|
if (E.isLeft(result)) throwHTTPErr(result.left);
|
||||||
|
return result.right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('list')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async listAllUserPAT(
|
||||||
|
@GqlUser() user: AuthUser,
|
||||||
|
@Query('offset', ParseIntPipe) offset: number,
|
||||||
|
@Query('limit', ParseIntPipe) limit: number,
|
||||||
|
) {
|
||||||
|
return await this.accessTokenService.listAllUserPAT(
|
||||||
|
user.uid,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('collection/:id')
|
||||||
|
@UseGuards(PATAuthGuard)
|
||||||
|
@UseInterceptors(AccessTokenInterceptor)
|
||||||
|
async fetchCollection(@GqlUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
const res = await this.teamCollectionService.getCollectionForCLI(
|
||||||
|
id,
|
||||||
|
user.uid,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (E.isLeft(res))
|
||||||
|
throw new BadRequestException(
|
||||||
|
createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID),
|
||||||
|
);
|
||||||
|
return res.right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('environment/:id')
|
||||||
|
@UseGuards(PATAuthGuard)
|
||||||
|
@UseInterceptors(AccessTokenInterceptor)
|
||||||
|
async fetchEnvironment(@GqlUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
const res = await this.teamEnvironmentsService.getTeamEnvironmentForCLI(
|
||||||
|
id,
|
||||||
|
user.uid,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (E.isLeft(res))
|
||||||
|
throw new BadRequestException(
|
||||||
|
createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID),
|
||||||
|
);
|
||||||
|
return res.right;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AccessTokenController } from './access-token.controller';
|
||||||
|
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||||
|
import { AccessTokenService } from './access-token.service';
|
||||||
|
import { TeamCollectionModule } from 'src/team-collection/team-collection.module';
|
||||||
|
import { TeamEnvironmentsModule } from 'src/team-environments/team-environments.module';
|
||||||
|
import { TeamModule } from 'src/team/team.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
TeamCollectionModule,
|
||||||
|
TeamEnvironmentsModule,
|
||||||
|
TeamModule,
|
||||||
|
],
|
||||||
|
controllers: [AccessTokenController],
|
||||||
|
providers: [AccessTokenService],
|
||||||
|
exports: [AccessTokenService],
|
||||||
|
})
|
||||||
|
export class AccessTokenModule {}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { AccessTokenService } from './access-token.service';
|
||||||
|
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||||
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
ACCESS_TOKEN_EXPIRY_INVALID,
|
||||||
|
ACCESS_TOKEN_LABEL_SHORT,
|
||||||
|
ACCESS_TOKEN_NOT_FOUND,
|
||||||
|
} from 'src/errors';
|
||||||
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
|
import { PersonalAccessToken } from '@prisma/client';
|
||||||
|
import { AccessToken } from 'src/types/AccessToken';
|
||||||
|
import { HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
const mockPrisma = mockDeep<PrismaService>();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
const accessTokenService = new AccessTokenService(mockPrisma);
|
||||||
|
|
||||||
|
const currentTime = new Date();
|
||||||
|
|
||||||
|
const user: AuthUser = {
|
||||||
|
uid: '123344',
|
||||||
|
email: 'dwight@dundermifflin.com',
|
||||||
|
displayName: 'Dwight Schrute',
|
||||||
|
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||||
|
isAdmin: false,
|
||||||
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
createdOn: currentTime,
|
||||||
|
currentGQLSession: {},
|
||||||
|
currentRESTSession: {},
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PATCreatedOn = new Date();
|
||||||
|
const expiryInDays = 7;
|
||||||
|
const PATExpiresOn = new Date(
|
||||||
|
PATCreatedOn.getTime() + expiryInDays * 24 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const userAccessToken: PersonalAccessToken = {
|
||||||
|
id: 'skfvhj8uvdfivb',
|
||||||
|
userUid: user.uid,
|
||||||
|
label: 'test',
|
||||||
|
token: '0140e328-b187-4823-ae4b-ed4bec832ac2',
|
||||||
|
expiresOn: PATExpiresOn,
|
||||||
|
createdOn: PATCreatedOn,
|
||||||
|
updatedOn: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const userAccessTokenCasted: AccessToken = {
|
||||||
|
id: userAccessToken.id,
|
||||||
|
label: userAccessToken.label,
|
||||||
|
createdOn: userAccessToken.createdOn,
|
||||||
|
lastUsedOn: userAccessToken.updatedOn,
|
||||||
|
expiresOn: userAccessToken.expiresOn,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockReset(mockPrisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AccessTokenService', () => {
|
||||||
|
describe('createPAT', () => {
|
||||||
|
test('should throw ACCESS_TOKEN_LABEL_SHORT if label is too short', async () => {
|
||||||
|
const result = await accessTokenService.createPAT(
|
||||||
|
{
|
||||||
|
label: 'a',
|
||||||
|
expiryInDays: 7,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
expect(result).toEqualLeft({
|
||||||
|
message: ACCESS_TOKEN_LABEL_SHORT,
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw ACCESS_TOKEN_EXPIRY_INVALID if expiry date is invalid', async () => {
|
||||||
|
const result = await accessTokenService.createPAT(
|
||||||
|
{
|
||||||
|
label: 'test',
|
||||||
|
expiryInDays: 9,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
expect(result).toEqualLeft({
|
||||||
|
message: ACCESS_TOKEN_EXPIRY_INVALID,
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully create a new Access Token', async () => {
|
||||||
|
mockPrisma.personalAccessToken.create.mockResolvedValueOnce(
|
||||||
|
userAccessToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await accessTokenService.createPAT(
|
||||||
|
{
|
||||||
|
label: userAccessToken.label,
|
||||||
|
expiryInDays,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
expect(result).toEqualRight({
|
||||||
|
token: `pat-${userAccessToken.token}`,
|
||||||
|
info: userAccessTokenCasted,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deletePAT', () => {
|
||||||
|
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
|
||||||
|
mockPrisma.personalAccessToken.delete.mockRejectedValueOnce(
|
||||||
|
'RecordNotFound',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await accessTokenService.deletePAT(userAccessToken.id);
|
||||||
|
expect(result).toEqualLeft({
|
||||||
|
message: ACCESS_TOKEN_NOT_FOUND,
|
||||||
|
statusCode: HttpStatus.NOT_FOUND,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully delete a new Access Token', async () => {
|
||||||
|
mockPrisma.personalAccessToken.delete.mockResolvedValueOnce(
|
||||||
|
userAccessToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await accessTokenService.deletePAT(userAccessToken.id);
|
||||||
|
expect(result).toEqualRight(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listAllUserPAT', () => {
|
||||||
|
test('should successfully return a list of user Access Tokens', async () => {
|
||||||
|
mockPrisma.personalAccessToken.findMany.mockResolvedValueOnce([
|
||||||
|
userAccessToken,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await accessTokenService.listAllUserPAT(user.uid, 0, 10);
|
||||||
|
expect(result).toEqual([userAccessTokenCasted]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUserPAT', () => {
|
||||||
|
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
|
||||||
|
mockPrisma.personalAccessToken.findUniqueOrThrow.mockRejectedValueOnce(
|
||||||
|
'NotFoundError',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await accessTokenService.getUserPAT(userAccessToken.token);
|
||||||
|
expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully return a user Access Tokens', async () => {
|
||||||
|
mockPrisma.personalAccessToken.findUniqueOrThrow.mockResolvedValueOnce({
|
||||||
|
...userAccessToken,
|
||||||
|
user,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await accessTokenService.getUserPAT(
|
||||||
|
`pat-${userAccessToken.token}`,
|
||||||
|
);
|
||||||
|
expect(result).toEqualRight({
|
||||||
|
user,
|
||||||
|
...userAccessToken,
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateLastUsedforPAT', () => {
|
||||||
|
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
|
||||||
|
mockPrisma.personalAccessToken.update.mockRejectedValueOnce(
|
||||||
|
'RecordNotFound',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await accessTokenService.updateLastUsedForPAT(
|
||||||
|
userAccessToken.token,
|
||||||
|
);
|
||||||
|
expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully update lastUsedOn for a user Access Tokens', async () => {
|
||||||
|
mockPrisma.personalAccessToken.update.mockResolvedValueOnce(
|
||||||
|
userAccessToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await accessTokenService.updateLastUsedForPAT(
|
||||||
|
`pat-${userAccessToken.token}`,
|
||||||
|
);
|
||||||
|
expect(result).toEqualRight(userAccessTokenCasted);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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 * as E from 'fp-ts/Either';
|
||||||
|
import {
|
||||||
|
ACCESS_TOKEN_EXPIRY_INVALID,
|
||||||
|
ACCESS_TOKEN_LABEL_SHORT,
|
||||||
|
ACCESS_TOKEN_NOT_FOUND,
|
||||||
|
} from 'src/errors';
|
||||||
|
import { CreateAccessTokenResponse } from './helper';
|
||||||
|
import { PersonalAccessToken } from '@prisma/client';
|
||||||
|
import { AccessToken } from 'src/types/AccessToken';
|
||||||
|
@Injectable()
|
||||||
|
export class AccessTokenService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
TITLE_LENGTH = 3;
|
||||||
|
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
|
||||||
|
*
|
||||||
|
* @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 PersonalAccessToken to a AccessToken model
|
||||||
|
* @param token database PersonalAccessToken
|
||||||
|
* @returns AccessToken model
|
||||||
|
*/
|
||||||
|
private cast(token: PersonalAccessToken): AccessToken {
|
||||||
|
return <AccessToken>{
|
||||||
|
id: token.id,
|
||||||
|
label: token.label,
|
||||||
|
createdOn: token.createdOn,
|
||||||
|
expiresOn: token.expiresOn,
|
||||||
|
lastUsedOn: token.updatedOn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract UUID from the token
|
||||||
|
*
|
||||||
|
* @param token Personal Access Token
|
||||||
|
* @returns UUID of the token
|
||||||
|
*/
|
||||||
|
private extractUUID(token): string | null {
|
||||||
|
if (!token.startsWith(this.TOKEN_PREFIX)) return null;
|
||||||
|
return token.slice(this.TOKEN_PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Personal Access Token
|
||||||
|
*
|
||||||
|
* @param createAccessTokenDto DTO for creating a Personal Access Token
|
||||||
|
* @param user AuthUser object
|
||||||
|
* @returns Either of the created token or error message
|
||||||
|
*/
|
||||||
|
async createPAT(createAccessTokenDto: CreateAccessTokenDto, user: AuthUser) {
|
||||||
|
const isTitleValid = isValidLength(
|
||||||
|
createAccessTokenDto.label,
|
||||||
|
this.TITLE_LENGTH,
|
||||||
|
);
|
||||||
|
if (!isTitleValid)
|
||||||
|
return E.left({
|
||||||
|
message: ACCESS_TOKEN_LABEL_SHORT,
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.validateExpirationDate(createAccessTokenDto.expiryInDays))
|
||||||
|
return E.left({
|
||||||
|
message: ACCESS_TOKEN_EXPIRY_INVALID,
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdPAT = await this.prisma.personalAccessToken.create({
|
||||||
|
data: {
|
||||||
|
userUid: user.uid,
|
||||||
|
label: createAccessTokenDto.label,
|
||||||
|
expiresOn: this.calculateExpirationDate(
|
||||||
|
createAccessTokenDto.expiryInDays,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res: CreateAccessTokenResponse = {
|
||||||
|
token: `${this.TOKEN_PREFIX}${createdPAT.token}`,
|
||||||
|
info: this.cast(createdPAT),
|
||||||
|
};
|
||||||
|
|
||||||
|
return E.right(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a Personal Access Token
|
||||||
|
*
|
||||||
|
* @param accessTokenID ID of the Personal Access Token
|
||||||
|
* @returns Either of true or error message
|
||||||
|
*/
|
||||||
|
async deletePAT(accessTokenID: string) {
|
||||||
|
try {
|
||||||
|
await this.prisma.personalAccessToken.delete({
|
||||||
|
where: { id: accessTokenID },
|
||||||
|
});
|
||||||
|
return E.right(true);
|
||||||
|
} catch {
|
||||||
|
return E.left({
|
||||||
|
message: ACCESS_TOKEN_NOT_FOUND,
|
||||||
|
statusCode: HttpStatus.NOT_FOUND,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all Personal Access Tokens of a user
|
||||||
|
*
|
||||||
|
* @param userUid UID of the user
|
||||||
|
* @param offset Offset for pagination
|
||||||
|
* @param limit Limit for pagination
|
||||||
|
* @returns Either of the list of Personal Access Tokens or error message
|
||||||
|
*/
|
||||||
|
async listAllUserPAT(userUid: string, offset: number, limit: number) {
|
||||||
|
const userPATs = await this.prisma.personalAccessToken.findMany({
|
||||||
|
where: {
|
||||||
|
userUid: userUid,
|
||||||
|
},
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
orderBy: {
|
||||||
|
createdOn: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userAccessTokenList = userPATs.map((pat) => this.cast(pat));
|
||||||
|
|
||||||
|
return userAccessTokenList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a Personal Access Token
|
||||||
|
*
|
||||||
|
* @param accessToken Personal Access Token
|
||||||
|
* @returns Either of the Personal Access Token or error message
|
||||||
|
*/
|
||||||
|
async getUserPAT(accessToken: string) {
|
||||||
|
const extractedToken = this.extractUUID(accessToken);
|
||||||
|
if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userPAT = await this.prisma.personalAccessToken.findUniqueOrThrow({
|
||||||
|
where: { token: extractedToken },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
return E.right(userPAT);
|
||||||
|
} catch {
|
||||||
|
return E.left(ACCESS_TOKEN_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the last used date of a Personal Access Token
|
||||||
|
*
|
||||||
|
* @param token Personal Access Token
|
||||||
|
* @returns Either of the updated Personal Access Token or error message
|
||||||
|
*/
|
||||||
|
async updateLastUsedForPAT(token: string) {
|
||||||
|
const extractedToken = this.extractUUID(token);
|
||||||
|
if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedAccessToken = await this.prisma.personalAccessToken.update({
|
||||||
|
where: { token: extractedToken },
|
||||||
|
data: {
|
||||||
|
updatedOn: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return E.right(this.cast(updatedAccessToken));
|
||||||
|
} catch {
|
||||||
|
return E.left(ACCESS_TOKEN_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Inputs to create a new PAT
|
||||||
|
export class CreateAccessTokenDto {
|
||||||
|
label: string;
|
||||||
|
expiryInDays: number | null;
|
||||||
|
}
|
||||||
17
packages/hoppscotch-backend/src/access-token/helper.ts
Normal file
17
packages/hoppscotch-backend/src/access-token/helper.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { AccessToken } from 'src/types/AccessToken';
|
||||||
|
|
||||||
|
// Response type of PAT creation method
|
||||||
|
export type CreateAccessTokenResponse = {
|
||||||
|
token: string;
|
||||||
|
info: AccessToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Response type of any error in PAT module
|
||||||
|
export type CLIErrorResponse = {
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return a CLIErrorResponse object
|
||||||
|
export function createCLIErrorResponse(reason: string): CLIErrorResponse {
|
||||||
|
return { reason };
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@ const dbAdminUsers: DbUser[] = [
|
|||||||
refreshToken: 'refreshToken',
|
refreshToken: 'refreshToken',
|
||||||
currentRESTSession: '',
|
currentRESTSession: '',
|
||||||
currentGQLSession: '',
|
currentGQLSession: '',
|
||||||
|
lastLoggedOn: new Date(),
|
||||||
createdOn: new Date(),
|
createdOn: new Date(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -85,20 +86,10 @@ const dbAdminUsers: DbUser[] = [
|
|||||||
refreshToken: 'refreshToken',
|
refreshToken: 'refreshToken',
|
||||||
currentRESTSession: '',
|
currentRESTSession: '',
|
||||||
currentGQLSession: '',
|
currentGQLSession: '',
|
||||||
|
lastLoggedOn: new Date(),
|
||||||
createdOn: new Date(),
|
createdOn: new Date(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const dbNonAminUser: DbUser = {
|
|
||||||
uid: 'uid 3',
|
|
||||||
displayName: 'displayName',
|
|
||||||
email: 'email@email.com',
|
|
||||||
photoURL: 'photoURL',
|
|
||||||
isAdmin: false,
|
|
||||||
refreshToken: 'refreshToken',
|
|
||||||
currentRESTSession: '',
|
|
||||||
currentGQLSession: '',
|
|
||||||
createdOn: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('AdminService', () => {
|
describe('AdminService', () => {
|
||||||
describe('fetchInvitedUsers', () => {
|
describe('fetchInvitedUsers', () => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { MailerModule } from './mailer/mailer.module';
|
|||||||
import { PosthogModule } from './posthog/posthog.module';
|
import { PosthogModule } from './posthog/posthog.module';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { AccessTokenModule } from './access-token/access-token.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -102,6 +103,7 @@ import { HealthModule } from './health/health.module';
|
|||||||
PosthogModule,
|
PosthogModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
AccessTokenModule,
|
||||||
],
|
],
|
||||||
providers: [GQLComplexityPlugin],
|
providers: [GQLComplexityPlugin],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Request,
|
Request,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { SignInMagicDto } from './dto/signin-magic.dto';
|
import { SignInMagicDto } from './dto/signin-magic.dto';
|
||||||
@@ -27,6 +28,7 @@ import { SkipThrottle } from '@nestjs/throttler';
|
|||||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { throwHTTPErr } from 'src/utils';
|
import { throwHTTPErr } from 'src/utils';
|
||||||
|
import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor';
|
||||||
|
|
||||||
@UseGuards(ThrottlerBehindProxyGuard)
|
@UseGuards(ThrottlerBehindProxyGuard)
|
||||||
@Controller({ path: 'auth', version: '1' })
|
@Controller({ path: 'auth', version: '1' })
|
||||||
@@ -110,6 +112,7 @@ export class AuthController {
|
|||||||
@Get('google/callback')
|
@Get('google/callback')
|
||||||
@SkipThrottle()
|
@SkipThrottle()
|
||||||
@UseGuards(GoogleSSOGuard)
|
@UseGuards(GoogleSSOGuard)
|
||||||
|
@UseInterceptors(UserLastLoginInterceptor)
|
||||||
async googleAuthRedirect(@Request() req, @Res() res) {
|
async googleAuthRedirect(@Request() req, @Res() res) {
|
||||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||||
@@ -135,6 +138,7 @@ export class AuthController {
|
|||||||
@Get('github/callback')
|
@Get('github/callback')
|
||||||
@SkipThrottle()
|
@SkipThrottle()
|
||||||
@UseGuards(GithubSSOGuard)
|
@UseGuards(GithubSSOGuard)
|
||||||
|
@UseInterceptors(UserLastLoginInterceptor)
|
||||||
async githubAuthRedirect(@Request() req, @Res() res) {
|
async githubAuthRedirect(@Request() req, @Res() res) {
|
||||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||||
@@ -160,6 +164,7 @@ export class AuthController {
|
|||||||
@Get('microsoft/callback')
|
@Get('microsoft/callback')
|
||||||
@SkipThrottle()
|
@SkipThrottle()
|
||||||
@UseGuards(MicrosoftSSOGuard)
|
@UseGuards(MicrosoftSSOGuard)
|
||||||
|
@UseInterceptors(UserLastLoginInterceptor)
|
||||||
async microsoftAuthRedirect(@Request() req, @Res() res) {
|
async microsoftAuthRedirect(@Request() req, @Res() res) {
|
||||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const user: AuthUser = {
|
|||||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
createdOn: currentTime,
|
createdOn: currentTime,
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
@@ -172,9 +173,11 @@ describe('verifyMagicLinkTokens', () => {
|
|||||||
// generateAuthTokens
|
// generateAuthTokens
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||||
// UpdateUserRefreshToken
|
// UpdateUserRefreshToken
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||||
// deletePasswordlessVerificationToken
|
// deletePasswordlessVerificationToken
|
||||||
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
||||||
|
// usersService.updateUserLastLoggedOn
|
||||||
|
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
|
||||||
|
|
||||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||||
expect(result).toEqualRight({
|
expect(result).toEqualRight({
|
||||||
@@ -197,9 +200,11 @@ describe('verifyMagicLinkTokens', () => {
|
|||||||
// generateAuthTokens
|
// generateAuthTokens
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||||
// UpdateUserRefreshToken
|
// UpdateUserRefreshToken
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||||
// deletePasswordlessVerificationToken
|
// deletePasswordlessVerificationToken
|
||||||
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
||||||
|
// usersService.updateUserLastLoggedOn
|
||||||
|
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
|
||||||
|
|
||||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||||
expect(result).toEqualRight({
|
expect(result).toEqualRight({
|
||||||
@@ -239,7 +244,7 @@ describe('verifyMagicLinkTokens', () => {
|
|||||||
// generateAuthTokens
|
// generateAuthTokens
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||||
// UpdateUserRefreshToken
|
// UpdateUserRefreshToken
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||||
E.left(USER_NOT_FOUND),
|
E.left(USER_NOT_FOUND),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -264,7 +269,7 @@ describe('verifyMagicLinkTokens', () => {
|
|||||||
// generateAuthTokens
|
// generateAuthTokens
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||||
// UpdateUserRefreshToken
|
// UpdateUserRefreshToken
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||||
// deletePasswordlessVerificationToken
|
// deletePasswordlessVerificationToken
|
||||||
mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound');
|
mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound');
|
||||||
|
|
||||||
@@ -280,7 +285,7 @@ describe('generateAuthTokens', () => {
|
|||||||
test('Should successfully generate tokens with valid inputs', async () => {
|
test('Should successfully generate tokens with valid inputs', async () => {
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||||
// UpdateUserRefreshToken
|
// UpdateUserRefreshToken
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||||
|
|
||||||
const result = await authService.generateAuthTokens(user.uid);
|
const result = await authService.generateAuthTokens(user.uid);
|
||||||
expect(result).toEqualRight({
|
expect(result).toEqualRight({
|
||||||
@@ -292,7 +297,7 @@ describe('generateAuthTokens', () => {
|
|||||||
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||||
// UpdateUserRefreshToken
|
// UpdateUserRefreshToken
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||||
E.left(USER_NOT_FOUND),
|
E.left(USER_NOT_FOUND),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -319,7 +324,7 @@ describe('refreshAuthTokens', () => {
|
|||||||
// generateAuthTokens
|
// generateAuthTokens
|
||||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||||
// UpdateUserRefreshToken
|
// UpdateUserRefreshToken
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||||
E.left(USER_NOT_FOUND),
|
E.left(USER_NOT_FOUND),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -348,7 +353,7 @@ describe('refreshAuthTokens', () => {
|
|||||||
// generateAuthTokens
|
// generateAuthTokens
|
||||||
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
|
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
|
||||||
// UpdateUserRefreshToken
|
// UpdateUserRefreshToken
|
||||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||||
E.right({
|
E.right({
|
||||||
...user,
|
...user,
|
||||||
refreshToken: 'sdhjcbjsdhcbshjdcb',
|
refreshToken: 'sdhjcbjsdhcbshjdcb',
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const refreshTokenHash = await argon2.hash(refreshToken);
|
const refreshTokenHash = await argon2.hash(refreshToken);
|
||||||
|
|
||||||
const updatedUser = await this.usersService.UpdateUserRefreshToken(
|
const updatedUser = await this.usersService.updateUserRefreshToken(
|
||||||
refreshTokenHash,
|
refreshTokenHash,
|
||||||
userUid,
|
userUid,
|
||||||
);
|
);
|
||||||
@@ -320,6 +320,8 @@ export class AuthService {
|
|||||||
statusCode: HttpStatus.NOT_FOUND,
|
statusCode: HttpStatus.NOT_FOUND,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.usersService.updateUserLastLoggedOn(passwordlessTokens.value.userUid);
|
||||||
|
|
||||||
return E.right(tokens.right);
|
return E.right(tokens.right);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -761,3 +761,39 @@ export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';
|
|||||||
* Inputs supplied are invalid
|
* Inputs supplied are invalid
|
||||||
*/
|
*/
|
||||||
export const INVALID_PARAMS = 'invalid_parameters' as const;
|
export const INVALID_PARAMS = 'invalid_parameters' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provided label for the access-token is short (less than 3 characters)
|
||||||
|
* (AccessTokenService)
|
||||||
|
*/
|
||||||
|
export const ACCESS_TOKEN_LABEL_SHORT = 'access_token/label_too_short';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provided expiryInDays value is not valid
|
||||||
|
* (AccessTokenService)
|
||||||
|
*/
|
||||||
|
export const ACCESS_TOKEN_EXPIRY_INVALID = 'access_token/expiry_days_invalid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provided PAT ID is invalid
|
||||||
|
* (AccessTokenService)
|
||||||
|
*/
|
||||||
|
export const ACCESS_TOKEN_NOT_FOUND = 'access_token/access_token_not_found';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AccessTokens is expired
|
||||||
|
* (AccessTokenService)
|
||||||
|
*/
|
||||||
|
export const ACCESS_TOKENS_EXPIRED = 'TOKEN_EXPIRED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AccessTokens is invalid
|
||||||
|
* (AccessTokenService)
|
||||||
|
*/
|
||||||
|
export const ACCESS_TOKENS_INVALID = 'TOKEN_INVALID';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AccessTokens is invalid
|
||||||
|
* (AccessTokenService)
|
||||||
|
*/
|
||||||
|
export const ACCESS_TOKENS_INVALID_DATA_ID = 'INVALID_ID';
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { AccessTokenService } from 'src/access-token/access-token.service';
|
||||||
|
import * as E from 'fp-ts/Either';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { ACCESS_TOKENS_EXPIRED, ACCESS_TOKENS_INVALID } from 'src/errors';
|
||||||
|
import { createCLIErrorResponse } from 'src/access-token/helper';
|
||||||
|
@Injectable()
|
||||||
|
export class PATAuthGuard implements CanActivate {
|
||||||
|
constructor(private accessTokenService: AccessTokenService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
if (!token) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
createCLIErrorResponse(ACCESS_TOKENS_INVALID),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAccessToken = await this.accessTokenService.getUserPAT(token);
|
||||||
|
if (E.isLeft(userAccessToken))
|
||||||
|
throw new BadRequestException(
|
||||||
|
createCLIErrorResponse(ACCESS_TOKENS_INVALID),
|
||||||
|
);
|
||||||
|
request.user = userAccessToken.right.user;
|
||||||
|
|
||||||
|
const accessToken = userAccessToken.right;
|
||||||
|
if (accessToken.expiresOn === null) return true;
|
||||||
|
|
||||||
|
const today = DateTime.now().toISO();
|
||||||
|
if (accessToken.expiresOn.toISOString() > today) return true;
|
||||||
|
|
||||||
|
throw new BadRequestException(
|
||||||
|
createCLIErrorResponse(ACCESS_TOKENS_EXPIRED),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable, map } from 'rxjs';
|
||||||
|
import { AccessTokenService } from 'src/access-token/access-token.service';
|
||||||
|
import * as E from 'fp-ts/Either';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccessTokenInterceptor implements NestInterceptor {
|
||||||
|
constructor(private readonly accessTokenService: AccessTokenService) {}
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const token = authHeader && authHeader.split(' ')[1];
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.handle().pipe(
|
||||||
|
map(async (data) => {
|
||||||
|
const userAccessToken =
|
||||||
|
await this.accessTokenService.updateLastUsedForPAT(token);
|
||||||
|
if (E.isLeft(userAccessToken)) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
|
import { UserService } from 'src/user/user.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserLastLoginInterceptor implements NestInterceptor {
|
||||||
|
constructor(private userService: UserService) {}
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
const user: AuthUser = context.switchToHttp().getRequest().user;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap(() => {
|
||||||
|
this.userService.updateUserLastLoggedOn(user.uid);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ const user: AuthUser = {
|
|||||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
lastLoggedOn: createdOn,
|
||||||
createdOn: createdOn,
|
createdOn: createdOn,
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { TeamRequest } from '@prisma/client';
|
||||||
|
|
||||||
// Type of data returned from the query to obtain all search results
|
// Type of data returned from the query to obtain all search results
|
||||||
export type SearchQueryReturnType = {
|
export type SearchQueryReturnType = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,3 +14,12 @@ export type ParentTreeQueryReturnType = {
|
|||||||
parentID: string;
|
parentID: string;
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
// Type of data returned from the query to fetch collection details from CLI
|
||||||
|
export type GetCollectionResponse = {
|
||||||
|
id: string;
|
||||||
|
data: string | null;
|
||||||
|
title: string;
|
||||||
|
parentID: string | null;
|
||||||
|
folders: GetCollectionResponse[];
|
||||||
|
requests: TeamRequest[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TEAM_COL_REORDERING_FAILED,
|
TEAM_COL_REORDERING_FAILED,
|
||||||
TEAM_COL_SAME_NEXT_COLL,
|
TEAM_COL_SAME_NEXT_COLL,
|
||||||
TEAM_INVALID_COLL_ID,
|
TEAM_INVALID_COLL_ID,
|
||||||
|
TEAM_MEMBER_NOT_FOUND,
|
||||||
TEAM_NOT_OWNER,
|
TEAM_NOT_OWNER,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
@@ -19,15 +20,18 @@ import { PubSubService } from 'src/pubsub/pubsub.service';
|
|||||||
import { AuthUser } from 'src/types/AuthUser';
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
import { TeamCollectionService } from './team-collection.service';
|
import { TeamCollectionService } from './team-collection.service';
|
||||||
import { TeamCollection } from './team-collection.model';
|
import { TeamCollection } from './team-collection.model';
|
||||||
|
import { TeamService } from 'src/team/team.service';
|
||||||
|
|
||||||
const mockPrisma = mockDeep<PrismaService>();
|
const mockPrisma = mockDeep<PrismaService>();
|
||||||
const mockPubSub = mockDeep<PubSubService>();
|
const mockPubSub = mockDeep<PubSubService>();
|
||||||
|
const mockTeamService = mockDeep<TeamService>();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const teamCollectionService = new TeamCollectionService(
|
const teamCollectionService = new TeamCollectionService(
|
||||||
mockPrisma,
|
mockPrisma,
|
||||||
mockPubSub as any,
|
mockPubSub as any,
|
||||||
|
mockTeamService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentTime = new Date();
|
const currentTime = new Date();
|
||||||
@@ -39,6 +43,7 @@ const user: AuthUser = {
|
|||||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
createdOn: currentTime,
|
createdOn: currentTime,
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
@@ -1738,3 +1743,63 @@ describe('updateTeamCollection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
//ToDo: write test cases for exportCollectionsToJSON
|
//ToDo: write test cases for exportCollectionsToJSON
|
||||||
|
|
||||||
|
describe('getCollectionForCLI', () => {
|
||||||
|
test('should throw TEAM_COLL_NOT_FOUND if collectionID is invalid', async () => {
|
||||||
|
mockPrisma.teamCollection.findUniqueOrThrow.mockRejectedValueOnce(
|
||||||
|
'NotFoundError',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await teamCollectionService.getCollectionForCLI(
|
||||||
|
'invalidID',
|
||||||
|
user.uid,
|
||||||
|
);
|
||||||
|
expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => {
|
||||||
|
mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce(
|
||||||
|
rootTeamCollection,
|
||||||
|
);
|
||||||
|
mockTeamService.getTeamMember.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await teamCollectionService.getCollectionForCLI(
|
||||||
|
rootTeamCollection.id,
|
||||||
|
user.uid,
|
||||||
|
);
|
||||||
|
expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test('should return the TeamCollection data for CLI', async () => {
|
||||||
|
// mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce(
|
||||||
|
// rootTeamCollection,
|
||||||
|
// );
|
||||||
|
// mockTeamService.getTeamMember.mockResolvedValue({
|
||||||
|
// membershipID: 'sdc3sfdv',
|
||||||
|
// userUid: user.uid,
|
||||||
|
// role: TeamMemberRole.OWNER,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const result = await teamCollectionService.getCollectionForCLI(
|
||||||
|
// rootTeamCollection.id,
|
||||||
|
// user.uid,
|
||||||
|
// );
|
||||||
|
// expect(result).toEqualRight({
|
||||||
|
// id: rootTeamCollection.id,
|
||||||
|
// data: JSON.stringify(rootTeamCollection.data),
|
||||||
|
// title: rootTeamCollection.title,
|
||||||
|
// parentID: rootTeamCollection.parentID,
|
||||||
|
// folders: [
|
||||||
|
// {
|
||||||
|
// id: childTeamCollection.id,
|
||||||
|
// data: JSON.stringify(childTeamCollection.data),
|
||||||
|
// title: childTeamCollection.title,
|
||||||
|
// parentID: childTeamCollection.parentID,
|
||||||
|
// folders: [],
|
||||||
|
// requests: [],
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// requests: [],
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,23 +18,34 @@ import {
|
|||||||
TEAM_COL_SEARCH_FAILED,
|
TEAM_COL_SEARCH_FAILED,
|
||||||
TEAM_REQ_PARENT_TREE_GEN_FAILED,
|
TEAM_REQ_PARENT_TREE_GEN_FAILED,
|
||||||
TEAM_COLL_PARENT_TREE_GEN_FAILED,
|
TEAM_COLL_PARENT_TREE_GEN_FAILED,
|
||||||
|
TEAM_MEMBER_NOT_FOUND,
|
||||||
} from '../errors';
|
} from '../errors';
|
||||||
import { PubSubService } from '../pubsub/pubsub.service';
|
import { PubSubService } from '../pubsub/pubsub.service';
|
||||||
import { escapeSqlLikeString, isValidLength } from 'src/utils';
|
import { escapeSqlLikeString, isValidLength } from 'src/utils';
|
||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client';
|
import {
|
||||||
|
Prisma,
|
||||||
|
TeamCollection as DBTeamCollection,
|
||||||
|
TeamRequest,
|
||||||
|
} from '@prisma/client';
|
||||||
import { CollectionFolder } from 'src/types/CollectionFolder';
|
import { CollectionFolder } from 'src/types/CollectionFolder';
|
||||||
import { stringToJson } from 'src/utils';
|
import { stringToJson } from 'src/utils';
|
||||||
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
|
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
|
||||||
import { ParentTreeQueryReturnType, SearchQueryReturnType } from './helper';
|
import {
|
||||||
|
GetCollectionResponse,
|
||||||
|
ParentTreeQueryReturnType,
|
||||||
|
SearchQueryReturnType,
|
||||||
|
} from './helper';
|
||||||
import { RESTError } from 'src/types/RESTError';
|
import { RESTError } from 'src/types/RESTError';
|
||||||
|
import { TeamService } from 'src/team/team.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TeamCollectionService {
|
export class TeamCollectionService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly pubsub: PubSubService,
|
private readonly pubsub: PubSubService,
|
||||||
|
private readonly teamService: TeamService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
TITLE_LENGTH = 3;
|
TITLE_LENGTH = 3;
|
||||||
@@ -1344,4 +1355,95 @@ export class TeamCollectionService {
|
|||||||
return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED);
|
return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all requests in a collection
|
||||||
|
*
|
||||||
|
* @param collectionID The Collection ID
|
||||||
|
* @returns A list of all requests in the collection
|
||||||
|
*/
|
||||||
|
private async getAllRequestsInCollection(collectionID: string) {
|
||||||
|
const dbTeamRequests = await this.prisma.teamRequest.findMany({
|
||||||
|
where: {
|
||||||
|
collectionID: collectionID,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
orderIndex: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamRequests = dbTeamRequests.map((tr) => {
|
||||||
|
return <TeamRequest>{
|
||||||
|
id: tr.id,
|
||||||
|
collectionID: tr.collectionID,
|
||||||
|
teamID: tr.teamID,
|
||||||
|
title: tr.title,
|
||||||
|
request: JSON.stringify(tr.request),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return teamRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Collection Tree for CLI
|
||||||
|
*
|
||||||
|
* @param parentID The parent Collection ID
|
||||||
|
* @returns Collection tree for CLI
|
||||||
|
*/
|
||||||
|
private async getCollectionTreeForCLI(parentID: string | null) {
|
||||||
|
const childCollections = await this.prisma.teamCollection.findMany({
|
||||||
|
where: { parentID },
|
||||||
|
orderBy: { orderIndex: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: GetCollectionResponse[] = [];
|
||||||
|
|
||||||
|
for (const collection of childCollections) {
|
||||||
|
const folder: GetCollectionResponse = {
|
||||||
|
id: collection.id,
|
||||||
|
data: collection.data === null ? null : JSON.stringify(collection.data),
|
||||||
|
title: collection.title,
|
||||||
|
parentID: collection.parentID,
|
||||||
|
folders: await this.getCollectionTreeForCLI(collection.id),
|
||||||
|
requests: await this.getAllRequestsInCollection(collection.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
response.push(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Collection for CLI
|
||||||
|
*
|
||||||
|
* @param collectionID The Collection ID
|
||||||
|
* @param userUid The User UID
|
||||||
|
* @returns An Either of the Collection details
|
||||||
|
*/
|
||||||
|
async getCollectionForCLI(collectionID: string, userUid: string) {
|
||||||
|
try {
|
||||||
|
const collection = await this.prisma.teamCollection.findUniqueOrThrow({
|
||||||
|
where: { id: collectionID },
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMember = await this.teamService.getTeamMember(
|
||||||
|
collection.teamID,
|
||||||
|
userUid,
|
||||||
|
);
|
||||||
|
if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
|
||||||
|
|
||||||
|
return E.right(<GetCollectionResponse>{
|
||||||
|
id: collection.id,
|
||||||
|
data: collection.data === null ? null : JSON.stringify(collection.data),
|
||||||
|
title: collection.title,
|
||||||
|
parentID: collection.parentID,
|
||||||
|
folders: await this.getCollectionTreeForCLI(collection.id),
|
||||||
|
requests: await this.getAllRequestsInCollection(collection.id),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return E.left(TEAM_COLL_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,24 @@ import {
|
|||||||
JSON_INVALID,
|
JSON_INVALID,
|
||||||
TEAM_ENVIRONMENT_NOT_FOUND,
|
TEAM_ENVIRONMENT_NOT_FOUND,
|
||||||
TEAM_ENVIRONMENT_SHORT_NAME,
|
TEAM_ENVIRONMENT_SHORT_NAME,
|
||||||
|
TEAM_MEMBER_NOT_FOUND,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
|
import { TeamService } from 'src/team/team.service';
|
||||||
|
import { TeamMemberRole } from 'src/team/team.model';
|
||||||
|
|
||||||
const mockPrisma = mockDeep<PrismaService>();
|
const mockPrisma = mockDeep<PrismaService>();
|
||||||
|
|
||||||
const mockPubSub = {
|
const mockPubSub = {
|
||||||
publish: jest.fn().mockResolvedValue(null),
|
publish: jest.fn().mockResolvedValue(null),
|
||||||
};
|
};
|
||||||
|
const mockTeamService = mockDeep<TeamService>();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const teamEnvironmentsService = new TeamEnvironmentsService(
|
const teamEnvironmentsService = new TeamEnvironmentsService(
|
||||||
mockPrisma,
|
mockPrisma,
|
||||||
mockPubSub as any,
|
mockPubSub as any,
|
||||||
|
mockTeamService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const teamEnvironment = {
|
const teamEnvironment = {
|
||||||
@@ -380,4 +385,47 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
expect(result).toEqual(0);
|
expect(result).toEqual(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getTeamEnvironmentForCLI', () => {
|
||||||
|
test('should successfully return a TeamEnvironment with valid ID', async () => {
|
||||||
|
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
|
||||||
|
teamEnvironment,
|
||||||
|
);
|
||||||
|
mockTeamService.getTeamMember.mockResolvedValue({
|
||||||
|
membershipID: 'sdc3sfdv',
|
||||||
|
userUid: '123454',
|
||||||
|
role: TeamMemberRole.OWNER,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await teamEnvironmentsService.getTeamEnvironmentForCLI(
|
||||||
|
teamEnvironment.id,
|
||||||
|
'123454',
|
||||||
|
);
|
||||||
|
expect(result).toEqualRight(teamEnvironment);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
|
||||||
|
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
|
||||||
|
'RejectOnNotFound',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await teamEnvironmentsService.getTeamEnvironment(
|
||||||
|
teamEnvironment.id,
|
||||||
|
);
|
||||||
|
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => {
|
||||||
|
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
|
||||||
|
teamEnvironment,
|
||||||
|
);
|
||||||
|
mockTeamService.getTeamMember.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await teamEnvironmentsService.getTeamEnvironmentForCLI(
|
||||||
|
teamEnvironment.id,
|
||||||
|
'333',
|
||||||
|
);
|
||||||
|
expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import { TeamEnvironment } from './team-environments.model';
|
|||||||
import {
|
import {
|
||||||
TEAM_ENVIRONMENT_NOT_FOUND,
|
TEAM_ENVIRONMENT_NOT_FOUND,
|
||||||
TEAM_ENVIRONMENT_SHORT_NAME,
|
TEAM_ENVIRONMENT_SHORT_NAME,
|
||||||
|
TEAM_MEMBER_NOT_FOUND,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import { isValidLength } from 'src/utils';
|
import { isValidLength } from 'src/utils';
|
||||||
|
import { TeamService } from 'src/team/team.service';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TeamEnvironmentsService {
|
export class TeamEnvironmentsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly pubsub: PubSubService,
|
private readonly pubsub: PubSubService,
|
||||||
|
private readonly teamService: TeamService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
TITLE_LENGTH = 3;
|
TITLE_LENGTH = 3;
|
||||||
@@ -242,4 +245,30 @@ export class TeamEnvironmentsService {
|
|||||||
});
|
});
|
||||||
return envCount;
|
return envCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get details of a TeamEnvironment for CLI.
|
||||||
|
*
|
||||||
|
* @param id TeamEnvironment ID
|
||||||
|
* @param userUid User UID
|
||||||
|
* @returns Either of a TeamEnvironment or error message
|
||||||
|
*/
|
||||||
|
async getTeamEnvironmentForCLI(id: string, userUid: string) {
|
||||||
|
try {
|
||||||
|
const teamEnvironment =
|
||||||
|
await this.prisma.teamEnvironment.findFirstOrThrow({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMember = await this.teamService.getTeamMember(
|
||||||
|
teamEnvironment.teamID,
|
||||||
|
userUid,
|
||||||
|
);
|
||||||
|
if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
|
||||||
|
|
||||||
|
return E.right(teamEnvironment);
|
||||||
|
} catch (error) {
|
||||||
|
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
packages/hoppscotch-backend/src/types/AccessToken.ts
Normal file
7
packages/hoppscotch-backend/src/types/AccessToken.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type AccessToken = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
createdOn: Date;
|
||||||
|
lastUsedOn: Date;
|
||||||
|
expiresOn: null | Date;
|
||||||
|
};
|
||||||
@@ -5,6 +5,6 @@ import { HttpStatus } from '@nestjs/common';
|
|||||||
** Since its REST we need to return the HTTP status code along with the error message
|
** Since its REST we need to return the HTTP status code along with the error message
|
||||||
*/
|
*/
|
||||||
export type RESTError = {
|
export type RESTError = {
|
||||||
message: string;
|
message: string | Record<string, string>;
|
||||||
statusCode: HttpStatus;
|
statusCode: HttpStatus;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const user: AuthUser = {
|
|||||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
createdOn: currentTime,
|
createdOn: currentTime,
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const user: AuthUser = {
|
|||||||
photoURL: 'https://example.com/photo.png',
|
photoURL: 'https://example.com/photo.png',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
|
lastLoggedOn: new Date(),
|
||||||
createdOn: new Date(),
|
createdOn: new Date(),
|
||||||
currentGQLSession: null,
|
currentGQLSession: null,
|
||||||
currentRESTSession: null,
|
currentRESTSession: null,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const user: AuthUser = {
|
|||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
createdOn: currentTime,
|
createdOn: currentTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export class User {
|
|||||||
})
|
})
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
|
||||||
|
@Field({
|
||||||
|
nullable: true,
|
||||||
|
description: 'Date when the user last logged in',
|
||||||
|
})
|
||||||
|
lastLoggedOn: Date;
|
||||||
|
|
||||||
@Field({
|
@Field({
|
||||||
description: 'Date when the user account was created',
|
description: 'Date when the user account was created',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const user: AuthUser = {
|
|||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
createdOn: currentTime,
|
createdOn: currentTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ const adminUser: AuthUser = {
|
|||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
createdOn: currentTime,
|
createdOn: currentTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ const users: AuthUser[] = [
|
|||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
createdOn: currentTime,
|
createdOn: currentTime,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -78,6 +81,7 @@ const users: AuthUser[] = [
|
|||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
createdOn: currentTime,
|
createdOn: currentTime,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -89,6 +93,7 @@ const users: AuthUser[] = [
|
|||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
createdOn: currentTime,
|
createdOn: currentTime,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -103,6 +108,7 @@ const adminUsers: AuthUser[] = [
|
|||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
createdOn: currentTime,
|
createdOn: currentTime,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -114,6 +120,7 @@ const adminUsers: AuthUser[] = [
|
|||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
createdOn: currentTime,
|
createdOn: currentTime,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -125,6 +132,7 @@ const adminUsers: AuthUser[] = [
|
|||||||
currentRESTSession: {},
|
currentRESTSession: {},
|
||||||
currentGQLSession: {},
|
currentGQLSession: {},
|
||||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
createdOn: currentTime,
|
createdOn: currentTime,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -495,6 +503,26 @@ describe('UserService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateUserLastLoggedOn', () => {
|
||||||
|
test('should resolve right and update user last logged on', async () => {
|
||||||
|
const currentTime = new Date();
|
||||||
|
mockPrisma.user.update.mockResolvedValueOnce({
|
||||||
|
...user,
|
||||||
|
lastLoggedOn: currentTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await userService.updateUserLastLoggedOn(user.uid);
|
||||||
|
expect(result).toEqualRight(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should resolve left and error when invalid user uid is passed', async () => {
|
||||||
|
mockPrisma.user.update.mockRejectedValueOnce('NotFoundError');
|
||||||
|
|
||||||
|
const result = await userService.updateUserLastLoggedOn('invalidUserUid');
|
||||||
|
expect(result).toEqualLeft(USER_NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('fetchAllUsers', () => {
|
describe('fetchAllUsers', () => {
|
||||||
test('should resolve right and return 20 users when cursor is null', async () => {
|
test('should resolve right and return 20 users when cursor is null', async () => {
|
||||||
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export class UserService {
|
|||||||
* @param userUid User uid
|
* @param userUid User uid
|
||||||
* @returns Either of User with updated refreshToken
|
* @returns Either of User with updated refreshToken
|
||||||
*/
|
*/
|
||||||
async UpdateUserRefreshToken(refreshTokenHash: string, userUid: string) {
|
async updateUserRefreshToken(refreshTokenHash: string, userUid: string) {
|
||||||
try {
|
try {
|
||||||
const user = await this.prisma.user.update({
|
const user = await this.prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
@@ -174,6 +174,7 @@ export class UserService {
|
|||||||
displayName: userDisplayName,
|
displayName: userDisplayName,
|
||||||
email: profile.emails[0].value,
|
email: profile.emails[0].value,
|
||||||
photoURL: userPhotoURL,
|
photoURL: userPhotoURL,
|
||||||
|
lastLoggedOn: new Date(),
|
||||||
providerAccounts: {
|
providerAccounts: {
|
||||||
create: {
|
create: {
|
||||||
provider: profile.provider,
|
provider: profile.provider,
|
||||||
@@ -221,7 +222,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update User displayName and photoURL
|
* Update User displayName and photoURL when logged in via a SSO provider
|
||||||
*
|
*
|
||||||
* @param user User object
|
* @param user User object
|
||||||
* @param profile Data received from SSO provider on the users account
|
* @param profile Data received from SSO provider on the users account
|
||||||
@@ -236,6 +237,7 @@ export class UserService {
|
|||||||
data: {
|
data: {
|
||||||
displayName: !profile.displayName ? null : profile.displayName,
|
displayName: !profile.displayName ? null : profile.displayName,
|
||||||
photoURL: !profile.photos ? null : profile.photos[0].value,
|
photoURL: !profile.photos ? null : profile.photos[0].value,
|
||||||
|
lastLoggedOn: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return E.right(updatedUser);
|
return E.right(updatedUser);
|
||||||
@@ -289,7 +291,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a user's data
|
* Update a user's displayName
|
||||||
* @param userUID User UID
|
* @param userUID User UID
|
||||||
* @param displayName User's displayName
|
* @param displayName User's displayName
|
||||||
* @returns a Either of User or error
|
* @returns a Either of User or error
|
||||||
@@ -316,6 +318,22 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user's lastLoggedOn timestamp
|
||||||
|
* @param userUID User UID
|
||||||
|
*/
|
||||||
|
async updateUserLastLoggedOn(userUid: string) {
|
||||||
|
try {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { uid: userUid },
|
||||||
|
data: { lastLoggedOn: new Date() },
|
||||||
|
});
|
||||||
|
return E.right(true);
|
||||||
|
} catch (e) {
|
||||||
|
return E.left(USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and parse currentRESTSession and currentGQLSession
|
* Validate and parse currentRESTSession and currentGQLSession
|
||||||
* @param sessionData string of the session
|
* @param sessionData string of the session
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export {}
|
|||||||
|
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
AccessTokensGenerate: typeof import('./components/accessTokens/Generate.vue')['default']
|
||||||
|
AccessTokensGenerateModal: typeof import('./components/accessTokens/GenerateModal.vue')['default']
|
||||||
|
AccessTokensList: typeof import('./components/accessTokens/List.vue')['default']
|
||||||
|
AccessTokensOverview: typeof import('./components/accessTokens/Overview.vue')['default']
|
||||||
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
|
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
|
||||||
AppBanner: typeof import('./components/app/Banner.vue')['default']
|
AppBanner: typeof import('./components/app/Banner.vue')['default']
|
||||||
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
|
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
|
||||||
@@ -148,7 +152,7 @@ declare module 'vue' {
|
|||||||
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
|
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
|
||||||
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
|
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
|
||||||
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
|
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
|
||||||
IconLucideBrush: (typeof import("~icons/lucide/brush"))["default"]
|
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
|
||||||
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
|
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
|
||||||
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||||
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
|
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
|
||||||
@@ -158,9 +162,10 @@ declare module 'vue' {
|
|||||||
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
||||||
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
||||||
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||||
IconLucideRss: (typeof import("~icons/lucide/rss"))["default"]
|
IconLucideRss: typeof import('~icons/lucide/rss')['default']
|
||||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||||
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||||
|
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
|
||||||
IconLucideX: typeof import('~icons/lucide/x')['default']
|
IconLucideX: typeof import('~icons/lucide/x')['default']
|
||||||
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
|
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
|
||||||
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
|
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<template>
|
||||||
|
<HoppSmartModal dialog title="New Personal Access Token" @close="hideModal">
|
||||||
|
<template #body>
|
||||||
|
<template v-if="accessToken">
|
||||||
|
<p class="text-amber-500 mb-4 border rounded-md border-amber-600 p-4">
|
||||||
|
Make sure to copy your personal access token now. You won’t be able to
|
||||||
|
see it again!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-md bg-primaryLight p-4 mt-4 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div class="text-secondaryDark">{{ accessToken }}</div>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
outline
|
||||||
|
filled
|
||||||
|
:icon="copyIcon"
|
||||||
|
@click="copyAccessToken"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-secondaryDark font-semibold">Label</div>
|
||||||
|
<HoppSmartInput
|
||||||
|
v-model="accessTokenLabel"
|
||||||
|
placeholder=" "
|
||||||
|
class="floating-input"
|
||||||
|
/>
|
||||||
|
<div class="text-secondaryLight">What's this token for?</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="expiration" class="text-secondaryDark font-semibold"
|
||||||
|
>Expiration</label
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 items-center gap-x-2">
|
||||||
|
<tippy
|
||||||
|
interactive
|
||||||
|
trigger="click"
|
||||||
|
theme="popover"
|
||||||
|
:on-shown="tippyActions?.focus()"
|
||||||
|
>
|
||||||
|
<HoppSmartSelectWrapper>
|
||||||
|
<input
|
||||||
|
id="expiration"
|
||||||
|
:value="expiration"
|
||||||
|
readonly
|
||||||
|
class="flex flex-1 cursor-pointer bg-transparent px-4 py-2 rounded border border-divider"
|
||||||
|
/>
|
||||||
|
</HoppSmartSelectWrapper>
|
||||||
|
|
||||||
|
<template #content="{ hide }">
|
||||||
|
<div
|
||||||
|
ref="tippyActions"
|
||||||
|
tabindex="0"
|
||||||
|
role="menu"
|
||||||
|
class="flex flex-col focus:outline-none"
|
||||||
|
@keyup.escape="hide"
|
||||||
|
>
|
||||||
|
<HoppSmartItem
|
||||||
|
v-for="expirationOption in Object.keys(expirationOptions)"
|
||||||
|
:key="expirationOption"
|
||||||
|
:label="expirationOption"
|
||||||
|
:icon="
|
||||||
|
expirationOption === expiration
|
||||||
|
? IconCircleDot
|
||||||
|
: IconCircle
|
||||||
|
"
|
||||||
|
:active="expirationOption === expiration"
|
||||||
|
:aria-selected="expirationOption === expiration"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
expiration = expirationOption
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</tippy>
|
||||||
|
|
||||||
|
<span class="text-secondaryLight">{{ expirationDateText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-secondaryDark font-semibold">Scope</div>
|
||||||
|
|
||||||
|
<p class="text-secondaryLight">
|
||||||
|
Read-only access to workspace data.<br />
|
||||||
|
Personal Access Tokens can't access your personal workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
v-if="accessToken"
|
||||||
|
:label="t('action.close')"
|
||||||
|
outline
|
||||||
|
filled
|
||||||
|
@click="hideModal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="flex items-center gap-x-2">
|
||||||
|
<HoppButtonPrimary
|
||||||
|
:loading="tokenGenerateActionLoading"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
label="Generate Token"
|
||||||
|
@click="generateAccessToken"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:label="t('action.cancel')"
|
||||||
|
outline
|
||||||
|
filled
|
||||||
|
@click="hideModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoppSmartModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from "@composables/i18n"
|
||||||
|
import { useToast } from "@composables/toast"
|
||||||
|
import { refAutoReset } from "@vueuse/core"
|
||||||
|
import { computed, ref } from "vue"
|
||||||
|
import { TippyComponent } from "vue-tippy"
|
||||||
|
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||||
|
import { shortDateTime } from "~/helpers/utils/date"
|
||||||
|
|
||||||
|
import IconCheck from "~icons/lucide/check"
|
||||||
|
import IconCircle from "~icons/lucide/circle"
|
||||||
|
import IconCircleDot from "~icons/lucide/circle-dot"
|
||||||
|
import IconCopy from "~icons/lucide/copy"
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tokenGenerateActionLoading: boolean
|
||||||
|
accessToken: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "hide-modal"): void
|
||||||
|
(
|
||||||
|
e: "generate-access-token",
|
||||||
|
{ label, expiryInDays }: { label: string; expiryInDays: number | null }
|
||||||
|
): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Template refs
|
||||||
|
const tippyActions = ref<TippyComponent[] | null>(null)
|
||||||
|
|
||||||
|
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||||
|
IconCopy,
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
|
||||||
|
const accessTokenLabel = ref<string>("")
|
||||||
|
const expiration = ref<string>("30 days")
|
||||||
|
|
||||||
|
const expirationOptions: Record<string, number | null> = {
|
||||||
|
"7 days": 7,
|
||||||
|
"30 days": 30,
|
||||||
|
"60 days": 60,
|
||||||
|
"90 days": 90,
|
||||||
|
"No expiration": null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationDateText = computed(() => {
|
||||||
|
const chosenExpiryInDays = expirationOptions[expiration.value]
|
||||||
|
|
||||||
|
if (chosenExpiryInDays === null) {
|
||||||
|
return "This token will never expire!"
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDate = new Date()
|
||||||
|
currentDate.setDate(currentDate.getDate() + chosenExpiryInDays)
|
||||||
|
|
||||||
|
const expirationDate = shortDateTime(currentDate, false)
|
||||||
|
return `This token will expire on ${expirationDate}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyAccessToken = () => {
|
||||||
|
if (!props.accessToken) {
|
||||||
|
toast.error("error.something_went_wrong")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
copyToClipboard(props.accessToken)
|
||||||
|
copyIcon.value = IconCheck
|
||||||
|
|
||||||
|
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateAccessToken = async () => {
|
||||||
|
if (!accessTokenLabel.value) {
|
||||||
|
toast.error("Please provide a label for the token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("generate-access-token", {
|
||||||
|
label: accessTokenLabel.value,
|
||||||
|
expiryInDays: expirationOptions[expiration.value],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideModal = () => emit("hide-modal")
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-2xl space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="{ id, label, lastUsedOn, expiresOn } in accessTokens"
|
||||||
|
:key="id"
|
||||||
|
class="flex items-center justify-between rounded border border-divider p-4"
|
||||||
|
>
|
||||||
|
<span class="font-semibold text-secondaryDark text-sm">
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-x-4">
|
||||||
|
<div class="text-secondaryLight space-y-1">
|
||||||
|
<div class="space-x-1">
|
||||||
|
<span class="font-semibold">Last used on:</span>
|
||||||
|
<span>
|
||||||
|
{{ shortDateTime(lastUsedOn, false) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-x-1">
|
||||||
|
<span class="font-semibold">Expires on:</span>
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
expiresOn ? shortDateTime(expiresOn, false) : "No expiration"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HoppButtonSecondary
|
||||||
|
label="Delete"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="
|
||||||
|
emit('delete-access-token', {
|
||||||
|
tokenId: id,
|
||||||
|
tokenLabel: label,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HoppSmartIntersection
|
||||||
|
v-if="hasMoreTokens"
|
||||||
|
@intersecting="emit('fetch-more-tokens')"
|
||||||
|
>
|
||||||
|
<div v-if="tokensListLoading" class="flex flex-col items-center py-3">
|
||||||
|
<HoppSmartSpinner />
|
||||||
|
</div>
|
||||||
|
</HoppSmartIntersection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { shortDateTime } from "~/helpers/utils/date"
|
||||||
|
import { AccessToken } from "~/pages/profile.vue"
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
accessTokens: AccessToken[]
|
||||||
|
hasMoreTokens: boolean
|
||||||
|
tokensListLoading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "fetch-more-tokens"): void
|
||||||
|
(
|
||||||
|
e: "delete-access-token",
|
||||||
|
{ tokenId, tokenLabel }: { tokenId: string; tokenLabel: string }
|
||||||
|
): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h4 class="font-semibold text-secondaryDark">Personal Access Tokens</h4>
|
||||||
|
|
||||||
|
<p class="text-secondaryLight">
|
||||||
|
Personal access tokens currently helps you connect the CLI to your
|
||||||
|
Hoppscotch account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HoppButtonSecondary
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
label="Generate new token"
|
||||||
|
@click="emit('show-access-tokens-generate-modal')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "show-access-tokens-generate-modal"): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(member, index) in membersList"
|
v-for="(member, index) in membersList"
|
||||||
:key="`member-${index}`"
|
:key="`member-${index}`"
|
||||||
class="flex divide-x divide-dividerLight"
|
class="flex"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
class="flex flex-1 bg-transparent px-4 py-2"
|
class="flex flex-1 bg-transparent px-4 py-2"
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
export function shortDateTime(date: string | number | Date) {
|
export function shortDateTime(
|
||||||
|
date: string | number | Date,
|
||||||
|
includeTime: boolean = true
|
||||||
|
) {
|
||||||
return new Date(date).toLocaleString("en-US", {
|
return new Date(date).toLocaleString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "numeric",
|
...(includeTime
|
||||||
minute: "numeric",
|
? {
|
||||||
second: "numeric",
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
second: "numeric",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,38 +182,79 @@
|
|||||||
<ProfileUserDelete />
|
<ProfileUserDelete />
|
||||||
</div>
|
</div>
|
||||||
</HoppSmartTab>
|
</HoppSmartTab>
|
||||||
|
|
||||||
<HoppSmartTab :id="'teams'" :label="t('team.title')">
|
<HoppSmartTab :id="'teams'" :label="t('team.title')">
|
||||||
<Teams :modal="false" class="p-4" />
|
<Teams :modal="false" class="p-4" />
|
||||||
</HoppSmartTab>
|
</HoppSmartTab>
|
||||||
|
|
||||||
|
<HoppSmartTab id="tokens" label="Tokens" class="space-y-4">
|
||||||
|
<AccessTokensOverview
|
||||||
|
@show-access-tokens-generate-modal="
|
||||||
|
showAccessTokensGenerateModal = true
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AccessTokensList
|
||||||
|
:access-tokens="accessTokens"
|
||||||
|
:has-more-tokens="hasMoreTokens"
|
||||||
|
:tokens-list-loading="tokensListLoading"
|
||||||
|
@delete-access-token="showDeleteAccessTokenConfirmation"
|
||||||
|
@fetch-more-tokens="fetchAccessTokens"
|
||||||
|
/>
|
||||||
|
</HoppSmartTab>
|
||||||
</HoppSmartTabs>
|
</HoppSmartTabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AccessTokensGenerateModal
|
||||||
|
v-if="showAccessTokensGenerateModal"
|
||||||
|
:access-token="accessToken"
|
||||||
|
:token-generate-action-loading="tokenGenerateActionLoading"
|
||||||
|
@generate-access-token="generateAccessToken"
|
||||||
|
@hide-modal="hideAccessTokenGenerateModal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HoppSmartConfirmModal
|
||||||
|
:show="confirmDeleteAccessToken"
|
||||||
|
:loading-state="tokenDeleteActionLoading"
|
||||||
|
:title="`Are you sure you want to delete the access token ${tokenToDelete?.label}?`"
|
||||||
|
@hide-modal="confirmDeleteAccessToken = false"
|
||||||
|
@resolve="() => deleteAccessToken()"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watchEffect, computed } from "vue"
|
import axios from "axios"
|
||||||
|
import * as E from "fp-ts/Either"
|
||||||
|
import { Ref, computed, onMounted, ref, watchEffect } from "vue"
|
||||||
|
|
||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
|
|
||||||
import { invokeAction } from "~/helpers/actions"
|
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
|
||||||
import { useI18n } from "@composables/i18n"
|
|
||||||
import { useToast } from "@composables/toast"
|
|
||||||
import { useSetting } from "@composables/settings"
|
|
||||||
import { useColorMode } from "@composables/theming"
|
|
||||||
import { usePageHead } from "@composables/head"
|
import { usePageHead } from "@composables/head"
|
||||||
|
import { useI18n } from "@composables/i18n"
|
||||||
|
import { useSetting } from "@composables/settings"
|
||||||
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
|
import { useColorMode } from "@composables/theming"
|
||||||
|
import { useToast } from "@composables/toast"
|
||||||
|
import { invokeAction } from "~/helpers/actions"
|
||||||
|
|
||||||
import { toggleSetting } from "~/newstore/settings"
|
import { toggleSetting } from "~/newstore/settings"
|
||||||
|
|
||||||
import IconVerified from "~icons/lucide/verified"
|
|
||||||
import IconSettings from "~icons/lucide/settings"
|
import IconSettings from "~icons/lucide/settings"
|
||||||
|
import IconVerified from "~icons/lucide/verified"
|
||||||
import * as E from "fp-ts/Either"
|
|
||||||
|
|
||||||
type ProfileTabs = "sync" | "teams"
|
type ProfileTabs = "sync" | "teams"
|
||||||
|
|
||||||
|
export type AccessToken = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
createdOn: Date
|
||||||
|
lastUsedOn: Date
|
||||||
|
expiresOn: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
const selectedProfileTab = ref<ProfileTabs>("sync")
|
const selectedProfileTab = ref<ProfileTabs>("sync")
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
@@ -224,6 +265,12 @@ usePageHead({
|
|||||||
title: computed(() => t("navigation.profile")),
|
title: computed(() => t("navigation.profile")),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const accessTokens: Ref<AccessToken[]> = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchAccessTokens()
|
||||||
|
})
|
||||||
|
|
||||||
const SYNC_COLLECTIONS = useSetting("syncCollections")
|
const SYNC_COLLECTIONS = useSetting("syncCollections")
|
||||||
const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
|
const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
|
||||||
const SYNC_HISTORY = useSetting("syncHistory")
|
const SYNC_HISTORY = useSetting("syncHistory")
|
||||||
@@ -236,6 +283,24 @@ const probableUser = useReadonlyStream(
|
|||||||
platform.auth.getProbableUser()
|
platform.auth.getProbableUser()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const confirmDeleteAccessToken = ref(false)
|
||||||
|
const hasMoreTokens = ref(true)
|
||||||
|
const showAccessTokensGenerateModal = ref(false)
|
||||||
|
const tokenDeleteActionLoading = ref(false)
|
||||||
|
const tokenGenerateActionLoading = ref(false)
|
||||||
|
const tokensListLoading = ref(false)
|
||||||
|
|
||||||
|
const accessToken: Ref<string | null> = ref(null)
|
||||||
|
const tokenToDelete = ref<{ id: string; label: string } | null>(null)
|
||||||
|
|
||||||
|
const limit = 10
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
const accessTokenEndpointMetadata = {
|
||||||
|
axiosPlatformConfig: platform.auth.axiosPlatformConfig?.() ?? {},
|
||||||
|
endpointPrefix: `${import.meta.env.VITE_BACKEND_API_URL}/access-tokens`,
|
||||||
|
}
|
||||||
|
|
||||||
const loadingCurrentUser = computed(() => {
|
const loadingCurrentUser = computed(() => {
|
||||||
if (!probableUser.value) return false
|
if (!probableUser.value) return false
|
||||||
else if (!currentUser.value) return true
|
else if (!currentUser.value) return true
|
||||||
@@ -305,4 +370,116 @@ const sendEmailVerification = () => {
|
|||||||
verifyingEmailAddress.value = false
|
verifyingEmailAddress.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchAccessTokens = async () => {
|
||||||
|
tokensListLoading.value = true
|
||||||
|
|
||||||
|
const { axiosPlatformConfig, endpointPrefix } = accessTokenEndpointMetadata
|
||||||
|
|
||||||
|
const endpoint = `${endpointPrefix}/list?offset=${offset}&limit=${limit}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(endpoint, axiosPlatformConfig)
|
||||||
|
|
||||||
|
accessTokens.value.push(...data)
|
||||||
|
|
||||||
|
if (data.length > 0) {
|
||||||
|
offset += data.length
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMoreTokens.value = data.length === limit
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Something went wrong while fetching the list of tokens")
|
||||||
|
} finally {
|
||||||
|
tokensListLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateAccessToken = async ({
|
||||||
|
label,
|
||||||
|
expiryInDays,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
expiryInDays: number | null
|
||||||
|
}) => {
|
||||||
|
tokenGenerateActionLoading.value = true
|
||||||
|
|
||||||
|
const { axiosPlatformConfig, endpointPrefix } = accessTokenEndpointMetadata
|
||||||
|
|
||||||
|
const endpoint = `${endpointPrefix}/create`
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
label,
|
||||||
|
expiryInDays,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data }: { data: { token: string; info: AccessToken } } =
|
||||||
|
await axios.post(endpoint, body, axiosPlatformConfig)
|
||||||
|
|
||||||
|
accessTokens.value.unshift(data.info)
|
||||||
|
accessToken.value = data.token
|
||||||
|
|
||||||
|
// Incrementing the offset value by 1 to account for the newly generated token
|
||||||
|
offset += 1
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Something went wrong while generating the access token`)
|
||||||
|
} finally {
|
||||||
|
tokenGenerateActionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAccessToken = async () => {
|
||||||
|
if (tokenToDelete.value === null) {
|
||||||
|
toast.error("error.something_went_wrong")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: tokenIdToDelete } = tokenToDelete.value
|
||||||
|
|
||||||
|
tokenDeleteActionLoading.value = true
|
||||||
|
|
||||||
|
const { axiosPlatformConfig, endpointPrefix } = accessTokenEndpointMetadata
|
||||||
|
|
||||||
|
const endpoint = `${endpointPrefix}/revoke?id=${tokenIdToDelete}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(endpoint, axiosPlatformConfig)
|
||||||
|
|
||||||
|
accessTokens.value = accessTokens.value.filter(
|
||||||
|
(token) => token.id !== tokenIdToDelete
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decreasing the offset value by 1 to account for the deleted token
|
||||||
|
offset = offset > 0 ? offset - 1 : offset
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Something went wrong while deleting the access token")
|
||||||
|
} finally {
|
||||||
|
tokenDeleteActionLoading.value = false
|
||||||
|
confirmDeleteAccessToken.value = false
|
||||||
|
|
||||||
|
tokenToDelete.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideAccessTokenGenerateModal = () => {
|
||||||
|
// Reset the reactive state variable holding access token value and hide the modal
|
||||||
|
accessToken.value = null
|
||||||
|
showAccessTokensGenerateModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDeleteAccessTokenConfirmation = ({
|
||||||
|
tokenId,
|
||||||
|
tokenLabel,
|
||||||
|
}: {
|
||||||
|
tokenId: string
|
||||||
|
tokenLabel: string
|
||||||
|
}) => {
|
||||||
|
confirmDeleteAccessToken.value = true
|
||||||
|
|
||||||
|
tokenToDelete.value = {
|
||||||
|
id: tokenId,
|
||||||
|
label: tokenLabel,
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user