Compare commits
22 Commits
fix/sh-ema
...
feat/cli-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dc9030559 | ||
|
|
4bd23a8f4c | ||
|
|
f4f3fdf2d5 | ||
|
|
f8ac6dfeb1 | ||
|
|
7d2d335b37 | ||
|
|
76875db865 | ||
|
|
96e2d87b57 | ||
|
|
be353d9f72 | ||
|
|
38bc2c12c3 | ||
|
|
97644fa508 | ||
|
|
eb3446ae23 | ||
|
|
6c29961d09 | ||
|
|
ef1117d8cc | ||
|
|
5c4b651aee | ||
|
|
391e5a20f5 | ||
|
|
4b8f3bd8da | ||
|
|
94248076e6 | ||
|
|
eecc3db4e9 | ||
|
|
426e7594f4 | ||
|
|
934dc473f0 | ||
|
|
be57255bf7 | ||
|
|
f89561da54 |
@@ -118,11 +118,11 @@ services:
|
||||
restart: always
|
||||
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)
|
||||
- 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
|
||||
volumes:
|
||||
# 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/
|
||||
depends_on:
|
||||
hoppscotch-db:
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"packageExtensions": {
|
||||
"httpsnippet@3.0.1": {
|
||||
"peerDependencies": {
|
||||
"dependencies": {
|
||||
"ajv": "6.12.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:18.8.0 AS builder
|
||||
FROM node:20.12.2 AS builder
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"assets": [
|
||||
"**/*.hbs"
|
||||
],
|
||||
"assets": [{ "include": "mailer/templates/**/*", "outDir": "dist" }],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoppscotch-backend",
|
||||
"version": "2024.3.1",
|
||||
"version": "2024.3.3",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -35,6 +35,7 @@
|
||||
"@nestjs/passport": "10.0.2",
|
||||
"@nestjs/platform-express": "10.2.7",
|
||||
"@nestjs/schedule": "4.0.1",
|
||||
"@nestjs/terminus": "10.2.3",
|
||||
"@nestjs/throttler": "5.0.1",
|
||||
"@prisma/client": "5.8.1",
|
||||
"argon2": "0.30.3",
|
||||
|
||||
@@ -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 {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
parentID String?
|
||||
data Json?
|
||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||
requests TeamRequest[]
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model TeamRequest {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
collectionID String
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
request Json
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model Shortcode {
|
||||
@@ -89,24 +89,26 @@ model TeamEnvironment {
|
||||
}
|
||||
|
||||
model User {
|
||||
uid String @id @default(cuid())
|
||||
displayName String?
|
||||
email String? @unique
|
||||
photoURL String?
|
||||
isAdmin Boolean @default(false)
|
||||
refreshToken String?
|
||||
providerAccounts Account[]
|
||||
VerificationToken VerificationToken[]
|
||||
settings UserSettings?
|
||||
UserHistory UserHistory[]
|
||||
UserEnvironments UserEnvironment[]
|
||||
userCollections UserCollection[]
|
||||
userRequests UserRequest[]
|
||||
currentRESTSession Json?
|
||||
currentGQLSession Json?
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
invitedUsers InvitedUsers[]
|
||||
shortcodes Shortcode[]
|
||||
uid String @id @default(cuid())
|
||||
displayName String?
|
||||
email String? @unique
|
||||
photoURL String?
|
||||
isAdmin Boolean @default(false)
|
||||
refreshToken String?
|
||||
providerAccounts Account[]
|
||||
VerificationToken VerificationToken[]
|
||||
settings UserSettings?
|
||||
UserHistory UserHistory[]
|
||||
UserEnvironments UserEnvironment[]
|
||||
userCollections UserCollection[]
|
||||
userRequests UserRequest[]
|
||||
currentRESTSession Json?
|
||||
currentGQLSession Json?
|
||||
lastLoggedOn DateTime?
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
invitedUsers InvitedUsers[]
|
||||
shortcodes Shortcode[]
|
||||
personalAccessTokens PersonalAccessToken[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
@@ -218,3 +220,14 @@ model InfraConfig {
|
||||
createdOn DateTime @default(now()) @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',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
lastLoggedOn: new Date(),
|
||||
createdOn: new Date(),
|
||||
},
|
||||
{
|
||||
@@ -85,20 +86,10 @@ const dbAdminUsers: DbUser[] = [
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
lastLoggedOn: 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('fetchInvitedUsers', () => {
|
||||
|
||||
@@ -26,6 +26,8 @@ import { loadInfraConfiguration } from './infra-config/helper';
|
||||
import { MailerModule } from './mailer/mailer.module';
|
||||
import { PosthogModule } from './posthog/posthog.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AccessTokenModule } from './access-token/access-token.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -100,6 +102,8 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
InfraConfigModule,
|
||||
PosthogModule,
|
||||
ScheduleModule.forRoot(),
|
||||
HealthModule,
|
||||
AccessTokenModule,
|
||||
],
|
||||
providers: [GQLComplexityPlugin],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Request,
|
||||
Res,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
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 { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'auth', version: '1' })
|
||||
@@ -110,6 +112,7 @@ export class AuthController {
|
||||
@Get('google/callback')
|
||||
@SkipThrottle()
|
||||
@UseGuards(GoogleSSOGuard)
|
||||
@UseInterceptors(UserLastLoginInterceptor)
|
||||
async googleAuthRedirect(@Request() req, @Res() res) {
|
||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
@@ -135,6 +138,7 @@ export class AuthController {
|
||||
@Get('github/callback')
|
||||
@SkipThrottle()
|
||||
@UseGuards(GithubSSOGuard)
|
||||
@UseInterceptors(UserLastLoginInterceptor)
|
||||
async githubAuthRedirect(@Request() req, @Res() res) {
|
||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
@@ -160,6 +164,7 @@ export class AuthController {
|
||||
@Get('microsoft/callback')
|
||||
@SkipThrottle()
|
||||
@UseGuards(MicrosoftSSOGuard)
|
||||
@UseInterceptors(UserLastLoginInterceptor)
|
||||
async microsoftAuthRedirect(@Request() req, @Res() res) {
|
||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
|
||||
@@ -51,6 +51,7 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
@@ -172,9 +173,11 @@ describe('verifyMagicLinkTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
// deletePasswordlessVerificationToken
|
||||
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
||||
// usersService.updateUserLastLoggedOn
|
||||
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualRight({
|
||||
@@ -197,9 +200,11 @@ describe('verifyMagicLinkTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
// deletePasswordlessVerificationToken
|
||||
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
||||
// usersService.updateUserLastLoggedOn
|
||||
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualRight({
|
||||
@@ -239,7 +244,7 @@ describe('verifyMagicLinkTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
@@ -264,7 +269,7 @@ describe('verifyMagicLinkTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
// deletePasswordlessVerificationToken
|
||||
mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound');
|
||||
|
||||
@@ -280,7 +285,7 @@ describe('generateAuthTokens', () => {
|
||||
test('Should successfully generate tokens with valid inputs', async () => {
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
|
||||
const result = await authService.generateAuthTokens(user.uid);
|
||||
expect(result).toEqualRight({
|
||||
@@ -292,7 +297,7 @@ describe('generateAuthTokens', () => {
|
||||
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
@@ -319,7 +324,7 @@ describe('refreshAuthTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
@@ -348,7 +353,7 @@ describe('refreshAuthTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.right({
|
||||
...user,
|
||||
refreshToken: 'sdhjcbjsdhcbshjdcb',
|
||||
|
||||
@@ -112,7 +112,7 @@ export class AuthService {
|
||||
|
||||
const refreshTokenHash = await argon2.hash(refreshToken);
|
||||
|
||||
const updatedUser = await this.usersService.UpdateUserRefreshToken(
|
||||
const updatedUser = await this.usersService.updateUserRefreshToken(
|
||||
refreshTokenHash,
|
||||
userUid,
|
||||
);
|
||||
@@ -320,6 +320,8 @@ export class AuthService {
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
this.usersService.updateUserLastLoggedOn(passwordlessTokens.value.userUid);
|
||||
|
||||
return E.right(tokens.right);
|
||||
}
|
||||
|
||||
|
||||
@@ -761,3 +761,39 @@ export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';
|
||||
* Inputs supplied are invalid
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
24
packages/hoppscotch-backend/src/health/health.controller.ts
Normal file
24
packages/hoppscotch-backend/src/health/health.controller.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckService,
|
||||
PrismaHealthIndicator,
|
||||
} from '@nestjs/terminus';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private health: HealthCheckService,
|
||||
private prismaHealth: PrismaHealthIndicator,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
check() {
|
||||
return this.health.check([
|
||||
async () => this.prismaHealth.pingCheck('database', this.prisma),
|
||||
]);
|
||||
}
|
||||
}
|
||||
10
packages/hoppscotch-backend/src/health/health.module.ts
Normal file
10
packages/hoppscotch-backend/src/health/health.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, TerminusModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -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',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: createdOn,
|
||||
createdOn: createdOn,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TeamRequest } from '@prisma/client';
|
||||
|
||||
// Type of data returned from the query to obtain all search results
|
||||
export type SearchQueryReturnType = {
|
||||
id: string;
|
||||
@@ -12,3 +14,12 @@ export type ParentTreeQueryReturnType = {
|
||||
parentID: 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_SAME_NEXT_COLL,
|
||||
TEAM_INVALID_COLL_ID,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
TEAM_NOT_OWNER,
|
||||
} from 'src/errors';
|
||||
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 { TeamCollectionService } from './team-collection.service';
|
||||
import { TeamCollection } from './team-collection.model';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
const mockTeamService = mockDeep<TeamService>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const teamCollectionService = new TeamCollectionService(
|
||||
mockPrisma,
|
||||
mockPubSub as any,
|
||||
mockTeamService,
|
||||
);
|
||||
|
||||
const currentTime = new Date();
|
||||
@@ -39,6 +43,7 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
@@ -1738,3 +1743,63 @@ describe('updateTeamCollection', () => {
|
||||
});
|
||||
|
||||
//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_REQ_PARENT_TREE_GEN_FAILED,
|
||||
TEAM_COLL_PARENT_TREE_GEN_FAILED,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
} from '../errors';
|
||||
import { PubSubService } from '../pubsub/pubsub.service';
|
||||
import { escapeSqlLikeString, isValidLength } from 'src/utils';
|
||||
import * as E from 'fp-ts/Either';
|
||||
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 { stringToJson } from 'src/utils';
|
||||
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
|
||||
import { ParentTreeQueryReturnType, SearchQueryReturnType } from './helper';
|
||||
import {
|
||||
GetCollectionResponse,
|
||||
ParentTreeQueryReturnType,
|
||||
SearchQueryReturnType,
|
||||
} from './helper';
|
||||
import { RESTError } from 'src/types/RESTError';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
|
||||
@Injectable()
|
||||
export class TeamCollectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pubsub: PubSubService,
|
||||
private readonly teamService: TeamService,
|
||||
) {}
|
||||
|
||||
TITLE_LENGTH = 3;
|
||||
@@ -1344,4 +1355,95 @@ export class TeamCollectionService {
|
||||
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,
|
||||
TEAM_ENVIRONMENT_NOT_FOUND,
|
||||
TEAM_ENVIRONMENT_SHORT_NAME,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
import { TeamMemberRole } from 'src/team/team.model';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
|
||||
const mockPubSub = {
|
||||
publish: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
const mockTeamService = mockDeep<TeamService>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const teamEnvironmentsService = new TeamEnvironmentsService(
|
||||
mockPrisma,
|
||||
mockPubSub as any,
|
||||
mockTeamService,
|
||||
);
|
||||
|
||||
const teamEnvironment = {
|
||||
@@ -380,4 +385,47 @@ describe('TeamEnvironmentsService', () => {
|
||||
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 {
|
||||
TEAM_ENVIRONMENT_NOT_FOUND,
|
||||
TEAM_ENVIRONMENT_SHORT_NAME,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { isValidLength } from 'src/utils';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
@Injectable()
|
||||
export class TeamEnvironmentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pubsub: PubSubService,
|
||||
private readonly teamService: TeamService,
|
||||
) {}
|
||||
|
||||
TITLE_LENGTH = 3;
|
||||
@@ -242,4 +245,30 @@ export class TeamEnvironmentsService {
|
||||
});
|
||||
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
|
||||
*/
|
||||
export type RESTError = {
|
||||
message: string;
|
||||
message: string | Record<string, string>;
|
||||
statusCode: HttpStatus;
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
|
||||
@@ -41,6 +41,7 @@ const user: AuthUser = {
|
||||
photoURL: 'https://example.com/photo.png',
|
||||
isAdmin: false,
|
||||
refreshToken: null,
|
||||
lastLoggedOn: new Date(),
|
||||
createdOn: new Date(),
|
||||
currentGQLSession: null,
|
||||
currentRESTSession: null,
|
||||
|
||||
@@ -27,6 +27,7 @@ const user: AuthUser = {
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ export class User {
|
||||
})
|
||||
isAdmin: boolean;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Date when the user last logged in',
|
||||
})
|
||||
lastLoggedOn: Date;
|
||||
|
||||
@Field({
|
||||
description: 'Date when the user account was created',
|
||||
})
|
||||
|
||||
@@ -42,6 +42,7 @@ const user: AuthUser = {
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
};
|
||||
|
||||
@@ -54,6 +55,7 @@ const adminUser: AuthUser = {
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
};
|
||||
|
||||
@@ -67,6 +69,7 @@ const users: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -78,6 +81,7 @@ const users: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -89,6 +93,7 @@ const users: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
];
|
||||
@@ -103,6 +108,7 @@ const adminUsers: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -114,6 +120,7 @@ const adminUsers: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -125,6 +132,7 @@ const adminUsers: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: 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', () => {
|
||||
test('should resolve right and return 20 users when cursor is null', async () => {
|
||||
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
||||
|
||||
@@ -114,7 +114,7 @@ export class UserService {
|
||||
* @param userUid User uid
|
||||
* @returns Either of User with updated refreshToken
|
||||
*/
|
||||
async UpdateUserRefreshToken(refreshTokenHash: string, userUid: string) {
|
||||
async updateUserRefreshToken(refreshTokenHash: string, userUid: string) {
|
||||
try {
|
||||
const user = await this.prisma.user.update({
|
||||
where: {
|
||||
@@ -174,6 +174,7 @@ export class UserService {
|
||||
displayName: userDisplayName,
|
||||
email: profile.emails[0].value,
|
||||
photoURL: userPhotoURL,
|
||||
lastLoggedOn: new Date(),
|
||||
providerAccounts: {
|
||||
create: {
|
||||
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 profile Data received from SSO provider on the users account
|
||||
@@ -236,6 +237,7 @@ export class UserService {
|
||||
data: {
|
||||
displayName: !profile.displayName ? null : profile.displayName,
|
||||
photoURL: !profile.photos ? null : profile.photos[0].value,
|
||||
lastLoggedOn: new Date(),
|
||||
},
|
||||
});
|
||||
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 displayName User's displayName
|
||||
* @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
|
||||
* @param sessionData string of the session
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ExecException } from "child_process";
|
||||
import { HoppErrorCode } from "../../types/errors";
|
||||
import { runCLI, getErrorCode, getTestJsonFilePath } from "../utils";
|
||||
|
||||
describe("Test `hopp test <file>` command:", () => {
|
||||
describe("Test `hopp test <file_path_or_id>` command:", () => {
|
||||
describe("Argument parsing", () => {
|
||||
test("Errors with the code `INVALID_ARGUMENT` for not supplying enough arguments", async () => {
|
||||
const args = "test";
|
||||
@@ -131,7 +131,7 @@ describe("Test `hopp test <file>` command:", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
describe("Test `hopp test <file_path_or_id> --env <file_path_or_id>` command:", () => {
|
||||
describe("Supplied environment export file validations", () => {
|
||||
const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
|
||||
|
||||
@@ -310,7 +310,7 @@ describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test `hopp test <file> --delay <delay_in_ms>` command:", () => {
|
||||
describe("Test `hopp test <file_path_or_id> --delay <delay_in_ms>` command:", () => {
|
||||
const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
|
||||
|
||||
test("Errors with the code `INVALID_ARGUMENT` on not supplying a delay value", async () => {
|
||||
@@ -343,3 +343,116 @@ describe("Test `hopp test <file> --delay <delay_in_ms>` command:", () => {
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip("Test `hopp test <file_path_or_id> --env <file_path_or_id> --token <access_token> --server <server_url>` command:", () => {
|
||||
const {
|
||||
REQ_BODY_ENV_VARS_COLL_ID,
|
||||
COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID,
|
||||
REQ_BODY_ENV_VARS_ENVS_ID,
|
||||
PERSONAL_ACCESS_TOKEN,
|
||||
} = process.env;
|
||||
|
||||
if (
|
||||
!REQ_BODY_ENV_VARS_COLL_ID ||
|
||||
!COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID ||
|
||||
!REQ_BODY_ENV_VARS_ENVS_ID ||
|
||||
!PERSONAL_ACCESS_TOKEN
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const SERVER_URL = "https://stage-shc.hoppscotch.io/backend";
|
||||
|
||||
describe("Validations", () => {
|
||||
test("Errors with the code `INVALID_ARGUMENT` on not supplying a value for the `--token` flag", async () => {
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --token`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_ARGUMENT` on not supplying a value for the `--server` flag", async () => {
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --server`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("Errors with the code `TOKEN_INVALID` if the supplied access token is invalid", async () => {
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --token invalid-token --server ${SERVER_URL}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("TOKEN_INVALID");
|
||||
});
|
||||
|
||||
test("Errors with the code `TOKEN_INVALID` if the supplied collection ID is invalid", async () => {
|
||||
const args = `test invalid-id --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ID");
|
||||
});
|
||||
|
||||
test("Errors with the code `TOKEN_INVALID` if the supplied environment ID is invalid", async () => {
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env invalid-id --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ID");
|
||||
});
|
||||
});
|
||||
|
||||
test("Successfully retrieves a collection with the ID", async () => {
|
||||
const args = `test ${COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Successfully retrieves collections and environments from a workspace using their respective IDs", async () => {
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Supports specifying collection file path along with environment ID", async () => {
|
||||
const TESTS_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const args = `test ${TESTS_PATH} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Supports specifying environment file path along with collection ID", async () => {
|
||||
const ENV_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${ENV_PATH} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Supports specifying both collection and environment file paths", async () => {
|
||||
const TESTS_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENV_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${TESTS_PATH} --env ${ENV_PATH} --token ${PERSONAL_ACCESS_TOKEN}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import { handleError } from "../handlers/error";
|
||||
import { parseDelayOption } from "../options/test/delay";
|
||||
import { parseEnvsData } from "../options/test/env";
|
||||
import { TestCmdOptions } from "../types/commands";
|
||||
import { HoppEnvs } from "../types/request";
|
||||
import { isHoppCLIError } from "../utils/checks";
|
||||
import {
|
||||
collectionsRunner,
|
||||
collectionsRunnerExit,
|
||||
collectionsRunnerResult,
|
||||
} from "../utils/collections";
|
||||
import { handleError } from "../handlers/error";
|
||||
import { parseCollectionData } from "../utils/mutators";
|
||||
import { parseEnvsData } from "../options/test/env";
|
||||
import { TestCmdOptions } from "../types/commands";
|
||||
import { parseDelayOption } from "../options/test/delay";
|
||||
import { HoppEnvs } from "../types/request";
|
||||
import { isHoppCLIError } from "../utils/checks";
|
||||
|
||||
export const test = (path: string, options: TestCmdOptions) => async () => {
|
||||
export const test = (pathOrId: string, options: TestCmdOptions) => async () => {
|
||||
try {
|
||||
const delay = options.delay ? parseDelayOption(options.delay) : 0
|
||||
const envs = options.env ? await parseEnvsData(options.env) : <HoppEnvs>{ global: [], selected: [] }
|
||||
const collections = await parseCollectionData(path)
|
||||
const delay = options.delay ? parseDelayOption(options.delay) : 0;
|
||||
|
||||
const report = await collectionsRunner({collections, envs, delay})
|
||||
const hasSucceeded = collectionsRunnerResult(report)
|
||||
collectionsRunnerExit(hasSucceeded)
|
||||
} catch(e) {
|
||||
if(isHoppCLIError(e)) {
|
||||
handleError(e)
|
||||
const envs = options.env
|
||||
? await parseEnvsData(options.env, options.token, options.server)
|
||||
: <HoppEnvs>{ global: [], selected: [] };
|
||||
|
||||
const collections = await parseCollectionData(
|
||||
pathOrId,
|
||||
options.token,
|
||||
options.server
|
||||
);
|
||||
|
||||
const report = await collectionsRunner({ collections, envs, delay });
|
||||
const hasSucceeded = collectionsRunnerResult(report);
|
||||
|
||||
collectionsRunnerExit(hasSucceeded);
|
||||
} catch (e) {
|
||||
if (isHoppCLIError(e)) {
|
||||
handleError(e);
|
||||
process.exit(1);
|
||||
}
|
||||
else throw e
|
||||
} else throw e;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,6 +82,15 @@ export const handleError = <T extends HoppErrorCode>(error: HoppError<T>) => {
|
||||
case "TESTS_FAILING":
|
||||
ERROR_MSG = error.data;
|
||||
break;
|
||||
case "TOKEN_EXPIRED":
|
||||
ERROR_MSG = `Token is expired: ${error.data}`;
|
||||
break;
|
||||
case "TOKEN_INVALID":
|
||||
ERROR_MSG = `Token is invalid/removed: ${error.data}`;
|
||||
break;
|
||||
case "INVALID_ID":
|
||||
ERROR_MSG = `The collection/environment is not valid/not accessible to the user: ${error.data}`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!S.isEmpty(ERROR_MSG)) {
|
||||
|
||||
@@ -49,14 +49,22 @@ program.exitOverride().configureOutput({
|
||||
program
|
||||
.command("test")
|
||||
.argument(
|
||||
"<file_path>",
|
||||
"path to a hoppscotch collection.json file for CI testing"
|
||||
"<file_path_or_id>",
|
||||
"path to a hoppscotch collection.json file or collection ID from a workspace for CI testing"
|
||||
)
|
||||
.option(
|
||||
"-e, --env <file_path_or_id>",
|
||||
"path to an environment variables json file or environment ID from a workspace"
|
||||
)
|
||||
.option("-e, --env <file_path>", "path to an environment variables json file")
|
||||
.option(
|
||||
"-d, --delay <delay_in_ms>",
|
||||
"delay in milliseconds(ms) between consecutive requests within a collection"
|
||||
)
|
||||
.option(
|
||||
"--token <access_token>",
|
||||
"personal access token to access collections/environments from a workspace"
|
||||
)
|
||||
.option("--server <server_url>", "server URL for SH instance")
|
||||
.allowExcessArguments(false)
|
||||
.allowUnknownOption(false)
|
||||
.description("running hoppscotch collection.json file")
|
||||
@@ -66,7 +74,7 @@ program
|
||||
"https://docs.hoppscotch.io/documentation/clients/cli#commands"
|
||||
)}`
|
||||
)
|
||||
.action(async (path, options) => await test(path, options)());
|
||||
.action(async (pathOrId, options) => await test(pathOrId, options)());
|
||||
|
||||
export const cli = async (args: string[]) => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Environment } from "@hoppscotch/data";
|
||||
import { Environment, EnvironmentSchemaVersion } from "@hoppscotch/data";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import fs from "fs/promises";
|
||||
import { entityReference } from "verzod";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -10,13 +12,94 @@ import {
|
||||
} from "../../types/request";
|
||||
import { readJsonFile } from "../../utils/mutators";
|
||||
|
||||
interface WorkspaceEnvironment {
|
||||
id: string;
|
||||
teamID: string;
|
||||
name: string;
|
||||
variables: HoppEnvPair[];
|
||||
}
|
||||
|
||||
// Helper functions to transform workspace environment data to the `HoppEnvironment` format
|
||||
const transformWorkspaceEnvironment = (
|
||||
workspaceEnvironment: WorkspaceEnvironment
|
||||
): Environment => {
|
||||
const { teamID, variables, ...rest } = workspaceEnvironment;
|
||||
|
||||
// Add `secret` field if the data conforms to an older schema
|
||||
const transformedEnvVars = variables.map((variable) => {
|
||||
if (!("secret" in variable)) {
|
||||
return {
|
||||
...(variable as HoppEnvPair),
|
||||
secret: false,
|
||||
} as HoppEnvPair;
|
||||
}
|
||||
|
||||
return variable;
|
||||
});
|
||||
|
||||
return {
|
||||
v: EnvironmentSchemaVersion,
|
||||
variables: transformedEnvVars,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses env json file for given path and validates the parsed env json object
|
||||
* @param path Path of env.json file to be parsed
|
||||
* @param pathOrId Path of env.json file to be parsed
|
||||
* @param [accessToken] Personal access token to fetch workspace environments
|
||||
* @param [serverUrl] server URL for SH instance
|
||||
* @returns For successful parsing we get HoppEnvs object
|
||||
*/
|
||||
export async function parseEnvsData(path: string) {
|
||||
const contents = await readJsonFile(path);
|
||||
export async function parseEnvsData(
|
||||
pathOrId: string,
|
||||
accessToken?: string,
|
||||
serverUrl?: string
|
||||
) {
|
||||
let contents = null;
|
||||
let fileExistsInPath = null;
|
||||
|
||||
try {
|
||||
await fs.access(pathOrId);
|
||||
fileExistsInPath = true;
|
||||
} catch (e) {
|
||||
fileExistsInPath = false;
|
||||
}
|
||||
|
||||
if (accessToken && !fileExistsInPath) {
|
||||
try {
|
||||
const hostname = serverUrl || "https://api.hoppscotch.io";
|
||||
const url = `${hostname.endsWith("/") ? hostname.slice(0, -1) : hostname}/v1/access-tokens/environment/${pathOrId}`;
|
||||
|
||||
const { data }: { data: WorkspaceEnvironment } = await axios.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
contents = transformWorkspaceEnvironment(data);
|
||||
} catch (err) {
|
||||
const errReason = (
|
||||
err as AxiosError<{
|
||||
reason?: any;
|
||||
message: string;
|
||||
error: string;
|
||||
statusCode: number;
|
||||
}>
|
||||
).response?.data?.reason;
|
||||
|
||||
if (errReason) {
|
||||
throw error({
|
||||
code: errReason,
|
||||
data: pathOrId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contents === null) {
|
||||
contents = await readJsonFile(pathOrId);
|
||||
}
|
||||
const envPairs: Array<HoppEnvPair | Record<string, string>> = [];
|
||||
|
||||
// The legacy key-value pair format that is still supported
|
||||
@@ -33,7 +116,7 @@ export async function parseEnvsData(path: string) {
|
||||
// CLI doesnt support bulk environments export
|
||||
// Hence we check for this case and throw an error if it matches the format
|
||||
if (HoppBulkEnvExportObjectResult.success) {
|
||||
throw error({ code: "BULK_ENV_FILE", path, data: error });
|
||||
throw error({ code: "BULK_ENV_FILE", path: pathOrId, data: error });
|
||||
}
|
||||
|
||||
// Checks if the environment file is of the correct format
|
||||
@@ -42,7 +125,7 @@ export async function parseEnvsData(path: string) {
|
||||
!HoppEnvKeyPairResult.success &&
|
||||
HoppEnvExportObjectResult.type === "err"
|
||||
) {
|
||||
throw error({ code: "MALFORMED_ENV_FILE", path, data: error });
|
||||
throw error({ code: "MALFORMED_ENV_FILE", path: pathOrId, data: error });
|
||||
}
|
||||
|
||||
if (HoppEnvKeyPairResult.success) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type TestCmdOptions = {
|
||||
env: string | undefined;
|
||||
delay: string | undefined;
|
||||
token: string | undefined;
|
||||
server: string | undefined;
|
||||
};
|
||||
|
||||
export type HOPP_ENV_FILE_EXT = "json";
|
||||
|
||||
@@ -26,6 +26,9 @@ type HoppErrors = {
|
||||
MALFORMED_ENV_FILE: HoppErrorPath & HoppErrorData;
|
||||
BULK_ENV_FILE: HoppErrorPath & HoppErrorData;
|
||||
INVALID_FILE_TYPE: HoppErrorData;
|
||||
TOKEN_EXPIRED: HoppErrorData;
|
||||
TOKEN_INVALID: HoppErrorData;
|
||||
INVALID_ID: HoppErrorData;
|
||||
};
|
||||
|
||||
export type HoppErrorCode = keyof HoppErrors;
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
|
||||
import {
|
||||
CollectionSchemaVersion,
|
||||
HoppCollection,
|
||||
HoppRESTRequest,
|
||||
} from "@hoppscotch/data";
|
||||
import fs from "fs/promises";
|
||||
import { entityReference } from "verzod";
|
||||
import { z } from "zod";
|
||||
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { error } from "../types/errors";
|
||||
import { FormDataEntry } from "../types/request";
|
||||
import { isHoppErrnoException } from "./checks";
|
||||
|
||||
interface WorkspaceCollection {
|
||||
id: string;
|
||||
data: string | null;
|
||||
title: string;
|
||||
parentID: string | null;
|
||||
folders: WorkspaceCollection[];
|
||||
requests: WorkspaceRequest[];
|
||||
}
|
||||
|
||||
interface WorkspaceRequest {
|
||||
id: string;
|
||||
collectionID: string;
|
||||
teamID: string;
|
||||
title: string;
|
||||
request: string;
|
||||
}
|
||||
|
||||
const getValidRequests = (
|
||||
collections: HoppCollection[],
|
||||
collectionFilePath: string
|
||||
@@ -42,6 +64,50 @@ const getValidRequests = (
|
||||
});
|
||||
};
|
||||
|
||||
// Helper functions to transform workspace collection data to the `HoppCollection` format
|
||||
const transformWorkspaceRequests = (requests: WorkspaceRequest[]) =>
|
||||
requests.map(({ request }) => JSON.parse(request));
|
||||
|
||||
const transformChildCollections = (
|
||||
childCollections: WorkspaceCollection[]
|
||||
): HoppCollection[] => {
|
||||
return childCollections.map(({ id, title, data, folders, requests }) => {
|
||||
const parsedData = data ? JSON.parse(data) : {};
|
||||
const { auth = { authType: "inherit", authActive: false }, headers = [] } =
|
||||
parsedData;
|
||||
|
||||
return {
|
||||
v: CollectionSchemaVersion,
|
||||
id,
|
||||
name: title,
|
||||
folders: transformChildCollections(folders),
|
||||
requests: transformWorkspaceRequests(requests),
|
||||
auth,
|
||||
headers,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const transformWorkspaceCollection = (
|
||||
collection: WorkspaceCollection
|
||||
): HoppCollection => {
|
||||
const { id, title, data, requests, folders } = collection;
|
||||
|
||||
const parsedData = data ? JSON.parse(data) : {};
|
||||
const { auth = { authType: "inherit", authActive: false }, headers = [] } =
|
||||
parsedData;
|
||||
|
||||
return {
|
||||
v: CollectionSchemaVersion,
|
||||
id,
|
||||
name: title,
|
||||
folders: transformChildCollections(folders),
|
||||
requests: transformWorkspaceRequests(requests),
|
||||
auth,
|
||||
headers,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses array of FormDataEntry to FormData.
|
||||
* @param values Array of FormDataEntry.
|
||||
@@ -92,16 +158,64 @@ export async function readJsonFile(path: string): Promise<unknown> {
|
||||
|
||||
/**
|
||||
* Parses collection json file for given path:context.path, and validates
|
||||
* the parsed collectiona array.
|
||||
* @param path Collection json file path.
|
||||
* @returns For successful parsing we get array of HoppCollection,
|
||||
* the parsed collectiona array
|
||||
* @param pathOrId Collection json file path
|
||||
* @param [accessToken] Personal access token to fetch workspace environments
|
||||
* @param [serverUrl] server URL for SH instance
|
||||
* @returns For successful parsing we get array of HoppCollection
|
||||
*/
|
||||
export async function parseCollectionData(
|
||||
path: string
|
||||
pathOrId: string,
|
||||
accessToken?: string,
|
||||
serverUrl?: string
|
||||
): Promise<HoppCollection[]> {
|
||||
let contents = await readJsonFile(path);
|
||||
let contents = null;
|
||||
let fileExistsInPath = null;
|
||||
|
||||
const maybeArrayOfCollections: unknown[] = Array.isArray(contents)
|
||||
try {
|
||||
await fs.access(pathOrId);
|
||||
fileExistsInPath = true;
|
||||
} catch (e) {
|
||||
fileExistsInPath = false;
|
||||
}
|
||||
|
||||
if (accessToken && !fileExistsInPath) {
|
||||
try {
|
||||
const hostname = serverUrl || "https://api.hoppscotch.io";
|
||||
const url = `${hostname.endsWith("/") ? hostname.slice(0, -1) : hostname}/v1/access-tokens/collection/${pathOrId}`;
|
||||
|
||||
const { data }: { data: WorkspaceCollection } = await axios.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
contents = transformWorkspaceCollection(data);
|
||||
} catch (err) {
|
||||
const errReason = (
|
||||
err as AxiosError<{
|
||||
reason?: any;
|
||||
message: string;
|
||||
error: string;
|
||||
statusCode: number;
|
||||
}>
|
||||
).response?.data?.reason;
|
||||
|
||||
if (errReason) {
|
||||
throw error({
|
||||
code: errReason,
|
||||
data: pathOrId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to reading from file if contents are not available
|
||||
if (contents === null) {
|
||||
contents = await readJsonFile(pathOrId);
|
||||
}
|
||||
|
||||
const maybeArrayOfCollections: HoppCollection[] = Array.isArray(contents)
|
||||
? contents
|
||||
: [contents];
|
||||
|
||||
@@ -110,12 +224,14 @@ export async function parseCollectionData(
|
||||
.safeParse(maybeArrayOfCollections);
|
||||
|
||||
if (!collectionSchemaParsedResult.success) {
|
||||
console.error(`Error is `, collectionSchemaParsedResult.error);
|
||||
|
||||
throw error({
|
||||
code: "MALFORMED_COLLECTION",
|
||||
path,
|
||||
path: pathOrId,
|
||||
data: "Please check the collection data.",
|
||||
});
|
||||
}
|
||||
|
||||
return getValidRequests(collectionSchemaParsedResult.data, path);
|
||||
return getValidRequests(collectionSchemaParsedResult.data, pathOrId);
|
||||
}
|
||||
|
||||
@@ -374,7 +374,8 @@
|
||||
"mutations": "Mutations",
|
||||
"schema": "Schema",
|
||||
"subscriptions": "Subscriptions",
|
||||
"switch_connection": "Switch connection"
|
||||
"switch_connection": "Switch connection",
|
||||
"url_placeholder": "Enter a GraphQL endpoint URL"
|
||||
},
|
||||
"graphql_collections": {
|
||||
"title": "GraphQL Collections"
|
||||
@@ -598,6 +599,7 @@
|
||||
"title": "Request",
|
||||
"type": "Request type",
|
||||
"url": "URL",
|
||||
"url_placeholder": "Enter a URL or paste a cURL command",
|
||||
"variables": "Variables",
|
||||
"view_my_links": "View my links"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,12 +11,13 @@
|
||||
"connect": "Подключиться",
|
||||
"connecting": "Соединение...",
|
||||
"copy": "Скопировать",
|
||||
"create": "Create",
|
||||
"create": "Создать",
|
||||
"delete": "Удалить",
|
||||
"disconnect": "Отключиться",
|
||||
"dismiss": "Скрыть",
|
||||
"dont_save": "Не сохранять",
|
||||
"download_file": "Скачать файл",
|
||||
"download_here": "Download here",
|
||||
"drag_to_reorder": "Перетягивайте для сортировки",
|
||||
"duplicate": "Дублировать",
|
||||
"edit": "Редактировать",
|
||||
@@ -24,6 +25,7 @@
|
||||
"go_back": "Вернуться",
|
||||
"go_forward": "Вперёд",
|
||||
"group_by": "Сгруппировать по",
|
||||
"hide_secret": "Hide secret",
|
||||
"label": "Название",
|
||||
"learn_more": "Узнать больше",
|
||||
"less": "Меньше",
|
||||
@@ -33,7 +35,7 @@
|
||||
"open_workspace": "Открыть пространство",
|
||||
"paste": "Вставить",
|
||||
"prettify": "Форматировать",
|
||||
"properties": "Properties",
|
||||
"properties": "Параметры",
|
||||
"remove": "Удалить",
|
||||
"rename": "Переименовать",
|
||||
"restore": "Восстановить",
|
||||
@@ -42,13 +44,14 @@
|
||||
"scroll_to_top": "Вверх",
|
||||
"search": "Поиск",
|
||||
"send": "Отправить",
|
||||
"share": "Share",
|
||||
"share": "Поделиться",
|
||||
"show_secret": "Show secret",
|
||||
"start": "Начать",
|
||||
"starting": "Запускаю",
|
||||
"stop": "Стоп",
|
||||
"to_close": "что бы закрыть",
|
||||
"to_close": "закрыть",
|
||||
"to_navigate": "для навигации",
|
||||
"to_select": "выборать",
|
||||
"to_select": "выбрать",
|
||||
"turn_off": "Выключить",
|
||||
"turn_on": "Включить",
|
||||
"undo": "Отменить",
|
||||
@@ -66,12 +69,12 @@
|
||||
"copy_interface_type": "Copy interface type",
|
||||
"copy_user_id": "Копировать токен пользователя",
|
||||
"developer_option": "Настройки разработчика",
|
||||
"developer_option_description": "Инструмент разработчика помогает обслуживить и развивить Hoppscotch",
|
||||
"developer_option_description": "Инструмент разработчика помогает обслуживать и развивать Hoppscotch",
|
||||
"discord": "Discord",
|
||||
"documentation": "Документация",
|
||||
"github": "GitHub",
|
||||
"help": "Справка, отзывы и документация",
|
||||
"home": "Дом",
|
||||
"home": "На главную",
|
||||
"invite": "Пригласить",
|
||||
"invite_description": "В Hoppscotch мы разработали простой и интуитивно понятный интерфейс для создания и управления вашими API. Hoppscotch - это инструмент, который помогает создавать, тестировать, документировать и делиться своими API.",
|
||||
"invite_your_friends": "Пригласить своих друзей",
|
||||
@@ -85,7 +88,7 @@
|
||||
"reload": "Перезагрузить",
|
||||
"search": "Поиск",
|
||||
"share": "Поделиться",
|
||||
"shortcuts": "Ярлыки",
|
||||
"shortcuts": "Горячие клавиши",
|
||||
"social_description": "Подписывайся на наши соц. сети и оставайся всегда в курсе последних новостей, обновлений и релизов.",
|
||||
"social_links": "Социальные сети",
|
||||
"spotlight": "Прожектор",
|
||||
@@ -96,17 +99,19 @@
|
||||
"type_a_command_search": "Введите команду или выполните поиск…",
|
||||
"we_use_cookies": "Мы используем куки",
|
||||
"whats_new": "Что нового?",
|
||||
"wiki": "Вики"
|
||||
"wiki": "Узнать больше"
|
||||
},
|
||||
"auth": {
|
||||
"account_exists": "Учетная запись существует с разными учетными данными - войдите, чтобы связать обе учетные записи",
|
||||
"all_sign_in_options": "Все варианты входа",
|
||||
"continue_with_auth_provider": "Continue with {provider}",
|
||||
"continue_with_email": "Продолжить с электронной почтой",
|
||||
"continue_with_github": "Продолжить с GitHub",
|
||||
"continue_with_github_enterprise": "Continue with GitHub Enterprise",
|
||||
"continue_with_google": "Продолжить с Google",
|
||||
"continue_with_microsoft": "Продолжить с Microsoft",
|
||||
"email": "Электронное письмо",
|
||||
"logged_out": "Вышли из",
|
||||
"logged_out": "Успешно вышли. Будем скучать!",
|
||||
"login": "Авторизоваться",
|
||||
"login_success": "Успешный вход в систему",
|
||||
"login_to_hoppscotch": "Войти в Hoppscotch",
|
||||
@@ -121,7 +126,7 @@
|
||||
"generate_token": "Сгенерировать токен",
|
||||
"graphql_headers": "Authorization Headers are sent as part of the payload to connection_init",
|
||||
"include_in_url": "Добавить в URL",
|
||||
"inherited_from": "Inherited {auth} from parent collection {collection} ",
|
||||
"inherited_from": "Унаследован тип аутентификации {auth} из родительской коллекции {collection}",
|
||||
"learn": "Узнать больше",
|
||||
"oauth": {
|
||||
"redirect_auth_server_returned_error": "Auth Server returned an error state",
|
||||
@@ -135,11 +140,32 @@
|
||||
"redirect_no_token_endpoint": "No Token Endpoint Defined",
|
||||
"something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect",
|
||||
"something_went_wrong_on_token_generation": "Something went wrong on token generation",
|
||||
"token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed"
|
||||
"token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed",
|
||||
"grant_type": "Grant Type",
|
||||
"grant_type_auth_code": "Authorization Code",
|
||||
"token_fetched_successfully": "Token fetched successfully",
|
||||
"token_fetch_failed": "Failed to fetch token",
|
||||
"validation_failed": "Validation Failed, please check the form fields",
|
||||
"label_authorization_endpoint": "Authorization Endpoint",
|
||||
"label_client_id": "Client ID",
|
||||
"label_client_secret": "Client Secret",
|
||||
"label_code_challenge": "Code Challenge",
|
||||
"label_code_challenge_method": "Code Challenge Method",
|
||||
"label_code_verifier": "Code Verifier",
|
||||
"label_scopes": "Scopes",
|
||||
"label_token_endpoint": "Token Endpoint",
|
||||
"label_use_pkce": "Use PKCE",
|
||||
"label_implicit": "Implicit",
|
||||
"label_password": "Password",
|
||||
"label_username": "Username",
|
||||
"label_auth_code": "Authorization Code",
|
||||
"label_client_credentials": "Client Credentials"
|
||||
},
|
||||
"pass_by_headers_label": "Headers",
|
||||
"pass_by_query_params_label": "Query Parameters",
|
||||
"pass_key_by": "Pass by",
|
||||
"password": "Пароль",
|
||||
"save_to_inherit": "Please save this request in any collection to inherit the authorization",
|
||||
"save_to_inherit": "Чтобы унаследовать аутентификации, нужно сохранить запрос в коллекции",
|
||||
"token": "Токен",
|
||||
"type": "Метод авторизации",
|
||||
"username": "Имя пользователя"
|
||||
@@ -149,6 +175,7 @@
|
||||
"different_parent": "Нельзя сортировать коллекцию с разной родительской коллекцией",
|
||||
"edit": "Редактировать коллекцию",
|
||||
"import_or_create": "Вы можете импортировать существующую или создать новую коллекцию",
|
||||
"import_collection": "Импортировать коллекцию",
|
||||
"invalid_name": "Укажите допустимое название коллекции",
|
||||
"invalid_root_move": "Коллекция уже в корне",
|
||||
"moved": "Перемещено успешно",
|
||||
@@ -157,38 +184,36 @@
|
||||
"name_length_insufficient": "Имя коллекции должно иметь 3 или более символов",
|
||||
"new": "Создать коллекцию",
|
||||
"order_changed": "Порядок коллекции обновлён",
|
||||
"properties": "Collection Properties",
|
||||
"properties_updated": "Collection Properties Updated",
|
||||
"properties": "Параметры коллекции",
|
||||
"properties_updated": "Параметры коллекции обновлены",
|
||||
"renamed": "Коллекция переименована",
|
||||
"request_in_use": "Запрос обрабатывается",
|
||||
"save_as": "Сохранить как",
|
||||
"save_to_collection": "Сохранить в коллекцию",
|
||||
"select": "Выбрать коллекцию",
|
||||
"select_location": "Выберите местоположение",
|
||||
"select_team": "Выберите команду",
|
||||
"team_collections": "Коллекции команд"
|
||||
"select_location": "Выберите местоположение"
|
||||
},
|
||||
"confirm": {
|
||||
"close_unsaved_tab": "Вы уверены что хотите закрыть эту вкладку?",
|
||||
"close_unsaved_tabs": "Вы уверены что хотите закрыть все эти вкладки? Несохранённые данные {count} вкладок будут утеряны.",
|
||||
"close_unsaved_tab": "Вы уверены, что хотите закрыть эту вкладку?",
|
||||
"close_unsaved_tabs": "Вы уверены, что хотите закрыть все эти вкладки? Несохранённые данные {count} вкладок будут утеряны.",
|
||||
"exit_team": "Вы точно хотите покинуть эту команду?",
|
||||
"logout": "Вы действительно хотите выйти?",
|
||||
"remove_collection": "Вы уверены, что хотите навсегда удалить эту коллекцию?",
|
||||
"remove_environment": "Вы действительно хотите удалить эту среду без возможности восстановления?",
|
||||
"remove_environment": "Вы действительно хотите удалить это окружение без возможности восстановления?",
|
||||
"remove_folder": "Вы уверены, что хотите навсегда удалить эту папку?",
|
||||
"remove_history": "Вы уверены, что хотите навсегда удалить всю историю?",
|
||||
"remove_request": "Вы уверены, что хотите навсегда удалить этот запрос?",
|
||||
"remove_shared_request": "Are you sure you want to permanently delete this shared request?",
|
||||
"remove_shared_request": "Вы уверены, что хотите навсегда удалить этот запрос?",
|
||||
"remove_team": "Вы уверены, что хотите удалить эту команду?",
|
||||
"remove_telemetry": "Вы действительно хотите отказаться от телеметрии?",
|
||||
"request_change": "Вы уверены что хотите сбросить текущий запрос, все не сохранённые данные будт утеряны?",
|
||||
"request_change": "Вы уверены, что хотите сбросить текущий запрос, все не сохранённые данные будт утеряны?",
|
||||
"save_unsaved_tab": "Вы хотите сохранить изменения в этой вкладке?",
|
||||
"sync": "Вы уверены, что хотите синхронизировать это рабочее пространство?"
|
||||
},
|
||||
"context_menu": {
|
||||
"add_parameters": "Add to parameters",
|
||||
"open_request_in_new_tab": "Open request in new tab",
|
||||
"set_environment_variable": "Set as variable"
|
||||
"add_parameters": "Добавить в список параметров",
|
||||
"open_request_in_new_tab": "Открыть запрос в новом окне",
|
||||
"set_environment_variable": "Добавить значение в переменную"
|
||||
},
|
||||
"cookies": {
|
||||
"modal": {
|
||||
@@ -227,24 +252,25 @@
|
||||
"collections": "Коллекции пустые",
|
||||
"documentation": "Подключите GraphQL endpoint, чтобы увидеть документацию.",
|
||||
"endpoint": "Endpoint не может быть пустым",
|
||||
"environments": "Окружения пусты",
|
||||
"environments": "Переменных окружения нет",
|
||||
"folder": "Папка пуста",
|
||||
"headers": "У этого запроса нет заголовков",
|
||||
"history": "История пуста",
|
||||
"invites": "Вы еще никого не приглашали",
|
||||
"members": "В этой команде еще нет участников",
|
||||
"parameters": "Этот запрос не имеет параметров",
|
||||
"parameters": "Этот запрос не содержит параметров",
|
||||
"pending_invites": "Пока что нет ожидающих заявок на вступление в команду",
|
||||
"profile": "Войдите, чтобы просмотреть свой профиль",
|
||||
"protocols": "Протоколы пустые",
|
||||
"request_variables": "Этот запрос не содержит никаких переменных",
|
||||
"secret_environments": "Секреты хранятся только на этом устройстве и не синхронизируются с сервером",
|
||||
"schema": "Подключиться к конечной точке GraphQL",
|
||||
"shared_requests": "Shared requests are empty",
|
||||
"shared_requests_logout": "Login to view your shared requests or create a new one",
|
||||
"shared_requests": "Вы еще не делились запросами с другими",
|
||||
"shared_requests_logout": "Нужно войти, чтобы делиться запросами и управлять ими",
|
||||
"subscription": "Нет подписок",
|
||||
"team_name": "Название команды пусто",
|
||||
"teams": "Команды пустые",
|
||||
"tests": "Для этого запроса нет тестов",
|
||||
"shortcodes": "Нет коротких ссылок"
|
||||
"tests": "Для этого запроса нет тестов"
|
||||
},
|
||||
"environment": {
|
||||
"add_to_global": "Добавить в глобальное окружение",
|
||||
@@ -252,53 +278,57 @@
|
||||
"create_new": "Создать новое окружение",
|
||||
"created": "Окружение создано",
|
||||
"deleted": "Окружение удалено",
|
||||
"duplicated": "Environment duplicated",
|
||||
"duplicated": "Окружение продублировано",
|
||||
"edit": "Редактировать окружение",
|
||||
"empty_variables": "No variables",
|
||||
"empty_variables": "Переменные еще не добавлены",
|
||||
"global": "Global",
|
||||
"global_variables": "Global variables",
|
||||
"global_variables": "Глобальные переменные",
|
||||
"import_or_create": "Импортировать или создать новое окружение",
|
||||
"invalid_name": "Укажите допустимое имя для окружения",
|
||||
"list": "Переменные окружения",
|
||||
"my_environments": "Мои окружения",
|
||||
"name": "Name",
|
||||
"name": "Имя",
|
||||
"nested_overflow": "максимальный уровень вложения переменных окружения - 10",
|
||||
"new": "Новая среда",
|
||||
"no_active_environment": "Нет активных окружений",
|
||||
"no_environment": "Нет окружения",
|
||||
"no_environment_description": "Не выбрано окружение, выберите что делать с переменными.",
|
||||
"quick_peek": "Environment Quick Peek",
|
||||
"quick_peek": "Быстрый просмотр переменных",
|
||||
"replace_with_variable": "Replace with variable",
|
||||
"scope": "Scope",
|
||||
"secrets": "Секретные переменные",
|
||||
"secret_value": "Секретное значение",
|
||||
"select": "Выберите среду",
|
||||
"set": "Set environment",
|
||||
"set_as_environment": "Set as environment",
|
||||
"set": "Выбрать окружение",
|
||||
"set_as_environment": "Поместить значение в переменную",
|
||||
"team_environments": "Окружения команды",
|
||||
"title": "Окружения",
|
||||
"updated": "Окружение обновлено",
|
||||
"value": "Value",
|
||||
"variable": "Variable",
|
||||
"value": "Значение",
|
||||
"variable": "Переменная",
|
||||
"variables": "Переменные",
|
||||
"variable_list": "Список переменных"
|
||||
},
|
||||
"error": {
|
||||
"authproviders_load_error": "Unable to load auth providers",
|
||||
"browser_support_sse": "Похоже, в этом браузере нет поддержки событий, отправленных сервером.",
|
||||
"check_console_details": "Подробности смотрите в журнале консоли.",
|
||||
"check_how_to_add_origin": "Инструкция как добавить origin в настройки расширения",
|
||||
"check_how_to_add_origin": "Инструкция как это сделать",
|
||||
"curl_invalid_format": "cURL неправильно отформатирован",
|
||||
"danger_zone": "Опасная зона",
|
||||
"delete_account": "Вы являетесь владельцем этой команды:",
|
||||
"delete_account_description": "Прежде чем удалить аккаунт вам необходимо либо назначить владельцом другого пользователя, либо удалить команды в которых вы являетесь владельцем.",
|
||||
"empty_profile_name": "Имя пользователя не может быть пустым",
|
||||
"empty_req_name": "Пустое имя запроса",
|
||||
"f12_details": "(F12 для подробностей)",
|
||||
"gql_prettify_invalid_query": "Не удалось определить недопустимый запрос, устранить синтаксические ошибки запроса и повторить попытку.",
|
||||
"gql_prettify_invalid_query": "Не удалось отформатировать, т.к. в запросе есть синтаксические ошибки. Устраните их и повторите попытку.",
|
||||
"incomplete_config_urls": "Не заполнены URL конфигурации",
|
||||
"incorrect_email": "Не корректный Email",
|
||||
"invalid_link": "Не корректная ссылка",
|
||||
"invalid_link_description": "Ссылка, по которой вы перешли, - недействительна, либо срок ее действия истек.",
|
||||
"invalid_embed_link": "The embed does not exist or is invalid.",
|
||||
"json_parsing_failed": "Не корректный JSON",
|
||||
"json_prettify_invalid_body": "Не удалось определить недопустимое тело, устранить синтаксические ошибки json и повторить попытку.",
|
||||
"json_prettify_invalid_body": "Не удалось определить формат строки, устраните синтаксические ошибки и повторите попытку.",
|
||||
"network_error": "Похоже, возникла проблема с соединением. Попробуйте еще раз.",
|
||||
"network_fail": "Не удалось отправить запрос",
|
||||
"no_collections_to_export": "Нечего экспортировать. Для начала нужно создать коллекцию.",
|
||||
@@ -306,8 +336,10 @@
|
||||
"no_environments_to_export": "Нечего экспортировать. Для начала нужно создать переменные окружения.",
|
||||
"no_results_found": "Совпадения не найдены",
|
||||
"page_not_found": "Эта страница не найдена",
|
||||
"please_install_extension": "Нужно установить специальное расширение и добавить этот домен как новый origin в настройках расширения.",
|
||||
"please_install_extension": "Ничего страшного. Просто нужно установить специальное расширение в браузере.",
|
||||
"proxy_error": "Proxy error",
|
||||
"reading_files": "Произошла ошибка при чтении файла или нескольких файлов",
|
||||
"same_profile_name": "Задано имя пользователя такое же как и было",
|
||||
"script_fail": "Не удалось выполнить сценарий предварительного запроса",
|
||||
"something_went_wrong": "Что-то пошло не так",
|
||||
"test_script_fail": "Не удалось выполнить тестирование запроса"
|
||||
@@ -315,13 +347,12 @@
|
||||
"export": {
|
||||
"as_json": "Экспорт как JSON",
|
||||
"create_secret_gist": "Создать секретный Gist",
|
||||
"create_secret_gist_tooltip_text": "Export as secret Gist",
|
||||
"failed": "Something went wrong while exporting",
|
||||
"secret_gist_success": "Successfully exported as secret Gist",
|
||||
"create_secret_gist_tooltip_text": "Экспортировать как секретный Gist",
|
||||
"failed": "Произошла ошибка во время экспорта",
|
||||
"secret_gist_success": "Успешно экспортировано как секретный Gist",
|
||||
"require_github": "Войдите через GitHub, чтобы создать секретную суть",
|
||||
"title": "Экспорт",
|
||||
"success": "Successfully exported",
|
||||
"gist_created": "Gist создан"
|
||||
"success": "Успешно экспортировано"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Все",
|
||||
@@ -346,7 +377,7 @@
|
||||
"switch_connection": "Изменить соединение"
|
||||
},
|
||||
"graphql_collections": {
|
||||
"title": "GraphQL Collections"
|
||||
"title": "Коллекции GraphQL"
|
||||
},
|
||||
"group": {
|
||||
"time": "Время",
|
||||
@@ -359,8 +390,8 @@
|
||||
},
|
||||
"helpers": {
|
||||
"authorization": "Заголовок авторизации будет автоматически сгенерирован при отправке запроса.",
|
||||
"collection_properties_authorization": " This authorization will be set for every request in this collection.",
|
||||
"collection_properties_header": "This header will be set for every request in this collection.",
|
||||
"collection_properties_authorization": "Этот заголовок авторизации будет подставляться при каждом запросе в этой коллекции.",
|
||||
"collection_properties_header": "Этот заголовок будет подставляться при каждом запросе в этой коллекции.",
|
||||
"generate_documentation_first": "Сначала создайте документацию",
|
||||
"network_fail": "Невозможно достичь конечной точки API. Проверьте подключение к сети и попробуйте еще раз.",
|
||||
"offline": "Кажется, вы не в сети. Данные в этой рабочей области могут быть устаревшими.",
|
||||
@@ -380,10 +411,12 @@
|
||||
"import": {
|
||||
"collections": "Импортировать коллекции",
|
||||
"curl": "Импортировать из cURL",
|
||||
"environments_from_gist": "Import From Gist",
|
||||
"environments_from_gist_description": "Import Hoppscotch Environments From Gist",
|
||||
"environments_from_gist": "Импортировать из Gist",
|
||||
"environments_from_gist_description": "Импортировать переменные окружения Hoppscotch из Gist",
|
||||
"failed": "Ошибка импорта",
|
||||
"from_file": "Import from File",
|
||||
"file_size_limit_exceeded_warning_multiple_files": "Выбранные файлы превышают рекомендованный лимит в 10MB. Были импортированы только первые {files}",
|
||||
"file_size_limit_exceeded_warning_single_file": "Размер выбранного в данный момент файла превышает рекомендуемый лимит в 10 МБ. Пожалуйста, выберите другой файл.",
|
||||
"from_file": "Импортировать из одного или нескольких файлов",
|
||||
"from_gist": "Импорт из Gist",
|
||||
"from_gist_description": "Импортировать через Gist URL",
|
||||
"from_insomnia": "Импортировать с Insomnia",
|
||||
@@ -398,9 +431,9 @@
|
||||
"from_postman_description": "Импортировать из коллекции Postman",
|
||||
"from_url": "Импортировать из URL",
|
||||
"gist_url": "Введите URL-адрес Gist",
|
||||
"gql_collections_from_gist_description": "Import GraphQL Collections From Gist",
|
||||
"gql_collections_from_gist_description": "Импортировать GraphQL коллекцию из Gist",
|
||||
"hoppscotch_environment": "Hoppscotch Environment",
|
||||
"hoppscotch_environment_description": "Import Hoppscotch Environment JSON file",
|
||||
"hoppscotch_environment_description": "Импортировать окружение Hoppscotch из JSON файла",
|
||||
"import_from_url_invalid_fetch": "Не удалить получить данные по этому URL",
|
||||
"import_from_url_invalid_file_format": "Ошибка при импорте коллекций",
|
||||
"import_from_url_invalid_type": "Неподдерживаемый тип. Поддерживаемые типы: 'hoppscotch', 'openapi', 'postman', 'insomnia'",
|
||||
@@ -409,16 +442,19 @@
|
||||
"json_description": "Импортировать из коллекции Hoppscotch",
|
||||
"postman_environment": "Postman Environment",
|
||||
"postman_environment_description": "Import Postman Environment from a JSON file",
|
||||
"success": "Успешно импортировано",
|
||||
"title": "Импортировать"
|
||||
},
|
||||
"inspections": {
|
||||
"description": "Inspect possible errors",
|
||||
"description": "Показать возможные ошибки",
|
||||
"environment": {
|
||||
"add_environment": "Add to Environment",
|
||||
"not_found": "Environment variable “{environment}” not found."
|
||||
"add_environment": "Добавить переменную",
|
||||
"add_environment_value": "Заполнить значение",
|
||||
"empty_value": "Значение переменной окружения '{variable}' пустое",
|
||||
"not_found": "Переменная окружения “{environment}” не задана."
|
||||
},
|
||||
"header": {
|
||||
"cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead."
|
||||
"cookie": "Из-за ограничений безопасности в веб версии нельзя задать Cookie параметры. Пожалуйста, используйте Hoppscotch Desktop приложение или используйте заголовок Authorization вместо этого."
|
||||
},
|
||||
"response": {
|
||||
"401_error": "Please check your authentication credentials.",
|
||||
@@ -427,12 +463,12 @@
|
||||
"default_error": "Please check your request.",
|
||||
"network_error": "Please check your network connection."
|
||||
},
|
||||
"title": "Inspector",
|
||||
"title": "Помощник",
|
||||
"url": {
|
||||
"extension_not_installed": "Extension not installed.",
|
||||
"extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.",
|
||||
"extention_enable_action": "Enable Browser Extension",
|
||||
"extention_not_enabled": "Extension not enabled."
|
||||
"extension_not_installed": "Расширение не установлено.",
|
||||
"extension_unknown_origin": "Убедитесь, что текущий домен добавлен в список доверенных ресурсов в расширении браузера",
|
||||
"extention_enable_action": "Подключить расширение",
|
||||
"extention_not_enabled": "Расширение в браузере не подключено."
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
@@ -445,11 +481,11 @@
|
||||
"modal": {
|
||||
"close_unsaved_tab": "У вас есть не сохранённые изменения",
|
||||
"collections": "Коллекции",
|
||||
"confirm": "Подтверждать",
|
||||
"confirm": "Подтвердите действие",
|
||||
"customize_request": "Customize Request",
|
||||
"edit_request": "Изменить запрос",
|
||||
"import_export": "Импорт Экспорт",
|
||||
"share_request": "Share Request"
|
||||
"share_request": "Поделиться запросом"
|
||||
},
|
||||
"mqtt": {
|
||||
"already_subscribed": "Вы уже подписаны на этот топик",
|
||||
@@ -485,7 +521,7 @@
|
||||
"doc": "Документы",
|
||||
"graphql": "GraphQL",
|
||||
"profile": "Профиль",
|
||||
"realtime": "В реальном времени",
|
||||
"realtime": "Realtime",
|
||||
"rest": "REST",
|
||||
"settings": "Настройки"
|
||||
},
|
||||
@@ -507,8 +543,8 @@
|
||||
"roles": "Роли",
|
||||
"roles_description": "Роли позволяют настраивать доступ конкретным людям к публичным коллекциям.",
|
||||
"updated": "Профиль обновлен",
|
||||
"viewer": "Зритель",
|
||||
"viewer_description": "Зрительно могут только просматривать и использовать запросы."
|
||||
"viewer": "Читатель",
|
||||
"viewer_description": "Могут только просматривать и использовать запросы."
|
||||
},
|
||||
"remove": {
|
||||
"star": "Удалить звезду"
|
||||
@@ -530,11 +566,11 @@
|
||||
"enter_curl": "Введите сюда команду cURL",
|
||||
"generate_code": "Сгенерировать код",
|
||||
"generated_code": "Сгенерированный код",
|
||||
"go_to_authorization_tab": "Go to Authorization",
|
||||
"go_to_body_tab": "Go to Body tab",
|
||||
"go_to_authorization_tab": "Перейти на вкладку авторизации",
|
||||
"go_to_body_tab": "Перейти на вкладку тела запроса",
|
||||
"header_list": "Список заголовков",
|
||||
"invalid_name": "Укажите имя для запроса",
|
||||
"method": "Методика",
|
||||
"method": "Метод",
|
||||
"moved": "Запрос перемещён",
|
||||
"name": "Имя запроса",
|
||||
"new": "Новый запрос",
|
||||
@@ -548,22 +584,22 @@
|
||||
"payload": "Полезная нагрузка",
|
||||
"query": "Запрос",
|
||||
"raw_body": "Необработанное тело запроса",
|
||||
"rename": "Переименость запрос",
|
||||
"rename": "Переименовать запрос",
|
||||
"renamed": "Запрос переименован",
|
||||
"request_variables": "Переменные запроса",
|
||||
"run": "Запустить",
|
||||
"save": "Сохранить",
|
||||
"save_as": "Сохранить как",
|
||||
"saved": "Запрос сохранен",
|
||||
"share": "Делиться",
|
||||
"share": "Поделиться",
|
||||
"share_description": "Поделиться Hoppscotch с друзьями",
|
||||
"share_request": "Share Request",
|
||||
"stop": "Stop",
|
||||
"share_request": "Поделиться запросом",
|
||||
"stop": "Стоп",
|
||||
"title": "Запрос",
|
||||
"type": "Тип запроса",
|
||||
"url": "URL",
|
||||
"variables": "Переменные",
|
||||
"view_my_links": "Посмотреть мои ссылки",
|
||||
"copy_link": "Копировать ссылку"
|
||||
"view_my_links": "Посмотреть мои ссылки"
|
||||
},
|
||||
"response": {
|
||||
"audio": "Аудио",
|
||||
@@ -586,7 +622,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"accent_color": "Основной цвет",
|
||||
"account": "Счет",
|
||||
"account": "Аккаунт",
|
||||
"account_deleted": "Ваш аккаунт был удалён",
|
||||
"account_description": "Настройте параметры своей учетной записи.",
|
||||
"account_email_description": "Ваш основной адрес электронной почты.",
|
||||
@@ -639,29 +675,29 @@
|
||||
"verify_email": "Подтвердить Email"
|
||||
},
|
||||
"shared_requests": {
|
||||
"button": "Button",
|
||||
"button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.",
|
||||
"copy_html": "Copy HTML",
|
||||
"copy_link": "Copy Link",
|
||||
"copy_markdown": "Copy Markdown",
|
||||
"creating_widget": "Creating widget",
|
||||
"customize": "Customize",
|
||||
"deleted": "Shared request deleted",
|
||||
"description": "Select a widget, you can change and customize this later",
|
||||
"embed": "Embed",
|
||||
"embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.",
|
||||
"link": "Link",
|
||||
"link_info": "Create a shareable link to share with anyone on the internet with view access.",
|
||||
"modified": "Shared request modified",
|
||||
"not_found": "Shared request not found",
|
||||
"open_new_tab": "Open in new tab",
|
||||
"button": "Кнопка",
|
||||
"button_info": "Создать кнопку 'Run in Hoppscotch' на свой сайт, блог или README.",
|
||||
"copy_html": "Копировать HTML код",
|
||||
"copy_link": "Копировать ссылку",
|
||||
"copy_markdown": "Копировать Markdown",
|
||||
"creating_widget": "Создание виджет",
|
||||
"customize": "Настроить",
|
||||
"deleted": "Запрос удален",
|
||||
"description": "Выберите вид как вы поделитесь запросом, позже вы сможете дополнительно его настроить",
|
||||
"embed": "Встраиваемое окно",
|
||||
"embed_info": "Добавьте небольшую площадку 'Hoppscotch API Playground' на свой веб-сайт, блог или документацию.",
|
||||
"link": "Ссылка",
|
||||
"link_info": "Создайте общедоступную ссылку, которой можно поделиться с любым пользователем, имеющим доступ к просмотру.",
|
||||
"modified": "Запрос изменен",
|
||||
"not_found": "Такой ссылке не нашлось",
|
||||
"open_new_tab": "Открыть в новом окне",
|
||||
"preview": "Preview",
|
||||
"run_in_hoppscotch": "Run in Hoppscotch",
|
||||
"theme": {
|
||||
"dark": "Dark",
|
||||
"light": "Light",
|
||||
"system": "System",
|
||||
"title": "Theme"
|
||||
"dark": "Темная",
|
||||
"light": "Светлая",
|
||||
"system": "Системная",
|
||||
"title": "Тема"
|
||||
}
|
||||
},
|
||||
"shortcut": {
|
||||
@@ -669,7 +705,7 @@
|
||||
"close_current_menu": "Закрыть текущее меню",
|
||||
"command_menu": "Меню поиска и команд",
|
||||
"help_menu": "Меню помощи",
|
||||
"show_all": "Горячие клавиши",
|
||||
"show_all": "Список горячих клавиш",
|
||||
"title": "Общий"
|
||||
},
|
||||
"miscellaneous": {
|
||||
@@ -696,20 +732,19 @@
|
||||
"get_method": "Выберите метод GET",
|
||||
"head_method": "Выберите метод HEAD",
|
||||
"import_curl": "Импортировать из cURL",
|
||||
"method": "Методика",
|
||||
"method": "Метод",
|
||||
"next_method": "Выберите следующий метод",
|
||||
"post_method": "Выберите метод POST",
|
||||
"previous_method": "Выбрать предыдущий метод",
|
||||
"put_method": "Выберите метод PUT",
|
||||
"rename": "Переименовать запрос",
|
||||
"reset_request": "Сбросить запрос",
|
||||
"save_request": "Сохарнить запрос",
|
||||
"save_request": "Сохранить запрос",
|
||||
"save_to_collections": "Сохранить в коллекции",
|
||||
"send_request": "Послать запрос",
|
||||
"share_request": "Share Request",
|
||||
"show_code": "Generate code snippet",
|
||||
"title": "Запрос",
|
||||
"copy_request_link": "Копировать ссылку на запрос"
|
||||
"share_request": "Поделиться запросом",
|
||||
"show_code": "Сгенерировать фрагмент кода из запроса",
|
||||
"title": "Запрос"
|
||||
},
|
||||
"response": {
|
||||
"copy": "Копировать запрос в буфер обмена",
|
||||
@@ -717,11 +752,11 @@
|
||||
"title": "Запрос"
|
||||
},
|
||||
"theme": {
|
||||
"black": "Черный режим",
|
||||
"dark": "Тёмный режим",
|
||||
"light": "Светлый режим",
|
||||
"system": "Определяется системой",
|
||||
"title": "Тема"
|
||||
"black": "Переключить на черный режим",
|
||||
"dark": "Переключить на тёмный режим",
|
||||
"light": "Переключить на светлый режим",
|
||||
"system": "Переключить на тему, исходя из настроек системы",
|
||||
"title": "Внешний вид"
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
@@ -730,6 +765,11 @@
|
||||
"more": "Показать больше",
|
||||
"sidebar": "Показать боковую панель"
|
||||
},
|
||||
"site_protection": {
|
||||
"error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status",
|
||||
"login_to_continue": "Login to continue",
|
||||
"login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance."
|
||||
},
|
||||
"socketio": {
|
||||
"communication": "Коммуникация",
|
||||
"connection_not_authorized": "Это SocketIO соединение не использует какую-либо авторизацию.",
|
||||
@@ -739,82 +779,89 @@
|
||||
"url": "URL"
|
||||
},
|
||||
"spotlight": {
|
||||
"change_language": "Change Language",
|
||||
"change_language": "Изменить язык",
|
||||
"environments": {
|
||||
"delete": "Delete current environment",
|
||||
"duplicate": "Duplicate current environment",
|
||||
"duplicate_global": "Duplicate global environment",
|
||||
"edit": "Edit current environment",
|
||||
"edit_global": "Edit global environment",
|
||||
"new": "Create new environment",
|
||||
"new_variable": "Create a new environment variable",
|
||||
"title": "Environments"
|
||||
"delete": "Удалить текущее окружение",
|
||||
"duplicate": "Дублировать текущее окружение",
|
||||
"duplicate_global": "Дублировать глобальное окружение",
|
||||
"edit": "Редактировать текущее окружение",
|
||||
"edit_global": "Редактировать глобальное окружение",
|
||||
"new": "Создать новое окружение",
|
||||
"new_variable": "Создать новую переменную окружения",
|
||||
"title": "Окружение"
|
||||
},
|
||||
"general": {
|
||||
"chat": "Chat with support",
|
||||
"help_menu": "Help and support",
|
||||
"open_docs": "Read Documentation",
|
||||
"open_github": "Open GitHub repository",
|
||||
"open_keybindings": "Keyboard shortcuts",
|
||||
"social": "Social",
|
||||
"title": "General"
|
||||
"chat": "Чат с поддержкой",
|
||||
"help_menu": "Помощь",
|
||||
"open_docs": "Почитать документацию",
|
||||
"open_github": "Открыть GitHub репозиторий",
|
||||
"open_keybindings": "Горячие клавиши",
|
||||
"social": "Соц. сети",
|
||||
"title": "Общее"
|
||||
},
|
||||
"graphql": {
|
||||
"connect": "Connect to server",
|
||||
"disconnect": "Disconnect from server"
|
||||
"connect": "Подключиться к серверу",
|
||||
"disconnect": "Отключиться от сервера"
|
||||
},
|
||||
"miscellaneous": {
|
||||
"invite": "Invite your friends to Hoppscotch",
|
||||
"title": "Miscellaneous"
|
||||
"invite": "Пригласить друзей в Hoppscotch",
|
||||
"title": "Другое"
|
||||
},
|
||||
"phrases": {
|
||||
"create_environment": "Создать окружение",
|
||||
"create_workspace": "Создать пространство",
|
||||
"import_collections": "Импортировать коллекцию",
|
||||
"share_request": "Поделиться запросом",
|
||||
"try": "Попробовать"
|
||||
},
|
||||
"request": {
|
||||
"save_as_new": "Save as new request",
|
||||
"select_method": "Select method",
|
||||
"switch_to": "Switch to",
|
||||
"tab_authorization": "Authorization tab",
|
||||
"tab_body": "Body tab",
|
||||
"tab_headers": "Headers tab",
|
||||
"tab_parameters": "Parameters tab",
|
||||
"tab_pre_request_script": "Pre-request script tab",
|
||||
"tab_query": "Query tab",
|
||||
"tab_tests": "Tests tab",
|
||||
"tab_variables": "Variables tab"
|
||||
"save_as_new": "Сохранить как новый запрос",
|
||||
"select_method": "Выбрать метод",
|
||||
"switch_to": "Переключиться",
|
||||
"tab_authorization": "На вкладку авторизации",
|
||||
"tab_body": "На вкладку тела запроса",
|
||||
"tab_headers": "На вкладку заголовков",
|
||||
"tab_parameters": "На вкладку параметров",
|
||||
"tab_pre_request_script": "На вкладку пред-скрипта запроса",
|
||||
"tab_query": "На вкладку запроса",
|
||||
"tab_tests": "На вкладку тестов",
|
||||
"tab_variables": "На вкладку переменных запроса"
|
||||
},
|
||||
"response": {
|
||||
"copy": "Copy response",
|
||||
"download": "Download response as file",
|
||||
"title": "Response"
|
||||
"copy": "Копировать содержимое ответа",
|
||||
"download": "Сказать содержимое ответа как файл",
|
||||
"title": "Ответ запроса"
|
||||
},
|
||||
"section": {
|
||||
"interceptor": "Interceptor",
|
||||
"interface": "Interface",
|
||||
"theme": "Theme",
|
||||
"user": "User"
|
||||
"interceptor": "Перехватчик",
|
||||
"interface": "Интерфейс",
|
||||
"theme": "Внешний вид",
|
||||
"user": "Пользователь"
|
||||
},
|
||||
"settings": {
|
||||
"change_interceptor": "Change Interceptor",
|
||||
"change_language": "Change Language",
|
||||
"change_interceptor": "Изменить перехватчик",
|
||||
"change_language": "Изменить язык",
|
||||
"theme": {
|
||||
"black": "Black",
|
||||
"dark": "Dark",
|
||||
"light": "Light",
|
||||
"system": "System preference"
|
||||
"black": "Черная",
|
||||
"dark": "Темная",
|
||||
"light": "Светлая",
|
||||
"system": "Как задано в системе"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"close_current": "Close current tab",
|
||||
"close_others": "Close all other tabs",
|
||||
"duplicate": "Duplicate current tab",
|
||||
"new_tab": "Open a new tab",
|
||||
"title": "Tabs"
|
||||
"close_current": "Закрыть текущую вкладку",
|
||||
"close_others": "Закрыть все вкладки",
|
||||
"duplicate": "Продублировать текущую вкладку",
|
||||
"new_tab": "Открыть в новой вкладке",
|
||||
"title": "Вкладки"
|
||||
},
|
||||
"workspace": {
|
||||
"delete": "Delete current team",
|
||||
"edit": "Edit current team",
|
||||
"invite": "Invite people to team",
|
||||
"new": "Create new team",
|
||||
"switch_to_personal": "Switch to your personal workspace",
|
||||
"title": "Teams"
|
||||
"delete": "Удалить текущую команду",
|
||||
"edit": "Редактировать текущую команду",
|
||||
"invite": "Пригласить людей в команду",
|
||||
"new": "Создать новую команду",
|
||||
"switch_to_personal": "Переключить на персональное пространство",
|
||||
"title": "Команды"
|
||||
}
|
||||
},
|
||||
"sse": {
|
||||
@@ -824,7 +871,7 @@
|
||||
},
|
||||
"state": {
|
||||
"bulk_mode": "Множественное редактирование",
|
||||
"bulk_mode_placeholder": "Каждый параметр должен начинаться с новой строки\nКлючи и значения разедляются двоеточием\nИспользуйте # для комментария",
|
||||
"bulk_mode_placeholder": "Каждый параметр должен начинаться с новой строки\nКлючи и значения разделяются двоеточием\nИспользуйте # для комментария",
|
||||
"cleared": "Очищено",
|
||||
"connected": "Связаны",
|
||||
"connected_to": "Подключено к {name}",
|
||||
@@ -843,20 +890,20 @@
|
||||
"download_failed": "Download failed",
|
||||
"download_started": "Скачивание началось",
|
||||
"enabled": "Включено",
|
||||
"file_imported": "Файл импортирован",
|
||||
"file_imported": "Файл успешно импортирован",
|
||||
"finished_in": "Завершено через {duration} мс",
|
||||
"hide": "Hide",
|
||||
"hide": "Скрыть",
|
||||
"history_deleted": "История удалена",
|
||||
"linewrap": "Обернуть линии",
|
||||
"loading": "Загрузка...",
|
||||
"message_received": "Сообщение: {message} получено по топику: {topic}",
|
||||
"mqtt_subscription_failed": "Что-то пошло не так, при попытке подписаться на топик: {topic}",
|
||||
"none": "Никто",
|
||||
"none": "Не задан",
|
||||
"nothing_found": "Ничего не найдено для",
|
||||
"published_error": "Что-то пошло не так при попытке опубликовать сообщение в топик {topic}: {message}",
|
||||
"published_message": "Опубликовано сообщение: {message} в топик: {topic}",
|
||||
"reconnection_error": "Не удалось переподключиться",
|
||||
"show": "Show",
|
||||
"show": "Показать",
|
||||
"subscribed_failed": "Не удалось подписаться на топик: {topic}",
|
||||
"subscribed_success": "Успешно подписался на топик: {topic}",
|
||||
"unsubscribed_failed": "Не удалось отписаться от топика: {topic}",
|
||||
@@ -871,7 +918,6 @@
|
||||
"forum": "Задавайте вопросы и получайте ответы",
|
||||
"github": "Подпишитесь на нас на Github",
|
||||
"shortcuts": "Просматривайте приложение быстрее",
|
||||
"team": "Свяжитесь с командой",
|
||||
"title": "Служба поддержки",
|
||||
"twitter": "Следуйте за нами на Twitter"
|
||||
},
|
||||
@@ -882,7 +928,7 @@
|
||||
"close_others": "Закрыть остальные вкладки",
|
||||
"collections": "Коллекции",
|
||||
"documentation": "Документация",
|
||||
"duplicate": "Duplicate Tab",
|
||||
"duplicate": "Дублировать вкладку",
|
||||
"environments": "Окружения",
|
||||
"headers": "Заголовки",
|
||||
"history": "История",
|
||||
@@ -892,7 +938,8 @@
|
||||
"queries": "Запросы",
|
||||
"query": "Запрос",
|
||||
"schema": "Схема",
|
||||
"shared_requests": "Shared Requests",
|
||||
"shared_requests": "Запросы в общем доступе",
|
||||
"share_tab_request": "Поделиться запросом",
|
||||
"socketio": "Socket.IO",
|
||||
"sse": "SSE",
|
||||
"tests": "Тесты",
|
||||
@@ -921,7 +968,6 @@
|
||||
"invite_tooltip": "Пригласить людей в Ваше рабочее пространство",
|
||||
"invited_to_team": "{owner} приглашает Вас присоединиться к команде {team}",
|
||||
"join": "Приглашение принято",
|
||||
"join_beta": "Присоединяйтесь к бета-программе, чтобы получить доступ к командам.",
|
||||
"join_team": "Присоединиться к {team}",
|
||||
"joined_team": "Вы присоединились к команде {team}",
|
||||
"joined_team_description": "Теперь Вы участник этой команды",
|
||||
@@ -950,6 +996,7 @@
|
||||
"permissions": "Разрешения",
|
||||
"same_target_destination": "Таже цель и конечная точка",
|
||||
"saved": "Команда сохранена",
|
||||
"search_title": "Team Requests",
|
||||
"select_a_team": "Выбрать команду",
|
||||
"success_invites": "Принятые приглашения",
|
||||
"title": "Команды",
|
||||
@@ -981,16 +1028,8 @@
|
||||
"workspace": {
|
||||
"change": "Изменить пространство",
|
||||
"personal": "Моё пространство",
|
||||
"other_workspaces": "Пространства",
|
||||
"team": "Пространство команды",
|
||||
"title": "Рабочие пространства"
|
||||
},
|
||||
"shortcodes": {
|
||||
"actions": "Действия",
|
||||
"created_on": "Создано",
|
||||
"deleted": "Удалёна",
|
||||
"method": "Метод",
|
||||
"not_found": "Короткая ссылка не найдена",
|
||||
"short_code": "Короткая ссылка",
|
||||
"url": "URL"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +68,9 @@
|
||||
"developer_option": "Developer options",
|
||||
"developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.",
|
||||
"discord": "Discord",
|
||||
"documentation": "Dökümanlar",
|
||||
"documentation": "Dokümanlar",
|
||||
"github": "GitHub",
|
||||
"help": "Yardım, geri bildirim ve dökümanlar",
|
||||
"help": "Yardım, geri bildirim ve dokümanlar",
|
||||
"home": "Ana sayfa",
|
||||
"invite": "Davet et",
|
||||
"invite_description": "Hoppscotch'ta API'lerinizi oluşturmak ve yönetmek için basit ve sezgisel bir arayüz tasarladık. Hoppscotch, API'lerinizi oluşturmanıza, test etmenize, belgelemenize ve paylaşmanıza yardımcı olan bir araçtır.",
|
||||
@@ -225,7 +225,7 @@
|
||||
"body": "Bu isteğin bir gövdesi yok",
|
||||
"collection": "Koleksiyon boş",
|
||||
"collections": "Koleksiyonlar boş",
|
||||
"documentation": "Dökümanları görmek için GraphQL uç noktasını bağlayın",
|
||||
"documentation": "Dokümanları görmek için GraphQL uç noktasını bağlayın",
|
||||
"endpoint": "Uç nokta boş olamaz",
|
||||
"environments": "Ortamlar boş",
|
||||
"folder": "Klasör boş",
|
||||
@@ -735,7 +735,7 @@
|
||||
"url": "Bağlantı"
|
||||
},
|
||||
"spotlight": {
|
||||
"change_language": "Change Language",
|
||||
"change_language": "Dil Değiştir",
|
||||
"environments": {
|
||||
"delete": "Delete current environment",
|
||||
"duplicate": "Duplicate current environment",
|
||||
@@ -744,7 +744,7 @@
|
||||
"edit_global": "Edit global environment",
|
||||
"new": "Create new environment",
|
||||
"new_variable": "Create a new environment variable",
|
||||
"title": "Environments"
|
||||
"title": "Ortamlar"
|
||||
},
|
||||
"general": {
|
||||
"chat": "Chat with support",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hoppscotch/common",
|
||||
"private": true,
|
||||
"version": "2024.3.1",
|
||||
"version": "2024.3.3",
|
||||
"scripts": {
|
||||
"dev": "pnpm exec npm-run-all -p -l dev:*",
|
||||
"test": "vitest --run",
|
||||
@@ -50,7 +50,7 @@
|
||||
"axios": "1.6.2",
|
||||
"buffer": "6.0.3",
|
||||
"cookie-es": "1.0.0",
|
||||
"dioc": "1.0.1",
|
||||
"dioc": "3.0.1",
|
||||
"esprima": "4.0.1",
|
||||
"events": "3.3.0",
|
||||
"fp-ts": "2.16.1",
|
||||
|
||||
@@ -32,6 +32,7 @@ declare module 'vue' {
|
||||
AppSpotlightEntryRESTHistory: typeof import('./components/app/spotlight/entry/RESTHistory.vue')['default']
|
||||
AppSpotlightEntryRESTRequest: typeof import('./components/app/spotlight/entry/RESTRequest.vue')['default']
|
||||
AppSpotlightEntryRESTTeamRequestEntry: typeof import('./components/app/spotlight/entry/RESTTeamRequestEntry.vue')['default']
|
||||
AppSpotlightSearch: typeof import('./components/app/SpotlightSearch.vue')['default']
|
||||
AppSupport: typeof import('./components/app/Support.vue')['default']
|
||||
Collections: typeof import('./components/collections/index.vue')['default']
|
||||
CollectionsAdd: typeof import('./components/collections/Add.vue')['default']
|
||||
|
||||
@@ -43,12 +43,19 @@
|
||||
@click="invokeAction('modals.support.toggle')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="flex"
|
||||
:class="{
|
||||
'flex-row-reverse gap-2':
|
||||
workspaceSelectorFlagEnabled && !currentUser,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="currentUser === null"
|
||||
class="inline-flex items-center space-x-2"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
v-if="!workspaceSelectorFlagEnabled"
|
||||
:icon="IconUploadCloud"
|
||||
:label="t('header.save_workspace')"
|
||||
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 hidden h-8 border border-emerald-600/25 bg-emerald-500/10 !text-emerald-500 hover:border-emerald-600/20 hover:bg-emerald-600/20 focus-visible:border-emerald-600/20 focus-visible:bg-emerald-600/20 md:flex"
|
||||
@@ -60,18 +67,22 @@
|
||||
@click="invokeAction('modals.login.toggle')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="inline-flex items-center space-x-2">
|
||||
<TeamsMemberStack
|
||||
v-if="
|
||||
workspace.type === 'team' &&
|
||||
selectedTeam &&
|
||||
selectedTeam.teamMembers.length > 1
|
||||
"
|
||||
:team-members="selectedTeam.teamMembers"
|
||||
show-count
|
||||
class="mx-2"
|
||||
@handle-click="handleTeamEdit()"
|
||||
/>
|
||||
<TeamsMemberStack
|
||||
v-else-if="
|
||||
currentUser !== null &&
|
||||
workspace.type === 'team' &&
|
||||
selectedTeam &&
|
||||
selectedTeam.teamMembers.length > 1
|
||||
"
|
||||
:team-members="selectedTeam.teamMembers"
|
||||
show-count
|
||||
class="mx-2"
|
||||
@handle-click="handleTeamEdit()"
|
||||
/>
|
||||
<div
|
||||
v-if="workspaceSelectorFlagEnabled || currentUser"
|
||||
class="inline-flex items-center space-x-2"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 divide-x divide-emerald-600/25 rounded border border-emerald-600/25 bg-emerald-500/10 focus-within:divide-emerald-600/20 focus-within:border-emerald-600/20 focus-within:bg-emerald-600/20 hover:divide-emerald-600/20 hover:border-emerald-600/20 hover:bg-emerald-600/20"
|
||||
>
|
||||
@@ -84,6 +95,7 @@
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="
|
||||
currentUser &&
|
||||
workspace.type === 'team' &&
|
||||
selectedTeam &&
|
||||
selectedTeam?.myRole === 'OWNER'
|
||||
@@ -124,7 +136,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
<span class="px-2">
|
||||
<span v-if="currentUser" class="px-2">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
@@ -259,6 +271,13 @@ import {
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
/**
|
||||
* Feature flag to enable the workspace selector login conversion
|
||||
*/
|
||||
const workspaceSelectorFlagEnabled = computed(
|
||||
() => !!platform.platformFeatureFlags.workspaceSwitcherLogin?.value
|
||||
)
|
||||
|
||||
/**
|
||||
* Once the PWA code is initialized, this holds a method
|
||||
* that can be called to show the user the installation
|
||||
@@ -380,6 +399,8 @@ const inviteTeam = (team: { name: string }, teamID: string) => {
|
||||
|
||||
// Show the workspace selected team invite modal if the user is an owner of the team else show the default invite modal
|
||||
const handleInvite = () => {
|
||||
if (!currentUser.value) return invokeAction("modals.login.toggle")
|
||||
|
||||
if (
|
||||
workspace.value.type === "team" &&
|
||||
workspace.value.teamID &&
|
||||
|
||||
@@ -32,7 +32,6 @@ import { useI18n } from "~/composables/i18n"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
|
||||
import MyCollectionImport from "~/components/importExport/ImportExportSteps/MyCollectionImport.vue"
|
||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconOpenAPI from "~icons/lucide/file"
|
||||
@@ -55,16 +54,15 @@ import { teamCollectionsExporter } from "~/helpers/import-export/export/teamColl
|
||||
|
||||
import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource"
|
||||
import { ImporterOrExporter } from "~/components/importExport/types"
|
||||
import { TeamWorkspace } from "~/services/workspace.service"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
|
||||
|
||||
type CollectionType =
|
||||
| {
|
||||
type: "team-collections"
|
||||
selectedTeam: SelectedTeam
|
||||
selectedTeam: TeamWorkspace
|
||||
}
|
||||
| { type: "my-collections" }
|
||||
|
||||
@@ -433,7 +431,7 @@ const HoppTeamCollectionsExporter: ImporterOrExporter = {
|
||||
props.collectionsType.selectedTeam
|
||||
) {
|
||||
const res = await teamCollectionsExporter(
|
||||
props.collectionsType.selectedTeam.id
|
||||
props.collectionsType.selectedTeam.teamID
|
||||
)
|
||||
|
||||
if (E.isRight(res)) {
|
||||
@@ -569,8 +567,8 @@ const hasTeamWriteAccess = computed(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
collectionsType.selectedTeam.myRole === "EDITOR" ||
|
||||
collectionsType.selectedTeam.myRole === "OWNER"
|
||||
collectionsType.selectedTeam.role === "EDITOR" ||
|
||||
collectionsType.selectedTeam.role === "OWNER"
|
||||
)
|
||||
})
|
||||
|
||||
@@ -578,17 +576,17 @@ const selectedTeamID = computed(() => {
|
||||
const { collectionsType } = props
|
||||
|
||||
return collectionsType.type === "team-collections"
|
||||
? collectionsType.selectedTeam?.id
|
||||
? collectionsType.selectedTeam?.teamID
|
||||
: undefined
|
||||
})
|
||||
|
||||
const getCollectionJSON = async () => {
|
||||
if (
|
||||
props.collectionsType.type === "team-collections" &&
|
||||
props.collectionsType.selectedTeam?.id
|
||||
props.collectionsType.selectedTeam?.teamID
|
||||
) {
|
||||
const res = await getTeamCollectionJSON(
|
||||
props.collectionsType.selectedTeam?.id
|
||||
props.collectionsType.selectedTeam?.teamID
|
||||
)
|
||||
|
||||
return E.isRight(res)
|
||||
|
||||
@@ -56,23 +56,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, watch } from "vue"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import {
|
||||
HoppGQLRequest,
|
||||
HoppRESTRequest,
|
||||
isHoppRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { computedWithControl } from "@vueuse/core"
|
||||
import { useService } from "dioc/vue"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { computed, nextTick, reactive, ref, watch } from "vue"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import {
|
||||
createRequestInCollection,
|
||||
updateTeamRequest,
|
||||
} from "~/helpers/backend/mutations/TeamRequest"
|
||||
import { Picked } from "~/helpers/types/HoppPicked"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import {
|
||||
cascadeParentCollectionForHeaderAuth,
|
||||
editGraphqlRequest,
|
||||
@@ -80,12 +82,10 @@ import {
|
||||
saveGraphqlRequestAs,
|
||||
saveRESTRequestAs,
|
||||
} from "~/newstore/collections"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import { computedWithControl } from "@vueuse/core"
|
||||
import { platform } from "~/platform"
|
||||
import { useService } from "dioc/vue"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { GQLTabService } from "~/services/tab/graphql"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { TeamWorkspace } from "~/services/workspace.service"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
@@ -93,12 +93,10 @@ const toast = useToast()
|
||||
const RESTTabs = useService(RESTTabService)
|
||||
const GQLTabs = useService(GQLTabService)
|
||||
|
||||
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
|
||||
|
||||
type CollectionType =
|
||||
| {
|
||||
type: "team-collections"
|
||||
selectedTeam: SelectedTeam
|
||||
selectedTeam: TeamWorkspace
|
||||
}
|
||||
| { type: "my-collections"; selectedTeam: undefined }
|
||||
|
||||
@@ -192,7 +190,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const updateTeam = (newTeam: SelectedTeam) => {
|
||||
const updateTeam = (newTeam: TeamWorkspace) => {
|
||||
collectionsType.value.selectedTeam = newTeam
|
||||
}
|
||||
|
||||
@@ -493,7 +491,7 @@ const updateTeamCollectionOrFolder = (
|
||||
const data = {
|
||||
title: requestUpdated.name,
|
||||
request: JSON.stringify(requestUpdated),
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
teamID: collectionsType.value.selectedTeam.teamID,
|
||||
}
|
||||
pipe(
|
||||
createRequestInCollection(collectionID, data),
|
||||
|
||||
@@ -387,7 +387,6 @@ import IconPlus from "~icons/lucide/plus"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconImport from "~icons/lucide/folder-down"
|
||||
import { computed, PropType, Ref, toRef } from "vue"
|
||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { TeamCollection } from "~/helpers/teams/TeamCollection"
|
||||
@@ -400,17 +399,16 @@ import * as O from "fp-ts/Option"
|
||||
import { Picked } from "~/helpers/types/HoppPicked.js"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { useService } from "dioc/vue"
|
||||
import { TeamWorkspace } from "~/services/workspace.service"
|
||||
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
const tabs = useService(RESTTabService)
|
||||
|
||||
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
|
||||
|
||||
type CollectionType =
|
||||
| {
|
||||
type: "team-collections"
|
||||
selectedTeam: SelectedTeam
|
||||
selectedTeam: TeamWorkspace
|
||||
}
|
||||
| { type: "my-collections"; selectedTeam: undefined }
|
||||
|
||||
@@ -614,7 +612,7 @@ const hasNoTeamAccess = computed(
|
||||
() =>
|
||||
props.collectionsType.type === "team-collections" &&
|
||||
(props.collectionsType.selectedTeam === undefined ||
|
||||
props.collectionsType.selectedTeam.myRole === "VIEWER")
|
||||
props.collectionsType.selectedTeam.role === "VIEWER")
|
||||
)
|
||||
|
||||
const isSelected = ({
|
||||
|
||||
@@ -200,7 +200,7 @@ const toast = useToast()
|
||||
defineProps<{
|
||||
// Whether to activate the ability to pick items (activates 'select' events)
|
||||
saveRequest: boolean
|
||||
picked: Picked
|
||||
picked: Picked | null
|
||||
}>()
|
||||
|
||||
const collections = useReadonlyStream(graphqlCollections$, [], "deep")
|
||||
|
||||
@@ -178,7 +178,6 @@ import { useI18n } from "@composables/i18n"
|
||||
import { Picked } from "~/helpers/types/HoppPicked"
|
||||
import { useReadonlyStream } from "~/composables/stream"
|
||||
import { useLocalState } from "~/newstore/localstate"
|
||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import {
|
||||
@@ -245,7 +244,7 @@ import {
|
||||
} from "~/helpers/collection/collection"
|
||||
import { currentReorderingStatus$ } from "~/newstore/reordering"
|
||||
import { defineActionHandler, invokeAction } from "~/helpers/actions"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
import { TeamWorkspace, WorkspaceService } from "~/services/workspace.service"
|
||||
import { useService } from "dioc/vue"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
|
||||
@@ -274,16 +273,14 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "select", payload: Picked | null): void
|
||||
(event: "update-team", team: SelectedTeam): void
|
||||
(event: "update-team", team: TeamWorkspace): void
|
||||
(event: "update-collection-type", type: CollectionType["type"]): void
|
||||
}>()
|
||||
|
||||
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
|
||||
|
||||
type CollectionType =
|
||||
| {
|
||||
type: "team-collections"
|
||||
selectedTeam: SelectedTeam
|
||||
selectedTeam: TeamWorkspace
|
||||
}
|
||||
| { type: "my-collections"; selectedTeam: undefined }
|
||||
|
||||
@@ -330,9 +327,7 @@ const requestMoveLoading = ref<string[]>([])
|
||||
// TeamList-Adapter
|
||||
const workspaceService = useService(WorkspaceService)
|
||||
const teamListAdapter = workspaceService.acquireTeamListAdapter(null)
|
||||
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
|
||||
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
|
||||
const teamListFetched = ref(false)
|
||||
|
||||
// Team Collection Adapter
|
||||
const teamCollectionAdapter = new TeamCollectionAdapter(null)
|
||||
@@ -378,7 +373,7 @@ watch(
|
||||
filterTexts,
|
||||
(newFilterText) => {
|
||||
if (collectionsType.value.type === "team-collections") {
|
||||
const selectedTeamID = collectionsType.value.selectedTeam?.id
|
||||
const selectedTeamID = collectionsType.value.selectedTeam?.teamID
|
||||
|
||||
selectedTeamID &&
|
||||
debouncedSearch(newFilterText, selectedTeamID)?.catch(() => {})
|
||||
@@ -435,28 +430,6 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => myTeams.value,
|
||||
(newTeams) => {
|
||||
if (newTeams && !teamListFetched.value) {
|
||||
teamListFetched.value = true
|
||||
if (REMEMBERED_TEAM_ID.value && currentUser.value) {
|
||||
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
|
||||
if (team) updateSelectedTeam(team)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => collectionsType.value.selectedTeam,
|
||||
(newTeam) => {
|
||||
if (newTeam) {
|
||||
teamCollectionAdapter.changeTeamID(newTeam.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const switchToMyCollections = () => {
|
||||
collectionsType.value.type = "my-collections"
|
||||
collectionsType.value.selectedTeam = undefined
|
||||
@@ -488,11 +461,12 @@ const expandTeamCollection = (collectionID: string) => {
|
||||
teamCollectionAdapter.expandCollection(collectionID)
|
||||
}
|
||||
|
||||
const updateSelectedTeam = (team: SelectedTeam) => {
|
||||
const updateSelectedTeam = (team: TeamWorkspace) => {
|
||||
if (team) {
|
||||
collectionsType.value.type = "team-collections"
|
||||
teamCollectionAdapter.changeTeamID(team.teamID)
|
||||
collectionsType.value.selectedTeam = team
|
||||
REMEMBERED_TEAM_ID.value = team.id
|
||||
REMEMBERED_TEAM_ID.value = team.teamID
|
||||
emit("update-team", team)
|
||||
emit("update-collection-type", "team-collections")
|
||||
}
|
||||
@@ -501,23 +475,14 @@ const updateSelectedTeam = (team: SelectedTeam) => {
|
||||
const workspace = workspaceService.currentWorkspace
|
||||
|
||||
// Used to switch collection type and team when user switch workspace in the global workspace switcher
|
||||
// Check if there is a teamID in the workspace, if yes, switch to team collections and select the team
|
||||
// If there is no teamID, switch to my collections
|
||||
watch(
|
||||
() => {
|
||||
const space = workspace.value
|
||||
return space.type === "personal" ? undefined : space.teamID
|
||||
},
|
||||
(teamID) => {
|
||||
if (teamID) {
|
||||
const team = myTeams.value?.find((t) => t.id === teamID)
|
||||
if (team) {
|
||||
updateSelectedTeam(team)
|
||||
}
|
||||
return
|
||||
workspace,
|
||||
(newWorkspace) => {
|
||||
if (newWorkspace.type === "personal") {
|
||||
switchToMyCollections()
|
||||
} else if (newWorkspace.type === "team") {
|
||||
updateSelectedTeam(newWorkspace)
|
||||
}
|
||||
|
||||
return switchToMyCollections()
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
@@ -545,7 +510,7 @@ const hasTeamWriteAccess = computed(() => {
|
||||
return false
|
||||
}
|
||||
|
||||
const role = collectionsType.value.selectedTeam?.myRole
|
||||
const role = collectionsType.value.selectedTeam?.role
|
||||
return role === "OWNER" || role === "EDITOR"
|
||||
})
|
||||
|
||||
@@ -760,7 +725,7 @@ const addNewRootCollection = (name: string) => {
|
||||
})
|
||||
|
||||
pipe(
|
||||
createNewRootCollection(name, collectionsType.value.selectedTeam.id),
|
||||
createNewRootCollection(name, collectionsType.value.selectedTeam.teamID),
|
||||
TE.match(
|
||||
(err: GQLError<string>) => {
|
||||
toast.error(`${getErrorMessage(err)}`)
|
||||
@@ -831,7 +796,7 @@ const onAddRequest = (requestName: string) => {
|
||||
|
||||
const data = {
|
||||
request: JSON.stringify(newRequest),
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
teamID: collectionsType.value.selectedTeam.teamID,
|
||||
title: requestName,
|
||||
}
|
||||
|
||||
@@ -1158,7 +1123,7 @@ const duplicateRequest = (payload: {
|
||||
|
||||
const data = {
|
||||
request: JSON.stringify(newRequest),
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
teamID: collectionsType.value.selectedTeam.teamID,
|
||||
title: `${request.name} - ${t("action.duplicate")}`,
|
||||
}
|
||||
|
||||
|
||||
@@ -364,6 +364,7 @@ const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
|
||||
teamID: team.id,
|
||||
teamName: team.name,
|
||||
type: "team",
|
||||
role: team.myRole,
|
||||
})
|
||||
}
|
||||
watch(
|
||||
|
||||
@@ -46,41 +46,38 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { isEqual } from "lodash-es"
|
||||
import { platform } from "~/platform"
|
||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
import { useReadonlyStream, useStream } from "@composables/stream"
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import { useService } from "dioc/vue"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { isEqual } from "lodash-es"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import { deleteTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
|
||||
import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter"
|
||||
import {
|
||||
deleteEnvironment,
|
||||
getSelectedEnvironmentIndex,
|
||||
globalEnv$,
|
||||
selectedEnvironmentIndex$,
|
||||
setSelectedEnvironmentIndex,
|
||||
} from "~/newstore/environments"
|
||||
import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { useLocalState } from "~/newstore/localstate"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import { deleteEnvironment } from "~/newstore/environments"
|
||||
import { deleteTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
import { useService } from "dioc/vue"
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import { platform } from "~/platform"
|
||||
import { TeamWorkspace, WorkspaceService } from "~/services/workspace.service"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
type EnvironmentType = "my-environments" | "team-environments"
|
||||
|
||||
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
|
||||
|
||||
type EnvironmentsChooseType = {
|
||||
type: EnvironmentType
|
||||
selectedTeam: SelectedTeam
|
||||
selectedTeam: TeamWorkspace | undefined
|
||||
}
|
||||
|
||||
const environmentType = ref<EnvironmentsChooseType>({
|
||||
@@ -102,11 +99,7 @@ const currentUser = useReadonlyStream(
|
||||
platform.auth.getCurrentUser()
|
||||
)
|
||||
|
||||
// TeamList-Adapter
|
||||
const workspaceService = useService(WorkspaceService)
|
||||
const teamListAdapter = workspaceService.acquireTeamListAdapter(null)
|
||||
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
|
||||
const teamListFetched = ref(false)
|
||||
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
|
||||
|
||||
const adapter = new TeamEnvironmentAdapter(undefined)
|
||||
@@ -118,29 +111,17 @@ const loading = computed(
|
||||
() => adapterLoading.value && teamEnvironmentList.value.length === 0
|
||||
)
|
||||
|
||||
watch(
|
||||
() => myTeams.value,
|
||||
(newTeams) => {
|
||||
if (newTeams && !teamListFetched.value) {
|
||||
teamListFetched.value = true
|
||||
if (REMEMBERED_TEAM_ID.value && currentUser.value) {
|
||||
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
|
||||
if (team) updateSelectedTeam(team)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const switchToMyEnvironments = () => {
|
||||
environmentType.value.selectedTeam = undefined
|
||||
updateEnvironmentType("my-environments")
|
||||
adapter.changeTeamID(undefined)
|
||||
}
|
||||
|
||||
const updateSelectedTeam = (newSelectedTeam: SelectedTeam | undefined) => {
|
||||
const updateSelectedTeam = (newSelectedTeam: TeamWorkspace | undefined) => {
|
||||
if (newSelectedTeam) {
|
||||
adapter.changeTeamID(newSelectedTeam.teamID)
|
||||
environmentType.value.selectedTeam = newSelectedTeam
|
||||
REMEMBERED_TEAM_ID.value = newSelectedTeam.id
|
||||
REMEMBERED_TEAM_ID.value = newSelectedTeam.teamID
|
||||
updateEnvironmentType("team-environments")
|
||||
}
|
||||
}
|
||||
@@ -148,15 +129,6 @@ const updateEnvironmentType = (newEnvironmentType: EnvironmentType) => {
|
||||
environmentType.value.type = newEnvironmentType
|
||||
}
|
||||
|
||||
watch(
|
||||
() => environmentType.value.selectedTeam,
|
||||
(newTeam) => {
|
||||
if (newTeam) {
|
||||
adapter.changeTeamID(newTeam.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const workspace = workspaceService.currentWorkspace
|
||||
|
||||
// Switch to my environments if workspace is personal and to team environments if workspace is team
|
||||
@@ -170,8 +142,7 @@ watch(workspace, (newWorkspace) => {
|
||||
})
|
||||
}
|
||||
} else if (newWorkspace.type === "team") {
|
||||
const team = myTeams.value?.find((t) => t.id === newWorkspace.teamID)
|
||||
updateSelectedTeam(team)
|
||||
updateSelectedTeam(newWorkspace)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -54,9 +54,7 @@
|
||||
:key="tab.id"
|
||||
:label="tab.label"
|
||||
>
|
||||
<div
|
||||
class="divide-y divide-dividerLight rounded border border-divider"
|
||||
>
|
||||
<div class="divide-y divide-dividerLight">
|
||||
<HoppSmartPlaceholder
|
||||
v-if="tab.variables.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
|
||||
@@ -56,9 +56,7 @@
|
||||
:key="tab.id"
|
||||
:label="tab.label"
|
||||
>
|
||||
<div
|
||||
class="divide-y divide-dividerLight rounded border border-divider"
|
||||
>
|
||||
<div class="divide-y divide-dividerLight">
|
||||
<HoppSmartPlaceholder
|
||||
v-if="tab.variables.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
class="sticky top-upperPrimaryStickyFold z-10 flex flex-1 flex-shrink-0 justify-between overflow-x-auto border-b border-dividerLight bg-primary"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
v-if="team === undefined || team.myRole === 'VIEWER'"
|
||||
v-if="team === undefined || team.role === 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
disabled
|
||||
class="!rounded-none"
|
||||
@@ -28,7 +28,7 @@
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="team !== undefined && team.myRole === 'VIEWER'"
|
||||
v-if="team !== undefined && team.role === 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
disabled
|
||||
:icon="IconImport"
|
||||
@@ -84,7 +84,7 @@
|
||||
)"
|
||||
:key="`environment-${index}`"
|
||||
:environment="environment"
|
||||
:is-viewer="team?.myRole === 'VIEWER'"
|
||||
:is-viewer="team?.role === 'VIEWER'"
|
||||
@edit-environment="editEnvironment(environment)"
|
||||
/>
|
||||
</div>
|
||||
@@ -103,16 +103,16 @@
|
||||
:show="showModalDetails"
|
||||
:action="action"
|
||||
:editing-environment="editingEnvironment"
|
||||
:editing-team-id="team?.id"
|
||||
:editing-team-id="team?.teamID"
|
||||
:editing-variable-name="editingVariableName"
|
||||
:is-secret-option-selected="secretOptionSelected"
|
||||
:is-viewer="team?.myRole === 'VIEWER'"
|
||||
:is-viewer="team?.role === 'VIEWER'"
|
||||
@hide-modal="displayModalEdit(false)"
|
||||
/>
|
||||
<EnvironmentsImportExport
|
||||
v-if="showModalImportExport"
|
||||
:team-environments="teamEnvironments"
|
||||
:team-id="team?.id"
|
||||
:team-id="team?.teamID"
|
||||
environment-type="TEAM_ENV"
|
||||
@hide-modal="displayModalImportExport(false)"
|
||||
/>
|
||||
@@ -129,16 +129,14 @@ import IconPlus from "~icons/lucide/plus"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconImport from "~icons/lucide/folder-down"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
import { TeamWorkspace } from "~/services/workspace.service"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
|
||||
|
||||
const props = defineProps<{
|
||||
team: SelectedTeam
|
||||
team: TeamWorkspace | undefined
|
||||
teamEnvironments: TeamEnvironment[]
|
||||
adapterError: GQLError<string> | null
|
||||
loading: boolean
|
||||
@@ -151,7 +149,7 @@ const editingEnvironment = ref<TeamEnvironment | null>(null)
|
||||
const editingVariableName = ref("")
|
||||
const secretOptionSelected = ref(false)
|
||||
|
||||
const isTeamViewer = computed(() => props.team?.myRole === "VIEWER")
|
||||
const isTeamViewer = computed(() => props.team?.role === "VIEWER")
|
||||
|
||||
const displayModalAdd = (shouldDisplay: boolean) => {
|
||||
action.value = "new"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="w-full rounded border border-divider bg-primaryLight px-4 py-2 text-secondaryDark"
|
||||
:placeholder="`${t('request.url')}`"
|
||||
:placeholder="`${t('graphql.url_placeholder')}`"
|
||||
:disabled="connected"
|
||||
@keyup.enter="onConnectClick"
|
||||
/>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
>
|
||||
<SmartEnvInput
|
||||
v-model="tab.document.request.endpoint"
|
||||
:placeholder="`${t('request.url')}`"
|
||||
:placeholder="`${t('request.url_placeholder')}`"
|
||||
:auto-complete-source="userHistories"
|
||||
:auto-complete-env="true"
|
||||
:inspection-results="tabResults"
|
||||
|
||||
@@ -37,13 +37,17 @@ import { TeamNameCodec } from "~/helpers/backend/types/TeamName"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { platform } from "~/platform"
|
||||
import { useService } from "dioc/vue"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
import { useLocalState } from "~/newstore/localstate"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
switchWorkspaceAfterCreation?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -52,8 +56,12 @@ const emit = defineEmits<{
|
||||
|
||||
const editingName = ref<string | null>(null)
|
||||
|
||||
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const workspaceService = useService(WorkspaceService)
|
||||
|
||||
const addNewTeam = async () => {
|
||||
isLoading.value = true
|
||||
await pipe(
|
||||
@@ -76,8 +84,19 @@ const addNewTeam = async () => {
|
||||
// Handle GQL errors (use err obj)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
(team) => {
|
||||
toast.success(`${t("team.new_created")}`)
|
||||
|
||||
if (props.switchWorkspaceAfterCreation) {
|
||||
REMEMBERED_TEAM_ID.value = team.id
|
||||
workspaceService.changeWorkspace({
|
||||
teamID: team.id,
|
||||
teamName: team.name,
|
||||
type: "team",
|
||||
role: team.myRole,
|
||||
})
|
||||
}
|
||||
|
||||
hideModal()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -59,14 +59,18 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loading && teamListAdapterError"
|
||||
v-else-if="teamListAdapterError"
|
||||
class="flex flex-col items-center py-4"
|
||||
>
|
||||
<icon-lucide-help-circle class="svg-icons mb-4" />
|
||||
{{ t("error.something_went_wrong") }}
|
||||
</div>
|
||||
</div>
|
||||
<TeamsAdd :show="showModalAdd" @hide-modal="displayModalAdd(false)" />
|
||||
<TeamsAdd
|
||||
:show="showModalAdd"
|
||||
:switch-workspace-after-creation="true"
|
||||
@hide-modal="displayModalAdd(false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -81,7 +85,7 @@ import { useColorMode } from "@composables/theming"
|
||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
import IconDone from "~icons/lucide/check"
|
||||
import { useLocalState } from "~/newstore/localstate"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { defineActionHandler, invokeAction } from "~/helpers/actions"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
import { useService } from "dioc/vue"
|
||||
import { useElementVisibility, useIntervalFn } from "@vueuse/core"
|
||||
@@ -154,6 +158,7 @@ const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
|
||||
teamID: team.id,
|
||||
teamName: team.name,
|
||||
type: "team",
|
||||
role: team.myRole,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,11 +174,14 @@ watch(
|
||||
(user) => {
|
||||
if (!user) {
|
||||
switchToPersonalWorkspace()
|
||||
teamListadapter.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const displayModalAdd = (shouldDisplay: boolean) => {
|
||||
if (!currentUser.value) return invokeAction("modals.login.toggle")
|
||||
|
||||
showModalAdd.value = shouldDisplay
|
||||
teamListadapter.fetchList()
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export default class TeamListAdapter {
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.teamList$.next([])
|
||||
this.isDispose = true
|
||||
clearTimeout(this.timeoutHandle as any)
|
||||
this.timeoutHandle = null
|
||||
|
||||
@@ -201,7 +201,7 @@ export class TeamSearchService extends Service {
|
||||
expandingCollections: Ref<string[]> = ref([])
|
||||
expandedCollections: Ref<string[]> = ref([])
|
||||
|
||||
// FUTURE-TODO: ideally this should return the search results / formatted results instead of directly manipulating the result set
|
||||
// TODO: ideally this should return the search results / formatted results instead of directly manipulating the result set
|
||||
// eg: do the spotlight formatting in the spotlight searcher and not here
|
||||
searchTeams = async (query: string, teamID: string) => {
|
||||
if (!query.length) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HoppModule } from "."
|
||||
import { Container, Service } from "dioc"
|
||||
import { Container, ServiceClassInstance } from "dioc"
|
||||
import { diocPlugin } from "dioc/vue"
|
||||
import { DebugService } from "~/services/debug.service"
|
||||
import { platform } from "~/platform"
|
||||
@@ -22,7 +22,7 @@ if (import.meta.env.DEV) {
|
||||
* services. Please use `useService` if within components or try to convert your
|
||||
* legacy subsystem into a service if possible.
|
||||
*/
|
||||
export function getService<T extends typeof Service<any> & { ID: string }>(
|
||||
export function getService<T extends ServiceClassInstance<any>>(
|
||||
service: T
|
||||
): InstanceType<T> {
|
||||
return serviceContainer.bind(service)
|
||||
@@ -30,11 +30,10 @@ export function getService<T extends typeof Service<any> & { ID: string }>(
|
||||
|
||||
export default <HoppModule>{
|
||||
onVueAppInit(app) {
|
||||
// TODO: look into this
|
||||
// @ts-expect-error Something weird with Vue versions
|
||||
app.use(diocPlugin, {
|
||||
container: serviceContainer,
|
||||
})
|
||||
|
||||
for (const service of platform.addedServices ?? []) {
|
||||
serviceContainer.bind(service)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { pluck, distinctUntilChanged } from "rxjs/operators"
|
||||
import { cloneDeep, defaultsDeep, has } from "lodash-es"
|
||||
import { Observable } from "rxjs"
|
||||
|
||||
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
|
||||
import { distinctUntilChanged, pluck } from "rxjs/operators"
|
||||
import { nextTick } from "vue"
|
||||
import { platform } from "~/platform"
|
||||
import type { KeysMatching } from "~/types/ts-utils"
|
||||
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
|
||||
|
||||
export const HoppBgColors = ["system", "light", "dark", "black"] as const
|
||||
|
||||
@@ -69,51 +70,63 @@ export type SettingsDef = {
|
||||
HAS_OPENED_SPOTLIGHT: boolean
|
||||
}
|
||||
|
||||
export const getDefaultSettings = (): SettingsDef => ({
|
||||
syncCollections: true,
|
||||
syncHistory: true,
|
||||
syncEnvironments: true,
|
||||
export const getDefaultSettings = (): SettingsDef => {
|
||||
const defaultSettings: SettingsDef = {
|
||||
syncCollections: true,
|
||||
syncHistory: true,
|
||||
syncEnvironments: true,
|
||||
|
||||
WRAP_LINES: {
|
||||
httpRequestBody: true,
|
||||
httpResponseBody: true,
|
||||
httpHeaders: true,
|
||||
httpParams: true,
|
||||
httpUrlEncoded: true,
|
||||
httpPreRequest: true,
|
||||
httpTest: true,
|
||||
httpRequestVariables: true,
|
||||
graphqlQuery: true,
|
||||
graphqlResponseBody: true,
|
||||
graphqlHeaders: false,
|
||||
graphqlVariables: false,
|
||||
graphqlSchema: true,
|
||||
importCurl: true,
|
||||
codeGen: true,
|
||||
cookie: true,
|
||||
},
|
||||
WRAP_LINES: {
|
||||
httpRequestBody: true,
|
||||
httpResponseBody: true,
|
||||
httpHeaders: true,
|
||||
httpParams: true,
|
||||
httpUrlEncoded: true,
|
||||
httpPreRequest: true,
|
||||
httpTest: true,
|
||||
httpRequestVariables: true,
|
||||
graphqlQuery: true,
|
||||
graphqlResponseBody: true,
|
||||
graphqlHeaders: false,
|
||||
graphqlVariables: false,
|
||||
graphqlSchema: true,
|
||||
importCurl: true,
|
||||
codeGen: true,
|
||||
cookie: true,
|
||||
},
|
||||
|
||||
CURRENT_INTERCEPTOR_ID: "browser", // TODO: Allow the platform definition to take this place
|
||||
CURRENT_INTERCEPTOR_ID: "",
|
||||
|
||||
// TODO: Interceptor related settings should move under the interceptor systems
|
||||
PROXY_URL: "https://proxy.hoppscotch.io/",
|
||||
URL_EXCLUDES: {
|
||||
auth: true,
|
||||
httpUser: true,
|
||||
httpPassword: true,
|
||||
bearerToken: true,
|
||||
oauth2Token: true,
|
||||
},
|
||||
THEME_COLOR: "indigo",
|
||||
BG_COLOR: "system",
|
||||
TELEMETRY_ENABLED: true,
|
||||
EXPAND_NAVIGATION: false,
|
||||
SIDEBAR: true,
|
||||
SIDEBAR_ON_LEFT: false,
|
||||
COLUMN_LAYOUT: true,
|
||||
// TODO: Interceptor related settings should move under the interceptor systems
|
||||
PROXY_URL: "https://proxy.hoppscotch.io/",
|
||||
URL_EXCLUDES: {
|
||||
auth: true,
|
||||
httpUser: true,
|
||||
httpPassword: true,
|
||||
bearerToken: true,
|
||||
oauth2Token: true,
|
||||
},
|
||||
THEME_COLOR: "indigo",
|
||||
BG_COLOR: "system",
|
||||
TELEMETRY_ENABLED: true,
|
||||
EXPAND_NAVIGATION: false,
|
||||
SIDEBAR: true,
|
||||
SIDEBAR_ON_LEFT: false,
|
||||
COLUMN_LAYOUT: true,
|
||||
|
||||
HAS_OPENED_SPOTLIGHT: false,
|
||||
})
|
||||
HAS_OPENED_SPOTLIGHT: false,
|
||||
}
|
||||
|
||||
// Wait for platform to initialize before setting CURRENT_INTERCEPTOR_ID
|
||||
nextTick(() => {
|
||||
applySetting(
|
||||
"CURRENT_INTERCEPTOR_ID",
|
||||
platform?.interceptors.default || "browser"
|
||||
)
|
||||
})
|
||||
|
||||
return defaultSettings
|
||||
}
|
||||
|
||||
type ApplySettingPayload = {
|
||||
[K in keyof SettingsDef]: {
|
||||
|
||||
@@ -64,13 +64,6 @@
|
||||
@submit="renameReqName"
|
||||
@hide-modal="showRenamingReqNameModal = false"
|
||||
/>
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmingCloseForTabID !== null"
|
||||
:confirm="t('modal.close_unsaved_tab')"
|
||||
:title="t('confirm.save_unsaved_tab')"
|
||||
@hide-modal="onCloseConfirmSaveTab"
|
||||
@resolve="onResolveConfirmSaveTab"
|
||||
/>
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmingCloseAllTabs"
|
||||
:confirm="t('modal.close_unsaved_tab')"
|
||||
@@ -78,6 +71,36 @@
|
||||
@hide-modal="confirmingCloseAllTabs = false"
|
||||
@resolve="onResolveConfirmCloseAllTabs"
|
||||
/>
|
||||
<HoppSmartModal
|
||||
v-if="confirmingCloseForTabID !== null"
|
||||
dialog
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:title="t('modal.close_unsaved_tab')"
|
||||
@close="confirmingCloseForTabID = null"
|
||||
>
|
||||
<template #body>
|
||||
<div class="text-center">
|
||||
{{ t("confirm.save_unsaved_tab") }}
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<HoppButtonPrimary
|
||||
v-focus
|
||||
:label="t?.('action.yes')"
|
||||
outline
|
||||
@click="onResolveConfirmSaveTab"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
:label="t?.('action.no')"
|
||||
filled
|
||||
outline
|
||||
@click="onCloseConfirmSaveTab"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
<CollectionsSaveRequest
|
||||
v-if="savingRequest"
|
||||
mode="rest"
|
||||
|
||||
@@ -8,14 +8,15 @@ import { AnalyticsPlatformDef } from "./analytics"
|
||||
import { InterceptorsPlatformDef } from "./interceptors"
|
||||
import { HoppModule } from "~/modules"
|
||||
import { InspectorsPlatformDef } from "./inspectors"
|
||||
import { Service } from "dioc"
|
||||
import { ServiceClassInstance } from "dioc"
|
||||
import { IOPlatformDef } from "./io"
|
||||
import { SpotlightPlatformDef } from "./spotlight"
|
||||
import { Ref } from "vue"
|
||||
|
||||
export type PlatformDef = {
|
||||
ui?: UIPlatformDef
|
||||
addedHoppModules?: HoppModule[]
|
||||
addedServices?: Array<typeof Service<unknown> & { ID: string }>
|
||||
addedServices?: Array<ServiceClassInstance<unknown>>
|
||||
auth: AuthPlatformDef
|
||||
analytics?: AnalyticsPlatformDef
|
||||
io: IOPlatformDef
|
||||
@@ -45,6 +46,11 @@ export type PlatformDef = {
|
||||
* If a value is not given, then the value is assumed to be true
|
||||
*/
|
||||
promptAsUsingCookies?: boolean
|
||||
|
||||
/**
|
||||
* Whether to show the A/B testing workspace switcher click login flow or not
|
||||
*/
|
||||
workspaceSwitcherLogin?: Ref<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Service } from "dioc"
|
||||
import { Container, ServiceClassInstance } from "dioc"
|
||||
import { Inspector } from "~/services/inspection"
|
||||
|
||||
/**
|
||||
@@ -8,8 +8,9 @@ export type PlatformInspectorsDef = {
|
||||
// We are keeping this as the only mode for now
|
||||
// So that if we choose to add other modes, we can do without breaking
|
||||
type: "service"
|
||||
service: typeof Service<unknown> & { ID: string } & {
|
||||
new (): Service & Inspector
|
||||
// TODO: I don't think this type is effective, we have to come up with a better impl
|
||||
service: ServiceClassInstance<unknown> & {
|
||||
new (c: Container): Inspector
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Service } from "dioc"
|
||||
import { Container, ServiceClassInstance } from "dioc"
|
||||
import { Interceptor } from "~/services/interceptor.service"
|
||||
|
||||
export type PlatformInterceptorDef =
|
||||
| { type: "standalone"; interceptor: Interceptor }
|
||||
| {
|
||||
type: "service"
|
||||
service: typeof Service<unknown> & { ID: string } & {
|
||||
new (): Service & Interceptor
|
||||
// TODO: I don't think this type is effective, we have to come up with a better impl
|
||||
service: ServiceClassInstance<unknown> & {
|
||||
new (c: Container): Interceptor
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Service } from "dioc"
|
||||
import { Container, ServiceClassInstance } from "dioc"
|
||||
import { SpotlightSearcher } from "~/services/spotlight"
|
||||
|
||||
export type SpotlightPlatformDef = {
|
||||
additionalSearchers?: Array<
|
||||
typeof Service<unknown> & { ID: string } & {
|
||||
new (): Service & SpotlightSearcher
|
||||
ServiceClassInstance<unknown> & {
|
||||
new (c: Container): SpotlightSearcher
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
@@ -31,9 +31,7 @@ export class ExtensionInspectorService extends Service implements Inspector {
|
||||
|
||||
private readonly inspection = this.bind(InspectionService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.inspection.registerInspector(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -133,9 +133,7 @@ export class ExtensionInterceptorService
|
||||
|
||||
public selectable = { type: "selectable" as const }
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.listenForExtensionStatus()
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,7 @@ export class EnvironmentMenuService extends Service implements ContextMenu {
|
||||
|
||||
private readonly contextMenu = this.bind(ContextMenuService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.contextMenu.registerMenu(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,9 +41,7 @@ export class ParameterMenuService extends Service implements ContextMenu {
|
||||
|
||||
private readonly contextMenu = this.bind(ContextMenuService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.contextMenu.registerMenu(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,9 +39,7 @@ export class URLMenuService extends Service implements ContextMenu {
|
||||
private readonly contextMenu = this.bind(ContextMenuService)
|
||||
private readonly restTab = this.bind(RESTTabService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.contextMenu.registerMenu(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,6 @@ export class CookieJarService extends Service {
|
||||
*/
|
||||
public cookieJar = ref(new Map<string, string[]>())
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
public parseSetCookieString(setCookieString: string) {
|
||||
return setCookieParse(setCookieString)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,7 @@ import { Service } from "dioc"
|
||||
export class DebugService extends Service {
|
||||
public static readonly ID = "DEBUG_SERVICE"
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
console.log("DebugService is initialized...")
|
||||
|
||||
const container = this.getContainer()
|
||||
|
||||
@@ -107,9 +107,7 @@ export class InspectionService extends Service {
|
||||
|
||||
private readonly restTab = this.bind(RESTTabService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.initializeListeners()
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ export class EnvironmentInspectorService extends Service implements Inspector {
|
||||
}
|
||||
)[0]
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.inspection.registerInspector(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,7 @@ export class HeaderInspectorService extends Service implements Inspector {
|
||||
private readonly inspection = this.bind(InspectionService)
|
||||
private readonly interceptorService = this.bind(InterceptorService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.inspection.registerInspector(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,7 @@ export class ResponseInspectorService extends Service implements Inspector {
|
||||
|
||||
private readonly inspection = this.bind(InspectionService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.inspection.registerInspector(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -178,9 +178,7 @@ export class InterceptorService extends Service {
|
||||
return this.interceptors.get(this.currentInterceptorID.value)
|
||||
})
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
// If the current interceptor is unselectable, select the first selectable one, else null
|
||||
watch([() => this.interceptors, this.currentInterceptorID], () => {
|
||||
if (!this.currentInterceptorID.value) return
|
||||
|
||||
@@ -109,10 +109,6 @@ export class OauthAuthService extends Service {
|
||||
public static readonly ID = "OAUTH_AUTH_SERVICE"
|
||||
|
||||
static redirectURI = `${window.location.origin}/oauth`
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
export const generateRandomString = () => {
|
||||
|
||||
@@ -89,10 +89,6 @@ export class PersistenceService extends Service {
|
||||
|
||||
public hoppLocalConfigStorage: StorageLike = localStorage
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
private showErrorToast(localStorageKey: string) {
|
||||
const toast = useToast()
|
||||
toast.error(
|
||||
|
||||
@@ -27,10 +27,6 @@ export class SecretEnvironmentService extends Service {
|
||||
*/
|
||||
public secretEnvironments = reactive(new Map<string, SecretVariable[]>())
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new secret environment.
|
||||
* @param id ID of the environment
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { nextTick, reactive, ref } from "vue"
|
||||
import { SpotlightSearcherResult } from "../../.."
|
||||
import { TestContainer } from "dioc/testing"
|
||||
import { Container } from "dioc"
|
||||
|
||||
async function flushPromises() {
|
||||
return await new Promise((r) => setTimeout(r))
|
||||
@@ -32,12 +33,15 @@ describe("StaticSpotlightSearcherService", () => {
|
||||
},
|
||||
})
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
// TODO: dioc > v3 does not recommend using constructors, move to onServiceInit
|
||||
constructor(c: Container) {
|
||||
super(c, {
|
||||
searchFields: ["text"],
|
||||
fieldWeights: {},
|
||||
})
|
||||
}
|
||||
|
||||
override onServiceInit() {
|
||||
this.setDocuments(this.documents)
|
||||
}
|
||||
|
||||
@@ -94,12 +98,15 @@ describe("StaticSpotlightSearcherService", () => {
|
||||
},
|
||||
})
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
// TODO: dioc > v3 does not recommend using constructors, move to onServiceInit
|
||||
constructor(c: Container) {
|
||||
super(c, {
|
||||
searchFields: ["text"],
|
||||
fieldWeights: {},
|
||||
})
|
||||
}
|
||||
|
||||
override onServiceInit() {
|
||||
this.setDocuments(this.documents)
|
||||
}
|
||||
|
||||
@@ -159,12 +166,15 @@ describe("StaticSpotlightSearcherService", () => {
|
||||
},
|
||||
})
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
// TODO: dioc > v3 does not recommend using constructors, move to onServiceInit
|
||||
constructor(c: Container) {
|
||||
super(c, {
|
||||
searchFields: ["text"],
|
||||
fieldWeights: {},
|
||||
})
|
||||
}
|
||||
|
||||
override onServiceInit() {
|
||||
this.setDocuments(this.documents)
|
||||
}
|
||||
|
||||
@@ -224,12 +234,15 @@ describe("StaticSpotlightSearcherService", () => {
|
||||
},
|
||||
})
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
// TODO: dioc > v3 does not recommend using constructors, move to onServiceInit
|
||||
constructor(c: Container) {
|
||||
super(c, {
|
||||
searchFields: ["text"],
|
||||
fieldWeights: {},
|
||||
})
|
||||
}
|
||||
|
||||
override onServiceInit() {
|
||||
this.setDocuments(this.documents)
|
||||
}
|
||||
|
||||
@@ -285,12 +298,15 @@ describe("StaticSpotlightSearcherService", () => {
|
||||
},
|
||||
})
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
// TODO: dioc > v3 does not recommend using constructors, move to onServiceInit
|
||||
constructor(c: Container) {
|
||||
super(c, {
|
||||
searchFields: ["text"],
|
||||
fieldWeights: {},
|
||||
})
|
||||
}
|
||||
|
||||
override onServiceInit() {
|
||||
this.setDocuments(this.documents)
|
||||
}
|
||||
|
||||
@@ -354,12 +370,15 @@ describe("StaticSpotlightSearcherService", () => {
|
||||
},
|
||||
})
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
// TODO: dioc > v3 does not recommend using constructors, move to onServiceInit
|
||||
constructor(c: Container) {
|
||||
super(c, {
|
||||
searchFields: ["text", "alternate"],
|
||||
fieldWeights: {},
|
||||
})
|
||||
}
|
||||
|
||||
override onServiceInit() {
|
||||
this.setDocuments(this.documents)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Service } from "dioc"
|
||||
import { Container, Service } from "dioc"
|
||||
import {
|
||||
type SpotlightSearcher,
|
||||
type SpotlightSearcherResult,
|
||||
@@ -67,8 +67,12 @@ export abstract class StaticSpotlightSearcherService<
|
||||
|
||||
private _documents: Record<string, Doc> = {}
|
||||
|
||||
constructor(private opts: StaticSpotlightSearcherOptions<Doc>) {
|
||||
super()
|
||||
// TODO: This pattern is no longer recommended in dioc > 3, move to something else
|
||||
constructor(
|
||||
c: Container,
|
||||
private opts: StaticSpotlightSearcherOptions<Doc>
|
||||
) {
|
||||
super(c)
|
||||
|
||||
this.minisearch = new MiniSearch({
|
||||
fields: opts.searchFields as string[],
|
||||
|
||||
@@ -50,9 +50,7 @@ export class CollectionsSpotlightSearcherService
|
||||
private readonly spotlight = this.bind(SpotlightService)
|
||||
private readonly workspaceService = this.bind(WorkspaceService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.spotlight.registerSearcher(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import IconEdit from "~icons/lucide/edit"
|
||||
import IconLayers from "~icons/lucide/layers"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
|
||||
import { Service } from "dioc"
|
||||
import { Container, Service } from "dioc"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
@@ -164,15 +164,18 @@ export class EnvironmentsSpotlightSearcherService extends StaticSpotlightSearche
|
||||
},
|
||||
})
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
// TODO: This pattern is no longer recommended in dioc > 3, move to something else
|
||||
constructor(c: Container) {
|
||||
super(c, {
|
||||
searchFields: ["text", "alternates"],
|
||||
fieldWeights: {
|
||||
text: 2,
|
||||
alternates: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
override onServiceInit() {
|
||||
this.setDocuments(this.documents)
|
||||
this.spotlight.registerSearcher(this)
|
||||
}
|
||||
@@ -277,9 +280,7 @@ export class SwitchEnvSpotlightSearcherService
|
||||
private readonly workspaceService = this.bind(WorkspaceService)
|
||||
private teamEnvironmentList: TeamEnvironment[] = []
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.spotlight.registerSearcher(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import IconBook from "~icons/lucide/book"
|
||||
import IconLifeBuoy from "~icons/lucide/life-buoy"
|
||||
import IconZap from "~icons/lucide/zap"
|
||||
import { platform } from "~/platform"
|
||||
import { Container } from "dioc"
|
||||
|
||||
type Doc = {
|
||||
text: string | string[]
|
||||
@@ -89,15 +90,18 @@ export class GeneralSpotlightSearcherService extends StaticSpotlightSearcherServ
|
||||
},
|
||||
})
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
// TODO: This is not recommended as of dioc > 3. Move to onServiceInit instead
|
||||
constructor(c: Container) {
|
||||
super(c, {
|
||||
searchFields: ["text", "alternates"],
|
||||
fieldWeights: {
|
||||
text: 2,
|
||||
alternates: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
override onServiceInit() {
|
||||
this.setDocuments(this.documents)
|
||||
this.spotlight.registerSearcher(this)
|
||||
}
|
||||
|
||||
@@ -66,9 +66,7 @@ export class HistorySpotlightSearcherService
|
||||
}
|
||||
)[0]
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.spotlight.registerSearcher(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,7 @@ export class InterceptorSpotlightSearcherService
|
||||
private readonly spotlight = this.bind(SpotlightService)
|
||||
private interceptorService = this.bind(InterceptorService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
override onServiceInit() {
|
||||
this.spotlight.registerSearcher(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "./base/static.searcher"
|
||||
|
||||
import IconShare from "~icons/lucide/share"
|
||||
import { Container } from "dioc"
|
||||
|
||||
type Doc = {
|
||||
text: string
|
||||
@@ -39,15 +40,18 @@ export class MiscellaneousSpotlightSearcherService extends StaticSpotlightSearch
|
||||
},
|
||||
})
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
// TODO: Constructors are no longer recommended as of dioc > 3, move to onServiceInit
|
||||
constructor(c: Container) {
|
||||
super(c, {
|
||||
searchFields: ["text", "alternates"],
|
||||
fieldWeights: {
|
||||
text: 2,
|
||||
alternates: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
override onServiceInit() {
|
||||
this.setDocuments(this.documents)
|
||||
this.spotlight.registerSearcher(this)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user