Compare commits
25 Commits
feat/githu
...
pr/jamesge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6babae291 | ||
|
|
986a4b1d54 | ||
|
|
dee7864a08 | ||
|
|
8a8cdcf78b | ||
|
|
17db483a35 | ||
|
|
80b9941399 | ||
|
|
0a71783eaa | ||
|
|
c326f54f7e | ||
|
|
1113c79e20 | ||
|
|
6fd30f9aca | ||
|
|
2c5b0dcd1b | ||
|
|
6f4455ba03 | ||
|
|
ba8c4480d9 | ||
|
|
380397cc55 | ||
|
|
d19807b212 | ||
|
|
c89c2a5f5c | ||
|
|
256553b9bb | ||
|
|
89d9951f3b | ||
|
|
dd65ad3103 | ||
|
|
018ed3db26 | ||
|
|
a9cd6c0c01 | ||
|
|
e53382666a | ||
|
|
7621ff2961 | ||
|
|
fc20b76080 | ||
|
|
146c73d7b6 |
@@ -1,40 +1,14 @@
|
||||
# Docker Compose config used for internal test and QA deployments
|
||||
# This just spins up the AIO container along with an attached DB to the standard HTTP ports with subpath access mode
|
||||
|
||||
# TODO: Add Healthcheck for the AIO container
|
||||
# THIS IS NOT TO BE USED FOR PERSONAL DEPLOYMENTS!
|
||||
# Internal Docker Compose Image used for internal testing deployments
|
||||
|
||||
version: "3.7"
|
||||
|
||||
services:
|
||||
# The service that spins up all 3 services at once in one container
|
||||
hoppscotch-aio:
|
||||
container_name: hoppscotch-aio
|
||||
restart: unless-stopped
|
||||
build:
|
||||
dockerfile: prod.Dockerfile
|
||||
context: .
|
||||
target: aio
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
|
||||
- ENABLE_SUBPATH_BASED_ACCESS=true
|
||||
depends_on:
|
||||
hoppscotch-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3080:80"
|
||||
|
||||
# The preset DB service, you can delete/comment the below lines if
|
||||
# you are using an external postgres instance
|
||||
# This will be exposed at port 5432
|
||||
hoppscotch-db:
|
||||
image: postgres:15
|
||||
ports:
|
||||
- "5432:5432"
|
||||
user: postgres
|
||||
environment:
|
||||
# The default user defined by the docker image
|
||||
POSTGRES_USER: postgres
|
||||
# NOTE: Please UPDATE THIS PASSWORD!
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: hoppscotch
|
||||
healthcheck:
|
||||
@@ -46,3 +20,29 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
hoppscotch-aio:
|
||||
container_name: hoppscotch-aio
|
||||
build:
|
||||
dockerfile: prod.Dockerfile
|
||||
context: .
|
||||
target: aio
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
|
||||
- ENABLE_SUBPATH_BASED_ACCESS=true
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
hoppscotch-db:
|
||||
condition: service_healthy
|
||||
command: ["sh", "-c", "pnpm exec prisma migrate deploy && node /usr/src/app/aio_run.mjs"]
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- curl
|
||||
- '-f'
|
||||
- 'http://localhost:80'
|
||||
interval: 2s
|
||||
timeout: 10s
|
||||
retries: 30
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ curlCheck() {
|
||||
fi
|
||||
}
|
||||
|
||||
curlCheck "http://localhost:3000"
|
||||
curlCheck "http://localhost:3100"
|
||||
curlCheck "http://localhost:3170/ping"
|
||||
if [ "$ENABLE_SUBPATH_BASED_ACCESS" = "true" ]; then
|
||||
curlCheck "http://localhost:80/backend/ping"
|
||||
else
|
||||
curlCheck "http://localhost:3000"
|
||||
curlCheck "http://localhost:3100"
|
||||
curlCheck "http://localhost:3170/ping"
|
||||
fi
|
||||
|
||||
@@ -84,6 +84,12 @@ export const USER_ALREADY_INVITED = 'admin/user_already_invited' as const;
|
||||
*/
|
||||
export const USER_UPDATE_FAILED = 'user/update_failed' as const;
|
||||
|
||||
/**
|
||||
* User display name validation failure
|
||||
* (UserService)
|
||||
*/
|
||||
export const USER_SHORT_DISPLAY_NAME = 'user/short_display_name' as const;
|
||||
|
||||
/**
|
||||
* User deletion failure
|
||||
* (UserService)
|
||||
@@ -750,3 +756,8 @@ export const DATABASE_TABLE_NOT_EXIST =
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';
|
||||
|
||||
/**
|
||||
* Inputs supplied are invalid
|
||||
*/
|
||||
export const INVALID_PARAMS = 'invalid_parameters' as const;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { TeamCollectionService } from './team-collection.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
@@ -7,6 +14,8 @@ import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorat
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { RESTTeamMemberGuard } from 'src/team/guards/rest-team-member.guard';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
import { RESTError } from 'src/types/RESTError';
|
||||
import { INVALID_PARAMS } from 'src/errors';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'team-collection', version: '1' })
|
||||
@@ -26,8 +35,15 @@ export class TeamCollectionController {
|
||||
@Query('take') take: string,
|
||||
@Query('skip') skip: string,
|
||||
) {
|
||||
if (!teamID || !searchQuery) {
|
||||
return <RESTError>{
|
||||
message: INVALID_PARAMS,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await this.teamCollectionService.searchByTitle(
|
||||
searchQuery,
|
||||
searchQuery.trim(),
|
||||
teamID,
|
||||
parseInt(take),
|
||||
parseInt(skip),
|
||||
|
||||
@@ -58,6 +58,29 @@ export class UserResolver {
|
||||
if (E.isLeft(updatedUser)) throwErr(updatedUser.left);
|
||||
return updatedUser.right;
|
||||
}
|
||||
|
||||
@Mutation(() => User, {
|
||||
description: 'Update a users display name',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async updateDisplayName(
|
||||
@GqlUser() user: AuthUser,
|
||||
@Args({
|
||||
name: 'updatedDisplayName',
|
||||
description: 'New name of user',
|
||||
type: () => String,
|
||||
})
|
||||
updatedDisplayName: string,
|
||||
) {
|
||||
const updatedUser = await this.userService.updateUserDisplayName(
|
||||
user.uid,
|
||||
updatedDisplayName,
|
||||
);
|
||||
|
||||
if (E.isLeft(updatedUser)) throwErr(updatedUser.left);
|
||||
return updatedUser.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Delete an user account',
|
||||
})
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { JSON_INVALID, USERS_NOT_FOUND, USER_NOT_FOUND } from 'src/errors';
|
||||
import {
|
||||
JSON_INVALID,
|
||||
USERS_NOT_FOUND,
|
||||
USER_NOT_FOUND,
|
||||
USER_SHORT_DISPLAY_NAME,
|
||||
} from 'src/errors';
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
@@ -480,6 +485,14 @@ describe('UserService', () => {
|
||||
);
|
||||
expect(result).toEqualLeft(USER_NOT_FOUND);
|
||||
});
|
||||
test('should resolve left and error when short display name is passed', async () => {
|
||||
const newDisplayName = '';
|
||||
const result = await userService.updateUserDisplayName(
|
||||
user.uid,
|
||||
newDisplayName,
|
||||
);
|
||||
expect(result).toEqualLeft(USER_SHORT_DISPLAY_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAllUsers', () => {
|
||||
|
||||
@@ -8,7 +8,11 @@ import * as T from 'fp-ts/Task';
|
||||
import * as A from 'fp-ts/Array';
|
||||
import { pipe, constVoid } from 'fp-ts/function';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { USERS_NOT_FOUND, USER_NOT_FOUND } from 'src/errors';
|
||||
import {
|
||||
USERS_NOT_FOUND,
|
||||
USER_NOT_FOUND,
|
||||
USER_SHORT_DISPLAY_NAME,
|
||||
} from 'src/errors';
|
||||
import { SessionType, User } from './user.model';
|
||||
import { USER_UPDATE_FAILED } from 'src/errors';
|
||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
@@ -291,6 +295,10 @@ export class UserService {
|
||||
* @returns a Either of User or error
|
||||
*/
|
||||
async updateUserDisplayName(userUID: string, displayName: string) {
|
||||
if (!displayName || displayName.length === 0) {
|
||||
return E.left(USER_SHORT_DISPLAY_NAME);
|
||||
}
|
||||
|
||||
try {
|
||||
const dbUpdatedUser = await this.prisma.user.update({
|
||||
where: { uid: userUID },
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("Test `hopp test <file>` command:", () => {
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
describe("Supplied collection export file validations", () => {
|
||||
test("Errors with the code `FILE_NOT_FOUND` if the supplied collection export file doesn't exist", async () => {
|
||||
@@ -66,6 +66,43 @@ describe("Test `hopp test <file>` command:", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Versioned entities", () => {
|
||||
describe("Collections & Requests", () => {
|
||||
const testFixtures = [
|
||||
{ fileName: "coll-v1-req-v0.json", collVersion: 1, reqVersion: 0 },
|
||||
{ fileName: "coll-v1-req-v1.json", collVersion: 1, reqVersion: 1 },
|
||||
{ fileName: "coll-v2-req-v2.json", collVersion: 2, reqVersion: 2 },
|
||||
{ fileName: "coll-v2-req-v3.json", collVersion: 2, reqVersion: 3 },
|
||||
];
|
||||
|
||||
testFixtures.forEach(({ collVersion, fileName, reqVersion }) => {
|
||||
test(`Successfully processes a supplied collection export file where the collection is based on the "v${collVersion}" schema and the request following the "v${reqVersion}" schema`, async () => {
|
||||
const args = `test ${getTestJsonFilePath(fileName, "collection")}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environments", () => {
|
||||
const testFixtures = [
|
||||
{ fileName: "env-v0.json", version: 0 },
|
||||
{ fileName: "env-v1.json", version: 1 },
|
||||
];
|
||||
|
||||
testFixtures.forEach(({ fileName, version }) => {
|
||||
test(`Successfully processes the supplied collection and environment export files where the environment is based on the "v${version}" schema`, async () => {
|
||||
const ENV_PATH = getTestJsonFilePath(fileName, "environment");
|
||||
const args = `test ${getTestJsonFilePath("sample-coll.json", "collection")} --env ${ENV_PATH}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Successfully processes a supplied collection export file of the expected format", async () => {
|
||||
const args = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
|
||||
const { error } = await runCLI(args);
|
||||
@@ -75,7 +112,8 @@ describe("Test `hopp test <file>` command:", () => {
|
||||
|
||||
test("Successfully inherits headers and authorization set at the root collection", async () => {
|
||||
const args = `test ${getTestJsonFilePath(
|
||||
"collection-level-headers-auth-coll.json", "collection"
|
||||
"collection-level-headers-auth-coll.json",
|
||||
"collection"
|
||||
)}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
@@ -84,7 +122,8 @@ describe("Test `hopp test <file>` command:", () => {
|
||||
|
||||
test("Persists environment variables set in the pre-request script for consumption in the test script", async () => {
|
||||
const args = `test ${getTestJsonFilePath(
|
||||
"pre-req-script-env-var-persistence-coll.json", "collection"
|
||||
"pre-req-script-env-var-persistence-coll.json",
|
||||
"collection"
|
||||
)}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
@@ -106,7 +145,8 @@ describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
|
||||
test("Errors with the code `INVALID_FILE_TYPE` if the supplied environment export file doesn't end with the `.json` extension", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --env ${getTestJsonFilePath(
|
||||
"notjson-coll.txt", "collection"
|
||||
"notjson-coll.txt",
|
||||
"collection"
|
||||
)}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
@@ -123,7 +163,10 @@ describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
});
|
||||
|
||||
test("Errors with the code `MALFORMED_ENV_FILE` on supplying a malformed environment export file", async () => {
|
||||
const ENV_PATH = getTestJsonFilePath("malformed-envs.json", "environment");
|
||||
const ENV_PATH = getTestJsonFilePath(
|
||||
"malformed-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `${VALID_TEST_ARGS} --env ${ENV_PATH}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
@@ -142,7 +185,10 @@ describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
});
|
||||
|
||||
test("Successfully resolves values from the supplied environment export file", async () => {
|
||||
const TESTS_PATH = getTestJsonFilePath("env-flag-tests-coll.json", "collection");
|
||||
const TESTS_PATH = getTestJsonFilePath(
|
||||
"env-flag-tests-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment");
|
||||
const args = `test ${TESTS_PATH} --env ${ENV_PATH}`;
|
||||
|
||||
@@ -151,8 +197,14 @@ describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
});
|
||||
|
||||
test("Successfully resolves environment variables referenced in the request body", async () => {
|
||||
const COLL_PATH = getTestJsonFilePath("req-body-env-vars-coll.json", "collection");
|
||||
const ENVS_PATH = getTestJsonFilePath("req-body-env-vars-envs.json", "environment");
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
@@ -160,7 +212,10 @@ describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
});
|
||||
|
||||
test("Works with shorth `-e` flag", async () => {
|
||||
const TESTS_PATH = getTestJsonFilePath("env-flag-tests-coll.json", "collection");
|
||||
const TESTS_PATH = getTestJsonFilePath(
|
||||
"env-flag-tests-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment");
|
||||
const args = `test ${TESTS_PATH} -e ${ENV_PATH}`;
|
||||
|
||||
@@ -183,7 +238,10 @@ describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
secretHeaderValue: "secret-header-value",
|
||||
};
|
||||
|
||||
const COLL_PATH = getTestJsonFilePath("secret-envs-coll.json", "collection");
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"secret-envs-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath("secret-envs.json", "environment");
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
@@ -197,8 +255,14 @@ describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
|
||||
// Prefers values specified in the environment export file over values set in the system environment
|
||||
test("Successfully picks the values for secret environment variables set directly in the environment export file and persists the environment variables set from the pre-request script", async () => {
|
||||
const COLL_PATH = getTestJsonFilePath("secret-envs-coll.json", "collection");
|
||||
const ENVS_PATH = getTestJsonFilePath("secret-supplied-values-envs.json", "environment");
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"secret-envs-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"secret-supplied-values-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error, stdout } = await runCLI(args);
|
||||
@@ -212,9 +276,13 @@ describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
// Values set from the scripting context takes the highest precedence
|
||||
test("Setting values for secret environment variables from the pre-request script overrides values set at the supplied environment export file", async () => {
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"secret-envs-persistence-coll.json", "collection"
|
||||
"secret-envs-persistence-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"secret-supplied-values-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath("secret-supplied-values-envs.json", "environment");
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error, stdout } = await runCLI(args);
|
||||
@@ -227,10 +295,12 @@ describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
|
||||
test("Persists secret environment variable values set from the pre-request script for consumption in the request and post-request script context", async () => {
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"secret-envs-persistence-scripting-coll.json", "collection"
|
||||
"secret-envs-persistence-scripting-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"secret-envs-persistence-scripting-envs.json", "environment"
|
||||
"secret-envs-persistence-scripting-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { isRESTCollection } from "../../../utils/checks";
|
||||
|
||||
describe("isRESTCollection", () => {
|
||||
test("Undefined collection value.", () => {
|
||||
expect(isRESTCollection(undefined)).toBeFalsy();
|
||||
});
|
||||
|
||||
test("Invalid id value.", () => {
|
||||
expect(
|
||||
isRESTCollection({
|
||||
v: 1,
|
||||
name: "test",
|
||||
id: 1,
|
||||
})
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
test("Invalid requests value.", () => {
|
||||
expect(
|
||||
isRESTCollection({
|
||||
v: 1,
|
||||
name: "test",
|
||||
id: "1",
|
||||
requests: null,
|
||||
})
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
test("Invalid folders value.", () => {
|
||||
expect(
|
||||
isRESTCollection({
|
||||
v: 1,
|
||||
name: "test",
|
||||
id: "1",
|
||||
requests: [],
|
||||
folders: undefined,
|
||||
})
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
test("Invalid RESTCollection(s) in folders.", () => {
|
||||
expect(
|
||||
isRESTCollection({
|
||||
v: 1,
|
||||
name: "test",
|
||||
id: "1",
|
||||
requests: [],
|
||||
folders: [
|
||||
{
|
||||
v: 1,
|
||||
name: "test1",
|
||||
id: "2",
|
||||
requests: undefined,
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
test("Invalid HoppRESTRequest(s) in requests.", () => {
|
||||
expect(
|
||||
isRESTCollection({
|
||||
v: 1,
|
||||
name: "test",
|
||||
id: "1",
|
||||
requests: [{}],
|
||||
folders: [],
|
||||
})
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
test("Valid RESTCollection.", () => {
|
||||
expect(
|
||||
isRESTCollection({
|
||||
v: 1,
|
||||
name: "test",
|
||||
id: "1",
|
||||
requests: [],
|
||||
folders: [],
|
||||
})
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"v": 1,
|
||||
"name": "coll-v1",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"url": "https://httpbin.org",
|
||||
"path": "/get",
|
||||
"headers": [
|
||||
{ "key": "Inactive-Header", "value": "Inactive Header", "active": false },
|
||||
{ "key": "Authorization", "value": "Bearer token123", "active": true }
|
||||
],
|
||||
"params": [
|
||||
{ "key": "key", "value": "value", "active": true },
|
||||
{ "key": "inactive-key", "value": "inactive-param", "active": false }
|
||||
],
|
||||
"name": "req-v0",
|
||||
"method": "GET",
|
||||
"preRequestScript": "",
|
||||
"testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"Inactive-Header\"]).toBe(undefined)\n})",
|
||||
"contentType": "application/json",
|
||||
"body": "",
|
||||
"auth": "Bearer Token",
|
||||
"bearerToken": "token123"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"v": 1,
|
||||
"name": "coll-v1",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Inactive-Header",
|
||||
"value": "Inactive Header",
|
||||
"active": false
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer token123",
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"key": "key",
|
||||
"value": "value",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "inactive-key",
|
||||
"value": "inactive-param",
|
||||
"active": false
|
||||
}
|
||||
],
|
||||
"name": "req-v1",
|
||||
"method": "GET",
|
||||
"preRequestScript": "",
|
||||
"testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"Inactive-Header\"]).toBe(undefined)\n})",
|
||||
"body": {
|
||||
"contentType": null,
|
||||
"body": null
|
||||
},
|
||||
"auth": {
|
||||
"authType": "bearer",
|
||||
"authActive": true,
|
||||
"token": "token123"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"v": 2,
|
||||
"name": "coll-v2",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "2",
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Inactive-Header",
|
||||
"value": "Inactive Header",
|
||||
"active": false
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer token123",
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"key": "key",
|
||||
"value": "value",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "inactive-key",
|
||||
"value": "inactive-param",
|
||||
"active": false
|
||||
}
|
||||
],
|
||||
"name": "req-v2",
|
||||
"method": "GET",
|
||||
"preRequestScript": "",
|
||||
"testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"Inactive-Header\"]).toBe(undefined)\n})",
|
||||
"body": {
|
||||
"contentType": null,
|
||||
"body": null
|
||||
},
|
||||
"auth": {
|
||||
"authType": "bearer",
|
||||
"authActive": true,
|
||||
"token": "token123"
|
||||
},
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": []
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"v": 2,
|
||||
"name": "coll-v2",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "3",
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Inactive-Header",
|
||||
"value": "Inactive Header",
|
||||
"active": false
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer token123",
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"key": "key",
|
||||
"value": "value",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "inactive-key",
|
||||
"value": "inactive-param",
|
||||
"active": false
|
||||
}
|
||||
],
|
||||
"name": "req-v3",
|
||||
"method": "GET",
|
||||
"preRequestScript": "",
|
||||
"testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"Inactive-Header\"]).toBe(undefined)\n})",
|
||||
"body": {
|
||||
"contentType": null,
|
||||
"body": null
|
||||
},
|
||||
"auth": {
|
||||
"authType": "bearer",
|
||||
"authActive": true,
|
||||
"token": "token123"
|
||||
},
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": []
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
[
|
||||
{
|
||||
"v": 1,
|
||||
"v": 2,
|
||||
"name": "CollectionA",
|
||||
"folders": [
|
||||
{
|
||||
"v": 1,
|
||||
"v": 2,
|
||||
"name": "FolderA",
|
||||
"folders": [
|
||||
{
|
||||
"v": 1,
|
||||
"v": 2,
|
||||
"name": "FolderB",
|
||||
"folders": [
|
||||
{
|
||||
"v": 1,
|
||||
"v": 2,
|
||||
"name": "FolderC",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
@@ -153,11 +153,11 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"v": 1,
|
||||
"v": 2,
|
||||
"name": "CollectionB",
|
||||
"folders": [
|
||||
{
|
||||
"v": 1,
|
||||
"v": 2,
|
||||
"name": "FolderA",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"v": 1,
|
||||
"name": "tests",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "2",
|
||||
"endpoint": "<<baseURL>>",
|
||||
"name": "",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"method": "GET",
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
},
|
||||
"preRequestScript": "",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});",
|
||||
"body": {
|
||||
"contentType": null,
|
||||
"body": null
|
||||
},
|
||||
"requestVariables": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "env-v0",
|
||||
"variables": [
|
||||
{
|
||||
"key": "baseURL",
|
||||
"value": "https://echo.hoppscotch.io"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "env-v0",
|
||||
"variables": [
|
||||
{
|
||||
"key": "baseURL",
|
||||
"value": "https://echo.hoppscotch.io",
|
||||
"secret": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export async function parseEnvsData(path: string) {
|
||||
|
||||
if (HoppEnvKeyPairResult.success) {
|
||||
for (const [key, value] of Object.entries(HoppEnvKeyPairResult.data)) {
|
||||
envPairs.push({ key, value });
|
||||
envPairs.push({ key, value, secret: false });
|
||||
}
|
||||
} else if (HoppEnvExportObjectResult.type === "ok") {
|
||||
envPairs.push(...HoppEnvExportObjectResult.value.variables);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { HoppCollection, isHoppRESTRequest } from "@hoppscotch/data";
|
||||
import * as A from "fp-ts/Array";
|
||||
import { CommanderError } from "commander";
|
||||
import { HoppCLIError, HoppErrnoException } from "../types/errors";
|
||||
|
||||
@@ -14,48 +12,6 @@ export const hasProperty = <P extends PropertyKey>(
|
||||
prop: P
|
||||
): target is Record<P, unknown> => prop in target;
|
||||
|
||||
/**
|
||||
* Typeguard to check valid Hoppscotch REST Collection.
|
||||
* @param param The object to be checked.
|
||||
* @returns True, if unknown parameter is valid Hoppscotch REST Collection;
|
||||
* False, otherwise.
|
||||
*/
|
||||
export const isRESTCollection = (param: unknown): param is HoppCollection => {
|
||||
if (!!param && typeof param === "object") {
|
||||
if (!hasProperty(param, "v") || typeof param.v !== "number") {
|
||||
return false;
|
||||
}
|
||||
if (!hasProperty(param, "name") || typeof param.name !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (hasProperty(param, "id") && typeof param.id !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (!hasProperty(param, "requests") || !Array.isArray(param.requests)) {
|
||||
return false;
|
||||
} else {
|
||||
// Checks each requests array to be valid HoppRESTRequest.
|
||||
const checkRequests = A.every(isHoppRESTRequest)(param.requests);
|
||||
if (!checkRequests) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!hasProperty(param, "folders") || !Array.isArray(param.folders)) {
|
||||
return false;
|
||||
} else {
|
||||
// Checks each folder to be valid REST collection.
|
||||
const checkFolders = A.every(isRESTCollection)(param.folders);
|
||||
if (!checkFolders) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if given error data is of type HoppCLIError, based on existence
|
||||
* of code property.
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
|
||||
import fs from "fs/promises";
|
||||
import { FormDataEntry } from "../types/request";
|
||||
import { entityReference } from "verzod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { error } from "../types/errors";
|
||||
import { isRESTCollection, isHoppErrnoException } from "./checks";
|
||||
import { HoppCollection } from "@hoppscotch/data";
|
||||
import { FormDataEntry } from "../types/request";
|
||||
import { isHoppErrnoException } from "./checks";
|
||||
|
||||
/**
|
||||
* Parses array of FormDataEntry to FormData.
|
||||
@@ -67,7 +70,11 @@ export async function parseCollectionData(
|
||||
? contents
|
||||
: [contents];
|
||||
|
||||
if (maybeArrayOfCollections.some((x) => !isRESTCollection(x))) {
|
||||
const collectionSchemaParsedResult = z
|
||||
.array(entityReference(HoppCollection))
|
||||
.safeParse(maybeArrayOfCollections);
|
||||
|
||||
if (!collectionSchemaParsedResult.success) {
|
||||
throw error({
|
||||
code: "MALFORMED_COLLECTION",
|
||||
path,
|
||||
@@ -75,5 +82,22 @@ export async function parseCollectionData(
|
||||
});
|
||||
}
|
||||
|
||||
return maybeArrayOfCollections as HoppCollection[];
|
||||
return collectionSchemaParsedResult.data.map((collection) => {
|
||||
const requestSchemaParsedResult = z
|
||||
.array(entityReference(HoppRESTRequest))
|
||||
.safeParse(collection.requests);
|
||||
|
||||
if (!requestSchemaParsedResult.success) {
|
||||
throw error({
|
||||
code: "MALFORMED_COLLECTION",
|
||||
path,
|
||||
data: "Please check the collection data.",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...collection,
|
||||
requests: requestSchemaParsedResult.data,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"hide_secret": "Hide secret",
|
||||
"label": "Label",
|
||||
"learn_more": "Learn more",
|
||||
"download_here": "Download here",
|
||||
"less": "Less",
|
||||
"more": "More",
|
||||
"new": "New",
|
||||
@@ -103,8 +104,10 @@
|
||||
"auth": {
|
||||
"account_exists": "Account exists with different credential - Login to link both accounts",
|
||||
"all_sign_in_options": "All sign in options",
|
||||
"continue_with_auth_provider": "Continue with {provider}",
|
||||
"continue_with_email": "Continue with Email",
|
||||
"continue_with_github": "Continue with GitHub",
|
||||
"continue_with_github_enterprise": "Continue with GitHub Enterprise",
|
||||
"continue_with_google": "Continue with Google",
|
||||
"continue_with_microsoft": "Continue with Microsoft",
|
||||
"email": "Email",
|
||||
@@ -312,6 +315,7 @@
|
||||
"danger_zone": "Danger zone",
|
||||
"delete_account": "Your account is currently an owner in these workspaces:",
|
||||
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these workspaces before you can delete your account.",
|
||||
"empty_profile_name": "Profile name cannot be empty",
|
||||
"empty_req_name": "Empty Request Name",
|
||||
"f12_details": "(F12 for details)",
|
||||
"gql_prettify_invalid_query": "Couldn't prettify an invalid query, solve query syntax errors and try again",
|
||||
@@ -331,6 +335,7 @@
|
||||
"page_not_found": "This page could not be found",
|
||||
"please_install_extension": "Please install the extension and add origin to the extension.",
|
||||
"proxy_error": "Proxy error",
|
||||
"same_profile_name": "Updated profile name is same as the current profile name",
|
||||
"script_fail": "Could not execute pre-request script",
|
||||
"something_went_wrong": "Something went wrong",
|
||||
"test_script_fail": "Could not execute post-request script",
|
||||
@@ -446,7 +451,7 @@
|
||||
"not_found": "Environment variable “{environment}” not found."
|
||||
},
|
||||
"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": "The browser doesn't allow Hoppscotch to set Cookie Headers. Please use Authorization Headers instead. However, our Hoppscotch Desktop App is live now and supports Cookies."
|
||||
},
|
||||
"response": {
|
||||
"401_error": "Please check your authentication credentials.",
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
@click="provider.action"
|
||||
/>
|
||||
|
||||
<hr v-if="additonalLoginItems.length > 0" />
|
||||
<hr v-if="additionalLoginItems.length > 0" />
|
||||
|
||||
<HoppSmartItem
|
||||
v-for="loginItem in additonalLoginItems"
|
||||
v-for="loginItem in additionalLoginItems"
|
||||
:key="loginItem.id"
|
||||
:icon="loginItem.icon"
|
||||
:label="loginItem.text(t)"
|
||||
@@ -170,7 +170,7 @@ type AuthProviderItem = {
|
||||
}
|
||||
|
||||
let allowedAuthProviders: AuthProviderItem[] = []
|
||||
let additonalLoginItems: LoginItemDef[] = []
|
||||
const additionalLoginItems: LoginItemDef[] = []
|
||||
|
||||
const doAdditionalLoginItemClickAction = async (item: LoginItemDef) => {
|
||||
await item.onClick()
|
||||
@@ -199,10 +199,33 @@ onMounted(async () => {
|
||||
allowedAuthProviders = enabledAuthProviders
|
||||
|
||||
// setup the additional login items
|
||||
additonalLoginItems =
|
||||
platform.auth.additionalLoginItems?.filter((item) =>
|
||||
res.right.includes(item.id)
|
||||
) ?? []
|
||||
platform.auth.additionalLoginItems?.forEach((item) => {
|
||||
if (res.right.includes(item.id)) {
|
||||
additionalLoginItems.push(item)
|
||||
}
|
||||
|
||||
// since the BE send the OIDC auth providers as OIDC:providerName,
|
||||
// we need to split the string and use the providerName as the text
|
||||
if (item.id === "OIDC") {
|
||||
res.right
|
||||
.filter((provider) => provider.startsWith("OIDC"))
|
||||
.forEach((provider) => {
|
||||
const OIDCName = provider.split(":")[1]
|
||||
const loginItemText = OIDCName
|
||||
? () =>
|
||||
t("auth.continue_with_auth_provider", {
|
||||
provider: OIDCName,
|
||||
})
|
||||
: item.text
|
||||
|
||||
const OIDCLoginItem = {
|
||||
...item,
|
||||
text: loginItemText,
|
||||
}
|
||||
additionalLoginItems.push(OIDCLoginItem)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
isLoadingAllowedAuthProviders.value = false
|
||||
})
|
||||
@@ -311,6 +334,14 @@ const authProvidersAvailable: AuthProviderItem[] = [
|
||||
action: signInWithGithub,
|
||||
isLoading: signingInWithGitHub,
|
||||
},
|
||||
// the authprovider either send github or github:enterprise and both are handled by the same route
|
||||
{
|
||||
id: "GITHUB:ENTERPRISE",
|
||||
icon: IconGithub,
|
||||
label: t("auth.continue_with_github_enterprise"),
|
||||
action: signInWithGithub,
|
||||
isLoading: signingInWithGitHub,
|
||||
},
|
||||
{
|
||||
id: "GOOGLE",
|
||||
icon: IconGoogle,
|
||||
|
||||
@@ -98,6 +98,7 @@ import { RESTTabService } from "~/services/tab/rest"
|
||||
import { useService } from "dioc/vue"
|
||||
import { useNestedSetting } from "~/composables/settings"
|
||||
import { toggleNestedSetting } from "~/newstore/settings"
|
||||
import { EditorView } from "@codemirror/view"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
@@ -124,6 +125,7 @@ useCodemirror(
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: false,
|
||||
onInit: (view: EditorView) => view.focus(),
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -273,6 +273,10 @@ const loading = computed(
|
||||
)
|
||||
|
||||
onLoggedIn(() => {
|
||||
if (adapter.isInitialized()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// wait for a bit to let the auth token to be set
|
||||
// because in some race conditions, the token is not set this fixes that
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="p-4 truncate">
|
||||
<label
|
||||
class="font-semibold text-secondaryDark"
|
||||
:class="{ 'cursor-pointer': compact && team.myRole === 'OWNER' }"
|
||||
@@ -131,6 +131,7 @@
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="t('confirm.remove_team')"
|
||||
:loading-state="loading"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="deleteTeam()"
|
||||
/>
|
||||
@@ -161,6 +162,8 @@ import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconUserX from "~icons/lucide/user-x"
|
||||
import IconUserPlus from "~icons/lucide/user-plus"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import { useService } from "dioc/vue"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
@@ -173,6 +176,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(e: "edit-team"): void
|
||||
(e: "invite-team"): void
|
||||
(e: "refetch-teams"): void
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
@@ -180,7 +184,12 @@ const toast = useToast()
|
||||
const confirmRemove = ref(false)
|
||||
const confirmExit = ref(false)
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const workspaceService = useService(WorkspaceService)
|
||||
|
||||
const deleteTeam = () => {
|
||||
loading.value = true
|
||||
pipe(
|
||||
backendDeleteTeam(props.teamID),
|
||||
TE.match(
|
||||
@@ -188,9 +197,25 @@ const deleteTeam = () => {
|
||||
// TODO: Better errors ? We know the possible errors now
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
console.error(err)
|
||||
loading.value = false
|
||||
confirmRemove.value = false
|
||||
},
|
||||
() => {
|
||||
toast.success(`${t("team.deleted")}`)
|
||||
loading.value = false
|
||||
emit("refetch-teams")
|
||||
|
||||
const currentWorkspace = workspaceService.currentWorkspace.value
|
||||
|
||||
// If the current workspace is the deleted workspace, change the workspace to personal
|
||||
if (
|
||||
currentWorkspace.type === "team" &&
|
||||
currentWorkspace.teamID === props.teamID
|
||||
) {
|
||||
workspaceService.changeWorkspace({ type: "personal" })
|
||||
}
|
||||
|
||||
confirmRemove.value = false
|
||||
}
|
||||
)
|
||||
)() // Tasks (and TEs) are lazy, so call the function returned
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<HoppButtonSecondary
|
||||
:label="`${t('team.create_new')}`"
|
||||
outline
|
||||
:icon="IconPlus"
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center">
|
||||
@@ -16,13 +17,6 @@
|
||||
:alt="`${t('empty.teams')}`"
|
||||
:text="`${t('empty.teams')}`"
|
||||
>
|
||||
<template #body>
|
||||
<HoppButtonSecondary
|
||||
:label="`${t('team.create_new')}`"
|
||||
filled
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</template>
|
||||
</HoppSmartPlaceholder>
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
@@ -39,6 +33,7 @@
|
||||
:compact="modal"
|
||||
@edit-team="editTeam(team, team.id)"
|
||||
@invite-team="inviteTeam(team, team.id)"
|
||||
@refetch-teams="refetchTeams"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!loading && adapterError" class="flex flex-col items-center">
|
||||
@@ -76,6 +71,7 @@ import { useReadonlyStream } from "@composables/stream"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
import { useService } from "dioc/vue"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@ type CodeMirrorOptions = {
|
||||
|
||||
// callback on editor update
|
||||
onUpdate?: (view: ViewUpdate) => void
|
||||
|
||||
// callback on view initialization
|
||||
onInit?: (view: EditorView) => void
|
||||
}
|
||||
|
||||
const hoppCompleterExt = (completer: Completer): Extension => {
|
||||
@@ -208,7 +211,9 @@ export function useCodemirror(
|
||||
el: Ref<any | null>,
|
||||
value: Ref<string | undefined>,
|
||||
options: CodeMirrorOptions
|
||||
): { cursor: Ref<{ line: number; ch: number }> } {
|
||||
): {
|
||||
cursor: Ref<{ line: number; ch: number }>
|
||||
} {
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
// Set default value for contextMenuEnabled if not provided
|
||||
@@ -383,6 +388,8 @@ export function useCodemirror(
|
||||
extensions,
|
||||
}),
|
||||
})
|
||||
|
||||
options.onInit?.(view.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -868,6 +868,38 @@ const samples = [
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
command: `curl --location 'https://api.example.net/id/1164/requests' \
|
||||
--header 'Accept: application/vnd.test-data.v2.1+json' \
|
||||
--header 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'data={"type":"test","typeId":"101"}' \
|
||||
--data-urlencode 'data2={"type":"test2","typeId":"123"}'`,
|
||||
response: makeRESTRequest({
|
||||
method: "POST",
|
||||
name: "Untitled",
|
||||
endpoint: "https://api.example.net/id/1164/requests",
|
||||
auth: {
|
||||
authType: "inherit",
|
||||
authActive: true,
|
||||
},
|
||||
body: {
|
||||
contentType: "application/x-www-form-urlencoded",
|
||||
body: `data: {"type":"test","typeId":"101"}
|
||||
data2: {"type":"test2","typeId":"123"}`,
|
||||
},
|
||||
params: [],
|
||||
headers: [
|
||||
{
|
||||
active: true,
|
||||
key: "Accept",
|
||||
value: "application/vnd.test-data.v2.1+json",
|
||||
},
|
||||
],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
describe("Parse curl command to Hopp REST Request", () => {
|
||||
|
||||
@@ -33,7 +33,27 @@ export const parseCurlCommand = (curlCommand: string) => {
|
||||
// const compressed = !!parsedArguments.compressed
|
||||
|
||||
curlCommand = preProcessCurlCommand(curlCommand)
|
||||
const parsedArguments = parser(curlCommand)
|
||||
|
||||
const args: parser.Arguments = parser(curlCommand)
|
||||
|
||||
const parsedArguments = pipe(
|
||||
args,
|
||||
O.fromPredicate(
|
||||
(args) =>
|
||||
objHasProperty("dataUrlencode", "string")(args) ||
|
||||
objHasProperty("dataUrlencode", "object")(args)
|
||||
),
|
||||
O.map((args) => {
|
||||
const urlEncodedData: string[] = Array.isArray(args.dataUrlencode)
|
||||
? args.dataUrlencode
|
||||
: [args.dataUrlencode]
|
||||
|
||||
const data = A.map(decodeURIComponent)(urlEncodedData)
|
||||
|
||||
return { ...args, d: data }
|
||||
}),
|
||||
O.getOrElse(() => args)
|
||||
)
|
||||
|
||||
const headerObject = getHeaders(parsedArguments)
|
||||
const { headers } = headerObject
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { pipe, flow } from "fp-ts/function"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import {
|
||||
HoppCollection,
|
||||
HoppRESTRequest,
|
||||
getDefaultGQLRequest,
|
||||
getDefaultRESTRequest,
|
||||
translateToNewRESTCollection,
|
||||
} from "@hoppscotch/data"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
import * as A from "fp-ts/Array"
|
||||
import { translateToNewRESTCollection, HoppCollection } from "@hoppscotch/data"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { flow, pipe } from "fp-ts/function"
|
||||
|
||||
import { IMPORTER_INVALID_FILE_FORMAT } from "."
|
||||
import { HoppGQLRequest, translateToNewGQLCollection } from "@hoppscotch/data"
|
||||
import { safeParseJSON } from "~/helpers/functional/json"
|
||||
import { translateToNewGQLCollection } from "@hoppscotch/data"
|
||||
import { entityReference } from "verzod"
|
||||
import { z } from "zod"
|
||||
import { IMPORTER_INVALID_FILE_FORMAT } from "."
|
||||
|
||||
export const hoppRESTImporter = (content: string[]) =>
|
||||
pipe(
|
||||
@@ -32,8 +36,24 @@ export const hoppRESTImporter = (content: string[]) =>
|
||||
* else translate it into one.
|
||||
*/
|
||||
const validateCollection = (collection: unknown) => {
|
||||
const result = entityReference(HoppCollection).safeParse(collection)
|
||||
if (result.success) return O.some(result.data)
|
||||
const collectionSchemaParsedResult = HoppCollection.safeParse(collection)
|
||||
|
||||
if (collectionSchemaParsedResult.type === "ok") {
|
||||
const requests = collectionSchemaParsedResult.value.requests.map(
|
||||
(request) => {
|
||||
const requestSchemaParsedResult = HoppRESTRequest.safeParse(request)
|
||||
|
||||
return requestSchemaParsedResult.type === "ok"
|
||||
? requestSchemaParsedResult.value
|
||||
: getDefaultRESTRequest()
|
||||
}
|
||||
)
|
||||
|
||||
return O.some({
|
||||
...collectionSchemaParsedResult.value,
|
||||
requests,
|
||||
})
|
||||
}
|
||||
|
||||
return O.some(translateToNewRESTCollection(collection))
|
||||
}
|
||||
@@ -64,9 +84,24 @@ export const hoppGQLImporter = (content: string) =>
|
||||
* @returns the collection if it is valid, else a translated version of the collection
|
||||
*/
|
||||
export const validateGQLCollection = (collection: unknown) => {
|
||||
const result = z.array(entityReference(HoppCollection)).safeParse(collection)
|
||||
const collectionSchemaParsedResult = HoppCollection.safeParse(collection)
|
||||
|
||||
if (result.success) return O.some(result.data)
|
||||
if (collectionSchemaParsedResult.type === "ok") {
|
||||
const requests = collectionSchemaParsedResult.value.requests.map(
|
||||
(request) => {
|
||||
const requestSchemaParsedResult = HoppGQLRequest.safeParse(request)
|
||||
|
||||
return requestSchemaParsedResult.type === "ok"
|
||||
? requestSchemaParsedResult.value
|
||||
: getDefaultGQLRequest()
|
||||
}
|
||||
)
|
||||
|
||||
return O.some({
|
||||
...collectionSchemaParsedResult.value,
|
||||
requests,
|
||||
})
|
||||
}
|
||||
|
||||
return O.some(translateToNewGQLCollection(collection))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { ref } from "vue"
|
||||
import {
|
||||
HoppRESTAuth,
|
||||
HoppRESTHeader,
|
||||
HoppRESTRequest,
|
||||
getDefaultRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import axios from "axios"
|
||||
import { Service } from "dioc"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { Ref, ref } from "vue"
|
||||
|
||||
import { runGQLQuery } from "../backend/GQLClient"
|
||||
import {
|
||||
GetCollectionChildrenDocument,
|
||||
@@ -7,14 +17,10 @@ import {
|
||||
GetSingleRequestDocument,
|
||||
} from "../backend/graphql"
|
||||
import { TeamCollection } from "./TeamCollection"
|
||||
import { HoppRESTAuth, HoppRESTHeader } from "@hoppscotch/data"
|
||||
|
||||
import * as E from "fp-ts/Either"
|
||||
import { platform } from "~/platform"
|
||||
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
|
||||
import { TeamRequest } from "./TeamRequest"
|
||||
import { Service } from "dioc"
|
||||
import axios from "axios"
|
||||
import { Ref } from "vue"
|
||||
|
||||
type CollectionSearchMeta = {
|
||||
isSearchResult?: boolean
|
||||
@@ -149,12 +155,21 @@ function convertToTeamTree(
|
||||
if (isAlreadyInserted) return
|
||||
|
||||
if (parentCollection) {
|
||||
const requestSchemaParsedResult = HoppRESTRequest.safeParse(
|
||||
request.request
|
||||
)
|
||||
|
||||
const effectiveRequest =
|
||||
requestSchemaParsedResult.type === "ok"
|
||||
? requestSchemaParsedResult.value
|
||||
: getDefaultRESTRequest()
|
||||
|
||||
parentCollection.requests = parentCollection.requests || []
|
||||
parentCollection.requests.push({
|
||||
id: request.id,
|
||||
collectionID: request.collectionID,
|
||||
title: request.title,
|
||||
request: request.request,
|
||||
request: effectiveRequest,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -199,16 +214,16 @@ export class TeamSearchService extends Service {
|
||||
this.searchResultsRequests = {}
|
||||
this.expandedCollections.value = []
|
||||
|
||||
const axiosPlatformConfig = platform.auth.axiosPlatformConfig?.() ?? {}
|
||||
|
||||
try {
|
||||
const searchResponse = await axios.get(
|
||||
`${
|
||||
this.endpoint
|
||||
}/team-collection/search/${teamID}?searchQuery=${encodeURIComponent(
|
||||
query
|
||||
)}}`,
|
||||
{
|
||||
withCredentials: true,
|
||||
}
|
||||
)}`,
|
||||
axiosPlatformConfig
|
||||
)
|
||||
|
||||
if (searchResponse.status !== 200) {
|
||||
|
||||
@@ -274,7 +274,7 @@ function getFinalBodyFromRequest(
|
||||
|
||||
if (request.body.contentType === "application/x-www-form-urlencoded") {
|
||||
const parsedBodyRecord = pipe(
|
||||
request.body.body,
|
||||
request.body.body ?? "",
|
||||
parseRawKeyValueEntriesE,
|
||||
E.map(
|
||||
flow(
|
||||
@@ -311,7 +311,7 @@ function getFinalBodyFromRequest(
|
||||
|
||||
if (request.body.contentType === "multipart/form-data") {
|
||||
return pipe(
|
||||
request.body.body,
|
||||
request.body.body ?? [],
|
||||
A.filter((x) => (x.key !== "" || x.isFile) && x.active), // Remove empty keys
|
||||
|
||||
// Sort files down
|
||||
|
||||
@@ -79,7 +79,7 @@ const importCollections = (url: unknown, type: unknown) =>
|
||||
content.data,
|
||||
TO.fromPredicate(isOfType("string")),
|
||||
TE.fromTaskOption(() => IMPORTER_INVALID_FILE_FORMAT),
|
||||
TE.chain((data) => importer.importer(data))
|
||||
TE.chain((data) => importer.importer([data]))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -210,6 +210,8 @@ import { toggleSetting } from "~/newstore/settings"
|
||||
import IconVerified from "~icons/lucide/verified"
|
||||
import IconSettings from "~icons/lucide/settings"
|
||||
|
||||
import * as E from "fp-ts/Either"
|
||||
|
||||
type ProfileTabs = "sync" | "teams"
|
||||
|
||||
const selectedProfileTab = ref<ProfileTabs>("sync")
|
||||
@@ -244,19 +246,28 @@ const displayName = ref(currentUser.value?.displayName || "")
|
||||
const updatingDisplayName = ref(false)
|
||||
watchEffect(() => (displayName.value = currentUser.value?.displayName || ""))
|
||||
|
||||
const updateDisplayName = () => {
|
||||
const updateDisplayName = async () => {
|
||||
if (!displayName.value) {
|
||||
toast.error(`${t("error.empty_profile_name")}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentUser.value?.displayName === displayName.value) {
|
||||
toast.error(`${t("error.same_profile_name")}`)
|
||||
return
|
||||
}
|
||||
|
||||
updatingDisplayName.value = true
|
||||
platform.auth
|
||||
.setDisplayName(displayName.value as string)
|
||||
.then(() => {
|
||||
toast.success(`${t("profile.updated")}`)
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
})
|
||||
.finally(() => {
|
||||
updatingDisplayName.value = false
|
||||
})
|
||||
|
||||
const res = await platform.auth.setDisplayName(displayName.value)
|
||||
|
||||
if (E.isLeft(res)) {
|
||||
toast.error(t("error.something_went_wrong"))
|
||||
} else if (E.isRight(res)) {
|
||||
toast.success(`${t("profile.updated")}`)
|
||||
}
|
||||
|
||||
updatingDisplayName.value = false
|
||||
}
|
||||
|
||||
const emailAddress = ref(currentUser.value?.email || "")
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Observable } from "rxjs"
|
||||
import { Component } from "vue"
|
||||
import { getI18n } from "~/modules/i18n"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { AxiosRequestConfig } from "axios"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
|
||||
/**
|
||||
* A common (and required) set of fields that describe a user.
|
||||
@@ -135,6 +137,15 @@ export type AuthPlatformDef = {
|
||||
*/
|
||||
getGQLClientOptions?: () => Partial<ClientOptions>
|
||||
|
||||
/**
|
||||
* called by the platform to provide additional/different config options when
|
||||
* sending requests with axios
|
||||
* eg: SH needs to include cookies in the request, while Central doesn't and throws a cors error if it does
|
||||
*
|
||||
* @returns AxiosRequestConfig
|
||||
*/
|
||||
axiosPlatformConfig?: () => AxiosRequestConfig
|
||||
|
||||
/**
|
||||
* Returns the string content that should be returned when the user selects to
|
||||
* copy auth token from Developer Options.
|
||||
@@ -219,9 +230,11 @@ export type AuthPlatformDef = {
|
||||
/**
|
||||
* Updates the display name of the user
|
||||
* @param name The new name to set this to.
|
||||
* @returns An empty promise that is resolved when the operation is complete
|
||||
* @returns A promise that resolves with the display name update status when the operation is complete
|
||||
*/
|
||||
setDisplayName: (name: string) => Promise<void>
|
||||
setDisplayName: (
|
||||
name: string
|
||||
) => Promise<E.Either<GQLError<string>, undefined>>
|
||||
|
||||
/**
|
||||
* Returns the list of allowed auth providers for the platform ( the currently supported ones are GOOGLE, GITHUB, EMAIL, MICROSOFT, SAML )
|
||||
|
||||
@@ -66,8 +66,8 @@ export class HeaderInspectorService extends Service implements Inspector {
|
||||
index: index,
|
||||
},
|
||||
doc: {
|
||||
text: this.t("action.learn_more"),
|
||||
link: "https://docs.hoppscotch.io/documentation/features/inspections",
|
||||
text: this.t("action.download_here"),
|
||||
link: "https://hoppscotch.com/download",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,7 +169,16 @@ export class CollectionsSpotlightSearcherService
|
||||
}
|
||||
|
||||
scopeHandle.run(() => {
|
||||
const isPersonalWorkspace = computed(
|
||||
() => this.workspaceService.currentWorkspace.value.type === "personal"
|
||||
)
|
||||
|
||||
watch(query, (query) => {
|
||||
if (!isPersonalWorkspace.value) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (pageCategory === "other") {
|
||||
results.value = []
|
||||
return
|
||||
|
||||
@@ -58,13 +58,32 @@ export class TeamsSpotlightSearcherService
|
||||
(query) => {
|
||||
if (this.workspaceService.currentWorkspace.value.type === "team") {
|
||||
const teamID = this.workspaceService.currentWorkspace.value.teamID
|
||||
debouncedSearch(query, teamID)?.catch((_) => {})
|
||||
debouncedSearch(query, teamID)?.catch(() => {})
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
)
|
||||
|
||||
// set the search section title based on the current workspace
|
||||
const teamName = computed(() => {
|
||||
return (
|
||||
(this.workspaceService.currentWorkspace.value.type === "team" &&
|
||||
this.workspaceService.currentWorkspace.value.teamName) ||
|
||||
this.t("team.search_title")
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
teamName,
|
||||
(newTeamName) => {
|
||||
this.searcherSectionTitle = newTeamName
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const onSessionEnd = () => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dist/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build:code": "vite build",
|
||||
"build:decl": "tsc --project tsconfig.decl.json",
|
||||
"build": "pnpm run build:code && pnpm run build:decl",
|
||||
@@ -46,4 +47,4 @@
|
||||
"verzod": "0.2.2",
|
||||
"zod": "3.22.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ import { translateToNewRequest } from "../rest"
|
||||
import { translateToGQLRequest } from "../graphql"
|
||||
|
||||
const versionedObject = z.object({
|
||||
// v is a stringified number
|
||||
v: z.string().regex(/^\d+$/).transform(Number),
|
||||
v: z.number(),
|
||||
})
|
||||
|
||||
export const HoppCollection = createVersionedEntity({
|
||||
@@ -26,7 +25,7 @@ export const HoppCollection = createVersionedEntity({
|
||||
// For V1 we have to check the schema
|
||||
const result = V1_VERSION.schema.safeParse(data)
|
||||
|
||||
return result.success ? 0 : null
|
||||
return result.success ? 1 : null
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation UpdateUserDisplayName($updatedDisplayName: String!) {
|
||||
updateDisplayName(updatedDisplayName: $updatedDisplayName) {
|
||||
displayName
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { runMutation } from "@hoppscotch/common/helpers/backend/GQLClient"
|
||||
import axios from "axios"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
UpdateUserDisplayNameDocument,
|
||||
UpdateUserDisplayNameMutation,
|
||||
UpdateUserDisplayNameMutationVariables,
|
||||
} from "../../api/generated/graphql"
|
||||
|
||||
const expectedAllowedProvidersSchema = z.object({
|
||||
// currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML"
|
||||
@@ -28,3 +34,12 @@ export const getAllowedAuthProviders = async () => {
|
||||
return E.left("SOMETHING_WENT_WRONG")
|
||||
}
|
||||
}
|
||||
|
||||
export const updateUserDisplayName = (updatedDisplayName: string) =>
|
||||
runMutation<
|
||||
UpdateUserDisplayNameMutation,
|
||||
UpdateUserDisplayNameMutationVariables,
|
||||
""
|
||||
>(UpdateUserDisplayNameDocument, {
|
||||
updatedDisplayName,
|
||||
})()
|
||||
|
||||
@@ -8,7 +8,8 @@ import { PersistenceService } from "@hoppscotch/common/services/persistence"
|
||||
import axios from "axios"
|
||||
import { BehaviorSubject, Subject } from "rxjs"
|
||||
import { Ref, ref, watch } from "vue"
|
||||
import { getAllowedAuthProviders } from "./auth.api"
|
||||
import { getAllowedAuthProviders, updateUserDisplayName } from "./auth.api"
|
||||
import * as E from "fp-ts/Either"
|
||||
|
||||
export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>()
|
||||
const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
|
||||
@@ -211,6 +212,13 @@ export const def: AuthPlatformDef = {
|
||||
}
|
||||
},
|
||||
|
||||
axiosPlatformConfig() {
|
||||
return {
|
||||
// for including cookies in the request
|
||||
withCredentials: true,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* it is not possible for us to know if the current cookie is expired because we cannot access http-only cookies from js
|
||||
* hence just returning if the currentUser$ has a value associated with it
|
||||
@@ -310,9 +318,22 @@ export const def: AuthPlatformDef = {
|
||||
async setEmailAddress(_email: string) {
|
||||
return
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
async setDisplayName(name: string) {
|
||||
return
|
||||
if (!name) return E.left("USER_NAME_CANNOT_BE_EMPTY")
|
||||
if (!currentUser$.value) return E.left("NO_USER_LOGGED_IN")
|
||||
|
||||
const res = await updateUserDisplayName(name)
|
||||
|
||||
if (E.isRight(res)) {
|
||||
setUser({
|
||||
...currentUser$.value,
|
||||
displayName: res.right.updateDisplayName.displayName ?? null,
|
||||
})
|
||||
|
||||
return E.right(undefined)
|
||||
}
|
||||
return E.left(res.left)
|
||||
},
|
||||
|
||||
async signOutUser() {
|
||||
|
||||
@@ -164,11 +164,14 @@
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"reenter_email": "Re-enter email",
|
||||
"remove_admin_failure": "Failed to remove admin status!!",
|
||||
"remove_admin_failure_only_one_admin": "Failed to remove admin status. There should be at least one admin!!",
|
||||
"remove_admin_success": "Admin status removed!!",
|
||||
"remove_admin_from_users_failure": "Failed to remove admin status from selected users!!",
|
||||
"remove_admin_from_users_success": "Admin status removed from selected users!!",
|
||||
"remove_admin_to_delete_user": "Remove admin privilege to delete the user!!",
|
||||
"remove_owner_to_delete_user": "Remove team ownership status to delete the user!!",
|
||||
"remove_admin_for_deletion": "Remove admin status before attempting deletion!!",
|
||||
"remove_owner_for_deletion": "One or more users are team owners. Update ownership before deletion!!",
|
||||
"remove_invitee_failure": "Removal of invitee failed!!",
|
||||
"remove_invitee_success": "Removal of invitee is successfull!!",
|
||||
"remove_member_failure": "Member couldn't be removed!!",
|
||||
@@ -253,7 +256,6 @@
|
||||
},
|
||||
"users": {
|
||||
"admin": "Admin",
|
||||
"admin_email": "Admin Email",
|
||||
"admin_id": "Admin ID",
|
||||
"cancel": "Cancel",
|
||||
"created_on": "Created On",
|
||||
@@ -270,6 +272,7 @@
|
||||
"invalid_user": "Invalid User",
|
||||
"invite_load_list_error": "Unable to Load Invited Users List",
|
||||
"invite_user": "Invite User",
|
||||
"invited_by": "Invited By",
|
||||
"invited_on": "Invited On",
|
||||
"invited_users": "Invited Users",
|
||||
"invitee_email": "Invitee Email",
|
||||
|
||||
@@ -51,7 +51,6 @@ declare module '@vue/runtime-core' {
|
||||
UsersDetails: typeof import('./components/users/Details.vue')['default']
|
||||
UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default']
|
||||
UsersSharedRequests: typeof import('./components/users/SharedRequests.vue')['default']
|
||||
UsersTable: typeof import('./components/users/Table.vue')['default']
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ export const UNAUTHORIZED = 'Unauthorized' as const;
|
||||
// Sometimes the backend returns Unauthorized error message as follows:
|
||||
export const GRAPHQL_UNAUTHORIZED = '[GraphQL] Unauthorized' as const;
|
||||
|
||||
export const DELETE_USER_FAILED_ONLY_ONE_ADMIN =
|
||||
'admin/only_one_admin_account_found' as const;
|
||||
export const ONLY_ONE_ADMIN_ACCOUNT_FOUND =
|
||||
'[GraphQL] admin/only_one_admin_account_found' as const;
|
||||
|
||||
export const ADMIN_CANNOT_BE_DELETED =
|
||||
'admin/admin_can_not_be_deleted' as const;
|
||||
@@ -17,3 +17,6 @@ export const ADMIN_CANNOT_BE_DELETED =
|
||||
// When trying to invite a user that is already invited
|
||||
export const USER_ALREADY_INVITED =
|
||||
'[GraphQL] admin/user_already_invited' as const;
|
||||
|
||||
// When attempting to delete a user who is an owner of a team
|
||||
export const USER_IS_OWNER = 'user/is_owner' as const;
|
||||
|
||||
119
packages/hoppscotch-sh-admin/src/helpers/userManagement.ts
Normal file
119
packages/hoppscotch-sh-admin/src/helpers/userManagement.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useToast } from '~/composables/toast';
|
||||
import { getI18n } from '~/modules/i18n';
|
||||
import { UserDeletionResult } from './backend/graphql';
|
||||
import { ADMIN_CANNOT_BE_DELETED, USER_IS_OWNER } from './errors';
|
||||
|
||||
type ToastMessage = {
|
||||
message: string;
|
||||
state: 'success' | 'error';
|
||||
};
|
||||
|
||||
const t = getI18n();
|
||||
const toast = useToast();
|
||||
|
||||
const displayToastMessages = (
|
||||
toastMessages: ToastMessage[],
|
||||
currentIndex: number
|
||||
) => {
|
||||
const { message, state } = toastMessages[currentIndex];
|
||||
|
||||
toast[state](message, {
|
||||
duration: 2000,
|
||||
onComplete: () => {
|
||||
if (currentIndex < toastMessages.length - 1) {
|
||||
displayToastMessages(toastMessages, currentIndex + 1);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const handleUserDeletion = (deletedUsersList: UserDeletionResult[]) => {
|
||||
const uniqueErrorMessages = new Set(
|
||||
deletedUsersList.map(({ errorMessage }) => errorMessage).filter(Boolean)
|
||||
) as Set<string>;
|
||||
|
||||
const isBulkAction = deletedUsersList.length > 1;
|
||||
|
||||
const deletedUserIDs = deletedUsersList
|
||||
.filter((user) => user.isDeleted)
|
||||
.map((user) => user.userUID);
|
||||
|
||||
// Show the success toast based on the action type if there are no errors
|
||||
if (uniqueErrorMessages.size === 0) {
|
||||
if (isBulkAction) {
|
||||
toast.success(
|
||||
isBulkAction
|
||||
? t('state.delete_user_success')
|
||||
: t('state.delete_users_success')
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t('state.delete_user_success'));
|
||||
return;
|
||||
}
|
||||
|
||||
const errMsgMap = {
|
||||
[ADMIN_CANNOT_BE_DELETED]: isBulkAction
|
||||
? t('state.remove_admin_for_deletion')
|
||||
: t('state.remove_admin_to_delete_user'),
|
||||
|
||||
[USER_IS_OWNER]: isBulkAction
|
||||
? t('state.remove_owner_for_deletion')
|
||||
: t('state.remove_owner_to_delete_user'),
|
||||
};
|
||||
const errMsgMapKeys = Object.keys(errMsgMap);
|
||||
|
||||
const toastMessages: ToastMessage[] = [];
|
||||
|
||||
if (isBulkAction) {
|
||||
// Indicates the actual count of users deleted (filtered via the `isDeleted` field)
|
||||
const deletedUsersCount = deletedUserIDs.length;
|
||||
|
||||
if (isBulkAction && deletedUsersCount > 0) {
|
||||
toastMessages.push({
|
||||
message: t('state.delete_some_users_success', {
|
||||
count: deletedUsersCount,
|
||||
}),
|
||||
state: 'success',
|
||||
});
|
||||
}
|
||||
const remainingDeletionsCount = deletedUsersList.length - deletedUsersCount;
|
||||
if (remainingDeletionsCount > 0) {
|
||||
toastMessages.push({
|
||||
message: t('state.delete_some_users_failure', {
|
||||
count: remainingDeletionsCount,
|
||||
}),
|
||||
state: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
uniqueErrorMessages.forEach((errorMessage) => {
|
||||
if (errMsgMapKeys.includes(errorMessage)) {
|
||||
toastMessages.push({
|
||||
message: errMsgMap[errorMessage as keyof typeof errMsgMap],
|
||||
state: 'error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback for the case where the error message is not in the compiled list
|
||||
if (
|
||||
Array.from(uniqueErrorMessages).some(
|
||||
(key) => !((key as string) in errMsgMap)
|
||||
)
|
||||
) {
|
||||
const fallbackErrMsg = isBulkAction
|
||||
? t('state.delete_users_failure')
|
||||
: t('state.delete_user_failure');
|
||||
|
||||
toastMessages.push({
|
||||
message: fallbackErrMsg,
|
||||
state: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
displayToastMessages(toastMessages, 0);
|
||||
};
|
||||
@@ -1,7 +1,23 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { I18n, createI18n } from 'vue-i18n';
|
||||
import { HoppModule } from '.';
|
||||
import messages from '@intlify/unplugin-vue-i18n/messages';
|
||||
|
||||
// A reference to the i18n instance
|
||||
let i18nInstance: I18n<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
string,
|
||||
false
|
||||
> | null = null;
|
||||
|
||||
/**
|
||||
* Returns the i18n instance
|
||||
*/
|
||||
export function getI18n() {
|
||||
return i18nInstance!.global.t;
|
||||
}
|
||||
|
||||
export default <HoppModule>{
|
||||
onVueAppInit(app) {
|
||||
const i18n = createI18n({
|
||||
@@ -11,6 +27,9 @@ export default <HoppModule>{
|
||||
legacy: false,
|
||||
allowComposition: true,
|
||||
});
|
||||
|
||||
app.use(i18n);
|
||||
|
||||
i18nInstance = i18n;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
RemoveUsersByAdminDocument,
|
||||
UserInfoDocument,
|
||||
} from '~/helpers/backend/graphql';
|
||||
import { ADMIN_CANNOT_BE_DELETED } from '~/helpers/errors';
|
||||
import { handleUserDeletion } from '~/helpers/userManagement';
|
||||
|
||||
const t = useI18n();
|
||||
const toast = useToast();
|
||||
@@ -207,19 +207,13 @@ const deleteUserMutation = async (id: string | null) => {
|
||||
|
||||
if (result.error) {
|
||||
toast.error(t('state.delete_user_failure'));
|
||||
router.push('/users');
|
||||
} else {
|
||||
const deletedUsers = result.data?.removeUsersByAdmin || [];
|
||||
|
||||
const isAdminError = deletedUsers.some(
|
||||
(user) => user.errorMessage === ADMIN_CANNOT_BE_DELETED
|
||||
);
|
||||
|
||||
isAdminError
|
||||
? toast.error(t('state.delete_user_failed_only_one_admin'))
|
||||
: toast.success(t('state.delete_user_success'));
|
||||
handleUserDeletion(deletedUsers);
|
||||
}
|
||||
confirmDeletion.value = false;
|
||||
deleteUserUID.value = null;
|
||||
router.push('/users');
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-x-auto mb-5">
|
||||
<div class="mb-3 flex items-center justify-end">
|
||||
<HoppButtonSecondary
|
||||
outline
|
||||
@@ -210,7 +210,7 @@
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmUsersToAdmin"
|
||||
:title="
|
||||
AreMultipleUsersSelected
|
||||
areMultipleUsersSelected
|
||||
? t('state.confirm_users_to_admin')
|
||||
: t('state.confirm_user_to_admin')
|
||||
"
|
||||
@@ -220,7 +220,7 @@
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmAdminsToUsers"
|
||||
:title="
|
||||
AreMultipleUsersSelectedToAdmin
|
||||
areMultipleUsersSelectedToAdmin
|
||||
? t('state.confirm_admins_to_users')
|
||||
: t('state.confirm_admin_to_user')
|
||||
"
|
||||
@@ -230,7 +230,7 @@
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmUsersDeletion"
|
||||
:title="
|
||||
AreMultipleUsersSelectedForDeletion
|
||||
areMultipleUsersSelectedForDeletion
|
||||
? t('state.confirm_users_deletion')
|
||||
: t('state.confirm_user_deletion')
|
||||
"
|
||||
@@ -259,10 +259,10 @@ import {
|
||||
UsersListV2Document,
|
||||
} from '~/helpers/backend/graphql';
|
||||
import {
|
||||
ADMIN_CANNOT_BE_DELETED,
|
||||
DELETE_USER_FAILED_ONLY_ONE_ADMIN,
|
||||
ONLY_ONE_ADMIN_ACCOUNT_FOUND,
|
||||
USER_ALREADY_INVITED,
|
||||
} from '~/helpers/errors';
|
||||
import { handleUserDeletion } from '~/helpers/userManagement';
|
||||
import IconCheck from '~icons/lucide/check';
|
||||
import IconLeft from '~icons/lucide/chevron-left';
|
||||
import IconRight from '~icons/lucide/chevron-right';
|
||||
@@ -309,16 +309,10 @@ const selectedRows = ref<UsersListQuery['infra']['allUsers']>([]);
|
||||
// Ensure this variable is declared outside the debounce function
|
||||
let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let toastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
onUnmounted(() => {
|
||||
if (debounceTimeout) {
|
||||
clearTimeout(debounceTimeout);
|
||||
}
|
||||
|
||||
if (toastTimeout) {
|
||||
clearTimeout(toastTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
// Debounce Function
|
||||
@@ -462,7 +456,7 @@ const confirmUsersToAdmin = ref(false);
|
||||
const usersToAdminUID = ref<string | null>(null);
|
||||
const usersToAdmin = useMutation(MakeUsersAdminDocument);
|
||||
|
||||
const AreMultipleUsersSelected = computed(() => selectedRows.value.length > 1);
|
||||
const areMultipleUsersSelected = computed(() => selectedRows.value.length > 1);
|
||||
|
||||
const confirmUserToAdmin = (id: string | null) => {
|
||||
confirmUsersToAdmin.value = true;
|
||||
@@ -482,11 +476,15 @@ const makeUsersToAdmin = async (id: string | null) => {
|
||||
|
||||
if (result.error) {
|
||||
toast.error(
|
||||
id ? t('state.admin_failure') : t('state.users_to_admin_failure')
|
||||
areMultipleUsersSelected.value
|
||||
? t('state.users_to_admin_failure')
|
||||
: t('state.admin_failure')
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
id ? t('state.admin_success') : t('state.users_to_admin_success')
|
||||
areMultipleUsersSelected.value
|
||||
? t('state.users_to_admin_success')
|
||||
: t('state.admin_success')
|
||||
);
|
||||
usersList.value = usersList.value.map((user) => ({
|
||||
...user,
|
||||
@@ -514,7 +512,7 @@ const resetConfirmAdminToUser = () => {
|
||||
adminsToUserUID.value = null;
|
||||
};
|
||||
|
||||
const AreMultipleUsersSelectedToAdmin = computed(
|
||||
const areMultipleUsersSelectedToAdmin = computed(
|
||||
() => selectedRows.value.length > 1
|
||||
);
|
||||
|
||||
@@ -524,16 +522,20 @@ const makeAdminsToUsers = async (id: string | null) => {
|
||||
const variables = { userUIDs };
|
||||
const result = await adminsToUser.executeMutation(variables);
|
||||
if (result.error) {
|
||||
if (result.error.message === ONLY_ONE_ADMIN_ACCOUNT_FOUND) {
|
||||
return toast.error(t('state.remove_admin_failure_only_one_admin'));
|
||||
}
|
||||
|
||||
toast.error(
|
||||
id
|
||||
? t('state.remove_admin_failure')
|
||||
: t('state.remove_admin_from_users_failure')
|
||||
areMultipleUsersSelected.value
|
||||
? t('state.remove_admin_from_users_failure')
|
||||
: t('state.remove_admin_failure')
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
id
|
||||
? t('state.remove_admin_success')
|
||||
: t('state.remove_admin_from_users_success')
|
||||
areMultipleUsersSelected.value
|
||||
? t('state.remove_admin_from_users_success')
|
||||
: t('state.remove_admin_success')
|
||||
);
|
||||
usersList.value = usersList.value.map((user) => ({
|
||||
...user,
|
||||
@@ -562,7 +564,7 @@ const resetConfirmUserDeletion = () => {
|
||||
deleteUserUID.value = null;
|
||||
};
|
||||
|
||||
const AreMultipleUsersSelectedForDeletion = computed(
|
||||
const areMultipleUsersSelectedForDeletion = computed(
|
||||
() => selectedRows.value.length > 1
|
||||
);
|
||||
|
||||
@@ -572,45 +574,22 @@ const deleteUsers = async (id: string | null) => {
|
||||
const result = await usersDeletion.executeMutation(variables);
|
||||
|
||||
if (result.error) {
|
||||
const errorMessage =
|
||||
result.error.message === DELETE_USER_FAILED_ONLY_ONE_ADMIN
|
||||
? t('state.delete_user_failed_only_one_admin')
|
||||
: id
|
||||
? t('state.delete_user_failure')
|
||||
: t('state.delete_users_failure');
|
||||
const errorMessage = areMultipleUsersSelected.value
|
||||
? t('state.delete_users_failure')
|
||||
: t('state.delete_user_failure');
|
||||
toast.error(errorMessage);
|
||||
} else {
|
||||
const deletedUsers = result.data?.removeUsersByAdmin || [];
|
||||
const deletedIDs = deletedUsers
|
||||
const deletedUserIDs = deletedUsers
|
||||
.filter((user) => user.isDeleted)
|
||||
.map((user) => user.userUID);
|
||||
|
||||
const isAdminError = deletedUsers.some(
|
||||
(user) => user.errorMessage === ADMIN_CANNOT_BE_DELETED
|
||||
);
|
||||
handleUserDeletion(deletedUsers);
|
||||
|
||||
usersList.value = usersList.value.filter(
|
||||
(user) => !deletedIDs.includes(user.uid)
|
||||
(user) => !deletedUserIDs.includes(user.uid)
|
||||
);
|
||||
|
||||
if (isAdminError) {
|
||||
toast.success(
|
||||
t('state.delete_some_users_success', { count: deletedIDs.length })
|
||||
);
|
||||
toast.error(
|
||||
t('state.delete_some_users_failure', {
|
||||
count: deletedUsers.length - deletedIDs.length,
|
||||
})
|
||||
);
|
||||
toastTimeout = setTimeout(() => {
|
||||
toast.error(t('state.remove_admin_for_deletion'));
|
||||
}, 2000);
|
||||
} else {
|
||||
toast.success(
|
||||
id ? t('state.delete_user_success') : t('state.delete_users_success')
|
||||
);
|
||||
}
|
||||
|
||||
selectedRows.value.splice(0, selectedRows.value.length);
|
||||
}
|
||||
confirmUsersDeletion.value = false;
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<template #action="{ item }">
|
||||
<div v-if="item" class="my-1 mr-2">
|
||||
<HoppButtonSecondary
|
||||
v-if="xlAndLarger"
|
||||
v-if="lgAndLarger"
|
||||
:icon="IconTrash"
|
||||
:label="t('users.revoke_invitation')"
|
||||
class="text-secondaryDark bg-red-500 hover:bg-red-600"
|
||||
@@ -119,7 +119,7 @@ const toast = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind);
|
||||
const xlAndLarger = breakpoints.greater('xl');
|
||||
const lgAndLarger = breakpoints.greater('lg');
|
||||
|
||||
// Get Proper Date Formats
|
||||
const getCreatedDate = (date: string) => format(new Date(date), 'dd-MM-yyyy');
|
||||
@@ -130,9 +130,8 @@ const { fetching, error, data } = useQuery({ query: InvitedUsersDocument });
|
||||
|
||||
// Table Headings
|
||||
const headings = [
|
||||
{ key: 'adminUid', label: t('users.admin_id') },
|
||||
{ key: 'adminEmail', label: t('users.admin_email') },
|
||||
{ key: 'inviteeEmail', label: t('users.invitee_email') },
|
||||
{ key: 'adminEmail', label: t('users.invited_by') },
|
||||
{ key: 'invitedOn', label: t('users.invited_on') },
|
||||
{ key: 'action', label: 'Action' },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user