Compare commits

...

18 Commits

Author SHA1 Message Date
nivedin
fa42fc1538 fix: update spelling 2024-03-20 15:25:34 +05:30
nivedin
0f4168d12c feat: add github enterprise auth option 2024-03-20 14:44:44 +05:30
Akash K
6b58915caa feat: oauth revamp + support for multiple grant types in oauth (#3885)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-20 00:18:03 +05:30
Akash K
457857a711 feat: team search in workspace search and spotlight (#3896)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-19 18:50:35 +05:30
Balu Babu
a3f3e3e62d refactor: collection search query (#3908) 2024-03-19 17:12:35 +05:30
Andrew Bastin
66f20d10e1 chore: enable subpath based access in test deploy docker compose 2024-03-19 16:26:04 +05:30
Andrew Bastin
32e9366609 chore: update test deploy docker compose aio port 2024-03-19 15:53:08 +05:30
Andrew Bastin
e41e956273 chore: add test deployment docker compose file 2024-03-19 14:39:57 +05:30
Nivedin
a14870f3f0 fix: collection auth headers active tab update bug and type fix (#3899) 2024-03-15 21:17:34 +05:30
Andrew Bastin
0e96665254 refactor: use trigram search index instead of full text search (#3900)
Co-authored-by: Balu Babu <balub997@gmail.com>
2024-03-15 20:10:12 +05:30
kaifulee
efdc1c2f5d chore: fix some typos (#3895)
Signed-off-by: kaifulee <cuishuang@outlook.com>
2024-03-15 20:06:34 +05:30
Andrew Bastin
c5334d4c06 chore(sh-admin): bump @hoppscotch/ui version to 0.1.3 2024-03-15 12:43:05 +05:30
Balu Babu
4f549974ed fix: reset infra-config bug (#3898) 2024-03-14 21:46:34 +05:30
Nivedin
41d617b507 fix: secret env bug in firebase due to undefined value (#3881)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-13 17:11:51 +05:30
Joel Jacob Stephen
be7387ed19 refactor(sh-admin): updated data sharing doc links + remove disabled property from all inputs in configurations (#3894) 2024-03-13 16:18:27 +05:30
Joel Jacob Stephen
acfb0189df feat(sh-admin): enhanced user management in admin dashboard (#3814)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-13 14:45:13 +05:30
Nivedin
8fdba760a2 refactor: personal workspace nomenclature update (#3893)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-13 14:21:23 +05:30
Nivedin
bf98009abb fix: request variable version syncing bug (#3889)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-12 11:42:05 +05:30
103 changed files with 6805 additions and 2882 deletions

48
docker-compose.deploy.yml Normal file
View File

@@ -0,0 +1,48 @@
# 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
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:
test:
[
"CMD-SHELL",
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"
]
interval: 5s
timeout: 5s
retries: 10

View File

@@ -112,7 +112,7 @@ services:
build:
dockerfile: packages/hoppscotch-backend/Dockerfile
context: .
target: dev
target: prod
env_file:
- ./.env
restart: always
@@ -122,7 +122,7 @@ services:
- 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:

View File

@@ -1,17 +1,22 @@
-- AlterTable
ALTER TABLE
-- This is a custom migration file which is not generated by Prisma.
-- The aim of this migration is to add text search indices to the TeamCollection and TeamRequest tables.
-- Create Extension
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Create GIN Trigram Index for Team Collection title
CREATE INDEX
"TeamCollection_title_trgm_idx"
ON
"TeamCollection"
ADD
titleSearch tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;
USING
GIN (title gin_trgm_ops);
-- AlterTable
ALTER TABLE
-- Create GIN Trigram Index for Team Collection title
CREATE INDEX
"TeamRequest_title_trgm_idx"
ON
"TeamRequest"
ADD
titleSearch tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;
USING
GIN (title gin_trgm_ops);
-- CreateIndex
CREATE INDEX "TeamCollection_textSearch_idx" ON "TeamCollection" USING GIN (titleSearch);
-- CreateIndex
CREATE INDEX "TeamRequest_textSearch_idx" ON "TeamRequest" USING GIN (titleSearch);

View File

@@ -321,25 +321,28 @@ export class InfraConfigService implements OnModuleInit {
* Reset all the InfraConfigs to their default values (from .env)
*/
async reset() {
// These are all the infra-configs that should not be reset
const RESET_EXCLUSION_LIST = [
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
];
try {
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
const updatedInfraConfigDefaultObjs = infraConfigDefaultObjs.filter(
(p) => RESET_EXCLUSION_LIST.includes(p.name) === false,
);
await this.prisma.infraConfig.deleteMany({
where: { name: { in: infraConfigDefaultObjs.map((p) => p.name) } },
where: {
name: {
in: updatedInfraConfigDefaultObjs.map((p) => p.name),
},
},
});
// Hardcode t
const updatedInfraConfigDefaultObjs = infraConfigDefaultObjs.filter(
(obj) => obj.name !== InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
);
await this.prisma.infraConfig.createMany({
data: [
...updatedInfraConfigDefaultObjs,
{
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
value: 'true',
},
],
data: updatedInfraConfigDefaultObjs,
});
stopApp();

View File

@@ -13,7 +13,7 @@ import { throwHTTPErr } from 'src/utils';
export class TeamCollectionController {
constructor(private readonly teamCollectionService: TeamCollectionService) {}
@Get('search/:teamID/:searchQuery')
@Get('search/:teamID')
@RequiresTeamRole(
TeamMemberRole.VIEWER,
TeamMemberRole.EDITOR,
@@ -21,7 +21,7 @@ export class TeamCollectionController {
)
@UseGuards(JwtAuthGuard, RESTTeamMemberGuard)
async searchByTitle(
@Param('searchQuery') searchQuery: string,
@Query('searchQuery') searchQuery: string,
@Param('teamID') teamID: string,
@Query('take') take: string,
@Query('skip') skip: string,

View File

@@ -20,7 +20,7 @@ import {
TEAM_COLL_PARENT_TREE_GEN_FAILED,
} from '../errors';
import { PubSubService } from '../pubsub/pubsub.service';
import { isValidLength } from 'src/utils';
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';
@@ -1125,7 +1125,7 @@ export class TeamCollectionService {
id: searchResults[i].id,
path: !fetchedParentTree
? []
: ([fetchedParentTree.right] as CollectionSearchNode[]),
: (fetchedParentTree.right as CollectionSearchNode[]),
});
}
@@ -1148,14 +1148,20 @@ export class TeamCollectionService {
skip: number,
) {
const query = Prisma.sql`
select id,title,'collection' AS type
from "TeamCollection"
where "TeamCollection"."teamID"=${teamID}
and titlesearch @@ to_tsquery(${searchQuery})
order by ts_rank(titlesearch,to_tsquery(${searchQuery}))
limit ${take}
SELECT
id,title,'collection' AS type
FROM
"TeamCollection"
WHERE
"TeamCollection"."teamID"=${teamID}
AND
title ILIKE ${`%${escapeSqlLikeString(searchQuery)}%`}
ORDER BY
similarity(title, ${searchQuery})
LIMIT ${take}
OFFSET ${skip === 0 ? 0 : (skip - 1) * take};
`;
try {
const res = await this.prisma.$queryRaw<SearchQueryReturnType[]>(query);
return E.right(res);
@@ -1180,12 +1186,17 @@ export class TeamCollectionService {
skip: number,
) {
const query = Prisma.sql`
select id,title,request->>'method' as method,'request' AS type
from "TeamRequest"
where "TeamRequest"."teamID"=${teamID}
and titlesearch @@ to_tsquery(${searchQuery})
order by ts_rank(titlesearch,to_tsquery(${searchQuery}))
limit ${take}
SELECT
id,title,request->>'method' as method,'request' AS type
FROM
"TeamRequest"
WHERE
"TeamRequest"."teamID"=${teamID}
AND
title ILIKE ${`%${escapeSqlLikeString(searchQuery)}%`}
ORDER BY
similarity(title, ${searchQuery})
LIMIT ${take}
OFFSET ${skip === 0 ? 0 : (skip - 1) * take};
`;
@@ -1250,45 +1261,53 @@ export class TeamCollectionService {
* @returns The parent tree of the parent collections
*/
private generateParentTree(parentCollections: ParentTreeQueryReturnType[]) {
function findChildren(id) {
function findChildren(id: string): CollectionSearchNode[] {
const collection = parentCollections.filter((item) => item.id === id)[0];
if (collection.parentID == null) {
return {
id: collection.id,
title: collection.title,
type: 'collection',
path: [],
};
return <CollectionSearchNode[]>[
{
id: collection.id,
title: collection.title,
type: 'collection' as const,
path: [],
},
];
}
const res = {
id: collection.id,
title: collection.title,
type: 'collection',
path: findChildren(collection.parentID),
};
const res = <CollectionSearchNode[]>[
{
id: collection.id,
title: collection.title,
type: 'collection' as const,
path: findChildren(collection.parentID),
},
];
return res;
}
if (parentCollections.length > 0) {
if (parentCollections[0].parentID == null) {
return {
return <CollectionSearchNode[]>[
{
id: parentCollections[0].id,
title: parentCollections[0].title,
type: 'collection',
path: [],
},
];
}
return <CollectionSearchNode[]>[
{
id: parentCollections[0].id,
title: parentCollections[0].title,
type: 'collection',
path: [],
};
}
return {
id: parentCollections[0].id,
title: parentCollections[0].title,
type: 'collection',
path: findChildren(parentCollections[0].parentID),
};
path: findChildren(parentCollections[0].parentID),
},
];
}
return null;
return <CollectionSearchNode[]>[];
}
/**

View File

@@ -250,3 +250,39 @@ export function checkEnvironmentAuthProvider(
}
}
}
/**
* Adds escape backslashes to the input so that it can be used inside
* SQL LIKE/ILIKE queries. Inspired by PHP's `mysql_real_escape_string`
* function.
*
* Eg. "100%" -> "100\\%"
*
* Source: https://stackoverflow.com/a/32648526
*/
export function escapeSqlLikeString(str: string) {
if (typeof str != 'string') return str;
return str.replace(/[\0\x08\x09\x1a\n\r"'\\\%]/g, function (char) {
switch (char) {
case '\0':
return '\\0';
case '\x08':
return '\\b';
case '\x09':
return '\\t';
case '\x1a':
return '\\z';
case '\n':
return '\\n';
case '\r':
return '\\r';
case '"':
case "'":
case '\\':
case '%':
return '\\' + char; // prepends a backslash to backslash, percent,
// and double/single quotes
}
});
}

View File

@@ -17,7 +17,7 @@
"folders": [],
"requests": [
{
"v": "2",
"v": "3",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestD",
"params": [],
@@ -53,7 +53,7 @@
],
"requests": [
{
"v": "2",
"v": "3",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestC",
"params": [],
@@ -90,7 +90,7 @@
],
"requests": [
{
"v": "2",
"v": "3",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestB",
"params": [],
@@ -119,7 +119,7 @@
],
"requests": [
{
"v": "2",
"v": "3",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestA",
"params": [],
@@ -162,7 +162,7 @@
"folders": [],
"requests": [
{
"v": "2",
"v": "3",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestB",
"params": [],
@@ -191,7 +191,7 @@
],
"requests": [
{
"v": "2",
"v": "3",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestA",
"params": [],

View File

@@ -4,7 +4,7 @@
"folders": [],
"requests": [
{
"v": "2",
"v": "3",
"endpoint": "<<URL>>",
"name": "test1",
"params": [],

View File

@@ -5,7 +5,7 @@
"folders": [],
"requests": [
{
"v": "2",
"v": "3",
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>",
"name": "",
"params": [],
@@ -13,10 +13,7 @@
"method": "GET",
"auth": {
"authType": "none",
"authActive": true,
"addTo": "Headers",
"key": "",
"value": ""
"authActive": true
},
"preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");",
"testScript": "// Check status code is 200\npwd.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(\"string\");\n});",
@@ -24,10 +21,10 @@
"contentType": "application/json",
"body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}"
},
"requestVariables": [],
"requestVariables": []
},
{
"v": "2",
"v": "3",
"endpoint": "https://echo.hoppscotch.dio/<<HEADERS_TYPE2>>",
"name": "success",
"params": [],
@@ -35,10 +32,7 @@
"method": "GET",
"auth": {
"authType": "none",
"authActive": true,
"addTo": "Headers",
"key": "",
"value": ""
"authActive": true
},
"preRequestScript": "pw.env.setd(\"HEADERS_TYPE2\", \"devblin_local2\");",
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(300);\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});",

View File

@@ -4,7 +4,7 @@
"folders": [],
"requests":
{
"v": "2",
"v": "3",
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>",
"name": "fail",
"params": [],
@@ -12,10 +12,7 @@
"method": "GET",
"auth": {
"authType": "none",
"authActive": true,
"addTo": "Headers",
"key": "",
"value": ""
"authActive": true
},
"preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");",
"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(\"string\");\n});",
@@ -26,7 +23,7 @@
"requestVariables": [],
},
{
"v": "2",
"v": "3",
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE2>>",
"name": "success",
"params": [],
@@ -34,10 +31,7 @@
"method": "GET",
"auth": {
"authType": "none",
"authActive": true,
"addTo": "Headers",
"key": "",
"value": ""
"authActive": true
},
"preRequestScript": "pw.env.set(\"HEADERS_TYPE2\", \"devblin_local2\");",
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(300);\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});",

View File

@@ -5,7 +5,7 @@
"folders": [],
"requests": [
{
"v": "2",
"v": "3",
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>",
"name": "",
"params": [],
@@ -13,10 +13,7 @@
"method": "GET",
"auth": {
"authType": "none",
"authActive": true,
"addTo": "Headers",
"key": "",
"value": ""
"authActive": true
},
"preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");",
"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});",
@@ -27,7 +24,7 @@
"requestVariables": []
},
{
"v": "2",
"v": "3",
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE2>>",
"name": "success",
"params": [],
@@ -35,10 +32,7 @@
"method": "GET",
"auth": {
"authType": "none",
"authActive": true,
"addTo": "Headers",
"key": "",
"value": ""
"authActive": true
},
"preRequestScript": "pw.env.set(\"HEADERS_TYPE2\", \"devblin_local2\");",
"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});",

View File

@@ -4,7 +4,7 @@
"folders": [],
"requests": [
{
"v": "2",
"v": "3",
"auth": { "authType": "none", "authActive": true },
"body": { "body": null, "contentType": null },
"name": "sample-req",

View File

@@ -4,7 +4,7 @@
"folders": [],
"requests": [
{
"v": "2",
"v": "3",
"name": "test-request",
"endpoint": "https://echo.hoppscotch.io",
"method": "POST",

View File

@@ -4,7 +4,7 @@
"folders": [],
"requests": [
{
"v": "2",
"v": "3",
"auth": { "authType": "none", "authActive": true },
"body": { "body": null, "contentType": null },
"name": "test-secret-headers",
@@ -23,7 +23,7 @@
"preRequestScript": "const secretHeaderValueFromPreReqScript = pw.env.get(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)"
},
{
"v": "2",
"v": "3",
"auth": { "authType": "none", "authActive": true },
"body": {
"body": "{\n \"secretBodyKey\": \"<<secretBodyValue>>\"\n}",
@@ -39,7 +39,7 @@
"preRequestScript": "const secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)"
},
{
"v": "2",
"v": "3",
"auth": { "authType": "none", "authActive": true },
"body": { "body": null, "contentType": null },
"name": "test-secret-query-params",
@@ -58,7 +58,7 @@
"preRequestScript": "const secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)"
},
{
"v": "2",
"v": "3",
"auth": {
"authType": "basic",
"password": "<<secretBasicAuthPassword>>",
@@ -76,7 +76,7 @@
"preRequestScript": ""
},
{
"v": "2",
"v": "3",
"auth": {
"token": "<<secretBearerToken>>",
"authType": "bearer",
@@ -95,7 +95,7 @@
"preRequestScript": "const secretBearerToken = pw.env.get(\"secretBearerToken\")\npw.env.set(\"preReqSecretBearerToken\", secretBearerToken)"
},
{
"v": "2",
"v": "3",
"auth": { "authType": "none", "authActive": true },
"body": { "body": null, "contentType": null },
"name": "test-secret-fallback",

View File

@@ -4,7 +4,7 @@
"folders": [],
"requests": [
{
"v": "2",
"v": "3",
"auth": {
"authType": "none",
"authActive": true
@@ -29,7 +29,7 @@
"preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)"
},
{
"v": "2",
"v": "3",
"auth": {
"authType": "none",
"authActive": true
@@ -54,7 +54,7 @@
"preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value-overriden\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)"
},
{
"v": "2",
"v": "3",
"auth": {
"authType": "none",
"authActive": true
@@ -73,7 +73,7 @@
"preRequestScript": "const secretBodyValue = pw.env.get(\"secretBodyValue\")\n\nif (!secretBodyValue) { \n pw.env.set(\"secretBodyValue\", \"secret-body-value\")\n}\n\nconst secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)"
},
{
"v": "2",
"v": "3",
"auth": {
"authType": "none",
"authActive": true
@@ -98,7 +98,7 @@
"preRequestScript": "const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n\nif (!secretQueryParamValue) {\n pw.env.set(\"secretQueryParamValue\", \"secret-query-param-value\")\n}\n\nconst secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)"
},
{
"v": "2",
"v": "3",
"auth": {
"authType": "basic",
"password": "<<secretBasicAuthPassword>>",
@@ -119,7 +119,7 @@
"preRequestScript": "let secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n\nlet secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\nif (!secretBasicAuthUsername) {\n pw.env.set(\"secretBasicAuthUsername\", \"test-user\")\n}\n\nif (!secretBasicAuthPassword) {\n pw.env.set(\"secretBasicAuthPassword\", \"test-pass\")\n}"
},
{
"v": "2",
"v": "3",
"auth": {
"token": "<<secretBearerToken>>",
"authType": "bearer",

View File

@@ -4,7 +4,7 @@
"folders": [],
"requests": [
{
"v": "2",
"v": "3",
"endpoint": "https://httpbin.org/post",
"name": "req",
"params": [],

View File

@@ -6,7 +6,7 @@ import { error } from "../../types/errors";
import {
HoppEnvKeyPairObject,
HoppEnvPair,
HoppEnvs
HoppEnvs,
} from "../../types/request";
import { readJsonFile } from "../../utils/mutators";
@@ -17,7 +17,7 @@ import { readJsonFile } from "../../utils/mutators";
*/
export async function parseEnvsData(path: string) {
const contents = await readJsonFile(path);
const envPairs: Array<Environment["variables"][number] | HoppEnvPair> = [];
const envPairs: Array<HoppEnvPair | Record<string, string>> = [];
// The legacy key-value pair format that is still supported
const HoppEnvKeyPairResult = HoppEnvKeyPairObject.safeParse(contents);
@@ -26,7 +26,9 @@ export async function parseEnvsData(path: string) {
const HoppEnvExportObjectResult = Environment.safeParse(contents);
// Shape of the bulk environment export object that is exported from the app
const HoppBulkEnvExportObjectResult = z.array(entityReference(Environment)).safeParse(contents)
const HoppBulkEnvExportObjectResult = z
.array(entityReference(Environment))
.safeParse(contents);
// CLI doesnt support bulk environments export
// Hence we check for this case and throw an error if it matches the format
@@ -36,7 +38,10 @@ export async function parseEnvsData(path: string) {
// Checks if the environment file is of the correct format
// If it doesnt match either of them, we throw an error
if (!HoppEnvKeyPairResult.success && HoppEnvExportObjectResult.type === "err") {
if (
!HoppEnvKeyPairResult.success &&
HoppEnvExportObjectResult.type === "err"
) {
throw error({ code: "MALFORMED_ENV_FILE", path, data: error });
}

View File

@@ -131,7 +131,7 @@ const getCollectionStack = (collections: HoppCollection[]): CollectionStack[] =>
* path of each request within collection-json file, failed-tests-report, errors,
* total execution duration for requests, pre-request-scripts, test-scripts.
* @returns True, if collection runner executed without any errors or failed test-cases.
* False, if errors occured or test-cases failed.
* False, if errors occurred or test-cases failed.
*/
export const collectionsRunnerResult = (
requestsReport: RequestReport[]

View File

@@ -112,7 +112,7 @@ export const printTestsMetrics = (testsMetrics: TestMetrics) => {
/**
* Prints details of each reported error for a request with error code.
* @param path Request's path in collection for which errors occured.
* @param path Request's path in collection for which errors occurred.
* @param errorsReport List of errors reported.
*/
export const printErrorsReport = (

View File

@@ -109,18 +109,31 @@ export function getEffectiveRESTRequest(
key: "Authorization",
value: `Basic ${btoa(`${username}:${password}`)}`,
});
} else if (
request.auth.authType === "bearer" ||
request.auth.authType === "oauth-2"
) {
} else if (request.auth.authType === "bearer") {
effectiveFinalHeaders.push({
active: true,
key: "Authorization",
value: `Bearer ${parseTemplateString(
request.auth.token,
envVariables
)}`,
value: `Bearer ${parseTemplateString(request.auth.token, envVariables)}`,
});
} else if (request.auth.authType === "oauth-2") {
const { addTo } = request.auth;
if (addTo === "HEADERS") {
effectiveFinalHeaders.push({
active: true,
key: "Authorization",
value: `Bearer ${parseTemplateString(request.auth.grantTypeInfo.token, envVariables)}`,
});
} else if (addTo === "QUERY_PARAMS") {
effectiveFinalParams.push({
active: true,
key: "access_token",
value: parseTemplateString(
request.auth.grantTypeInfo.token,
envVariables
),
});
}
} else if (request.auth.authType === "api-key") {
const { key, value, addTo } = request.auth;
if (addTo === "Headers") {

View File

@@ -41,10 +41,10 @@ const processVariables = (variable: Environment["variables"][number]) => {
...variable,
value:
"value" in variable ? variable.value : process.env[variable.key] || "",
}
};
}
return variable
}
return variable;
};
/**
* Processes given envs, which includes processing each variable in global
@@ -56,10 +56,10 @@ const processEnvs = (envs: HoppEnvs) => {
const processedEnvs = {
global: envs.global.map(processVariables),
selected: envs.selected.map(processVariables),
}
};
return processedEnvs
}
return processedEnvs;
};
/**
* Transforms given request data to request-config used by request-runner to
@@ -70,7 +70,7 @@ const processEnvs = (envs: HoppEnvs) => {
export const createRequest = (req: EffectiveHoppRESTRequest): RequestConfig => {
const config: RequestConfig = {
supported: true,
displayUrl: req.effectiveFinalDisplayURL
displayUrl: req.effectiveFinalDisplayURL,
};
const { finalBody, finalEndpoint, finalHeaders, finalParams } = getRequest;
const reqParams = finalParams(req);
@@ -131,6 +131,7 @@ export const requestRunner =
let status: number;
const baseResponse = await axios(requestConfig);
const { config } = baseResponse;
// PR-COMMENT: type error
const runnerResponse: RequestRunnerResponse = {
...baseResponse,
endpoint: getRequest.endpoint(config.url),
@@ -257,10 +258,13 @@ export const processRequest =
let updatedEnvs = <HoppEnvs>{};
// Fetch values for secret environment variables from system environment
const processedEnvs = processEnvs(envs)
const processedEnvs = processEnvs(envs);
// Executing pre-request-script
const preRequestRes = await preRequestScriptRunner(request, processedEnvs)();
const preRequestRes = await preRequestScriptRunner(
request,
processedEnvs
)();
if (E.isLeft(preRequestRes)) {
printPreRequestRunner.fail();
@@ -347,7 +351,7 @@ export const processRequest =
*/
export const preProcessRequest = (
request: HoppRESTRequest,
collection: HoppCollection,
collection: HoppCollection
): HoppRESTRequest => {
const tempRequest = Object.assign({}, request);
const { headers: parentHeaders, auth: parentAuth } = collection;
@@ -372,8 +376,10 @@ export const preProcessRequest = (
// Filter out header entries present in the parent (folder/collection) under the same name
// This ensures the child headers take precedence over the parent headers
const filteredEntries = parentHeaders.filter((parentHeaderEntries) => {
return !tempRequest.headers.some((reqHeaderEntries) => reqHeaderEntries.key === parentHeaderEntries.key)
})
return !tempRequest.headers.some(
(reqHeaderEntries) => reqHeaderEntries.key === parentHeaderEntries.key
);
});
tempRequest.headers.push(...filteredEntries);
} else if (!tempRequest.headers) {
tempRequest.headers = [];

View File

@@ -103,8 +103,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",
@@ -137,7 +139,26 @@
"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_key_by": "Pass by",
"password": "Password",
@@ -281,7 +302,7 @@
"updated": "Environment updated",
"value": "Value",
"variable": "Variable",
"variables":"Variables",
"variables": "Variables",
"variable_list": "Variable List"
},
"error": {
@@ -961,7 +982,8 @@
"success_invites": "Success invites",
"title": "Workspaces",
"we_sent_invite_link": "We sent an invite link to all invitees!",
"we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the workspace."
"we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the workspace.",
"search_title": "Team Requests"
},
"team_environment": {
"deleted": "Environment Deleted",
@@ -988,7 +1010,6 @@
"workspace": {
"change": "Change workspace",
"personal": "Personal Workspace",
"personal_workspace": "{name}'s Workspace",
"other_workspaces": "My Workspaces",
"team": "Workspace",
"title": "Workspaces"

View File

@@ -3,212 +3,214 @@
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import "@vue/runtime-core"
export {}
declare module 'vue' {
declare module "@vue/runtime-core" {
export interface GlobalComponents {
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppBanner: typeof import('./components/app/Banner.vue')['default']
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
AppFooter: typeof import('./components/app/Footer.vue')['default']
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
AppHeader: typeof import('./components/app/Header.vue')['default']
AppInspection: typeof import('./components/app/Inspection.vue')['default']
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
AppLogo: typeof import('./components/app/Logo.vue')['default']
AppOptions: typeof import('./components/app/Options.vue')['default']
AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default']
AppShare: typeof import('./components/app/Share.vue')['default']
AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default']
AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default']
AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default']
AppSidenav: typeof import('./components/app/Sidenav.vue')['default']
AppSpotlight: typeof import('./components/app/spotlight/index.vue')['default']
AppSpotlightEntry: typeof import('./components/app/spotlight/Entry.vue')['default']
AppSpotlightEntryGQLHistory: typeof import('./components/app/spotlight/entry/GQLHistory.vue')['default']
AppSpotlightEntryGQLRequest: typeof import('./components/app/spotlight/entry/GQLRequest.vue')['default']
AppSpotlightEntryIconSelected: typeof import('./components/app/spotlight/entry/IconSelected.vue')['default']
AppSpotlightEntryRESTHistory: typeof import('./components/app/spotlight/entry/RESTHistory.vue')['default']
AppSpotlightEntryRESTRequest: typeof import('./components/app/spotlight/entry/RESTRequest.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']
CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default']
CollectionsAddRequest: typeof import('./components/collections/AddRequest.vue')['default']
CollectionsCollection: typeof import('./components/collections/Collection.vue')['default']
CollectionsEdit: typeof import('./components/collections/Edit.vue')['default']
CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default']
CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default']
CollectionsGraphql: typeof import('./components/collections/graphql/index.vue')['default']
CollectionsGraphqlAdd: typeof import('./components/collections/graphql/Add.vue')['default']
CollectionsGraphqlAddFolder: typeof import('./components/collections/graphql/AddFolder.vue')['default']
CollectionsGraphqlAddRequest: typeof import('./components/collections/graphql/AddRequest.vue')['default']
CollectionsGraphqlCollection: typeof import('./components/collections/graphql/Collection.vue')['default']
CollectionsGraphqlEdit: typeof import('./components/collections/graphql/Edit.vue')['default']
CollectionsGraphqlEditFolder: typeof import('./components/collections/graphql/EditFolder.vue')['default']
CollectionsGraphqlEditRequest: typeof import('./components/collections/graphql/EditRequest.vue')['default']
CollectionsGraphqlFolder: typeof import('./components/collections/graphql/Folder.vue')['default']
CollectionsGraphqlImportExport: typeof import('./components/collections/graphql/ImportExport.vue')['default']
CollectionsGraphqlRequest: typeof import('./components/collections/graphql/Request.vue')['default']
CollectionsImportExport: typeof import('./components/collections/ImportExport.vue')['default']
CollectionsMyCollections: typeof import('./components/collections/MyCollections.vue')['default']
CollectionsProperties: typeof import('./components/collections/Properties.vue')['default']
CollectionsRequest: typeof import('./components/collections/Request.vue')['default']
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default']
CookiesEditCookie: typeof import('./components/cookies/EditCookie.vue')['default']
Embeds: typeof import('./components/embeds/index.vue')['default']
Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default']
EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default']
EnvironmentsMyEnvironment: typeof import('./components/environments/my/Environment.vue')['default']
EnvironmentsSelector: typeof import('./components/environments/Selector.vue')['default']
EnvironmentsTeams: typeof import('./components/environments/teams/index.vue')['default']
EnvironmentsTeamsDetails: typeof import('./components/environments/teams/Details.vue')['default']
EnvironmentsTeamsEnvironment: typeof import('./components/environments/teams/Environment.vue')['default']
FirebaseLogin: typeof import('./components/firebase/Login.vue')['default']
FirebaseLogout: typeof import('./components/firebase/Logout.vue')['default']
GraphqlAuthorization: typeof import('./components/graphql/Authorization.vue')['default']
GraphqlField: typeof import('./components/graphql/Field.vue')['default']
GraphqlHeaders: typeof import('./components/graphql/Headers.vue')['default']
GraphqlQuery: typeof import('./components/graphql/Query.vue')['default']
GraphqlRequest: typeof import('./components/graphql/Request.vue')['default']
GraphqlRequestOptions: typeof import('./components/graphql/RequestOptions.vue')['default']
GraphqlRequestTab: typeof import('./components/graphql/RequestTab.vue')['default']
GraphqlResponse: typeof import('./components/graphql/Response.vue')['default']
GraphqlSidebar: typeof import('./components/graphql/Sidebar.vue')['default']
GraphqlSubscriptionLog: typeof import('./components/graphql/SubscriptionLog.vue')['default']
GraphqlTabHead: typeof import('./components/graphql/TabHead.vue')['default']
GraphqlType: typeof import('./components/graphql/Type.vue')['default']
GraphqlTypeLink: typeof import('./components/graphql/TypeLink.vue')['default']
GraphqlVariable: typeof import('./components/graphql/Variable.vue')['default']
History: typeof import('./components/history/index.vue')['default']
HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default']
HistoryRestCard: typeof import('./components/history/rest/Card.vue')['default']
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand']
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder']
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
HoppSmartTree: typeof import('@hoppscotch/ui')['HoppSmartTree']
HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow']
HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows']
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
HttpAuthorizationApiKey: typeof import('./components/http/authorization/ApiKey.vue')['default']
HttpAuthorizationBasic: typeof import('./components/http/authorization/Basic.vue')['default']
HttpBody: typeof import('./components/http/Body.vue')['default']
HttpBodyParameters: typeof import('./components/http/BodyParameters.vue')['default']
HttpCodegenModal: typeof import('./components/http/CodegenModal.vue')['default']
HttpHeaders: typeof import('./components/http/Headers.vue')['default']
HttpImportCurl: typeof import('./components/http/ImportCurl.vue')['default']
HttpOAuth2Authorization: typeof import('./components/http/OAuth2Authorization.vue')['default']
HttpParameters: typeof import('./components/http/Parameters.vue')['default']
HttpPreRequestScript: typeof import('./components/http/PreRequestScript.vue')['default']
HttpRawBody: typeof import('./components/http/RawBody.vue')['default']
HttpReqChangeConfirmModal: typeof import('./components/http/ReqChangeConfirmModal.vue')['default']
HttpRequest: typeof import('./components/http/Request.vue')['default']
HttpRequestOptions: typeof import('./components/http/RequestOptions.vue')['default']
HttpRequestTab: typeof import('./components/http/RequestTab.vue')['default']
HttpResponse: typeof import('./components/http/Response.vue')['default']
HttpResponseMeta: typeof import('./components/http/ResponseMeta.vue')['default']
HttpSidebar: typeof import('./components/http/Sidebar.vue')['default']
HttpTabHead: typeof import('./components/http/TabHead.vue')['default']
HttpTestResult: typeof import('./components/http/TestResult.vue')['default']
HttpTestResultEntry: typeof import('./components/http/TestResultEntry.vue')['default']
HttpTestResultEnv: typeof import('./components/http/TestResultEnv.vue')['default']
HttpTestResultReport: typeof import('./components/http/TestResultReport.vue')['default']
HttpTests: typeof import('./components/http/Tests.vue')['default']
HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default']
IconLucideActivity: typeof import('~icons/lucide/activity')['default']
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
IconLucideInfo: typeof import('~icons/lucide/info')['default']
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
ImportExportImportExportSourcesList: typeof import('./components/importExport/ImportExportSourcesList.vue')['default']
ImportExportImportExportStepsFileImport: typeof import('./components/importExport/ImportExportSteps/FileImport.vue')['default']
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.vue')['default']
InterceptorsErrorPlaceholder: typeof import('./components/interceptors/ErrorPlaceholder.vue')['default']
InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default']
LensesRenderersHTMLLensRenderer: typeof import('./components/lenses/renderers/HTMLLensRenderer.vue')['default']
LensesRenderersImageLensRenderer: typeof import('./components/lenses/renderers/ImageLensRenderer.vue')['default']
LensesRenderersJSONLensRenderer: typeof import('./components/lenses/renderers/JSONLensRenderer.vue')['default']
LensesRenderersPDFLensRenderer: typeof import('./components/lenses/renderers/PDFLensRenderer.vue')['default']
LensesRenderersRawLensRenderer: typeof import('./components/lenses/renderers/RawLensRenderer.vue')['default']
LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default']
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default']
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']
RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default']
RealtimeLog: typeof import('./components/realtime/Log.vue')['default']
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
Share: typeof import('./components/share/index.vue')['default']
ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default']
ShareCustomizeModal: typeof import('./components/share/CustomizeModal.vue')['default']
ShareModal: typeof import('./components/share/Modal.vue')['default']
ShareRequest: typeof import('./components/share/Request.vue')['default']
ShareTemplatesButton: typeof import('./components/share/templates/Button.vue')['default']
ShareTemplatesEmbeds: typeof import('./components/share/templates/Embeds.vue')['default']
ShareTemplatesLink: typeof import('./components/share/templates/Link.vue')['default']
SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default']
SmartChangeLanguage: typeof import('./components/smart/ChangeLanguage.vue')['default']
SmartColorModePicker: typeof import('./components/smart/ColorModePicker.vue')['default']
SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default']
TabPrimary: typeof import('./components/tab/Primary.vue')['default']
TabSecondary: typeof import('./components/tab/Secondary.vue')['default']
Teams: typeof import('./components/teams/index.vue')['default']
TeamsAdd: typeof import('./components/teams/Add.vue')['default']
TeamsEdit: typeof import('./components/teams/Edit.vue')['default']
TeamsInvite: typeof import('./components/teams/Invite.vue')['default']
TeamsMemberStack: typeof import('./components/teams/MemberStack.vue')['default']
TeamsModal: typeof import('./components/teams/Modal.vue')['default']
TeamsTeam: typeof import('./components/teams/Team.vue')['default']
Tippy: typeof import('vue-tippy')['Tippy']
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
AppActionHandler: (typeof import("./components/app/ActionHandler.vue"))["default"]
AppBanner: (typeof import("./components/app/Banner.vue"))["default"]
AppContextMenu: (typeof import("./components/app/ContextMenu.vue"))["default"]
AppDeveloperOptions: (typeof import("./components/app/DeveloperOptions.vue"))["default"]
AppFooter: (typeof import("./components/app/Footer.vue"))["default"]
AppGitHubStarButton: (typeof import("./components/app/GitHubStarButton.vue"))["default"]
AppHeader: (typeof import("./components/app/Header.vue"))["default"]
AppInspection: (typeof import("./components/app/Inspection.vue"))["default"]
AppInterceptor: (typeof import("./components/app/Interceptor.vue"))["default"]
AppLogo: (typeof import("./components/app/Logo.vue"))["default"]
AppOptions: (typeof import("./components/app/Options.vue"))["default"]
AppPaneLayout: (typeof import("./components/app/PaneLayout.vue"))["default"]
AppShare: (typeof import("./components/app/Share.vue"))["default"]
AppShortcuts: (typeof import("./components/app/Shortcuts.vue"))["default"]
AppShortcutsEntry: (typeof import("./components/app/ShortcutsEntry.vue"))["default"]
AppShortcutsPrompt: (typeof import("./components/app/ShortcutsPrompt.vue"))["default"]
AppSidenav: (typeof import("./components/app/Sidenav.vue"))["default"]
AppSpotlight: (typeof import("./components/app/spotlight/index.vue"))["default"]
AppSpotlightEntry: (typeof import("./components/app/spotlight/Entry.vue"))["default"]
AppSpotlightEntryGQLHistory: (typeof import("./components/app/spotlight/entry/GQLHistory.vue"))["default"]
AppSpotlightEntryGQLRequest: (typeof import("./components/app/spotlight/entry/GQLRequest.vue"))["default"]
AppSpotlightEntryIconSelected: (typeof import("./components/app/spotlight/entry/IconSelected.vue"))["default"]
AppSpotlightEntryRESTHistory: (typeof import("./components/app/spotlight/entry/RESTHistory.vue"))["default"]
AppSpotlightEntryRESTRequest: (typeof import("./components/app/spotlight/entry/RESTRequest.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"]
CollectionsAddFolder: (typeof import("./components/collections/AddFolder.vue"))["default"]
CollectionsAddRequest: (typeof import("./components/collections/AddRequest.vue"))["default"]
CollectionsCollection: (typeof import("./components/collections/Collection.vue"))["default"]
CollectionsEdit: (typeof import("./components/collections/Edit.vue"))["default"]
CollectionsEditFolder: (typeof import("./components/collections/EditFolder.vue"))["default"]
CollectionsEditRequest: (typeof import("./components/collections/EditRequest.vue"))["default"]
CollectionsGraphql: (typeof import("./components/collections/graphql/index.vue"))["default"]
CollectionsGraphqlAdd: (typeof import("./components/collections/graphql/Add.vue"))["default"]
CollectionsGraphqlAddFolder: (typeof import("./components/collections/graphql/AddFolder.vue"))["default"]
CollectionsGraphqlAddRequest: (typeof import("./components/collections/graphql/AddRequest.vue"))["default"]
CollectionsGraphqlCollection: (typeof import("./components/collections/graphql/Collection.vue"))["default"]
CollectionsGraphqlEdit: (typeof import("./components/collections/graphql/Edit.vue"))["default"]
CollectionsGraphqlEditFolder: (typeof import("./components/collections/graphql/EditFolder.vue"))["default"]
CollectionsGraphqlEditRequest: (typeof import("./components/collections/graphql/EditRequest.vue"))["default"]
CollectionsGraphqlFolder: (typeof import("./components/collections/graphql/Folder.vue"))["default"]
CollectionsGraphqlImportExport: (typeof import("./components/collections/graphql/ImportExport.vue"))["default"]
CollectionsGraphqlRequest: (typeof import("./components/collections/graphql/Request.vue"))["default"]
CollectionsImportExport: (typeof import("./components/collections/ImportExport.vue"))["default"]
CollectionsMyCollections: (typeof import("./components/collections/MyCollections.vue"))["default"]
CollectionsProperties: (typeof import("./components/collections/Properties.vue"))["default"]
CollectionsRequest: (typeof import("./components/collections/Request.vue"))["default"]
CollectionsSaveRequest: (typeof import("./components/collections/SaveRequest.vue"))["default"]
CollectionsTeamCollections: (typeof import("./components/collections/TeamCollections.vue"))["default"]
CookiesAllModal: (typeof import("./components/cookies/AllModal.vue"))["default"]
CookiesEditCookie: (typeof import("./components/cookies/EditCookie.vue"))["default"]
Embeds: (typeof import("./components/embeds/index.vue"))["default"]
Environments: (typeof import("./components/environments/index.vue"))["default"]
EnvironmentsAdd: (typeof import("./components/environments/Add.vue"))["default"]
EnvironmentsImportExport: (typeof import("./components/environments/ImportExport.vue"))["default"]
EnvironmentsMy: (typeof import("./components/environments/my/index.vue"))["default"]
EnvironmentsMyDetails: (typeof import("./components/environments/my/Details.vue"))["default"]
EnvironmentsMyEnvironment: (typeof import("./components/environments/my/Environment.vue"))["default"]
EnvironmentsSelector: (typeof import("./components/environments/Selector.vue"))["default"]
EnvironmentsTeams: (typeof import("./components/environments/teams/index.vue"))["default"]
EnvironmentsTeamsDetails: (typeof import("./components/environments/teams/Details.vue"))["default"]
EnvironmentsTeamsEnvironment: (typeof import("./components/environments/teams/Environment.vue"))["default"]
FirebaseLogin: (typeof import("./components/firebase/Login.vue"))["default"]
FirebaseLogout: (typeof import("./components/firebase/Logout.vue"))["default"]
GraphqlAuthorization: (typeof import("./components/graphql/Authorization.vue"))["default"]
GraphqlField: (typeof import("./components/graphql/Field.vue"))["default"]
GraphqlHeaders: (typeof import("./components/graphql/Headers.vue"))["default"]
GraphqlQuery: (typeof import("./components/graphql/Query.vue"))["default"]
GraphqlRequest: (typeof import("./components/graphql/Request.vue"))["default"]
GraphqlRequestOptions: (typeof import("./components/graphql/RequestOptions.vue"))["default"]
GraphqlRequestTab: (typeof import("./components/graphql/RequestTab.vue"))["default"]
GraphqlResponse: (typeof import("./components/graphql/Response.vue"))["default"]
GraphqlSidebar: (typeof import("./components/graphql/Sidebar.vue"))["default"]
GraphqlSubscriptionLog: (typeof import("./components/graphql/SubscriptionLog.vue"))["default"]
GraphqlTabHead: (typeof import("./components/graphql/TabHead.vue"))["default"]
GraphqlType: (typeof import("./components/graphql/Type.vue"))["default"]
GraphqlTypeLink: (typeof import("./components/graphql/TypeLink.vue"))["default"]
GraphqlVariable: (typeof import("./components/graphql/Variable.vue"))["default"]
History: (typeof import("./components/history/index.vue"))["default"]
HistoryGraphqlCard: (typeof import("./components/history/graphql/Card.vue"))["default"]
HistoryRestCard: (typeof import("./components/history/rest/Card.vue"))["default"]
HoppButtonPrimary: (typeof import("@hoppscotch/ui"))["HoppButtonPrimary"]
HoppButtonSecondary: (typeof import("@hoppscotch/ui"))["HoppButtonSecondary"]
HoppSmartAnchor: (typeof import("@hoppscotch/ui"))["HoppSmartAnchor"]
HoppSmartCheckbox: (typeof import("@hoppscotch/ui"))["HoppSmartCheckbox"]
HoppSmartConfirmModal: (typeof import("@hoppscotch/ui"))["HoppSmartConfirmModal"]
HoppSmartExpand: (typeof import("@hoppscotch/ui"))["HoppSmartExpand"]
HoppSmartFileChip: (typeof import("@hoppscotch/ui"))["HoppSmartFileChip"]
HoppSmartInput: (typeof import("@hoppscotch/ui"))["HoppSmartInput"]
HoppSmartIntersection: (typeof import("@hoppscotch/ui"))["HoppSmartIntersection"]
HoppSmartItem: (typeof import("@hoppscotch/ui"))["HoppSmartItem"]
HoppSmartLink: (typeof import("@hoppscotch/ui"))["HoppSmartLink"]
HoppSmartModal: (typeof import("@hoppscotch/ui"))["HoppSmartModal"]
HoppSmartPicture: (typeof import("@hoppscotch/ui"))["HoppSmartPicture"]
HoppSmartPlaceholder: (typeof import("@hoppscotch/ui"))["HoppSmartPlaceholder"]
HoppSmartProgressRing: (typeof import("@hoppscotch/ui"))["HoppSmartProgressRing"]
HoppSmartRadio: (typeof import("@hoppscotch/ui"))["HoppSmartRadio"]
HoppSmartRadioGroup: (typeof import("@hoppscotch/ui"))["HoppSmartRadioGroup"]
HoppSmartSelectWrapper: (typeof import("@hoppscotch/ui"))["HoppSmartSelectWrapper"]
HoppSmartSlideOver: (typeof import("@hoppscotch/ui"))["HoppSmartSlideOver"]
HoppSmartSpinner: (typeof import("@hoppscotch/ui"))["HoppSmartSpinner"]
HoppSmartTab: (typeof import("@hoppscotch/ui"))["HoppSmartTab"]
HoppSmartTabs: (typeof import("@hoppscotch/ui"))["HoppSmartTabs"]
HoppSmartToggle: (typeof import("@hoppscotch/ui"))["HoppSmartToggle"]
HoppSmartTree: (typeof import("@hoppscotch/ui"))["HoppSmartTree"]
HoppSmartWindow: (typeof import("@hoppscotch/ui"))["HoppSmartWindow"]
HoppSmartWindows: (typeof import("@hoppscotch/ui"))["HoppSmartWindows"]
HttpAuthorization: (typeof import("./components/http/Authorization.vue"))["default"]
HttpAuthorizationApiKey: (typeof import("./components/http/authorization/ApiKey.vue"))["default"]
HttpAuthorizationBasic: (typeof import("./components/http/authorization/Basic.vue"))["default"]
HttpBody: (typeof import("./components/http/Body.vue"))["default"]
HttpBodyParameters: (typeof import("./components/http/BodyParameters.vue"))["default"]
HttpCodegenModal: (typeof import("./components/http/CodegenModal.vue"))["default"]
HttpHeaders: (typeof import("./components/http/Headers.vue"))["default"]
HttpImportCurl: (typeof import("./components/http/ImportCurl.vue"))["default"]
HttpOAuth2Authorization: (typeof import("./components/http/OAuth2Authorization.vue"))["default"]
HttpParameters: (typeof import("./components/http/Parameters.vue"))["default"]
HttpPreRequestScript: (typeof import("./components/http/PreRequestScript.vue"))["default"]
HttpRawBody: (typeof import("./components/http/RawBody.vue"))["default"]
HttpReqChangeConfirmModal: (typeof import("./components/http/ReqChangeConfirmModal.vue"))["default"]
HttpRequest: (typeof import("./components/http/Request.vue"))["default"]
HttpRequestOptions: (typeof import("./components/http/RequestOptions.vue"))["default"]
HttpRequestTab: (typeof import("./components/http/RequestTab.vue"))["default"]
HttpResponse: (typeof import("./components/http/Response.vue"))["default"]
HttpResponseMeta: (typeof import("./components/http/ResponseMeta.vue"))["default"]
HttpSidebar: (typeof import("./components/http/Sidebar.vue"))["default"]
HttpTabHead: (typeof import("./components/http/TabHead.vue"))["default"]
HttpTestResult: (typeof import("./components/http/TestResult.vue"))["default"]
HttpTestResultEntry: (typeof import("./components/http/TestResultEntry.vue"))["default"]
HttpTestResultEnv: (typeof import("./components/http/TestResultEnv.vue"))["default"]
HttpTestResultReport: (typeof import("./components/http/TestResultReport.vue"))["default"]
HttpTests: (typeof import("./components/http/Tests.vue"))["default"]
HttpURLEncodedParams: (typeof import("./components/http/URLEncodedParams.vue"))["default"]
IconLucideActivity: (typeof import("~icons/lucide/activity"))["default"]
IconLucideAlertTriangle: (typeof import("~icons/lucide/alert-triangle"))["default"]
IconLucideArrowLeft: (typeof import("~icons/lucide/arrow-left"))["default"]
IconLucideArrowUpRight: (typeof import("~icons/lucide/arrow-up-right"))["default"]
IconLucideBrush: (typeof import("~icons/lucide/brush"))["default"]
IconLucideCheckCircle: (typeof import("~icons/lucide/check-circle"))["default"]
IconLucideChevronRight: (typeof import("~icons/lucide/chevron-right"))["default"]
IconLucideGlobe: (typeof import("~icons/lucide/globe"))["default"]
IconLucideHelpCircle: (typeof import("~icons/lucide/help-circle"))["default"]
IconLucideInbox: (typeof import("~icons/lucide/inbox"))["default"]
IconLucideInfo: (typeof import("~icons/lucide/info"))["default"]
IconLucideLayers: (typeof import("~icons/lucide/layers"))["default"]
IconLucideListEnd: (typeof import("~icons/lucide/list-end"))["default"]
IconLucideMinus: (typeof import("~icons/lucide/minus"))["default"]
IconLucideRss: (typeof import("~icons/lucide/rss"))["default"]
IconLucideSearch: (typeof import("~icons/lucide/search"))["default"]
IconLucideUsers: (typeof import("~icons/lucide/users"))["default"]
IconLucideX: (typeof import("~icons/lucide/x"))["default"]
ImportExportBase: (typeof import("./components/importExport/Base.vue"))["default"]
ImportExportImportExportList: (typeof import("./components/importExport/ImportExportList.vue"))["default"]
ImportExportImportExportSourcesList: (typeof import("./components/importExport/ImportExportSourcesList.vue"))["default"]
ImportExportImportExportStepsFileImport: (typeof import("./components/importExport/ImportExportSteps/FileImport.vue"))["default"]
ImportExportImportExportStepsMyCollectionImport: (typeof import("./components/importExport/ImportExportSteps/MyCollectionImport.vue"))["default"]
ImportExportImportExportStepsUrlImport: (typeof import("./components/importExport/ImportExportSteps/UrlImport.vue"))["default"]
InterceptorsErrorPlaceholder: (typeof import("./components/interceptors/ErrorPlaceholder.vue"))["default"]
InterceptorsExtensionSubtitle: (typeof import("./components/interceptors/ExtensionSubtitle.vue"))["default"]
LensesHeadersRenderer: (typeof import("./components/lenses/HeadersRenderer.vue"))["default"]
LensesHeadersRendererEntry: (typeof import("./components/lenses/HeadersRendererEntry.vue"))["default"]
LensesRenderersAudioLensRenderer: (typeof import("./components/lenses/renderers/AudioLensRenderer.vue"))["default"]
LensesRenderersHTMLLensRenderer: (typeof import("./components/lenses/renderers/HTMLLensRenderer.vue"))["default"]
LensesRenderersImageLensRenderer: (typeof import("./components/lenses/renderers/ImageLensRenderer.vue"))["default"]
LensesRenderersJSONLensRenderer: (typeof import("./components/lenses/renderers/JSONLensRenderer.vue"))["default"]
LensesRenderersPDFLensRenderer: (typeof import("./components/lenses/renderers/PDFLensRenderer.vue"))["default"]
LensesRenderersRawLensRenderer: (typeof import("./components/lenses/renderers/RawLensRenderer.vue"))["default"]
LensesRenderersVideoLensRenderer: (typeof import("./components/lenses/renderers/VideoLensRenderer.vue"))["default"]
LensesRenderersXMLLensRenderer: (typeof import("./components/lenses/renderers/XMLLensRenderer.vue"))["default"]
LensesResponseBodyRenderer: (typeof import("./components/lenses/ResponseBodyRenderer.vue"))["default"]
ProfileUserDelete: (typeof import("./components/profile/UserDelete.vue"))["default"]
RealtimeCommunication: (typeof import("./components/realtime/Communication.vue"))["default"]
RealtimeConnectionConfig: (typeof import("./components/realtime/ConnectionConfig.vue"))["default"]
RealtimeLog: (typeof import("./components/realtime/Log.vue"))["default"]
RealtimeLogEntry: (typeof import("./components/realtime/LogEntry.vue"))["default"]
RealtimeSubscription: (typeof import("./components/realtime/Subscription.vue"))["default"]
SettingsExtension: (typeof import("./components/settings/Extension.vue"))["default"]
SettingsProxy: (typeof import("./components/settings/Proxy.vue"))["default"]
Share: (typeof import("./components/share/index.vue"))["default"]
ShareCreateModal: (typeof import("./components/share/CreateModal.vue"))["default"]
ShareCustomizeModal: (typeof import("./components/share/CustomizeModal.vue"))["default"]
ShareModal: (typeof import("./components/share/Modal.vue"))["default"]
ShareRequest: (typeof import("./components/share/Request.vue"))["default"]
ShareTemplatesButton: (typeof import("./components/share/templates/Button.vue"))["default"]
ShareTemplatesEmbeds: (typeof import("./components/share/templates/Embeds.vue"))["default"]
ShareTemplatesLink: (typeof import("./components/share/templates/Link.vue"))["default"]
SmartAccentModePicker: (typeof import("./components/smart/AccentModePicker.vue"))["default"]
SmartChangeLanguage: (typeof import("./components/smart/ChangeLanguage.vue"))["default"]
SmartColorModePicker: (typeof import("./components/smart/ColorModePicker.vue"))["default"]
SmartEnvInput: (typeof import("./components/smart/EnvInput.vue"))["default"]
TabPrimary: (typeof import("./components/tab/Primary.vue"))["default"]
TabSecondary: (typeof import("./components/tab/Secondary.vue"))["default"]
Teams: (typeof import("./components/teams/index.vue"))["default"]
TeamsAdd: (typeof import("./components/teams/Add.vue"))["default"]
TeamsEdit: (typeof import("./components/teams/Edit.vue"))["default"]
TeamsInvite: (typeof import("./components/teams/Invite.vue"))["default"]
TeamsMemberStack: (typeof import("./components/teams/MemberStack.vue"))["default"]
TeamsModal: (typeof import("./components/teams/Modal.vue"))["default"]
TeamsTeam: (typeof import("./components/teams/Team.vue"))["default"]
Tippy: (typeof import("vue-tippy"))["Tippy"]
WorkspaceCurrent: (typeof import("./components/workspace/Current.vue"))["default"]
WorkspaceSelector: (typeof import("./components/workspace/Selector.vue"))["default"]
}
}

View File

@@ -331,14 +331,9 @@ const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const workspace = workspaceService.currentWorkspace
const workspaceName = computed(() => {
if (workspace.value.type === "personal") {
return currentUser.value?.displayName
? t("workspace.personal_workspace", {
name: currentUser.value.displayName,
})
: t("workspace.personal")
}
return workspace.value.teamName
return workspace.value.type === "personal"
? t("workspace.personal")
: workspace.value.teamName
})
const refetchTeams = () => {

View File

@@ -0,0 +1,32 @@
<template>
<span class="flex flex-1 items-center space-x-2">
<template v-for="(title, index) in collectionTitles" :key="index">
<span class="block" :class="{ truncate: index !== 0 }">
{{ title }}
</span>
<icon-lucide-chevron-right class="flex flex-shrink-0" />
</template>
<span
v-if="request"
class="flex flex-shrink-0 truncate rounded-md border border-dividerDark px-1 text-tiny font-semibold"
:style="{ color: getMethodLabelColor(request.method) }"
>
{{ request.method.toUpperCase() }}
</span>
<span v-if="request" class="block">
{{ request.name }}
</span>
</span>
</template>
<script setup lang="ts">
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
defineProps<{
collectionTitles: string[]
request: {
name: string
method: string
}
}>()
</script>

View File

@@ -111,6 +111,7 @@ import { RequestSpotlightSearcherService } from "~/services/spotlight/searchers/
import { ResponseSpotlightSearcherService } from "~/services/spotlight/searchers/response.searcher"
import { SettingsSpotlightSearcherService } from "~/services/spotlight/searchers/settings.searcher"
import { TabSpotlightSearcherService } from "~/services/spotlight/searchers/tab.searcher"
import { TeamsSpotlightSearcherService } from "~/services/spotlight/searchers/teamRequest.searcher"
import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/user.searcher"
import {
SwitchWorkspaceSpotlightSearcherService,
@@ -144,6 +145,7 @@ useService(SwitchEnvSpotlightSearcherService)
useService(WorkspaceSpotlightSearcherService)
useService(SwitchWorkspaceSpotlightSearcherService)
useService(InterceptorSpotlightSearcherService)
useService(TeamsSpotlightSearcherService)
platform.spotlight?.additionalSearchers?.forEach((searcher) =>
useService(searcher)

View File

@@ -8,7 +8,7 @@
>
<template #body>
<HoppSmartTabs
v-model="selectedOptionTab"
v-model="activeTab"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-0 z-10 !-py-4"
render-inactive-tabs
>
@@ -16,7 +16,6 @@
<HttpHeaders
v-model="editableCollection"
:is-collection-property="true"
@change-tab="changeOptionTab"
/>
<div
class="bg-bannerInfo px-4 py-2 flex items-center sticky bottom-0"
@@ -34,6 +33,7 @@
:is-collection-property="true"
:is-root-collection="editingProperties?.isRootCollection"
:inherited-properties="editingProperties?.inheritedProperties"
:source="source"
/>
<div
class="bg-bannerInfo px-4 py-2 flex items-center sticky bottom-0"
@@ -64,21 +64,24 @@
</template>
<script setup lang="ts">
import { watch, ref } from "vue"
import { useI18n } from "@composables/i18n"
import { HoppCollection } from "@hoppscotch/data"
import { RESTOptionTabs } from "../http/RequestOptions.vue"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
import { HoppCollection, HoppRESTAuth, HoppRESTHeaders } from "@hoppscotch/data"
import { clone } from "lodash-es"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { PersistenceService } from "~/services/persistence"
import { useService } from "dioc/vue"
import { ref, watch } from "vue"
import { useVModel } from "@vueuse/core"
const persistenceService = useService(PersistenceService)
const t = useI18n()
type EditingProperties = {
collection: HoppCollection | TeamCollection | null
export type EditingProperties = {
collection: Partial<HoppCollection> | null
isRootCollection: boolean
path: string
inheritedProperties: HoppInheritedProperty | undefined
inheritedProperties?: HoppInheritedProperty
}
const props = withDefaults(
@@ -86,6 +89,8 @@ const props = withDefaults(
show: boolean
loadingState: boolean
editingProperties: EditingProperties | null
source: "REST" | "GraphQL"
modelValue: string
}>(),
{
show: false,
@@ -95,50 +100,67 @@ const props = withDefaults(
)
const emit = defineEmits<{
(e: "set-collection-properties", newCollection: any): void
(
e: "set-collection-properties",
newCollection: Omit<EditingProperties, "inheritedProperties">
): void
(e: "hide-modal"): void
(e: "update:modelValue"): void
}>()
const editableCollection = ref({
body: {
contentType: null,
body: null,
},
const editableCollection = ref<{
headers: HoppRESTHeaders
auth: HoppRESTAuth
}>({
headers: [],
auth: {
authType: "inherit",
authActive: false,
},
}) as any
})
const selectedOptionTab = ref("headers")
watch(
editableCollection,
(updatedEditableCollection) => {
if (props.show) {
persistenceService.setLocalConfig(
"unsaved_collection_properties",
JSON.stringify(<EditingProperties>{
collection: updatedEditableCollection,
isRootCollection: props.editingProperties?.isRootCollection,
path: props.editingProperties?.path,
inheritedProperties: props.editingProperties?.inheritedProperties,
})
)
}
},
{
deep: true,
}
)
const changeOptionTab = (tab: RESTOptionTabs) => {
selectedOptionTab.value = tab
}
const activeTab = useVModel(props, "modelValue", emit)
watch(
() => props.show,
(show) => {
if (show && props.editingProperties?.collection) {
editableCollection.value.auth = clone(
props.editingProperties.collection.auth
props.editingProperties.collection.auth as HoppRESTAuth
)
editableCollection.value.headers = clone(
props.editingProperties.collection.headers
props.editingProperties.collection.headers as HoppRESTHeaders
)
} else {
editableCollection.value = {
body: {
contentType: null,
body: null,
},
headers: [],
auth: {
authType: "inherit",
authActive: false,
},
}
persistenceService.removeLocalConfig("unsaved_collection_properties")
}
}
)
@@ -146,7 +168,6 @@ watch(
const saveEditedCollection = () => {
if (!props.editingProperties) return
const finalCollection = clone(editableCollection.value)
delete finalCollection.body
const collection = {
path: props.editingProperties.path,
collection: {
@@ -155,10 +176,12 @@ const saveEditedCollection = () => {
},
isRootCollection: props.editingProperties.isRootCollection,
}
emit("set-collection-properties", collection)
emit("set-collection-properties", collection as EditingProperties)
persistenceService.removeLocalConfig("unsaved_collection_properties")
}
const hideModal = () => {
persistenceService.removeLocalConfig("unsaved_collection_properties")
emit("hide-modal")
}
</script>

View File

@@ -9,7 +9,7 @@
"
>
<HoppButtonSecondary
v-if="hasNoTeamAccess"
v-if="hasNoTeamAccess || isShowingSearchResults"
v-tippy="{ theme: 'tooltip' }"
disabled
class="!rounded-none"
@@ -36,8 +36,9 @@
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:disabled="
collectionsType.type === 'team-collections' &&
collectionsType.selectedTeam === undefined
(collectionsType.type === 'team-collections' &&
collectionsType.selectedTeam === undefined) ||
isShowingSearchResults
"
:icon="IconImport"
:title="t('modal.import_export')"
@@ -58,7 +59,7 @@
:collections-type="collectionsType.type"
:is-open="isOpen"
:export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess"
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
:collection-move-loading="collectionMoveLoading"
:is-last-item="node.data.isLastItem"
:is-selected="
@@ -128,6 +129,14 @@
})
}
"
@click="
() => {
handleCollectionClick({
collectionID: node.id,
isOpen,
})
}
"
/>
<CollectionsCollection
v-if="node.data.type === 'folders'"
@@ -137,7 +146,7 @@
:collections-type="collectionsType.type"
:is-open="isOpen"
:export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess"
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
:collection-move-loading="collectionMoveLoading"
:is-last-item="node.data.isLastItem"
:is-selected="
@@ -209,6 +218,15 @@
})
}
"
@click="
() => {
handleCollectionClick({
// for the folders, we get a path, so we need to get the last part of the path which is the folder id
collectionID: node.id.split('/').pop() as string,
isOpen,
})
}
"
/>
<CollectionsRequest
v-if="node.data.type === 'requests'"
@@ -218,7 +236,7 @@
:collections-type="collectionsType.type"
:duplicate-loading="duplicateLoading"
:is-active="isActiveRequest(node.data.data.data.id)"
:has-no-team-access="hasNoTeamAccess"
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
:request-move-loading="requestMoveLoading"
:is-last-item="node.data.isLastItem"
:is-selected="
@@ -283,7 +301,15 @@
</template>
<template #emptyNode="{ node }">
<HoppSmartPlaceholder
v-if="node === null"
v-if="filterText.length !== 0 && teamCollectionList.length === 0"
:text="`${t('state.nothing_found')}${filterText}`"
>
<template #icon>
<icon-lucide-search class="svg-icons opacity-75" />
</template>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node === null"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
@@ -394,6 +420,11 @@ const props = defineProps({
default: () => ({ type: "my-collections", selectedTeam: undefined }),
required: true,
},
filterText: {
type: String as PropType<string>,
default: "",
required: true,
},
teamCollectionList: {
type: Array as PropType<TeamCollection[]>,
default: () => [],
@@ -436,6 +467,8 @@ const props = defineProps({
},
})
const isShowingSearchResults = computed(() => props.filterText.length > 0)
const emit = defineEmits<{
(
event: "add-request",
@@ -543,6 +576,14 @@ const emit = defineEmits<{
}
}
): void
(
event: "collection-click",
payload: {
// if the collection is open or not in the tree
isOpen: boolean
collectionID: string
}
): void
(event: "select", payload: Picked | null): void
(event: "expand-team-collection", payload: string): void
(event: "display-modal-add"): void
@@ -555,6 +596,18 @@ const getPath = (path: string) => {
return pathArray.join("/")
}
const handleCollectionClick = (payload: {
collectionID: string
isOpen: boolean
}) => {
const { collectionID, isOpen } = payload
emit("collection-click", {
collectionID,
isOpen,
})
}
const teamCollectionsList = toRef(props, "teamCollectionList")
const hasNoTeamAccess = computed(

View File

@@ -146,8 +146,10 @@
@hide-modal="displayModalImportExport(false)"
/>
<CollectionsProperties
v-model="collectionPropertiesModalActiveTab"
:show="showModalEditProperties"
:editing-properties="editingProperties"
source="GraphQL"
@hide-modal="displayModalEditProperties(false)"
@set-collection-properties="setCollectionProperties"
/>
@@ -155,7 +157,7 @@
</template>
<script setup lang="ts">
import { nextTick, ref } from "vue"
import { nextTick, onMounted, ref } from "vue"
import { clone, cloneDeep } from "lodash-es"
import {
graphqlCollections$,
@@ -178,6 +180,7 @@ import { GQLTabService } from "~/services/tab/graphql"
import { computed } from "vue"
import {
HoppCollection,
HoppGQLAuth,
HoppGQLRequest,
makeGQLRequest,
} from "@hoppscotch/data"
@@ -186,6 +189,10 @@ import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { updateInheritedPropertiesForAffectedRequests } from "~/helpers/collection/collection"
import { useToast } from "~/composables/toast"
import { getRequestsByPath } from "~/helpers/collection/request"
import { PersistenceService } from "~/services/persistence"
import { PersistedOAuthConfig } from "~/services/oauth/oauth.service"
import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue"
import { EditingProperties } from "../Properties.vue"
const t = useI18n()
const toast = useToast()
@@ -232,6 +239,52 @@ const editingProperties = ref<{
const filterText = ref("")
const persistenceService = useService(PersistenceService)
const collectionPropertiesModalActiveTab = ref<GQLOptionTabs>("headers")
onMounted(() => {
const localOAuthTempConfig =
persistenceService.getLocalConfig("oauth_temp_config")
if (!localOAuthTempConfig) {
return
}
const { context, source, token }: PersistedOAuthConfig =
JSON.parse(localOAuthTempConfig)
if (source === "REST") {
return
}
if (context?.type === "collection-properties") {
// load the unsaved editing properties
const unsavedCollectionPropertiesString = persistenceService.getLocalConfig(
"unsaved_collection_properties"
)
if (unsavedCollectionPropertiesString) {
const unsavedCollectionProperties: EditingProperties<"GraphQL"> =
JSON.parse(unsavedCollectionPropertiesString)
const auth = unsavedCollectionProperties.collection?.auth
if (auth?.authType === "oauth-2") {
const grantTypeInfo = auth.grantTypeInfo
grantTypeInfo && (grantTypeInfo.token = token ?? "")
}
editingProperties.value = unsavedCollectionProperties
}
persistenceService.removeLocalConfig("oauth_temp_config")
collectionPropertiesModalActiveTab.value = "authorization"
showModalEditProperties.value = true
}
})
const filteredCollections = computed(() => {
const collectionsClone = clone(collections.value)

View File

@@ -24,7 +24,6 @@
autocomplete="off"
class="flex w-full bg-transparent px-4 py-2 h-8"
:placeholder="t('action.search')"
:disabled="collectionsType.type === 'team-collections'"
/>
</div>
<CollectionsMyCollections
@@ -58,8 +57,15 @@
<CollectionsTeamCollections
v-else
:collections-type="collectionsType"
:team-collection-list="teamCollectionList"
:team-loading-collections="teamLoadingCollections"
:team-collection-list="
filterTexts.length > 0 ? teamsSearchResults : teamCollectionList
"
:team-loading-collections="
filterTexts.length > 0
? collectionsBeingLoadedFromSearch
: teamLoadingCollections
"
:filter-text="filterTexts"
:export-loading="exportLoading"
:duplicate-loading="duplicateLoading"
:save-request="saveRequest"
@@ -87,6 +93,7 @@
@expand-team-collection="expandTeamCollection"
@display-modal-add="displayModalAdd(true)"
@display-modal-import-export="displayModalImportExport(true)"
@collection-click="handleCollectionClick"
/>
<div
class="py-15 hidden flex-1 flex-col items-center justify-center bg-primaryDark px-4 text-secondaryLight"
@@ -154,8 +161,10 @@
@hide-modal="displayTeamModalAdd(false)"
/>
<CollectionsProperties
v-model="collectionPropertiesModalActiveTab"
:show="showModalEditProperties"
:editing-properties="editingProperties"
source="REST"
@hide-modal="displayModalEditProperties(false)"
@set-collection-properties="setCollectionProperties"
/>
@@ -163,7 +172,7 @@
</template>
<script setup lang="ts">
import { computed, nextTick, PropType, ref, watch } from "vue"
import { computed, nextTick, onMounted, PropType, ref, watch } from "vue"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { Picked } from "~/helpers/types/HoppPicked"
@@ -199,7 +208,7 @@ import {
HoppRESTRequest,
makeCollection,
} from "@hoppscotch/data"
import { cloneDeep, isEqual } from "lodash-es"
import { cloneDeep, debounce, isEqual } from "lodash-es"
import { GQLError } from "~/helpers/backend/GQLClient"
import {
createNewRootCollection,
@@ -240,6 +249,11 @@ import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { TeamSearchService } from "~/helpers/teams/TeamsSearch.service"
import { PersistenceService } from "~/services/persistence"
import { PersistedOAuthConfig } from "~/services/oauth/oauth.service"
import { RESTOptionTabs } from "../http/RequestOptions.vue"
import { EditingProperties } from "./Properties.vue"
const t = useI18n()
const toast = useToast()
@@ -291,12 +305,7 @@ const editingRequestName = ref("")
const editingRequestIndex = ref<number | null>(null)
const editingRequestID = ref<string | null>(null)
const editingProperties = ref<{
collection: Omit<HoppCollection, "v"> | TeamCollection | null
isRootCollection: boolean
path: string
inheritedProperties?: HoppInheritedProperty
}>({
const editingProperties = ref<EditingProperties>({
collection: null,
isRootCollection: false,
path: "",
@@ -336,6 +345,99 @@ const teamLoadingCollections = useReadonlyStream(
[]
)
const {
cascadeParentCollectionForHeaderAuthForSearchResults,
searchTeams,
teamsSearchResults,
teamsSearchResultsLoading,
expandCollection,
expandingCollections,
} = useService(TeamSearchService)
watch(teamsSearchResults, (newSearchResults) => {
if (newSearchResults.length === 1 && filterTexts.value.length > 0) {
expandCollection(newSearchResults[0].id)
}
})
const debouncedSearch = debounce(searchTeams, 400)
const collectionsBeingLoadedFromSearch = computed(() => {
const collections = []
if (teamsSearchResultsLoading.value) {
collections.push("root")
}
collections.push(...expandingCollections.value)
return collections
})
watch(
filterTexts,
(newFilterText) => {
if (collectionsType.value.type === "team-collections") {
const selectedTeamID = collectionsType.value.selectedTeam?.id
selectedTeamID &&
debouncedSearch(newFilterText, selectedTeamID)?.catch((_) => {})
}
},
{
immediate: true,
}
)
const persistenceService = useService(PersistenceService)
const collectionPropertiesModalActiveTab = ref<RESTOptionTabs>("headers")
onMounted(() => {
const localOAuthTempConfig =
persistenceService.getLocalConfig("oauth_temp_config")
if (!localOAuthTempConfig) {
return
}
const { context, source, token }: PersistedOAuthConfig =
JSON.parse(localOAuthTempConfig)
if (source === "GraphQL") {
return
}
if (context?.type === "collection-properties") {
// load the unsaved editing properties
const unsavedCollectionPropertiesString = persistenceService.getLocalConfig(
"unsaved_collection_properties"
)
if (unsavedCollectionPropertiesString) {
const unsavedCollectionProperties: EditingProperties<"REST"> = JSON.parse(
unsavedCollectionPropertiesString
)
// casting because the type `EditingProperties["collection"]["auth"] and the usage in Properties.vue is different. there it's casted as an any.
// FUTURE-TODO: look into this
// @ts-expect-error because of the above reason
const auth = unsavedCollectionProperties.collection?.auth as HoppRESTAuth
if (auth?.authType === "oauth-2") {
const grantTypeInfo = auth.grantTypeInfo
grantTypeInfo && (grantTypeInfo.token = token ?? "")
}
editingProperties.value = unsavedCollectionProperties
}
persistenceService.removeLocalConfig("oauth_temp_config")
collectionPropertiesModalActiveTab.value = "authorization"
showModalEditProperties.value = true
}
})
watch(
() => myTeams.value,
(newTeams) => {
@@ -364,7 +466,28 @@ const switchToMyCollections = () => {
teamCollectionAdapter.changeTeamID(null)
}
/**
* right now, for search results, we rely on collection click + isOpen to expand the collection
*/
const handleCollectionClick = (payload: {
collectionID: string
isOpen: boolean
}) => {
if (
filterTexts.value.length > 0 &&
teamsSearchResults.value.length &&
payload.isOpen
) {
expandCollection(payload.collectionID)
return
}
}
const expandTeamCollection = (collectionID: string) => {
if (filterTexts.value.length > 0 && teamsSearchResults.value) {
return
}
teamCollectionAdapter.expandCollection(collectionID)
}
@@ -739,7 +862,7 @@ const onAddRequest = (requestName: string) => {
saveContext: {
originLocation: "team-collection",
requestID: createRequestInCollection.id,
collectionID: createRequestInCollection.collection.id,
collectionID: path,
teamID: createRequestInCollection.collection.team.id,
},
inheritedProperties: {
@@ -1330,13 +1453,25 @@ const selectRequest = (selectedRequest: {
let possibleTab = null
if (collectionsType.value.type === "team-collections") {
const { auth, headers } =
teamCollectionAdapter.cascadeParentCollectionForHeaderAuth(folderPath)
let inheritedProperties: HoppInheritedProperty | undefined = undefined
possibleTab = tabs.getTabRefWithSaveContext({
if (filterTexts.value.length > 0) {
const collectionID = folderPath.split("/").at(-1)
if (!collectionID) return
inheritedProperties =
cascadeParentCollectionForHeaderAuthForSearchResults(collectionID)
} else {
inheritedProperties =
teamCollectionAdapter.cascadeParentCollectionForHeaderAuth(folderPath)
}
const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "team-collection",
requestID: requestIndex,
})
if (possibleTab) {
tabs.setActiveTab(possibleTab.value.id)
} else {
@@ -1348,10 +1483,7 @@ const selectRequest = (selectedRequest: {
requestID: requestIndex,
collectionID: folderPath,
},
inheritedProperties: {
auth,
headers,
},
inheritedProperties: inheritedProperties,
})
}
} else {
@@ -2021,7 +2153,7 @@ const editProperties = (payload: {
{
parentID: "",
parentName: "",
inheritedHeaders: [],
inheritedHeader: {},
},
],
} as HoppInheritedProperty
@@ -2039,7 +2171,7 @@ const editProperties = (payload: {
}
editingProperties.value = {
collection,
collection: collection as Partial<HoppCollection>,
isRootCollection: isAlreadyInRoot(collectionIndex),
path: collectionIndex,
inheritedProperties,
@@ -2083,7 +2215,7 @@ const editProperties = (payload: {
}
editingProperties.value = {
collection: coll,
collection: coll as unknown as Partial<HoppCollection>,
isRootCollection: isAlreadyInRoot(collectionIndex),
path: collectionIndex,
inheritedProperties,
@@ -2094,11 +2226,12 @@ const editProperties = (payload: {
}
const setCollectionProperties = (newCollection: {
collection: HoppCollection
path: string
collection: Partial<HoppCollection> | null
isRootCollection: boolean
path: string
}) => {
const { collection, path, isRootCollection } = newCollection
if (!collection) return
if (collectionsType.value.type === "my-collections") {
if (isRootCollection) {
@@ -2148,8 +2281,7 @@ const setCollectionProperties = (newCollection: {
auth,
headers,
},
"rest",
"team"
"rest"
)
}, 200)
}

View File

@@ -165,7 +165,7 @@ import { environmentsStore } from "~/newstore/environments"
import { platform } from "~/platform"
import { useService } from "dioc/vue"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
import { uniqueId } from "lodash-es"
import { uniqueID } from "~/helpers/utils/uniqueID"
type EnvironmentVariable = {
id: number
@@ -277,7 +277,7 @@ const workingEnv = computed(() => {
} as Environment
} else if (props.action === "new") {
return {
id: uniqueId(),
id: uniqueID(),
name: "",
variables: props.envVars(),
}
@@ -331,7 +331,7 @@ watch(
: "variables"
if (props.editingEnvironmentIndex !== "Global") {
editingID.value = workingEnv.value?.id ?? uniqueId()
editingID.value = workingEnv.value?.id || uniqueID()
}
vars.value = pipe(
workingEnv.value?.variables ?? [],
@@ -416,14 +416,12 @@ const saveEnvironment = () => {
const variables = pipe(
filteredVariables,
A.map((e) =>
e.secret ? { key: e.key, secret: e.secret, value: undefined } : e
)
A.map((e) => (e.secret ? { key: e.key, secret: e.secret } : e))
)
const environmentUpdated: Environment = {
v: 1,
id: uniqueId(),
id: uniqueID(),
name: editingName.value,
variables,
}

View File

@@ -360,7 +360,7 @@ const saveEnvironment = async () => {
return
}
const filterdVariables = pipe(
const filteredVariables = pipe(
vars.value,
A.filterMap(
flow(
@@ -371,17 +371,15 @@ const saveEnvironment = async () => {
)
const secretVariables = pipe(
filterdVariables,
filteredVariables,
A.filterMapWithIndex((i, e) =>
e.secret ? O.some({ key: e.key, value: e.value, varIndex: i }) : O.none
)
)
const variables = pipe(
filterdVariables,
A.map((e) =>
e.secret ? { key: e.key, secret: e.secret, value: undefined } : e
)
filteredVariables,
A.map((e) => (e.secret ? { key: e.key, secret: e.secret } : e))
)
const environmentUpdated: Environment = {

View File

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

View File

@@ -82,7 +82,7 @@
:active="authName === 'OAuth 2.0'"
@click="
() => {
auth.authType = 'oauth-2'
selectOAuth2AuthType()
hide()
}
"
@@ -189,12 +189,12 @@
<div v-if="auth.authType === 'oauth-2'">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="auth.token"
v-model="auth.grantTypeInfo.token"
:environment-highlights="false"
placeholder="Token"
/>
</div>
<HttpOAuth2Authorization v-model="auth" />
<HttpOAuth2Authorization v-model="auth" source="GraphQL" />
</div>
<div v-if="auth.authType === 'api-key'">
<HttpAuthorizationApiKey v-model="auth" />
@@ -220,19 +220,22 @@
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { pluckRef } from "@composables/ref"
import { useColorMode } from "@composables/theming"
import { HoppGQLAuth, HoppGQLAuthOAuth2 } from "@hoppscotch/data"
import { useVModel } from "@vueuse/core"
import { computed, onMounted, ref } from "vue"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import IconCircle from "~icons/lucide/circle"
import IconCircleDot from "~icons/lucide/circle-dot"
import IconExternalLink from "~icons/lucide/external-link"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconTrash2 from "~icons/lucide/trash-2"
import IconExternalLink from "~icons/lucide/external-link"
import IconCircleDot from "~icons/lucide/circle-dot"
import IconCircle from "~icons/lucide/circle"
import { computed, ref } from "vue"
import { HoppGQLAuth } from "@hoppscotch/data"
import { pluckRef } from "@composables/ref"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { useVModel } from "@vueuse/core"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { onMounted } from "vue"
import { getDefaultAuthCodeOauthFlowParams } from "~/services/oauth/flows/authCode"
const t = useI18n()
@@ -280,6 +283,30 @@ const getAuthName = (type: HoppGQLAuth["authType"] | undefined) => {
return AUTH_KEY_NAME[type] ? AUTH_KEY_NAME[type] : "None"
}
const selectOAuth2AuthType = () => {
const defaultGrantTypeInfo: HoppGQLAuthOAuth2["grantTypeInfo"] = {
...getDefaultAuthCodeOauthFlowParams(),
grantType: "AUTHORIZATION_CODE",
token: "",
}
// @ts-expect-error - the existing grantTypeInfo might be in the auth object, typescript doesnt know that
const existingGrantTypeInfo = auth.value.grantTypeInfo as
| HoppGQLAuthOAuth2["grantTypeInfo"]
| undefined
const grantTypeInfo = existingGrantTypeInfo
? existingGrantTypeInfo
: defaultGrantTypeInfo
auth.value = <HoppGQLAuth>{
...auth.value,
authType: "oauth-2",
addTo: "HEADERS",
grantTypeInfo: grantTypeInfo,
}
}
const authActive = pluckRef(auth, "authActive")
const clearContent = () => {

View File

@@ -579,12 +579,18 @@ const getComputedAuthHeaders = (
})
} else if (
request.auth.authType === "bearer" ||
request.auth.authType === "oauth-2"
(request.auth.authType === "oauth-2" && request.auth.addTo === "HEADERS")
) {
const requestAuth = request.auth
const isOAuth2 = requestAuth.authType === "oauth-2"
const token = isOAuth2 ? requestAuth.grantTypeInfo.token : requestAuth.token
headers.push({
active: true,
key: "Authorization",
value: `Bearer ${request.auth.token}`,
value: `Bearer ${token}`,
})
} else if (request.auth.authType === "api-key") {
const { key, addTo } = request.auth

View File

@@ -82,7 +82,7 @@
:active="authName === 'OAuth 2.0'"
@click="
() => {
auth.authType = 'oauth-2'
selectOAuth2AuthType()
hide()
}
"
@@ -177,15 +177,24 @@
/>
</div>
</div>
<div v-if="auth.authType === 'oauth-2'">
<div v-if="auth.authType === 'oauth-2'" class="w-full">
<div class="flex flex-1 border-b border-dividerLight">
<!-- Ensure a new object is assigned here to avoid reactivity issues -->
<SmartEnvInput
v-model="auth.token"
:model-value="auth.grantTypeInfo.token"
placeholder="Token"
:envs="envs"
@update:model-value="
auth.grantTypeInfo = { ...auth.grantTypeInfo, token: $event }
"
/>
</div>
<HttpOAuth2Authorization v-model="auth" :envs="envs" />
<HttpOAuth2Authorization
v-model="auth"
:is-collection-property="isCollectionProperty"
:envs="envs"
:source="source"
/>
</div>
<div v-if="auth.authType === 'api-key'">
<HttpAuthorizationApiKey v-model="auth" :envs="envs" />
@@ -217,7 +226,7 @@ import IconExternalLink from "~icons/lucide/external-link"
import IconCircleDot from "~icons/lucide/circle-dot"
import IconCircle from "~icons/lucide/circle"
import { computed, ref } from "vue"
import { HoppRESTAuth } from "@hoppscotch/data"
import { HoppRESTAuth, HoppRESTAuthOAuth2 } from "@hoppscotch/data"
import { pluckRef } from "@composables/ref"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
@@ -226,17 +235,27 @@ import { onMounted } from "vue"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { AggregateEnvironment } from "~/newstore/environments"
import { getDefaultAuthCodeOauthFlowParams } from "~/services/oauth/flows/authCode"
const t = useI18n()
const colorMode = useColorMode()
const props = defineProps<{
modelValue: HoppRESTAuth
isCollectionProperty?: boolean
isRootCollection?: boolean
inheritedProperties?: HoppInheritedProperty
envs?: AggregateEnvironment[]
}>()
const props = withDefaults(
defineProps<{
modelValue: HoppRESTAuth
isCollectionProperty?: boolean
isRootCollection?: boolean
inheritedProperties?: HoppInheritedProperty
envs?: AggregateEnvironment[]
source?: "REST" | "GraphQL"
}>(),
{
source: "REST",
envs: undefined,
inheritedProperties: undefined,
}
)
const emit = defineEmits<{
(e: "update:modelValue", value: HoppRESTAuth): void
@@ -272,6 +291,30 @@ const getAuthName = (type: HoppRESTAuth["authType"] | undefined) => {
return AUTH_KEY_NAME[type] ? AUTH_KEY_NAME[type] : "None"
}
const selectOAuth2AuthType = () => {
const defaultGrantTypeInfo: HoppRESTAuthOAuth2["grantTypeInfo"] = {
...getDefaultAuthCodeOauthFlowParams(),
grantType: "AUTHORIZATION_CODE",
token: "",
}
// @ts-expect-error - the existing grantTypeInfo might be in the auth object, typescript doesnt know that
const existingGrantTypeInfo = auth.value.grantTypeInfo as
| HoppRESTAuthOAuth2["grantTypeInfo"]
| undefined
const grantTypeInfo = existingGrantTypeInfo
? existingGrantTypeInfo
: defaultGrantTypeInfo
auth.value = <HoppRESTAuth>{
...auth.value,
authType: "oauth-2",
addTo: "HEADERS",
grantTypeInfo: grantTypeInfo,
}
}
const authActive = pluckRef(auth, "authActive")
const clearContent = () => {

View File

@@ -307,6 +307,7 @@ import { useColorMode } from "@composables/theming"
import { computed, reactive, ref, watch } from "vue"
import { isEqual, cloneDeep } from "lodash-es"
import {
HoppRESTAuth,
HoppRESTHeader,
HoppRESTRequest,
parseRawKeyValueEntriesE,
@@ -364,7 +365,12 @@ const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
// v-model integration with props and emit
const props = defineProps<{
modelValue: HoppRESTRequest
modelValue:
| HoppRESTRequest
| {
headers: HoppRESTHeader[]
auth: HoppRESTAuth
}
isCollectionProperty?: boolean
inheritedProperties?: HoppInheritedProperty
envs?: AggregateEnvironment[]

View File

@@ -15,8 +15,6 @@ import { computed } from "vue"
import { useI18n } from "~/composables/i18n"
import { useService } from "dioc/vue"
import { WorkspaceService } from "~/services/workspace.service"
import { useReadonlyStream } from "~/composables/stream"
import { platform } from "~/platform"
const props = defineProps<{
section?: string
@@ -28,23 +26,11 @@ const t = useI18n()
const workspaceService = useService(WorkspaceService)
const workspace = workspaceService.currentWorkspace
const currentUser = useReadonlyStream(
platform.auth.getProbableUserStream(),
platform.auth.getProbableUser()
)
const currentWorkspace = computed(() => {
const personalWorkspaceName = currentUser.value?.displayName
? t("workspace.personal_workspace", { name: currentUser.value.displayName })
: t("workspace.personal")
if (props.isOnlyPersonal) {
return personalWorkspaceName
if (props.isOnlyPersonal || workspace.value.type === "personal") {
return t("workspace.personal")
}
if (workspace.value.type === "team") {
return teamWorkspaceName.value
}
return personalWorkspaceName
return teamWorkspaceName.value
})
const teamWorkspaceName = computed(() => {

View File

@@ -3,7 +3,7 @@
<div class="flex flex-col">
<div class="flex flex-col">
<HoppSmartItem
:label="personalWorkspaceName"
:label="t('workspace.personal')"
:icon="IconUser"
:info-icon="workspace.type === 'personal' ? IconDone : undefined"
:active-info-icon="workspace.type === 'personal'"
@@ -96,12 +96,6 @@ const currentUser = useReadonlyStream(
platform.auth.getProbableUser()
)
const personalWorkspaceName = computed(() =>
currentUser.value?.displayName
? t("workspace.personal_workspace", { name: currentUser.value.displayName })
: t("workspace.personal")
)
const workspaceService = useService(WorkspaceService)
const teamListadapter = workspaceService.acquireTeamListAdapter(null)
const myTeams = useReadonlyStream(teamListadapter.teamList$, [])

View File

@@ -39,7 +39,7 @@ export function onLoggedIn(exec: (user: HoppUser) => void) {
* the auth system.
*
* NOTE: Unlike `onLoggedIn` for which the callback will be called once on mount with the current state,
* here the callback will only be called on authentication event occurances.
* here the callback will only be called on authentication event occurrences.
* You might want to check the auth state from an `onMounted` hook or something
* if you want to access the initial state
*

View File

@@ -1,4 +1,4 @@
import { customRef, onBeforeUnmount, Ref, watch } from "vue"
import { customRef, onBeforeUnmount, ref, Ref, UnwrapRef, watch } from "vue"
export function pluckRef<T, K extends keyof T>(ref: Ref<T>, key: K): Ref<T[K]> {
return customRef((track, trigger) => {
@@ -31,3 +31,16 @@ export function pluckMultipleFromRef<T, K extends Array<keyof T>>(
): { [key in K[number]]: Ref<T[key]> } {
return Object.fromEntries(keys.map((x) => [x, pluckRef(sourceRef, x)])) as any
}
export const refWithCallbackOnChange = <T>(
initialValue: T,
callback: (value: UnwrapRef<T>) => void
) => {
const targetRef = ref(initialValue)
watch(targetRef, (value) => {
callback(value)
})
return targetRef
}

View File

@@ -109,7 +109,6 @@ export function updateSaveContextForAffectedRequests(
}
}
}
/**
* Used to check the new folder path is close to the save context folder path or not
* @param folderPathCurrent The path saved as the inherited path in the inherited properties
@@ -123,120 +122,109 @@ function folderPathCloseToSaveContext(
saveContextPath: string
) {
if (!folderPathCurrent) return newFolderPath
const folderPathCurrentArray = folderPathCurrent.split("/")
const newFolderPathArray = newFolderPath.split("/")
const saveContextFolderPathArray = saveContextPath.split("/")
let folderPathCurrentMatch = 0
const folderPathCurrentMatch = folderPathCurrentArray.filter(
(folder, i) => folder === saveContextFolderPathArray[i]
).length
for (let i = 0; i < folderPathCurrentArray.length; i++) {
if (folderPathCurrentArray[i] === saveContextFolderPathArray[i]) {
folderPathCurrentMatch++
const newFolderPathMatch = newFolderPathArray.filter(
(folder, i) => folder === saveContextFolderPathArray[i]
).length
return folderPathCurrentMatch > newFolderPathMatch
? folderPathCurrent
: newFolderPath
}
function removeDuplicatesAndKeepLast(arr: HoppInheritedProperty["headers"]) {
const keyMap: { [key: string]: number[] } = {} // Map to store array of indices for each key
// Populate keyMap with the indices of each key
arr.forEach((item, index) => {
const key = item.inheritedHeader.key
if (!(key in keyMap)) {
keyMap[key] = []
}
keyMap[key].push(index)
})
// Create a new array containing only the last occurrence of each key
const result = []
for (const key in keyMap) {
if (Object.prototype.hasOwnProperty.call(keyMap, key)) {
const lastIndex = keyMap[key][keyMap[key].length - 1]
result.push(arr[lastIndex])
}
}
let newFolderPathMatch = 0
for (let i = 0; i < newFolderPathArray.length; i++) {
if (newFolderPathArray[i] === saveContextFolderPathArray[i]) {
newFolderPathMatch++
}
}
if (folderPathCurrentMatch > newFolderPathMatch) {
return folderPathCurrent
}
return newFolderPath
// Sort the result array based on the parentID
result.sort((a, b) => a.parentID.localeCompare(b.parentID))
return result
}
export function updateInheritedPropertiesForAffectedRequests(
path: string,
inheritedProperties: HoppInheritedProperty,
type: "rest" | "graphql",
workspace: "personal" | "team" = "personal"
type: "rest" | "graphql"
) {
const tabService =
type === "rest" ? getService(RESTTabService) : getService(GQLTabService)
let tabs
if (workspace === "personal") {
tabs = tabService.getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath.startsWith(path)
)
})
} else {
tabs = tabService.getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "team-collection" &&
tab.document.saveContext.collectionID?.startsWith(path)
)
})
}
const effectedTabs = tabService.getTabsRefTo((tab) => {
const saveContext = tab.document.saveContext
const tabsEffectedByAuth = tabs.filter((tab) => {
if (workspace === "personal") {
return (
tab.value.document.saveContext?.originLocation === "user-collection" &&
tab.value.document.saveContext.folderPath.startsWith(path) &&
path ===
folderPathCloseToSaveContext(
tab.value.document.inheritedProperties?.auth.parentID,
path,
tab.value.document.saveContext.folderPath
)
)
}
const saveContextPath =
saveContext?.originLocation === "team-collection"
? saveContext.collectionID
: saveContext?.folderPath
return (
tab.value.document.saveContext?.originLocation === "team-collection" &&
tab.value.document.saveContext.collectionID?.startsWith(path) &&
path ===
folderPathCloseToSaveContext(
tab.value.document.inheritedProperties?.auth.parentID,
path,
tab.value.document.saveContext.collectionID
)
)
return saveContextPath?.startsWith(path) ?? false
})
const tabsEffectedByHeaders = tabs.filter((tab) => {
return (
tab.value.document.inheritedProperties &&
tab.value.document.inheritedProperties.headers.some(
effectedTabs.map((tab) => {
const inheritedParentID =
tab.value.document.inheritedProperties?.auth.parentID
const contextPath =
tab.value.document.saveContext?.originLocation === "team-collection"
? tab.value.document.saveContext.collectionID
: tab.value.document.saveContext?.folderPath
const effectedPath = folderPathCloseToSaveContext(
inheritedParentID,
path,
contextPath ?? ""
)
if (effectedPath === path) {
if (tab.value.document.inheritedProperties) {
tab.value.document.inheritedProperties.auth = inheritedProperties.auth
}
}
if (tab.value.document.inheritedProperties?.headers) {
// filter out the headers with the parentID not as the path
const headers = tab.value.document.inheritedProperties.headers.filter(
(header) => header.parentID !== path
)
// filter out the headers with the parentID as the path in the inheritedProperties
const inheritedHeaders = inheritedProperties.headers.filter(
(header) => header.parentID === path
)
)
})
for (const tab of tabsEffectedByAuth) {
tab.value.document.inheritedProperties = inheritedProperties
}
// merge the headers with the parentID as the path
const mergedHeaders = removeDuplicatesAndKeepLast([
...new Set([...inheritedHeaders, ...headers]),
])
for (const tab of tabsEffectedByHeaders) {
const headers = tab.value.document.inheritedProperties?.headers.map(
(header) => {
if (header.parentID === path) {
return {
...header,
inheritedHeader: inheritedProperties.headers.find(
(inheritedHeader) =>
inheritedHeader.inheritedHeader?.key ===
header.inheritedHeader?.key
)?.inheritedHeader,
}
}
return header
}
)
tab.value.document.inheritedProperties = {
...tab.value.document.inheritedProperties,
headers,
tab.value.document.inheritedProperties.headers = mergedHeaders
}
}
})
}
function resetSaveContextForAffectedRequests(folderPath: string) {

View File

@@ -66,12 +66,20 @@ export function getRequestsByPath(
let currentCollection = collections[pathArray[0]]
if (pathArray.length === 1) {
return currentCollection.requests
const latestVersionedRequests = currentCollection.requests.filter(
(req): req is HoppRESTRequest => req.v === "3"
)
return latestVersionedRequests
}
for (let i = 1; i < pathArray.length; i++) {
const folder = currentCollection.folders[pathArray[i]]
if (folder) currentCollection = folder
}
return currentCollection.requests
const latestVersionedRequests = currentCollection.requests.filter(
(req): req is HoppRESTRequest => req.v === "3"
)
return latestVersionedRequests
}

View File

@@ -1,10 +1,10 @@
/**
* Converts an array of key-value tuples (for e.g ["key", "value"]), into a record.
* (for eg. output -> { "key": "value" })
* NOTE: This function will discard duplicate key occurances and only keep the last occurance. If you do not want that behaviour,
* NOTE: This function will discard duplicate key occurrences and only keep the last occurrence. If you do not want that behaviour,
* use `tupleWithSamesKeysToRecord`.
* @param tuples Array of tuples ([key, value])
* @returns A record with value corresponding to the last occurance of that key
* @returns A record with value corresponding to the last occurrence of that key
*/
export const tupleToRecord = <
KeyType extends string | number | symbol,

View File

@@ -269,8 +269,16 @@ export const runGQLOperation = async (options: RunQueryOptions) => {
const username = auth.username
const password = auth.password
finalHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}`
} else if (auth.authType === "bearer" || auth.authType === "oauth-2") {
} else if (auth.authType === "bearer") {
finalHeaders.Authorization = `Bearer ${auth.token}`
} else if (auth.authType === "oauth-2") {
const { addTo } = auth
if (addTo === "HEADERS") {
finalHeaders.Authorization = `Bearer ${auth.grantTypeInfo.token}`
} else if (addTo === "QUERY_PARAMS") {
params["access_token"] = auth.grantTypeInfo.token
}
} else if (auth.authType === "api-key") {
const { key, value, addTo } = auth
if (addTo === "Headers") {

View File

@@ -4,11 +4,12 @@ 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 { isPlainObject as _isPlainObject } from "lodash-es"
import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { safeParseJSON } from "~/helpers/functional/json"
import { translateToNewGQLCollection } from "@hoppscotch/data"
import { entityReference } from "verzod"
import { z } from "zod"
export const hoppRESTImporter = (content: string[]) =>
pipe(
@@ -26,26 +27,14 @@ export const hoppRESTImporter = (content: string[]) =>
TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT)
)
/**
* checks if a value is a plain object
*/
const isPlainObject = (value: any): value is object => _isPlainObject(value)
/**
* checks if a collection matches the schema for a hoppscotch collection.
* here 2 is the latest version of the schema.
*/
const isValidCollection = (collection: unknown): collection is HoppCollection =>
isPlainObject(collection) && "v" in collection && collection.v === 2
/**
* checks if a collection is a valid hoppscotch collection.
* else translate it into one.
*/
const validateCollection = (collection: unknown) => {
if (isValidCollection(collection)) {
return O.some(collection)
}
const result = entityReference(HoppCollection).safeParse(collection)
if (result.success) return O.some(result.data)
return O.some(translateToNewRESTCollection(collection))
}
@@ -75,8 +64,9 @@ export const hoppGQLImporter = (content: string) =>
* @returns the collection if it is valid, else a translated version of the collection
*/
export const validateGQLCollection = (collection: unknown) => {
if (isValidCollection(collection)) {
return O.some(collection)
}
const result = z.array(entityReference(HoppCollection)).safeParse(collection)
if (result.success) return O.some(result.data)
return O.some(translateToNewGQLCollection(collection))
}

View File

@@ -111,12 +111,16 @@ const getHoppReqAuth = (req: InsomniaRequestResource): HoppRESTAuth => {
return {
authType: "oauth-2",
authActive: !(auth.disabled ?? false),
accessTokenURL: replaceVarTemplating(auth.accessTokenUrl ?? ""),
authURL: replaceVarTemplating(auth.authorizationUrl ?? ""),
clientID: replaceVarTemplating(auth.clientId ?? ""),
oidcDiscoveryURL: "",
scope: replaceVarTemplating(auth.scope ?? ""),
token: "",
grantTypeInfo: {
authEndpoint: replaceVarTemplating(auth.authorizationUrl ?? ""),
clientID: replaceVarTemplating(auth.clientId ?? ""),
clientSecret: "",
grantType: "AUTHORIZATION_CODE",
scopes: replaceVarTemplating(auth.scope ?? ""),
token: "",
isPKCE: false,
tokenEndpoint: replaceVarTemplating(auth.accessTokenUrl ?? ""),
},
}
else if (auth.type === "bearer")
return {

View File

@@ -6,7 +6,7 @@ import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { z } from "zod"
import { NonSecretEnvironment } from "@hoppscotch/data"
import { safeParseJSONOrYAML } from "~/helpers/functional/yaml"
import { uniqueId } from "lodash-es"
import { uniqueID } from "~/helpers/utils/uniqueID"
const insomniaResourcesSchema = z.object({
resources: z.array(
@@ -67,7 +67,7 @@ export const insomniaEnvImporter = (contents: string[]) => {
if (parsedInsomniaEnv.success) {
const environment: NonSecretEnvironment = {
id: uniqueId(),
id: uniqueID(),
v: 1,
name: parsedInsomniaEnv.data.name,
variables: Object.entries(parsedInsomniaEnv.data.data).map(

View File

@@ -279,67 +279,92 @@ const resolveOpenAPIV3SecurityObj = (
return {
authType: "oauth-2",
authActive: true,
accessTokenURL: scheme.flows.authorizationCode.tokenUrl ?? "",
authURL: scheme.flows.authorizationCode.authorizationUrl ?? "",
clientID: "",
oidcDiscoveryURL: "",
scope: _schemeData.join(" "),
token: "",
grantTypeInfo: {
grantType: "AUTHORIZATION_CODE",
authEndpoint: scheme.flows.authorizationCode.authorizationUrl ?? "",
clientID: "",
scopes: _schemeData.join(" "),
token: "",
isPKCE: false,
tokenEndpoint: scheme.flows.authorizationCode.tokenUrl ?? "",
clientSecret: "",
},
addTo: "HEADERS",
}
} else if (scheme.flows.implicit) {
return {
authType: "oauth-2",
authActive: true,
authURL: scheme.flows.implicit.authorizationUrl ?? "",
accessTokenURL: "",
clientID: "",
oidcDiscoveryURL: "",
scope: _schemeData.join(" "),
token: "",
grantTypeInfo: {
grantType: "IMPLICIT",
authEndpoint: scheme.flows.implicit.authorizationUrl ?? "",
clientID: "",
token: "",
scopes: _schemeData.join(" "),
},
addTo: "HEADERS",
}
} else if (scheme.flows.password) {
return {
authType: "oauth-2",
authActive: true,
authURL: "",
accessTokenURL: scheme.flows.password.tokenUrl ?? "",
clientID: "",
oidcDiscoveryURL: "",
scope: _schemeData.join(" "),
token: "",
grantTypeInfo: {
grantType: "PASSWORD",
clientID: "",
authEndpoint: scheme.flows.password.tokenUrl,
clientSecret: "",
password: "",
username: "",
token: "",
scopes: _schemeData.join(" "),
},
addTo: "HEADERS",
}
} else if (scheme.flows.clientCredentials) {
return {
authType: "oauth-2",
authActive: true,
accessTokenURL: scheme.flows.clientCredentials.tokenUrl ?? "",
authURL: "",
clientID: "",
oidcDiscoveryURL: "",
scope: _schemeData.join(" "),
token: "",
grantTypeInfo: {
grantType: "CLIENT_CREDENTIALS",
authEndpoint: scheme.flows.clientCredentials.tokenUrl ?? "",
clientID: "",
clientSecret: "",
scopes: _schemeData.join(" "),
token: "",
},
addTo: "HEADERS",
}
}
return {
authType: "oauth-2",
authActive: true,
accessTokenURL: "",
authURL: "",
clientID: "",
oidcDiscoveryURL: "",
scope: _schemeData.join(" "),
token: "",
grantTypeInfo: {
grantType: "AUTHORIZATION_CODE",
authEndpoint: "",
clientID: "",
scopes: _schemeData.join(" "),
token: "",
isPKCE: false,
tokenEndpoint: "",
clientSecret: "",
},
addTo: "HEADERS",
}
} else if (scheme.type === "openIdConnect") {
return {
authType: "oauth-2",
authActive: true,
accessTokenURL: "",
authURL: "",
clientID: "",
oidcDiscoveryURL: scheme.openIdConnectUrl ?? "",
scope: _schemeData.join(" "),
token: "",
grantTypeInfo: {
grantType: "AUTHORIZATION_CODE",
authEndpoint: "",
clientID: "",
scopes: _schemeData.join(" "),
token: "",
isPKCE: false,
tokenEndpoint: "",
clientSecret: "",
},
addTo: "HEADERS",
}
}
@@ -416,56 +441,76 @@ const resolveOpenAPIV2SecurityScheme = (
return {
authType: "oauth-2",
authActive: true,
accessTokenURL: scheme.tokenUrl ?? "",
authURL: scheme.authorizationUrl ?? "",
clientID: "",
oidcDiscoveryURL: "",
scope: _schemeData.join(" "),
token: "",
grantTypeInfo: {
authEndpoint: scheme.authorizationUrl ?? "",
clientID: "",
clientSecret: "",
grantType: "AUTHORIZATION_CODE",
scopes: _schemeData.join(" "),
token: "",
isPKCE: false,
tokenEndpoint: scheme.tokenUrl ?? "",
},
addTo: "HEADERS",
}
} else if (scheme.flow === "implicit") {
return {
authType: "oauth-2",
authActive: true,
accessTokenURL: "",
authURL: scheme.authorizationUrl ?? "",
clientID: "",
oidcDiscoveryURL: "",
scope: _schemeData.join(" "),
token: "",
grantTypeInfo: {
authEndpoint: scheme.authorizationUrl ?? "",
clientID: "",
grantType: "IMPLICIT",
scopes: _schemeData.join(" "),
token: "",
},
addTo: "HEADERS",
}
} else if (scheme.flow === "application") {
return {
authType: "oauth-2",
authActive: true,
accessTokenURL: scheme.tokenUrl ?? "",
authURL: "",
clientID: "",
oidcDiscoveryURL: "",
scope: _schemeData.join(" "),
token: "",
grantTypeInfo: {
authEndpoint: scheme.tokenUrl ?? "",
clientID: "",
clientSecret: "",
grantType: "CLIENT_CREDENTIALS",
scopes: _schemeData.join(" "),
token: "",
},
addTo: "HEADERS",
}
} else if (scheme.flow === "password") {
return {
authType: "oauth-2",
authActive: true,
accessTokenURL: scheme.tokenUrl ?? "",
authURL: "",
clientID: "",
oidcDiscoveryURL: "",
scope: _schemeData.join(" "),
token: "",
grantTypeInfo: {
grantType: "PASSWORD",
authEndpoint: scheme.tokenUrl ?? "",
clientID: "",
clientSecret: "",
password: "",
scopes: _schemeData.join(" "),
token: "",
username: "",
},
addTo: "HEADERS",
}
}
return {
authType: "oauth-2",
authActive: true,
accessTokenURL: "",
authURL: "",
clientID: "",
oidcDiscoveryURL: "",
scope: _schemeData.join(" "),
token: "",
grantTypeInfo: {
authEndpoint: "",
clientID: "",
clientSecret: "",
grantType: "AUTHORIZATION_CODE",
scopes: _schemeData.join(" "),
token: "",
isPKCE: false,
tokenEndpoint: "",
},
addTo: "HEADERS",
}
}

View File

@@ -162,25 +162,36 @@ const getHoppReqAuth = (item: Item): HoppRESTAuth => {
),
}
} else if (auth.type === "oauth2") {
const accessTokenURL = replacePMVarTemplating(
getVariableValue(auth.oauth2, "accessTokenUrl") ?? ""
)
const authURL = replacePMVarTemplating(
getVariableValue(auth.oauth2, "authUrl") ?? ""
)
const clientId = replacePMVarTemplating(
getVariableValue(auth.oauth2, "clientId") ?? ""
)
const scope = replacePMVarTemplating(
getVariableValue(auth.oauth2, "scope") ?? ""
)
const token = replacePMVarTemplating(
getVariableValue(auth.oauth2, "accessToken") ?? ""
)
return {
authType: "oauth-2",
authActive: true,
accessTokenURL: replacePMVarTemplating(
getVariableValue(auth.oauth2, "accessTokenUrl") ?? ""
),
authURL: replacePMVarTemplating(
getVariableValue(auth.oauth2, "authUrl") ?? ""
),
clientID: replacePMVarTemplating(
getVariableValue(auth.oauth2, "clientId") ?? ""
),
scope: replacePMVarTemplating(
getVariableValue(auth.oauth2, "scope") ?? ""
),
token: replacePMVarTemplating(
getVariableValue(auth.oauth2, "accessToken") ?? ""
),
oidcDiscoveryURL: "",
grantTypeInfo: {
grantType: "AUTHORIZATION_CODE",
authEndpoint: authURL,
clientID: clientId,
scopes: scope,
token: token,
tokenEndpoint: accessTokenURL,
clientSecret: "",
isPKCE: false,
},
addTo: "HEADERS",
}
}

View File

@@ -1,11 +1,11 @@
import { Environment } from "@hoppscotch/data"
import * as O from "fp-ts/Option"
import * as TE from "fp-ts/TaskEither"
import { uniqueId } from "lodash-es"
import { z } from "zod"
import { safeParseJSON } from "~/helpers/functional/json"
import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { uniqueID } from "~/helpers/utils/uniqueID"
const postmanEnvSchema = z.object({
name: z.string(),
@@ -49,7 +49,7 @@ export const postmanEnvImporter = (contents: string[]) => {
// Convert `values` to `variables` to match the format expected by the system
const environments: Environment[] = validationResult.data.map(
({ name, values }) => ({
id: uniqueId(),
id: uniqueID(),
v: 1,
name,
variables: values.map((entires) => ({ ...entires, secret: false })),

View File

@@ -0,0 +1,610 @@
import { ref } from "vue"
import { runGQLQuery } from "../backend/GQLClient"
import {
GetCollectionChildrenDocument,
GetCollectionRequestsDocument,
GetSingleCollectionDocument,
GetSingleRequestDocument,
} from "../backend/graphql"
import { TeamCollection } from "./TeamCollection"
import { HoppRESTAuth, HoppRESTHeader } from "@hoppscotch/data"
import * as E from "fp-ts/Either"
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
insertedWhileExpanding?: boolean
}
type CollectionSearchNode =
| {
type: "request"
title: string
method: string
id: string
// parent collections
path: CollectionSearchNode[]
}
| {
type: "collection"
title: string
id: string
// parent collections
path: CollectionSearchNode[]
}
type _SearchCollection = TeamCollection & {
parentID: string | null
meta?: CollectionSearchMeta
}
type _SearchRequest = {
id: string
collectionID: string
title: string
request: {
name: string
method: string
}
meta?: CollectionSearchMeta
}
function convertToTeamCollection(
node: CollectionSearchNode & {
meta?: CollectionSearchMeta
},
existingCollections: Record<string, _SearchCollection>,
existingRequests: Record<string, _SearchRequest>
) {
if (node.type === "request") {
existingRequests[node.id] = {
id: node.id,
collectionID: node.path[0].id,
title: node.title,
request: {
name: node.title,
method: node.method,
},
meta: {
isSearchResult: node.meta?.isSearchResult || false,
},
}
if (node.path[0]) {
// add parent collections to the collections array recursively
convertToTeamCollection(
node.path[0],
existingCollections,
existingRequests
)
}
} else {
existingCollections[node.id] = {
id: node.id,
title: node.title,
children: [],
requests: [],
data: null,
parentID: node.path[0]?.id,
meta: {
isSearchResult: node.meta?.isSearchResult || false,
},
}
if (node.path[0]) {
// add parent collections to the collections array recursively
convertToTeamCollection(
node.path[0],
existingCollections,
existingRequests
)
}
}
return {
existingCollections,
existingRequests,
}
}
function convertToTeamTree(
collections: (TeamCollection & { parentID: string | null })[],
requests: TeamRequest[]
) {
const collectionTree: TeamCollection[] = []
collections.forEach((collection) => {
const parentCollection = collection.parentID
? collections.find((c) => c.id === collection.parentID)
: null
const isAlreadyInserted = parentCollection?.children?.find(
(c) => c.id === collection.id
)
if (isAlreadyInserted) return
if (parentCollection) {
parentCollection.children = parentCollection.children || []
parentCollection.children.push(collection)
} else {
collectionTree.push(collection)
}
})
requests.forEach((request) => {
const parentCollection = collections.find(
(c) => c.id === request.collectionID
)
const isAlreadyInserted = parentCollection?.requests?.find(
(r) => r.id === request.id
)
if (isAlreadyInserted) return
if (parentCollection) {
parentCollection.requests = parentCollection.requests || []
parentCollection.requests.push({
id: request.id,
collectionID: request.collectionID,
title: request.title,
request: request.request,
})
}
})
return collectionTree
}
export class TeamSearchService extends Service {
public static readonly ID = "TeamSearchService"
public endpoint = import.meta.env.VITE_BACKEND_API_URL
public teamsSearchResultsLoading = ref(false)
public teamsSearchResults = ref<TeamCollection[]>([])
public teamsSearchResultsFormattedForSpotlight = ref<
{
collectionTitles: string[]
request: {
id: string
name: string
method: string
}
}[]
>([])
searchResultsCollections: Record<string, _SearchCollection> = {}
searchResultsRequests: Record<string, _SearchRequest> = {}
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
// eg: do the spotlight formatting in the spotlight searcher and not here
searchTeams = async (query: string, teamID: string) => {
if (!query.length) {
return
}
this.teamsSearchResultsLoading.value = true
this.searchResultsCollections = {}
this.searchResultsRequests = {}
this.expandedCollections.value = []
try {
const searchResponse = await axios.get(
`${
this.endpoint
}/team-collection/search/${teamID}?searchQuery=${encodeURIComponent(
query
)}}`,
{
withCredentials: true,
}
)
if (searchResponse.status !== 200) {
return
}
const searchResults = searchResponse.data.data as CollectionSearchNode[]
searchResults
.map((node) => {
const { existingCollections, existingRequests } =
convertToTeamCollection(
{
...node,
meta: {
isSearchResult: true,
},
},
{},
{}
)
return {
collections: existingCollections,
requests: existingRequests,
}
})
.forEach(({ collections, requests }) => {
this.searchResultsCollections = {
...this.searchResultsCollections,
...collections,
}
this.searchResultsRequests = {
...this.searchResultsRequests,
...requests,
}
})
const collectionFetchingPromises = Object.values(
this.searchResultsCollections
).map((col) => {
return getSingleCollection(col.id)
})
const requestFetchingPromises = Object.values(
this.searchResultsRequests
).map((req) => {
return getSingleRequest(req.id)
})
const collectionResponses = await Promise.all(collectionFetchingPromises)
const requestResponses = await Promise.all(requestFetchingPromises)
requestResponses.map((res) => {
if (E.isLeft(res)) {
return
}
const request = res.right.request
if (!request) return
this.searchResultsRequests[request.id] = {
id: request.id,
title: request.title,
request: JSON.parse(request.request) as TeamRequest["request"],
collectionID: request.collectionID,
}
})
collectionResponses.map((res) => {
if (E.isLeft(res)) {
return
}
const collection = res.right.collection
if (!collection) return
this.searchResultsCollections[collection.id].data =
collection.data ?? null
})
const collectionTree = convertToTeamTree(
Object.values(this.searchResultsCollections),
// asserting because we've already added the missing properties after fetching the full details
Object.values(this.searchResultsRequests) as TeamRequest[]
)
this.teamsSearchResults.value = collectionTree
this.teamsSearchResultsFormattedForSpotlight.value = Object.values(
this.searchResultsRequests
).map((request) => {
return formatTeamsSearchResultsForSpotlight(
{
collectionID: request.collectionID,
name: request.title,
method: request.request.method,
id: request.id,
},
Object.values(this.searchResultsCollections)
)
})
} catch (error) {
console.error(error)
}
this.teamsSearchResultsLoading.value = false
}
cascadeParentCollectionForHeaderAuthForSearchResults = (
collectionID: string
): HoppInheritedProperty => {
const defaultInheritedAuth: HoppInheritedProperty["auth"] = {
parentID: "",
parentName: "",
inheritedAuth: {
authType: "none",
authActive: true,
},
}
const defaultInheritedHeaders: HoppInheritedProperty["headers"] = []
const collection = Object.values(this.searchResultsCollections).find(
(col) => col.id === collectionID
)
if (!collection)
return { auth: defaultInheritedAuth, headers: defaultInheritedHeaders }
const inheritedAuthData = this.findInheritableParentAuth(collectionID)
const inheritedHeadersData = this.findInheritableParentHeaders(collectionID)
return {
auth: E.isRight(inheritedAuthData)
? inheritedAuthData.right
: defaultInheritedAuth,
headers: E.isRight(inheritedHeadersData)
? Object.values(inheritedHeadersData.right)
: defaultInheritedHeaders,
}
}
findInheritableParentAuth = (
collectionID: string
): E.Either<
string,
{
parentID: string
parentName: string
inheritedAuth: HoppRESTAuth
}
> => {
const collection = Object.values(this.searchResultsCollections).find(
(col) => col.id === collectionID
)
if (!collection) {
return E.left("PARENT_NOT_FOUND" as const)
}
// has inherited data
if (collection.data) {
const parentInheritedData = JSON.parse(collection.data) as {
auth: HoppRESTAuth
headers: HoppRESTHeader[]
}
const inheritedAuth = parentInheritedData.auth
if (inheritedAuth.authType !== "inherit") {
return E.right({
parentID: collectionID,
parentName: collection.title,
inheritedAuth: inheritedAuth,
})
}
}
if (!collection.parentID) {
return E.left("PARENT_INHERITED_DATA_NOT_FOUND")
}
return this.findInheritableParentAuth(collection.parentID)
}
findInheritableParentHeaders = (
collectionID: string,
existingHeaders: Record<
string,
HoppInheritedProperty["headers"][number]
> = {}
): E.Either<
string,
Record<string, HoppInheritedProperty["headers"][number]>
> => {
const collection = Object.values(this.searchResultsCollections).find(
(col) => col.id === collectionID
)
if (!collection) {
return E.left("PARENT_NOT_FOUND" as const)
}
// see if it has headers to inherit, if yes, add it to the existing headers
if (collection.data) {
const parentInheritedData = JSON.parse(collection.data) as {
auth: HoppRESTAuth
headers: HoppRESTHeader[]
}
const inheritedHeaders = parentInheritedData.headers
if (inheritedHeaders) {
inheritedHeaders.forEach((header) => {
if (!existingHeaders[header.key]) {
existingHeaders[header.key] = {
parentID: collection.id,
parentName: collection.title,
inheritedHeader: header,
}
}
})
}
}
if (collection.parentID) {
return this.findInheritableParentHeaders(
collection.parentID,
existingHeaders
)
}
return E.right(existingHeaders)
}
expandCollection = async (collectionID: string) => {
if (this.expandingCollections.value.includes(collectionID)) return
const collectionToExpand = Object.values(
this.searchResultsCollections
).find((col) => col.id === collectionID)
const isAlreadyExpanded =
this.expandedCollections.value.includes(collectionID)
// only allow search result collections to be expanded
if (
isAlreadyExpanded ||
!collectionToExpand ||
!(
collectionToExpand.meta?.isSearchResult ||
collectionToExpand.meta?.insertedWhileExpanding
)
)
return
this.expandingCollections.value.push(collectionID)
const childCollectionsPromise = getCollectionChildCollections(collectionID)
const childRequestsPromise = getCollectionChildRequests(collectionID)
const [childCollections, childRequests] = await Promise.all([
childCollectionsPromise,
childRequestsPromise,
])
if (E.isLeft(childCollections)) {
return
}
if (E.isLeft(childRequests)) {
return
}
childCollections.right.collection?.children
.map((child) => ({
id: child.id,
title: child.title,
data: child.data ?? null,
children: [],
requests: [],
}))
.forEach((child) => {
this.searchResultsCollections[child.id] = {
...child,
parentID: collectionID,
meta: {
isSearchResult: false,
insertedWhileExpanding: true,
},
}
})
childRequests.right.requestsInCollection
.map((request) => ({
id: request.id,
collectionID: collectionID,
title: request.title,
request: JSON.parse(request.request) as TeamRequest["request"],
}))
.forEach((request) => {
this.searchResultsRequests[request.id] = {
...request,
meta: {
isSearchResult: false,
insertedWhileExpanding: true,
},
}
})
this.teamsSearchResults.value = convertToTeamTree(
Object.values(this.searchResultsCollections),
// asserting because we've already added the missing properties after fetching the full details
Object.values(this.searchResultsRequests) as TeamRequest[]
)
// remove the collection after expanding
this.expandingCollections.value = this.expandingCollections.value.filter(
(colID) => colID !== collectionID
)
this.expandedCollections.value.push(collectionID)
}
}
const getSingleCollection = (collectionID: string) =>
runGQLQuery({
query: GetSingleCollectionDocument,
variables: {
collectionID,
},
})
const getSingleRequest = (requestID: string) =>
runGQLQuery({
query: GetSingleRequestDocument,
variables: {
requestID,
},
})
const getCollectionChildCollections = (collectionID: string) =>
runGQLQuery({
query: GetCollectionChildrenDocument,
variables: {
collectionID,
},
})
const getCollectionChildRequests = (collectionID: string) =>
runGQLQuery({
query: GetCollectionRequestsDocument,
variables: {
collectionID,
},
})
const formatTeamsSearchResultsForSpotlight = (
request: {
collectionID: string
name: string
method: string
id: string
},
parentCollections: (TeamCollection & { parentID: string | null })[]
) => {
let collectionTitles: string[] = []
let parentCollectionID: string | null = request.collectionID
while (true) {
if (!parentCollectionID) {
break
}
const parentCollection = parentCollections.find(
(col) => col.id === parentCollectionID
)
if (!parentCollection) {
break
}
collectionTitles = [parentCollection.title, ...collectionTitles]
parentCollectionID = parentCollection.parentID
}
return {
collectionTitles,
request: {
name: request.name,
method: request.method,
id: request.id,
},
}
}

View File

@@ -18,6 +18,8 @@ import {
HoppRESTParam,
parseRawKeyValueEntriesE,
parseTemplateStringE,
HoppRESTAuth,
HoppRESTHeaders,
} from "@hoppscotch/data"
import { arrayFlatMap, arraySort } from "../functional/array"
import { toFormData } from "../functional/formData"
@@ -44,7 +46,12 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
*/
export const getComputedAuthHeaders = (
envVars: Environment["variables"],
req?: HoppRESTRequest,
req?:
| HoppRESTRequest
| {
auth: HoppRESTAuth
headers: HoppRESTHeaders
},
auth?: HoppRESTRequest["auth"],
parse = true
) => {
@@ -75,16 +82,17 @@ export const getComputedAuthHeaders = (
})
} else if (
request.auth.authType === "bearer" ||
request.auth.authType === "oauth-2"
(request.auth.authType === "oauth-2" && request.auth.addTo === "HEADERS")
) {
const token =
request.auth.authType === "bearer"
? request.auth.token
: request.auth.grantTypeInfo.token
headers.push({
active: true,
key: "Authorization",
value: `Bearer ${
parse
? parseTemplateString(request.auth.token, envVars)
: request.auth.token
}`,
value: `Bearer ${parse ? parseTemplateString(token, envVars) : token}`,
})
} else if (request.auth.authType === "api-key") {
const { key, addTo } = request.auth
@@ -108,7 +116,12 @@ export const getComputedAuthHeaders = (
* @returns The list of headers
*/
export const getComputedBodyHeaders = (
req: HoppRESTRequest
req:
| HoppRESTRequest
| {
auth: HoppRESTAuth
headers: HoppRESTHeaders
}
): HoppRESTHeader[] => {
// If a content-type is already defined, that will override this
if (
@@ -118,8 +131,10 @@ export const getComputedBodyHeaders = (
)
return []
if (!("body" in req)) return []
// Body should have a non-null content-type
if (req.body.contentType === null) return []
if (!req.body || req.body.contentType === null) return []
return [
{
@@ -143,7 +158,12 @@ export type ComputedHeader = {
* @returns The headers that are generated along with the source of that header
*/
export const getComputedHeaders = (
req: HoppRESTRequest,
req:
| HoppRESTRequest
| {
auth: HoppRESTAuth
headers: HoppRESTHeaders
},
envVars: Environment["variables"],
parse = true
): ComputedHeader[] => {
@@ -177,17 +197,40 @@ export const getComputedParams = (
): ComputedParam[] => {
// When this gets complex, its best to split this function off (like with getComputedHeaders)
// API-key auth can be added to query params
if (!req.auth || !req.auth.authActive) return []
if (req.auth.authType !== "api-key") return []
if (req.auth.addTo !== "Query params") return []
if (!req.auth || !req.auth.authActive) {
return []
}
if (req.auth.authType !== "api-key" && req.auth.authType !== "oauth-2") {
return []
}
if (req.auth.addTo !== "QUERY_PARAMS") {
return []
}
if (req.auth.authType === "api-key") {
return [
{
source: "auth" as const,
param: {
active: true,
key: parseTemplateString(req.auth.key, envVars),
value: parseTemplateString(req.auth.value, envVars),
},
},
]
}
const { grantTypeInfo } = req.auth
return [
{
source: "auth",
param: {
active: true,
key: parseTemplateString(req.auth.key, envVars),
value: parseTemplateString(req.auth.value, envVars),
key: "access_token",
value: parseTemplateString(grantTypeInfo.token, envVars),
},
},
]

View File

@@ -0,0 +1,3 @@
export const uniqueID = (length = 16) => {
return Math.random().toString(36).substring(2, length)
}

View File

@@ -1,7 +1,8 @@
import { Environment } from "@hoppscotch/data"
import { cloneDeep, isEqual, uniqueId } from "lodash-es"
import { cloneDeep, isEqual } from "lodash-es"
import { combineLatest, Observable } from "rxjs"
import { distinctUntilChanged, map, pluck } from "rxjs/operators"
import { uniqueID } from "~/helpers/utils/uniqueID"
import { getService } from "~/modules/dioc"
import DispatchingStore, {
defineDispatchers,
@@ -22,7 +23,7 @@ const defaultEnvironmentsState = {
environments: [
{
v: 1,
id: uniqueId(),
id: uniqueID(),
name: "My Environment Variables",
variables: [],
},
@@ -100,7 +101,7 @@ const dispatchers = defineDispatchers({
}
: {
v: 1,
id: "",
id: uniqueID(),
name,
variables,
},
@@ -123,7 +124,7 @@ const dispatchers = defineDispatchers({
...environments,
{
...cloneDeep(newEnvironment),
id: uniqueId(),
id: uniqueID(),
name: `${newEnvironment.name} - Duplicate`,
},
],

View File

@@ -5,23 +5,31 @@
</template>
<script setup lang="ts">
import { handleOAuthRedirect } from "~/helpers/oauth"
import { useToast } from "~/composables/toast"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import * as E from "fp-ts/Either"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
import * as E from "fp-ts/Either"
import { onMounted } from "vue"
import { RESTTabService } from "~/services/tab/rest"
import { useRouter } from "vue-router"
import {
PersistedOAuthConfig,
routeOAuthRedirect,
} from "~/services/oauth/oauth.service"
import { PersistenceService } from "~/services/persistence"
import { GQLTabService } from "~/services/tab/graphql"
const t = useI18n()
const router = useRouter()
const toast = useToast()
const tabs = useService(RESTTabService)
const gqlTabs = useService(GQLTabService)
const persistenceService = useService(PersistenceService)
const restTabs = useService(RESTTabService)
function translateOAuthRedirectError(error: string) {
switch (error) {
@@ -60,22 +68,58 @@ function translateOAuthRedirectError(error: string) {
}
onMounted(async () => {
const tokenInfo = await handleOAuthRedirect()
const localOAuthTempConfig =
persistenceService.getLocalConfig("oauth_temp_config")
if (!localOAuthTempConfig) {
toast.error(t("authorization.oauth.something_went_wrong_on_oauth_redirect"))
router.push("/")
return
}
const persistedOAuthConfig: PersistedOAuthConfig =
JSON.parse(localOAuthTempConfig)
const { context, source } = persistedOAuthConfig
const tokenInfo = await routeOAuthRedirect()
if (E.isLeft(tokenInfo)) {
toast.error(translateOAuthRedirectError(tokenInfo.left))
router.push("/")
router.push(source === "REST" ? "/" : "/graphql")
return
}
// Indicates the access token generation flow originated from the modal for setting authorization/headers at the collection level
if (context?.type === "collection-properties") {
// Set the access token in `localStorage` to retrieve from the modal while redirecting back
persistenceService.setLocalConfig(
"oauth_temp_config",
JSON.stringify(<PersistedOAuthConfig>{
...persistedOAuthConfig,
token: tokenInfo.right.access_token,
})
)
toast.success(t("authorization.oauth.token_fetched_successfully"))
router.push(source === "REST" ? "/" : "/graphql")
return
}
const routeToRedirect = source === "GraphQL" ? "/graphql" : "/"
const tabService = source === "GraphQL" ? gqlTabs : restTabs
if (
tabs.currentActiveTab.value.document.request.auth.authType === "oauth-2"
tabService.currentActiveTab.value.document.request.auth.authType ===
"oauth-2"
) {
tabs.currentActiveTab.value.document.request.auth.token =
tabService.currentActiveTab.value.document.request.auth.grantTypeInfo.token =
tokenInfo.right.access_token
router.push("/")
return
toast.success(t("authorization.oauth.token_fetched_successfully"))
}
router.push(routeToRedirect)
})
</script>

View File

@@ -231,6 +231,7 @@ export class ExtensionInterceptorService
try {
const result = await extensionHook.sendRequest({
...req,
headers: req.headers ?? {},
wantsBinary: true,
})

View File

@@ -0,0 +1,293 @@
import { PersistenceService } from "~/services/persistence"
import {
OauthAuthService,
PersistedOAuthConfig,
createFlowConfig,
decodeResponseAsJSON,
generateRandomString,
} from "../oauth.service"
import { z } from "zod"
import { getService } from "~/modules/dioc"
import * as E from "fp-ts/Either"
import { InterceptorService } from "~/services/interceptor.service"
import { AuthCodeGrantTypeParams } from "@hoppscotch/data"
const persistenceService = getService(PersistenceService)
const interceptorService = getService(InterceptorService)
const AuthCodeOauthFlowParamsSchema = AuthCodeGrantTypeParams.pick({
authEndpoint: true,
tokenEndpoint: true,
clientID: true,
clientSecret: true,
scopes: true,
isPKCE: true,
codeVerifierMethod: true,
})
.refine(
(params) => {
return (
params.authEndpoint.length >= 1 &&
params.tokenEndpoint.length >= 1 &&
params.clientID.length >= 1 &&
params.clientSecret.length >= 1 &&
(!params.scopes || params.scopes.trim().length >= 1)
)
},
{
message: "Minimum length requirement not met for one or more parameters",
}
)
.refine((params) => (params.isPKCE ? !!params.codeVerifierMethod : true), {
message: "codeVerifierMethod is required when using PKCE",
path: ["codeVerifierMethod"],
})
export type AuthCodeOauthFlowParams = z.infer<
typeof AuthCodeOauthFlowParamsSchema
>
export const getDefaultAuthCodeOauthFlowParams =
(): AuthCodeOauthFlowParams => ({
authEndpoint: "",
tokenEndpoint: "",
clientID: "",
clientSecret: "",
scopes: undefined,
isPKCE: false,
codeVerifierMethod: "S256",
})
const initAuthCodeOauthFlow = async ({
tokenEndpoint,
clientID,
clientSecret,
scopes,
authEndpoint,
isPKCE,
codeVerifierMethod,
}: AuthCodeOauthFlowParams) => {
const state = generateRandomString()
let codeVerifier: string | undefined
let codeChallenge: string | undefined
if (isPKCE) {
codeVerifier = generateCodeVerifier()
codeChallenge = await generateCodeChallenge(
codeVerifier,
codeVerifierMethod
)
}
let oauthTempConfig: {
state: string
grant_type: "AUTHORIZATION_CODE"
authEndpoint: string
tokenEndpoint: string
clientSecret: string
clientID: string
isPKCE: boolean
codeVerifier?: string
codeVerifierMethod?: string
codeChallenge?: string
scopes?: string
} = {
state,
grant_type: "AUTHORIZATION_CODE",
authEndpoint,
tokenEndpoint,
clientSecret,
clientID,
isPKCE,
codeVerifierMethod,
scopes,
}
if (codeVerifier && codeChallenge) {
oauthTempConfig = {
...oauthTempConfig,
codeVerifier,
codeChallenge,
}
}
const localOAuthTempConfig =
persistenceService.getLocalConfig("oauth_temp_config")
const persistedOAuthConfig: PersistedOAuthConfig = localOAuthTempConfig
? { ...JSON.parse(localOAuthTempConfig) }
: {}
const { grant_type, ...rest } = oauthTempConfig
// persist the state so we can compare it when we get redirected back
// also persist the grant_type,tokenEndpoint and clientSecret so we can use them when we get redirected back
persistenceService.setLocalConfig(
"oauth_temp_config",
JSON.stringify(<PersistedOAuthConfig>{
...persistedOAuthConfig,
fields: rest,
grant_type,
})
)
let url: URL
try {
url = new URL(authEndpoint)
} catch (e) {
return E.left("INVALID_AUTH_ENDPOINT")
}
url.searchParams.set("grant_type", "authorization_code")
url.searchParams.set("client_id", clientID)
url.searchParams.set("state", state)
url.searchParams.set("response_type", "code")
url.searchParams.set("redirect_uri", OauthAuthService.redirectURI)
if (scopes) url.searchParams.set("scope", scopes)
if (codeVerifierMethod && codeChallenge) {
url.searchParams.set("code_challenge", codeChallenge)
url.searchParams.set("code_challenge_method", codeVerifierMethod)
}
// Redirect to the authorization server
window.location.assign(url.toString())
return E.right(undefined)
}
const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => {
// parse the query string
const params = new URLSearchParams(window.location.search)
const code = params.get("code")
const state = params.get("state")
const error = params.get("error")
if (error) {
return E.left("AUTH_SERVER_RETURNED_ERROR")
}
if (!code) {
return E.left("AUTH_TOKEN_REQUEST_FAILED")
}
const expectedSchema = z.object({
source: z.optional(z.string()),
state: z.string(),
tokenEndpoint: z.string(),
clientSecret: z.string(),
clientID: z.string(),
codeVerifier: z.string().optional(),
codeChallenge: z.string().optional(),
})
const decodedLocalConfig = expectedSchema.safeParse(
JSON.parse(localConfig).fields
)
if (!decodedLocalConfig.success) {
return E.left("INVALID_LOCAL_CONFIG")
}
// check if the state matches
if (decodedLocalConfig.data.state !== state) {
return E.left("INVALID_STATE")
}
// exchange the code for a token
const formData = new URLSearchParams()
formData.append("grant_type", "authorization_code")
formData.append("code", code)
formData.append("client_id", decodedLocalConfig.data.clientID)
formData.append("client_secret", decodedLocalConfig.data.clientSecret)
formData.append("redirect_uri", OauthAuthService.redirectURI)
if (decodedLocalConfig.data.codeVerifier) {
formData.append("code_verifier", decodedLocalConfig.data.codeVerifier)
}
const { response } = interceptorService.runRequest({
url: decodedLocalConfig.data.tokenEndpoint,
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
data: formData.toString(),
})
const res = await response
if (E.isLeft(res)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const responsePayload = decodeResponseAsJSON(res.right)
if (E.isLeft(responsePayload)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const withAccessTokenSchema = z.object({
access_token: z.string(),
})
const parsedTokenResponse = withAccessTokenSchema.safeParse(
responsePayload.right
)
return parsedTokenResponse.success
? E.right(parsedTokenResponse.data)
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
}
const generateCodeVerifier = () => {
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const length = Math.floor(Math.random() * (128 - 43 + 1)) + 43 // Random length between 43 and 128
let codeVerifier = ""
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * characters.length)
codeVerifier += characters[randomIndex]
}
return codeVerifier
}
const generateCodeChallenge = async (
codeVerifier: string,
strategy: AuthCodeOauthFlowParams["codeVerifierMethod"]
) => {
if (strategy === "plain") {
return codeVerifier
}
const encoder = new TextEncoder()
const data = encoder.encode(codeVerifier)
const buffer = await crypto.subtle.digest("SHA-256", data)
return encodeArrayBufferAsUrlEncodedBase64(buffer)
}
const encodeArrayBufferAsUrlEncodedBase64 = (buffer: ArrayBuffer) => {
const hashArray = Array.from(new Uint8Array(buffer))
const hashBase64URL = btoa(String.fromCharCode(...hashArray))
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_")
return hashBase64URL
}
export default createFlowConfig(
"AUTHORIZATION_CODE" as const,
AuthCodeOauthFlowParamsSchema,
initAuthCodeOauthFlow,
handleRedirectForAuthCodeOauthFlow
)

View File

@@ -0,0 +1,183 @@
import {
OauthAuthService,
createFlowConfig,
decodeResponseAsJSON,
} from "../oauth.service"
import { z } from "zod"
import { getService } from "~/modules/dioc"
import * as E from "fp-ts/Either"
import { InterceptorService } from "~/services/interceptor.service"
import { useToast } from "~/composables/toast"
import { ClientCredentialsGrantTypeParams } from "@hoppscotch/data"
const interceptorService = getService(InterceptorService)
const ClientCredentialsFlowParamsSchema = ClientCredentialsGrantTypeParams.pick(
{
authEndpoint: true,
clientID: true,
clientSecret: true,
scopes: true,
}
).refine(
(params) => {
return (
params.authEndpoint.length >= 1 &&
params.clientID.length >= 1 &&
params.clientSecret.length >= 1 &&
(!params.scopes || params.scopes.length >= 1)
)
},
{
message: "Minimum length requirement not met for one or more parameters",
}
)
export type ClientCredentialsFlowParams = z.infer<
typeof ClientCredentialsFlowParamsSchema
>
export const getDefaultClientCredentialsFlowParams =
(): ClientCredentialsFlowParams => ({
authEndpoint: "",
clientID: "",
clientSecret: "",
scopes: undefined,
})
const initClientCredentialsOAuthFlow = async ({
clientID,
clientSecret,
scopes,
authEndpoint,
}: ClientCredentialsFlowParams) => {
const toast = useToast()
const formData = new URLSearchParams()
formData.append("grant_type", "client_credentials")
formData.append("client_id", clientID)
formData.append("client_secret", clientSecret)
if (scopes) {
formData.append("scope", scopes)
}
const { response } = interceptorService.runRequest({
url: authEndpoint,
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
data: formData.toString(),
})
const res = await response
if (E.isLeft(res)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const responsePayload = decodeResponseAsJSON(res.right)
if (E.isLeft(responsePayload)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const withAccessTokenSchema = z.object({
access_token: z.string(),
})
const parsedTokenResponse = withAccessTokenSchema.safeParse(
responsePayload.right
)
if (!parsedTokenResponse.success) {
toast.error("AUTH_TOKEN_REQUEST_INVALID_RESPONSE")
}
return parsedTokenResponse.success
? E.right(parsedTokenResponse.data)
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
}
const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => {
// parse the query string
const params = new URLSearchParams(window.location.search)
const code = params.get("code")
const state = params.get("state")
const error = params.get("error")
if (error) {
return E.left("AUTH_SERVER_RETURNED_ERROR")
}
if (!code) {
return E.left("AUTH_TOKEN_REQUEST_FAILED")
}
const expectedSchema = z.object({
state: z.string(),
tokenEndpoint: z.string(),
clientSecret: z.string(),
clientID: z.string(),
})
const decodedLocalConfig = expectedSchema.safeParse(JSON.parse(localConfig))
if (!decodedLocalConfig.success) {
return E.left("INVALID_LOCAL_CONFIG")
}
// check if the state matches
if (decodedLocalConfig.data.state !== state) {
return E.left("INVALID_STATE")
}
// exchange the code for a token
const formData = new URLSearchParams()
formData.append("code", code)
formData.append("client_id", decodedLocalConfig.data.clientID)
formData.append("client_secret", decodedLocalConfig.data.clientSecret)
formData.append("redirect_uri", OauthAuthService.redirectURI)
const { response } = interceptorService.runRequest({
url: decodedLocalConfig.data.tokenEndpoint,
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
data: formData.toString(),
})
const res = await response
if (E.isLeft(res)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const responsePayload = new TextDecoder("utf-8")
.decode(res.right.data as any)
.replaceAll("\x00", "")
const withAccessTokenSchema = z.object({
access_token: z.string(),
})
const parsedTokenResponse = withAccessTokenSchema.safeParse(
JSON.parse(responsePayload)
)
return parsedTokenResponse.success
? E.right(parsedTokenResponse.data)
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
}
export default createFlowConfig(
"CLIENT_CREDENTIALS" as const,
ClientCredentialsFlowParamsSchema,
initClientCredentialsOAuthFlow,
handleRedirectForAuthCodeOauthFlow
)

View File

@@ -0,0 +1,135 @@
import { PersistenceService } from "~/services/persistence"
import {
OauthAuthService,
PersistedOAuthConfig,
createFlowConfig,
generateRandomString,
} from "../oauth.service"
import { z } from "zod"
import { getService } from "~/modules/dioc"
import * as E from "fp-ts/Either"
import { ImplicitOauthFlowParams } from "@hoppscotch/data"
const persistenceService = getService(PersistenceService)
const ImplicitOauthFlowParamsSchema = ImplicitOauthFlowParams.pick({
authEndpoint: true,
clientID: true,
scopes: true,
}).refine((params) => {
return (
params.authEndpoint.length >= 1 &&
params.clientID.length >= 1 &&
(params.scopes === undefined || params.scopes.length >= 1)
)
})
export type ImplicitOauthFlowParams = z.infer<
typeof ImplicitOauthFlowParamsSchema
>
export const getDefaultImplicitOauthFlowParams =
(): ImplicitOauthFlowParams => ({
authEndpoint: "",
clientID: "",
scopes: undefined,
})
const initImplicitOauthFlow = async ({
clientID,
scopes,
authEndpoint,
}: ImplicitOauthFlowParams) => {
const state = generateRandomString()
const localOAuthTempConfig =
persistenceService.getLocalConfig("oauth_temp_config")
const persistedOAuthConfig: PersistedOAuthConfig = localOAuthTempConfig
? { ...JSON.parse(localOAuthTempConfig) }
: {}
// Persist the necessary information for retrieval while getting redirected back
persistenceService.setLocalConfig(
"oauth_temp_config",
JSON.stringify(<PersistedOAuthConfig>{
...persistedOAuthConfig,
fields: {
clientID,
authEndpoint,
scopes,
state,
},
grant_type: "IMPLICIT",
})
)
let url: URL
try {
url = new URL(authEndpoint)
} catch {
return E.left("INVALID_AUTH_ENDPOINT")
}
url.searchParams.set("client_id", clientID)
url.searchParams.set("state", state)
url.searchParams.set("response_type", "token")
url.searchParams.set("redirect_uri", OauthAuthService.redirectURI)
if (scopes) url.searchParams.set("scope", scopes)
// Redirect to the authorization server
window.location.assign(url.toString())
return E.right(undefined)
}
const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => {
// parse the query string
const params = new URLSearchParams(window.location.search)
const paramsFromHash = new URLSearchParams(window.location.hash.substring(1))
const accessToken =
params.get("access_token") || paramsFromHash.get("access_token")
const state = params.get("state") || paramsFromHash.get("state")
const error = params.get("error") || paramsFromHash.get("error")
if (error) {
return E.left("AUTH_SERVER_RETURNED_ERROR")
}
if (!accessToken) {
return E.left("AUTH_TOKEN_REQUEST_FAILED")
}
const expectedSchema = z.object({
source: z.optional(z.string()),
state: z.string(),
clientID: z.string(),
})
const decodedLocalConfig = expectedSchema.safeParse(
JSON.parse(localConfig).fields
)
if (!decodedLocalConfig.success) {
return E.left("INVALID_LOCAL_CONFIG")
}
// check if the state matches
if (decodedLocalConfig.data.state !== state) {
return E.left("INVALID_STATE")
}
return E.right({
access_token: accessToken,
})
}
export default createFlowConfig(
"IMPLICIT" as const,
ImplicitOauthFlowParamsSchema,
initImplicitOauthFlow,
handleRedirectForAuthCodeOauthFlow
)

View File

@@ -0,0 +1,189 @@
import {
OauthAuthService,
createFlowConfig,
decodeResponseAsJSON,
} from "../oauth.service"
import { z } from "zod"
import { getService } from "~/modules/dioc"
import * as E from "fp-ts/Either"
import { InterceptorService } from "~/services/interceptor.service"
import { useToast } from "~/composables/toast"
import { PasswordGrantTypeParams } from "@hoppscotch/data"
const interceptorService = getService(InterceptorService)
const PasswordFlowParamsSchema = PasswordGrantTypeParams.pick({
authEndpoint: true,
clientID: true,
clientSecret: true,
scopes: true,
username: true,
password: true,
}).refine(
(params) => {
return (
params.authEndpoint.length >= 1 &&
params.clientID.length >= 1 &&
params.clientSecret.length >= 1 &&
params.username.length >= 1 &&
params.password.length >= 1 &&
(!params.scopes || params.scopes.length >= 1)
)
},
{
message: "Minimum length requirement not met for one or more parameters",
}
)
export type PasswordFlowParams = z.infer<typeof PasswordFlowParamsSchema>
export const getDefaultPasswordFlowParams = (): PasswordFlowParams => ({
authEndpoint: "",
clientID: "",
clientSecret: "",
scopes: undefined,
username: "",
password: "",
})
const initPasswordOauthFlow = async ({
password,
username,
clientID,
clientSecret,
scopes,
authEndpoint,
}: PasswordFlowParams) => {
const toast = useToast()
const formData = new URLSearchParams()
formData.append("grant_type", "password")
formData.append("client_id", clientID)
formData.append("client_secret", clientSecret)
formData.append("username", username)
formData.append("password", password)
if (scopes) {
formData.append("scope", scopes)
}
const { response } = interceptorService.runRequest({
url: authEndpoint,
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
data: formData.toString(),
})
const res = await response
if (E.isLeft(res) || res.right.status !== 200) {
toast.error("AUTH_TOKEN_REQUEST_FAILED")
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const responsePayload = new TextDecoder("utf-8")
.decode(res.right.data as any)
.replaceAll("\x00", "")
const withAccessTokenSchema = z.object({
access_token: z.string(),
})
const parsedTokenResponse = withAccessTokenSchema.safeParse(
JSON.parse(responsePayload)
)
if (!parsedTokenResponse.success) {
toast.error("AUTH_TOKEN_REQUEST_INVALID_RESPONSE")
}
return parsedTokenResponse.success
? E.right(parsedTokenResponse.data)
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
}
const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => {
// parse the query string
const params = new URLSearchParams(window.location.search)
const code = params.get("code")
const state = params.get("state")
const error = params.get("error")
if (error) {
return E.left("AUTH_SERVER_RETURNED_ERROR")
}
if (!code) {
return E.left("AUTH_TOKEN_REQUEST_FAILED")
}
const expectedSchema = z.object({
state: z.string(),
tokenEndpoint: z.string(),
clientSecret: z.string(),
clientID: z.string(),
})
const decodedLocalConfig = expectedSchema.safeParse(JSON.parse(localConfig))
if (!decodedLocalConfig.success) {
return E.left("INVALID_LOCAL_CONFIG")
}
// check if the state matches
if (decodedLocalConfig.data.state !== state) {
return E.left("INVALID_STATE")
}
// exchange the code for a token
const formData = new URLSearchParams()
formData.append("code", code)
formData.append("client_id", decodedLocalConfig.data.clientID)
formData.append("client_secret", decodedLocalConfig.data.clientSecret)
formData.append("redirect_uri", OauthAuthService.redirectURI)
const { response } = interceptorService.runRequest({
url: decodedLocalConfig.data.tokenEndpoint,
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
data: formData.toString(),
})
const res = await response
if (E.isLeft(res)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const responsePayload = decodeResponseAsJSON(res.right)
if (E.isLeft(responsePayload)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const withAccessTokenSchema = z.object({
access_token: z.string(),
})
const parsedTokenResponse = withAccessTokenSchema.safeParse(
responsePayload.right
)
return parsedTokenResponse.success
? E.right(parsedTokenResponse.data)
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
}
export default createFlowConfig(
"PASSWORD" as const,
PasswordFlowParamsSchema,
initPasswordOauthFlow,
handleRedirectForAuthCodeOauthFlow
)

View File

@@ -0,0 +1,124 @@
import { Service } from "dioc"
import { PersistenceService } from "../persistence"
import { ZodType, z } from "zod"
import * as E from "fp-ts/Either"
import authCode, { AuthCodeOauthFlowParams } from "./flows/authCode"
import implicit, { ImplicitOauthFlowParams } from "./flows/implicit"
import { getService } from "~/modules/dioc"
import { HoppCollection } from "@hoppscotch/data"
import { TeamCollection } from "~/helpers/backend/graphql"
export type PersistedOAuthConfig = {
source: "REST" | "GraphQL"
context?: {
type: "collection-properties" | "request-tab"
metadata: {
collection?: HoppCollection | TeamCollection
collectionID?: string
}
}
grant_type: string
fields?: (AuthCodeOauthFlowParams | ImplicitOauthFlowParams) & {
state: string
}
token?: string
}
const persistenceService = getService(PersistenceService)
export const grantTypesInvolvingRedirect = ["AUTHORIZATION_CODE", "IMPLICIT"]
export const routeOAuthRedirect = async () => {
// get the temp data from the local storage
const localOAuthTempConfig =
persistenceService.getLocalConfig("oauth_temp_config")
if (!localOAuthTempConfig) {
return E.left("INVALID_STATE")
}
const expectedSchema = z.object({
source: z.optional(z.string()),
grant_type: z.string(),
})
const decodedLocalConfig = expectedSchema.safeParse(
JSON.parse(localOAuthTempConfig)
)
if (!decodedLocalConfig.success) {
return E.left("INVALID_STATE")
}
// route the request to the correct flow
const flowConfig = [authCode, implicit].find(
(flow) => flow.flow === decodedLocalConfig.data.grant_type
)
if (!flowConfig) {
return E.left("INVALID_STATE")
}
return flowConfig?.onRedirectReceived(localOAuthTempConfig)
}
export function createFlowConfig<
Flow extends string,
AuthParams extends Record<string, unknown>,
InitFuncReturnObject extends Record<string, unknown>,
>(
flow: Flow,
params: ZodType<AuthParams>,
init: (
params: AuthParams
) =>
| E.Either<string, InitFuncReturnObject>
| Promise<E.Either<string, InitFuncReturnObject>>
| E.Either<string, undefined>
| Promise<E.Either<string, undefined>>,
onRedirectReceived: (localConfig: string) => Promise<
E.Either<
string,
{
access_token: string
}
>
>
) {
return {
flow,
params,
init,
onRedirectReceived,
}
}
export const decodeResponseAsJSON = (response: { data: any }) => {
try {
const responsePayload = new TextDecoder("utf-8")
.decode(response.data as any)
.replaceAll("\x00", "")
return E.right(JSON.parse(responsePayload) as Record<string, unknown>)
} catch (error) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
}
export class OauthAuthService extends Service {
public static readonly ID = "OAUTH_AUTH_SERVICE"
static redirectURI = `${window.location.origin}/oauth`
constructor() {
super()
}
}
export const generateRandomString = () => {
const length = 64
const possible =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
const values = crypto.getRandomValues(new Uint8Array(length))
return values.reduce((acc, x) => acc + possible[x % possible.length], "")
}

View File

@@ -25,7 +25,7 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
folders: [],
requests: [
{
v: "2",
v: "3",
endpoint: "https://echo.hoppscotch.io",
name: "Echo test",
params: [],
@@ -50,7 +50,7 @@ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [
folders: [],
requests: [
{
v: 2,
v: 3,
name: "Echo test",
url: "https://echo.hoppscotch.io/graphql",
headers: [],
@@ -138,7 +138,7 @@ export const REST_HISTORY_MOCK: RESTHistoryEntry[] = [
preRequestScript: "",
testScript: "",
requestVariables: [],
v: "2",
v: "3",
},
responseMeta: { duration: 807, statusCode: 200 },
star: false,
@@ -150,7 +150,7 @@ export const GQL_HISTORY_MOCK: GQLHistoryEntry[] = [
{
v: 1,
request: {
v: 2,
v: 3,
name: "Untitled",
url: "https://echo.hoppscotch.io/graphql",
query: "query Request { url }",
@@ -171,7 +171,7 @@ export const GQL_TAB_STATE_MOCK: PersistableTabState<HoppGQLDocument> = {
tabID: "5edbe8d4-65c9-4381-9354-5f1bf05d8ccc",
doc: {
request: {
v: 2,
v: 3,
name: "Untitled",
url: "https://echo.hoppscotch.io/graphql",
headers: [],
@@ -194,7 +194,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState<HoppRESTDocument> = {
tabID: "e6e8d800-caa8-44a2-a6a6-b4765a3167aa",
doc: {
request: {
v: "2",
v: "3",
endpoint: "https://echo.hoppscotch.io",
name: "Echo test",
params: [],

View File

@@ -0,0 +1,140 @@
import { Service } from "dioc"
import {
SpotlightSearcher,
SpotlightSearcherResult,
SpotlightSearcherSessionState,
SpotlightService,
} from ".."
import { getI18n } from "~/modules/i18n"
import { Ref, computed, effectScope, markRaw, watch } from "vue"
import { TeamSearchService } from "~/helpers/teams/TeamsSearch.service"
import { cloneDeep, debounce } from "lodash-es"
import IconFolder from "~icons/lucide/folder"
import { WorkspaceService } from "~/services/workspace.service"
import RESTTeamRequestEntry from "~/components/app/spotlight/entry/RESTTeamRequestEntry.vue"
import { RESTTabService } from "~/services/tab/rest"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { HoppRESTRequest } from "@hoppscotch/data"
export class TeamsSpotlightSearcherService
extends Service
implements SpotlightSearcher
{
public static readonly ID = "TEAMS_SPOTLIGHT_SEARCHER_SERVICE"
private t = getI18n()
public searcherID = "teams"
public searcherSectionTitle = this.t("team.search_title")
private readonly spotlight = this.bind(SpotlightService)
private readonly teamsSearch = this.bind(TeamSearchService)
private readonly workspaceService = this.bind(WorkspaceService)
private readonly tabs = this.bind(RESTTabService)
constructor() {
super()
this.spotlight.registerSearcher(this)
}
createSearchSession(
query: Readonly<Ref<string>>
): [Ref<SpotlightSearcherSessionState>, () => void] {
const isTeamWorkspace = computed(
() => this.workspaceService.currentWorkspace.value.type === "team"
)
const scopeHandle = effectScope()
scopeHandle.run(() => {
const debouncedSearch = debounce(this.teamsSearch.searchTeams, 400)
watch(
query,
(query) => {
if (this.workspaceService.currentWorkspace.value.type === "team") {
const teamID = this.workspaceService.currentWorkspace.value.teamID
debouncedSearch(query, teamID)?.catch((_) => {})
}
},
{
immediate: true,
}
)
})
const onSessionEnd = () => {
scopeHandle.stop()
}
const resultObj = computed<SpotlightSearcherSessionState>(() => {
return isTeamWorkspace.value
? {
loading: this.teamsSearch.teamsSearchResultsLoading.value,
results:
this.teamsSearch.teamsSearchResultsFormattedForSpotlight.value.map(
(result) => ({
id: result.request.id,
icon: markRaw(IconFolder),
score: 1, // make a better scoring system for this
text: {
type: "custom",
component: markRaw(RESTTeamRequestEntry),
componentProps: {
collectionTitles: result.collectionTitles,
request: result.request,
},
},
})
),
}
: {
loading: false,
results: [],
}
})
return [resultObj, onSessionEnd]
}
onResultSelect(result: SpotlightSearcherResult): void {
let inheritedProperties: HoppInheritedProperty | undefined = undefined
const selectedRequest = this.teamsSearch.searchResultsRequests[result.id]
if (!selectedRequest) return
const collectionID = result.id
if (!collectionID) return
inheritedProperties =
this.teamsSearch.cascadeParentCollectionForHeaderAuthForSearchResults(
collectionID
)
const possibleTab = this.tabs.getTabRefWithSaveContext({
originLocation: "team-collection",
requestID: result.id,
})
if (possibleTab) {
this.tabs.setActiveTab(possibleTab.value.id)
} else {
this.tabs.createNewTab({
request: cloneDeep(selectedRequest.request as HoppRESTRequest),
isDirty: false,
saveContext: {
originLocation: "team-collection",
requestID: selectedRequest.id,
collectionID: selectedRequest.collectionID,
},
inheritedProperties: inheritedProperties,
})
}
}
}

View File

@@ -52,8 +52,6 @@ export function makeCollection(x: Omit<HoppCollection, "v">): HoppCollection {
* @returns The proper new collection format
*/
export function translateToNewRESTCollection(x: any): HoppCollection {
if (x.v && x.v === CollectionSchemaVersion) return x
// Legacy
const name = x.name ?? "Untitled"
const folders = (x.folders ?? []).map(translateToNewRESTCollection)
@@ -81,8 +79,6 @@ export function translateToNewRESTCollection(x: any): HoppCollection {
* @returns The proper new collection format
*/
export function translateToNewGQLCollection(x: any): HoppCollection {
if (x.v && x.v === CollectionSchemaVersion) return x
// Legacy
const name = x.name ?? "Untitled"
const folders = (x.folders ?? []).map(translateToNewGQLCollection)

View File

@@ -5,7 +5,7 @@ import { InferredEntity, createVersionedEntity } from "verzod"
import { z } from "zod"
import V0_VERSION from "./v/0"
import V1_VERSION from "./v/1"
import V1_VERSION, { uniqueID } from "./v/1"
const versionedObject = z.object({
v: z.number(),
@@ -165,7 +165,7 @@ export const translateToNewEnvironment = (x: any): Environment => {
if (x.v && x.v === EnvironmentSchemaVersion) return x
// Legacy
const id = x.id ?? ""
const id = x.id || uniqueID()
const name = x.name ?? "Untitled"
const variables = (x.variables ?? []).map(translateToNewEnvironmentVariables)

View File

@@ -2,6 +2,8 @@ import { z } from "zod"
import { defineVersion } from "verzod"
import { V0_SCHEMA } from "./0"
export const uniqueID = () => Math.random().toString(36).substring(2, 16)
export const V1_SCHEMA = z.object({
v: z.literal(1),
id: z.string(),
@@ -28,7 +30,7 @@ export default defineVersion({
const result: z.infer<typeof V1_SCHEMA> = {
...old,
v: 1,
id: old.id ?? "",
id: old.id || uniqueID(),
variables: old.variables.map((variable) => {
return {
...variable,

View File

@@ -2,29 +2,32 @@ import { InferredEntity, createVersionedEntity } from "verzod"
import { z } from "zod"
import V1_VERSION from "./v/1"
import V2_VERSION from "./v/2"
import V3_VERSION from "./v/3"
export { GQLHeader } from "./v/1"
export {
HoppGQLAuth,
HoppGQLAuthAPIKey,
HoppGQLAuthBasic,
HoppGQLAuthBearer,
HoppGQLAuthNone,
HoppGQLAuthOAuth2,
HoppGQLAuthInherit,
} from "./v/2"
export const GQL_REQ_SCHEMA_VERSION = 2
export { HoppGQLAuth } from "./v/3"
export { HoppGQLAuthOAuth2 } from "./v/3"
export const GQL_REQ_SCHEMA_VERSION = 3
const versionedObject = z.object({
v: z.number(),
})
export const HoppGQLRequest = createVersionedEntity({
latestVersion: 2,
latestVersion: 3,
versionMap: {
1: V1_VERSION,
2: V2_VERSION,
3: V3_VERSION,
},
getVersion(x) {
const result = versionedObject.safeParse(x)

View File

@@ -71,7 +71,7 @@ export const HoppGQLAuth = z
export type HoppGQLAuth = z.infer<typeof HoppGQLAuth>
const V2_SCHEMA = z.object({
export const V2_SCHEMA = z.object({
id: z.optional(z.string()),
v: z.literal(2),

View File

@@ -0,0 +1,77 @@
import { z } from "zod"
import { defineVersion } from "verzod"
import { HoppRESTAuthOAuth2 } from "../../rest"
import {
HoppGQLAuthAPIKey,
HoppGQLAuthBasic,
HoppGQLAuthBearer,
HoppGQLAuthInherit,
HoppGQLAuthNone,
V2_SCHEMA,
} from "./2"
export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest"
export type HoppGqlAuthOAuth2 = z.infer<typeof HoppRESTAuthOAuth2>
export const HoppGQLAuth = z
.discriminatedUnion("authType", [
HoppGQLAuthNone,
HoppGQLAuthInherit,
HoppGQLAuthBasic,
HoppGQLAuthBearer,
HoppGQLAuthAPIKey,
HoppRESTAuthOAuth2, // both rest and gql have the same auth type for oauth2
])
.and(
z.object({
authActive: z.boolean(),
})
)
export type HoppGQLAuth = z.infer<typeof HoppGQLAuth>
export const V3_SCHEMA = V2_SCHEMA.extend({
v: z.literal(3),
auth: HoppGQLAuth,
})
export default defineVersion({
initial: false,
schema: V3_SCHEMA,
up(old: z.infer<typeof V2_SCHEMA>) {
if (old.auth.authType === "oauth-2") {
const { token, accessTokenURL, scope, clientID, authURL } = old.auth
return {
...old,
v: 3 as const,
auth: {
...old.auth,
authType: "oauth-2" as const,
grantTypeInfo: {
grantType: "AUTHORIZATION_CODE" as const,
authEndpoint: authURL,
tokenEndpoint: accessTokenURL,
clientID: clientID,
clientSecret: "",
scopes: scope,
isPKCE: false,
token,
},
addTo: "HEADERS" as const,
},
}
}
return {
...old,
v: 3 as const,
auth: {
...old.auth,
},
}
},
})

View File

@@ -4,32 +4,39 @@ import cloneDeep from "lodash/cloneDeep"
import V0_VERSION from "./v/0"
import V1_VERSION from "./v/1"
import V2_VERSION from "./v/2"
import V3_VERSION from "./v/3"
import { createVersionedEntity, InferredEntity } from "verzod"
import { lodashIsEqualEq, mapThenEq, undefinedEq } from "../utils/eq"
import {
HoppRESTAuth,
HoppRESTReqBody,
HoppRESTHeaders,
HoppRESTParams,
} from "./v/1"
import { HoppRESTReqBody, HoppRESTHeaders, HoppRESTParams } from "./v/1"
import { HoppRESTAuth } from "./v/3"
import { HoppRESTRequestVariables } from "./v/2"
import { z } from "zod"
export * from "./content-types"
export {
FormDataKeyValue,
HoppRESTReqBodyFormData,
HoppRESTAuth,
HoppRESTAuthAPIKey,
HoppRESTAuthBasic,
HoppRESTAuthInherit,
HoppRESTAuthBearer,
HoppRESTAuthNone,
HoppRESTAuthOAuth2,
HoppRESTReqBody,
HoppRESTHeaders,
} from "./v/1"
export {
HoppRESTAuth,
HoppRESTAuthOAuth2,
AuthCodeGrantTypeParams,
ClientCredentialsGrantTypeParams,
ImplicitOauthFlowParams,
PasswordGrantTypeParams,
} from "./v/3"
export { HoppRESTRequestVariables } from "./v/2"
const versionedObject = z.object({
@@ -38,11 +45,12 @@ const versionedObject = z.object({
})
export const HoppRESTRequest = createVersionedEntity({
latestVersion: 2,
latestVersion: 3,
versionMap: {
0: V0_VERSION,
1: V1_VERSION,
2: V2_VERSION,
3: V3_VERSION,
},
getVersion(data) {
// For V1 onwards we have the v string storing the number
@@ -84,7 +92,7 @@ const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
),
})
export const RESTReqSchemaVersion = "2"
export const RESTReqSchemaVersion = "3"
export type HoppRESTParam = HoppRESTRequest["params"][number]
export type HoppRESTHeader = HoppRESTRequest["headers"][number]
@@ -179,7 +187,7 @@ export function makeRESTRequest(
export function getDefaultRESTRequest(): HoppRESTRequest {
return {
v: "2",
v: "3",
endpoint: "https://echo.hoppscotch.io",
name: "Untitled",
params: [],

View File

@@ -18,22 +18,8 @@ export const HoppRESTRequestVariables = z.array(
export type HoppRESTRequestVariables = z.infer<typeof HoppRESTRequestVariables>
const V2_SCHEMA = z.object({
export const V2_SCHEMA = V1_SCHEMA.extend({
v: z.literal("2"),
id: z.optional(z.string()), // Firebase Firestore ID
name: z.string(),
method: z.string(),
endpoint: z.string(),
params: HoppRESTParams,
headers: HoppRESTHeaders,
preRequestScript: z.string().catch(""),
testScript: z.string().catch(""),
auth: HoppRESTAuth,
body: HoppRESTReqBody,
requestVariables: HoppRESTRequestVariables,
})

View File

@@ -0,0 +1,127 @@
import { z } from "zod"
import {
HoppRESTAuthAPIKey,
HoppRESTAuthBasic,
HoppRESTAuthBearer,
HoppRESTAuthInherit,
HoppRESTAuthNone,
} from "./1"
import { V2_SCHEMA } from "./2"
import { defineVersion } from "verzod"
export const AuthCodeGrantTypeParams = z.object({
grantType: z.literal("AUTHORIZATION_CODE"),
authEndpoint: z.string().trim(),
tokenEndpoint: z.string().trim(),
clientID: z.string().trim(),
clientSecret: z.string().trim(),
scopes: z.string().trim().optional(),
token: z.string().catch(""),
isPKCE: z.boolean(),
codeVerifierMethod: z
.union([z.literal("plain"), z.literal("S256")])
.optional(),
})
export const ClientCredentialsGrantTypeParams = z.object({
grantType: z.literal("CLIENT_CREDENTIALS"),
authEndpoint: z.string().trim(),
clientID: z.string().trim(),
clientSecret: z.string().trim(),
scopes: z.string().trim().optional(),
token: z.string().catch(""),
})
export const PasswordGrantTypeParams = z.object({
grantType: z.literal("PASSWORD"),
authEndpoint: z.string().trim(),
clientID: z.string().trim(),
clientSecret: z.string().trim(),
scopes: z.string().trim().optional(),
username: z.string().trim(),
password: z.string().trim(),
token: z.string().catch(""),
})
export const ImplicitOauthFlowParams = z.object({
grantType: z.literal("IMPLICIT"),
authEndpoint: z.string().trim(),
clientID: z.string().trim(),
scopes: z.string().trim().optional(),
token: z.string().catch(""),
})
export const HoppRESTAuthOAuth2 = z.object({
authType: z.literal("oauth-2"),
grantTypeInfo: z.discriminatedUnion("grantType", [
AuthCodeGrantTypeParams,
ClientCredentialsGrantTypeParams,
PasswordGrantTypeParams,
ImplicitOauthFlowParams,
]),
addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"),
})
export type HoppRESTAuthOAuth2 = z.infer<typeof HoppRESTAuthOAuth2>
export const HoppRESTAuth = z
.discriminatedUnion("authType", [
HoppRESTAuthNone,
HoppRESTAuthInherit,
HoppRESTAuthBasic,
HoppRESTAuthBearer,
HoppRESTAuthOAuth2,
HoppRESTAuthAPIKey,
])
.and(
z.object({
authActive: z.boolean(),
})
)
export type HoppRESTAuth = z.infer<typeof HoppRESTAuth>
// V2_SCHEMA has one change in HoppRESTAuthOAuth2, we'll add the grant_type field
export const V3_SCHEMA = V2_SCHEMA.extend({
v: z.literal("3"),
auth: HoppRESTAuth,
})
export default defineVersion({
initial: false,
schema: V3_SCHEMA,
up(old: z.infer<typeof V2_SCHEMA>) {
if (old.auth.authType === "oauth-2") {
const { token, accessTokenURL, scope, clientID, authURL } = old.auth
return {
...old,
v: "3" as const,
auth: {
...old.auth,
authType: "oauth-2" as const,
grantTypeInfo: {
grantType: "AUTHORIZATION_CODE" as const,
authEndpoint: authURL,
tokenEndpoint: accessTokenURL,
clientID: clientID,
clientSecret: "",
scopes: scope,
isPKCE: false,
token,
},
addTo: "HEADERS" as const,
},
}
}
return {
...old,
v: "3" as const,
auth: {
...old.auth,
},
}
},
})

View File

@@ -112,10 +112,15 @@ function exportedCollectionToHoppCollection(
folders: restCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType)
),
requests: restCollection.requests.map(
({
id,
requests: restCollection.requests.map((request) => {
const requestParsedResult = HoppRESTRequest.safeParse(request)
if (requestParsedResult.type === "ok") {
return requestParsedResult.value
}
const {
v,
id,
auth,
body,
endpoint,
@@ -125,20 +130,23 @@ function exportedCollectionToHoppCollection(
params,
preRequestScript,
testScript,
}) => ({
id,
requestVariables,
} = request
return {
v,
auth,
body,
endpoint,
headers,
method,
id,
name,
endpoint,
method,
params,
requestVariables: requestVariables,
auth,
headers,
body,
preRequestScript,
testScript,
})
),
}
}),
}
} else {
const gqlCollection = collection as ExportedUserCollectionGQL

View File

@@ -120,10 +120,15 @@ function exportedCollectionToHoppCollection(
folders: restCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType)
),
requests: restCollection.requests.map(
({
id,
requests: restCollection.requests.map((request) => {
const requestParsedResult = HoppRESTRequest.safeParse(request)
if (requestParsedResult.type === "ok") {
return requestParsedResult.value
}
const {
v,
id,
auth,
body,
endpoint,
@@ -134,21 +139,22 @@ function exportedCollectionToHoppCollection(
preRequestScript,
testScript,
requestVariables,
}) => ({
id,
} = request
return {
v,
auth,
body,
endpoint,
headers,
method,
id,
name,
endpoint,
method,
params,
requestVariables: requestVariables,
auth,
headers,
body,
preRequestScript,
testScript,
requestVariables,
})
),
}
}),
auth: data.auth,
headers: data.headers,
}

View File

@@ -106,8 +106,18 @@
"admin_failure": "Failed to make user an admin!!",
"admin_success": "User is now an admin!!",
"and": "and",
"clear_selection": "Clear Selection",
"configure_auth": "Check out the documentation to configure auth providers.",
"confirm_admin_to_user": "Do you want to remove admin status from this user?",
"confirm_admins_to_users": "Do you want to remove admin status from selected users?",
"confirm_delete_invite": "Do you want to revoke the selected invite?",
"confirm_delete_invites": "Do you want to revoke selected invites?",
"confirm_user_deletion": "Confirm user deletion?",
"confirm_users_deletion": "Do you want to delete selected users?",
"confirm_user_to_admin": "Do you want to make this user into an admin?",
"confirm_users_to_admin": "Do you want to make selected users into admins?",
"confirm_logout": "Confirm Logout",
"created_on": "Created On",
"continue_email": "Continue with Email",
"continue_github": "Continue with Github",
"continue_google": "Continue with Google",
@@ -116,12 +126,21 @@
"create_team_failure": "Failed to create workspace!!",
"create_team_success": "Workspace created successfully!!",
"data_sharing_failure": "Failed to update data sharing settings",
"delete_invite_failure": "Failed to delete invite!!",
"delete_invites_failure": "Failed to delete selected invites!!",
"delete_invite_success": "Invite deleted successfully!!",
"delete_invites_success": "Selected invites deleted successfully!!",
"delete_request_failure": "Shared Request deletion failed!!",
"delete_request_success": "Shared Request deleted successfully!!",
"delete_team_failure": "Workspace deletion failed!!",
"delete_team_success": "Workspace deleted successfully!!",
"delete_some_users_failure": "Number of Users Not Deleted: {count}",
"delete_some_users_success": "Number of Users Deleted: {count}",
"delete_user_failed_only_one_admin": "Failed to delete user. There should be atleast one admin!!",
"delete_user_failure": "User deletion failed!!",
"delete_users_failure": "Failed to delete selected users!!",
"delete_user_success": "User deleted successfully!!",
"delete_users_success": "Selected users deleted successfully!!",
"email": "Email",
"email_failure": "Failed to send invitation",
"email_signin_failure": "Failed to login with Email",
@@ -146,16 +165,22 @@
"reenter_email": "Re-enter email",
"remove_admin_failure": "Failed to remove admin status!!",
"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_admin_for_deletion": "Remove admin status before attempting deletion!!",
"remove_invitee_failure": "Removal of invitee failed!!",
"remove_invitee_success": "Removal of invitee is successfull!!",
"remove_member_failure": "Member couldn't be removed!!",
"remove_member_success": "Member removed successfully!!",
"rename_team_failure": "Failed to rename workspace!!",
"rename_team_success": "Workspace renamed successfully!",
"rename_user_failure": "Failed to rename user!!",
"rename_user_success": "User renamed successfully!!",
"require_auth_provider": "You need to set atleast one authentication provider to log in.",
"role_update_failed": "Roles updation has failed!!",
"role_update_success": "Roles updated successfully!!",
"selected": "{count} selected",
"self_host_docs": "Self Host Documentation",
"send_magic_link": "Send magic link",
"setup_failure": "Setup has failed!!",
@@ -164,7 +189,11 @@
"sign_in_options": "All sign in option",
"sign_out": "Sign out",
"team_name_too_short": "Workspace name should be atleast 6 characters long!!",
"user_not_found": "User not found in the infra!!"
"team_name_long": "Workspace name should be atleast 6 characters long!!",
"user_already_invited": "Failed to send invite. User is already invited!!",
"user_not_found": "User not found in the infra!!",
"users_to_admin_success": "Selected users are elevated to admin status!!",
"users_to_admin_failure": "Failed to elevate selected users to admin status!!"
},
"teams": {
"add_member": "Add Member",
@@ -201,7 +230,7 @@
"name": "Workspace Name",
"no_members": "No members in this workspace. Add members to this workspace to collaborate",
"no_pending_invites": "No pending invites",
"no_teams": "No workspaces found",
"no_teams": "No workspaces found..",
"pending_invites": "Pending invites",
"roles": "Roles",
"roles_description": "Roles are used to control access to the shared collections.",
@@ -226,16 +255,17 @@
"admin": "Admin",
"admin_email": "Admin Email",
"admin_id": "Admin ID",
"confirm_admin_to_user": "Do you want to remove admin status from this user?",
"confirm_user_deletion": "Confirm user deletion?",
"confirm_user_to_admin": "Do you want to make this user into an admin?",
"cancel": "Cancel",
"created_on": "Created On",
"date": "Date",
"delete": "Delete",
"delete_user": "Delete User",
"delete_users": "Delete Users",
"details": "Details",
"edit": "Edit",
"email": "Email",
"email_address": "Email Address",
"empty_name": "Name cannot be empty!!",
"id": "User ID",
"invalid_user": "Invalid User",
"invite_load_list_error": "Unable to Load Invited Users List",
@@ -245,14 +275,18 @@
"invitee_email": "Invitee Email",
"load_info_error": "Unable to load user info",
"load_list_error": "Unable to Load Users List",
"make_admin": "Make admin",
"make_admin": "Make Admin",
"name": "Name",
"no_invite": "No invited users found",
"no_invite": "No pending invites found",
"no_shared_requests": "No shared requests created by the user",
"no_users": "No users found",
"not_found": "User not found",
"pending_invites": "Pending Invites",
"remove_admin_privilege": "Remove Admin Privilege",
"remove_admin_status": "Remove Admin Status",
"rename": "Rename",
"revoke_invitation": "Revoke Invitation",
"searchbar_placeholder": "Search by name or email..",
"send_invite": "Send Invite",
"show_more": "Show more",
"uid": "UID",

View File

@@ -17,7 +17,7 @@
"@fontsource-variable/material-symbols-rounded": "5.0.5",
"@fontsource-variable/roboto-mono": "5.0.6",
"@graphql-typed-document-node/core": "3.1.1",
"@hoppscotch/ui": "0.1.0",
"@hoppscotch/ui": "0.1.3",
"@hoppscotch/vue-toasted": "0.1.0",
"@intlify/unplugin-vue-i18n": "1.2.0",
"@types/cors": "2.8.13",
@@ -78,4 +78,4 @@
"singleQuote": true,
"semi": true
}
}
}

View File

@@ -29,8 +29,11 @@ declare module '@vue/runtime-core' {
HoppSmartTable: typeof import('@hoppscotch/ui')['HoppSmartTable']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUser: typeof import('~icons/lucide/user')['default']
SettingsAuthProvider: typeof import('./components/settings/AuthProvider.vue')['default']
SettingsConfigurations: typeof import('./components/settings/Configurations.vue')['default']
SettingsDataSharing: typeof import('./components/settings/DataSharing.vue')['default']
@@ -48,6 +51,7 @@ 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']
}
}

View File

@@ -45,7 +45,6 @@
:type="
isMasked(provider.name, field.key) ? 'password' : 'text'
"
:disabled="isMasked(provider.name, field.key)"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1"
/>

View File

@@ -21,13 +21,12 @@
</HoppSmartToggle>
</div>
<!-- TODO: Update the link below -->
<HoppButtonSecondary
outline
filled
:icon="IconShieldQuestion"
:label="t('configs.data_sharing.see_shared')"
to="http://docs.hoppscotch.io"
to="https://docs.hoppscotch.io/documentation/self-host/community-edition/telemetry"
blank
class="w-min my-2"
/>

View File

@@ -37,7 +37,6 @@
<HoppSmartInput
v-model="smtpConfigs.fields[field.key]"
:type="isMasked(field.key) ? 'password' : 'text'"
:disabled="isMasked(field.key)"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1"
/>

View File

@@ -28,10 +28,9 @@
>
{{ t('data_sharing.toggle_description') }}
</HoppSmartToggle>
<!-- TODO: Update link -->
<HoppSmartAnchor
blank
to="http://docs.hoppscotch.io"
to="https://docs.hoppscotch.io/documentation/self-host/community-edition/telemetry"
:label="t('data_sharing.see_shared')"
class="underline"
/>

View File

@@ -24,11 +24,40 @@
</div>
<template v-for="(info, key) in userInfo" :key="key">
<div v-if="info.condition">
<label class="text-secondaryDark" :for="key">{{ info.label }}</label>
<div v-if="key === 'displayName'" class="flex flex-col space-y-3">
<label class="text-accentContrast" for="teamname"
>{{ t('users.name') }}
</label>
<div
class="w-full p-3 mt-2 bg-divider border-gray-600 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
class="flex bg-divider rounded-md items-stretch flex-1 border border-divider"
:class="{
'!border-accent': isNameBeingEdited,
}"
>
<HoppSmartInput
v-model="updatedUserName"
styles="bg-transparent flex-1 rounded-md !rounded-r-none disabled:select-none border-r-0 disabled:cursor-default disabled:opacity-50"
:placeholder="t('users.name')"
:disabled="!isNameBeingEdited"
>
<template #button>
<HoppButtonPrimary
class="!rounded-l-none"
filled
:icon="isNameBeingEdited ? IconSave : IconEdit"
:label="
isNameBeingEdited ? t('users.rename') : t('users.edit')
"
@click="handleNameEdit"
/>
</template>
</HoppSmartInput>
</div>
</div>
<div v-else-if="info.condition">
<label class="text-secondaryDark" :for="key">{{ info.label }}</label>
<div class="w-full p-3 mt-2 bg-divider border-gray-600 rounded-md">
<span>{{ info.value }}</span>
</div>
</div>
@@ -70,10 +99,17 @@
</template>
<script setup lang="ts">
import { useMutation } from '@urql/vue';
import { format } from 'date-fns';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { UserInfoQuery } from '~/helpers/backend/graphql';
import {
UpdateUserDisplayNameByAdminDocument,
UserInfoQuery,
} from '~/helpers/backend/graphql';
import IconEdit from '~icons/lucide/edit';
import IconSave from '~icons/lucide/save';
import IconTrash from '~icons/lucide/trash';
import IconUserCheck from '~icons/lucide/user-check';
import IconUserMinus from '~icons/lucide/user-minus';
@@ -89,6 +125,7 @@ const emit = defineEmits<{
(event: 'delete-user', userID: string): void;
(event: 'make-admin', userID: string): void;
(event: 'remove-admin', userID: string): void;
(event: 'update-user-name', newName: string): void;
}>();
// Get Proper Date Formats
@@ -120,4 +157,62 @@ const userInfo = {
value: getCreatedDateAndTime(createdOn),
},
};
// Contains the actual user name
const userName = computed({
get: () => props.user.displayName,
set: (value) => {
return value;
},
});
// Contains the stored user name from the actual name before being edited
const currentUserName = ref('');
// Set the current user name to the actual user name
onMounted(() => {
if (displayName) currentUserName.value = displayName;
});
// Contains the user name that is being edited
const updatedUserName = computed({
get: () => currentUserName.value,
set: (value) => {
currentUserName.value = value;
},
});
// Rename the user
const isNameBeingEdited = ref(false);
const userRename = useMutation(UpdateUserDisplayNameByAdminDocument);
const handleNameEdit = () => {
if (isNameBeingEdited.value) {
// If the name is not changed, then return control
if (userName.value !== updatedUserName.value) {
renameUserName();
} else isNameBeingEdited.value = false;
} else {
isNameBeingEdited.value = true;
}
};
const renameUserName = async () => {
if (updatedUserName.value?.trim() === '') {
toast.error(t('users.empty_name'));
return;
}
const variables = { userUID: uid, name: updatedUserName.value as string };
const result = await userRename.executeMutation(variables);
if (result.error) {
toast.error(t('state.rename_user_failure'));
} else {
isNameBeingEdited.value = false;
toast.success(t('state.rename_user_success'));
emit('update-user-name', updatedUserName.value as string);
userName.value = updatedUserName.value;
}
};
</script>

View File

@@ -10,6 +10,7 @@
v-model="email"
:label="t('users.email_address')"
input-styles="floating-input"
@submit="sendInvite"
/>
</template>
<template #footer>
@@ -18,7 +19,12 @@
:label="t('users.send_invite')"
@click="sendInvite"
/>
<HoppButtonSecondary label="Cancel" outline filled @click="hideModal" />
<HoppButtonSecondary
:label="t('users.cancel')"
outline
filled
@click="hideModal"
/>
</span>
</template>
</HoppSmartModal>

View File

@@ -1,78 +1,68 @@
<template>
<div class="px-4">
<div class="px-4 mt-7">
<div v-if="fetching" class="flex justify-center">
<HoppSmartSpinner />
</div>
<div v-else-if="error">{{ t('shared_requests.load_list_error') }}</div>
<div v-else-if="sharedRequests.length === 0" class="mt-5">
<div v-else-if="sharedRequests.length === 0">
{{ t('users.no_shared_requests') }}
</div>
<HoppSmartTable v-else class="mt-8" :list="sharedRequests">
<HoppSmartTable v-else :headings="headings" :list="sharedRequests">
<template #head>
<tr
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
>
<th class="px-6 py-2">{{ t('shared_requests.id') }}</th>
<th class="px-6 py-2 w-30">{{ t('shared_requests.url') }}</th>
<th class="px-6 py-2">{{ t('shared_requests.created_on') }}</th>
<!-- Empty Heading for the Action Button -->
<th class="px-6 py-2 text-center">
{{ t('shared_requests.action') }}
</th>
</tr>
<th class="px-6 py-2">{{ t('shared_requests.id') }}</th>
<th class="px-6 py-2 w-30">{{ t('shared_requests.url') }}</th>
<th class="px-6 py-2">{{ t('shared_requests.created_on') }}</th>
<!-- Empty Heading for the Action Button -->
<th class="px-6 py-2 text-center">
{{ t('shared_requests.action') }}
</th>
</template>
<template #body="{ list: sharedRequests }">
<tr
v-for="request in sharedRequests"
:key="request.id"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
>
<td class="flex py-4 px-7 max-w-50">
<span class="truncate">
{{ request.id }}
</span>
</td>
<template #body="{ row: sharedRequest }">
<td class="flex py-4 px-7 max-w-50">
<span class="truncate">
{{ sharedRequest.id }}
</span>
</td>
<td class="py-4 px-7 w-96">
{{ sharedRequestURL(request.request) }}
</td>
<td class="py-4 px-7 w-96">
{{ sharedRequestURL(sharedRequest.request) }}
</td>
<td class="py-2 px-7">
{{ getCreatedDate(request.createdOn) }}
<div class="text-gray-400 text-tiny">
{{ getCreatedTime(request.createdOn) }}
</div>
</td>
<td class="py-2 px-7">
{{ getCreatedDate(sharedRequest.createdOn) }}
<div class="text-gray-400 text-tiny">
{{ getCreatedTime(sharedRequest.createdOn) }}
</div>
</td>
<td class="flex justify-center">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.open_request')"
:to="`${shortcodeBaseURL}/r/${request.id}`"
:blank="true"
:icon="IconExternalLink"
class="px-3 text-emerald-500 hover:text-accent"
/>
<td class="flex justify-center">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.open_request')"
:to="`${shortcodeBaseURL}/r/${sharedRequest.id}`"
:blank="true"
:icon="IconExternalLink"
class="px-3 text-emerald-500 hover:text-accent"
/>
<UiAutoResetIcon
:title="t('shared_requests.copy')"
:icon="{ default: IconCopy, temporary: IconCheck }"
@click="copySharedRequest(request.id)"
/>
<UiAutoResetIcon
:title="t('shared_requests.copy')"
:icon="{ default: IconCopy, temporary: IconCheck }"
@click="copySharedRequest(sharedRequest.id)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.delete')"
:icon="IconTrash"
color="red"
class="px-3"
@click="deleteSharedRequest(request.id)"
/>
</td>
</tr>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.delete')"
:icon="IconTrash"
color="red"
class="px-3"
@click="deleteSharedRequest(sharedRequest.id)"
/>
</td>
</template>
</HoppSmartTable>
@@ -136,11 +126,18 @@ const {
} = usePagedQuery(
SharedRequestsDocument,
(x) => x.infra.allShortcodes,
(x) => x.id,
sharedRequestsPerPage,
{ cursor: undefined, take: sharedRequestsPerPage, email: props.email }
{ cursor: undefined, take: sharedRequestsPerPage, email: props.email },
(x) => x.id
);
const headings = [
{ key: 'id', label: t('shared_requests.id') },
{ key: 'request', label: t('shared_requests.url') },
{ key: 'createdOn', label: t('shared_requests.created_on') },
{ key: 'action', label: t('shared_requests.action') },
];
// Return request endpoint from the request object
const sharedRequestURL = (request: string) => {
const parsedRequest = JSON.parse(request);
@@ -174,17 +171,17 @@ const deleteSharedRequestMutation = async (id: string | null) => {
return;
}
const variables = { codeID: id };
await sharedRequestDeletion.executeMutation(variables).then((result) => {
if (result.error) {
toast.error(t('state.delete_request_failure'));
} else {
sharedRequests.value = sharedRequests.value.filter(
(request) => request.id !== id
);
refetch();
toast.success(t('state.delete_request_success'));
}
});
const result = await sharedRequestDeletion.executeMutation(variables);
if (result.error) {
toast.error(t('state.delete_request_failure'));
} else {
sharedRequests.value = sharedRequests.value.filter(
(request) => request.id !== id
);
refetch();
toast.success(t('state.delete_request_success'));
}
confirmDeletion.value = false;
deleteSharedRequestID.value = null;
};

View File

@@ -1,4 +1,4 @@
import { Ref, onMounted, ref } from 'vue';
import { onMounted, ref, Ref } from 'vue';
import { DocumentNode } from 'graphql';
import { TypedDocumentNode, useClientHandle } from '@urql/vue';
@@ -9,9 +9,9 @@ export function usePagedQuery<
>(
query: string | TypedDocumentNode<Result, Vars> | DocumentNode,
getList: (result: Result) => ListItem[],
getCursor: (value: ListItem) => string,
itemsPerPage: number,
variables: Vars
baseVariables: Vars,
getCursor?: (value: ListItem) => string
) {
const { client } = useClientHandle();
const fetching = ref(true);
@@ -20,21 +20,25 @@ export function usePagedQuery<
const currentPage = ref(0);
const hasNextPage = ref(true);
const fetchNextPage = async () => {
const fetchNextPage = async (additionalVariables?: Vars) => {
let variables = { ...baseVariables };
fetching.value = true;
const cursor =
list.value.length > 0 ? getCursor(list.value.at(-1)!) : undefined;
const variablesForPagination = {
...variables,
take: itemsPerPage,
cursor,
};
const result = await client
.query(query, variablesForPagination)
.toPromise();
// Cursor based pagination
if (getCursor) {
const cursor =
list.value.length > 0
? getCursor(list.value.at(-1) as ListItem)
: undefined;
variables = { ...variables, cursor };
}
// Offset based pagination
else if (additionalVariables) {
variables = { ...variables, ...additionalVariables };
}
const result = await client.query(query, variables).toPromise();
if (result.error) {
error.value = true;
fetching.value = false;
@@ -63,11 +67,14 @@ export function usePagedQuery<
}
};
const refetch = async () => {
const refetch = async (variables?: Vars) => {
currentPage.value = 0;
hasNextPage.value = true;
list.value = [];
await fetchNextPage();
if (hasNextPage.value) {
variables ? await fetchNextPage(variables) : await fetchNextPage();
}
};
return {

View File

@@ -0,0 +1,3 @@
mutation DemoteUsersByAdmin($userUIDs: [ID!]!) {
demoteUsersByAdmin(userUIDs: $userUIDs)
}

View File

@@ -1,3 +0,0 @@
mutation MakeUserAdmin($uid: ID!) {
makeUserAdmin(userUID: $uid)
}

View File

@@ -0,0 +1,3 @@
mutation MakeUsersAdmin($userUIDs: [ID!]!) {
makeUsersAdmin(userUIDs: $userUIDs)
}

View File

@@ -1,3 +0,0 @@
mutation RemoveUserAsAdmin($uid: ID!) {
removeUserAsAdmin(userUID: $uid)
}

View File

@@ -1,3 +0,0 @@
mutation RemoveUserByAdmin($uid: ID!) {
removeUserByAdmin(userUID: $uid)
}

View File

@@ -0,0 +1,7 @@
mutation RemoveUsersByAdmin($userUIDs: [ID!]!) {
removeUsersByAdmin(userUIDs: $userUIDs) {
userUID
isDeleted
errorMessage
}
}

View File

@@ -0,0 +1,3 @@
mutation RevokeUserInvitationsByAdmin($inviteeEmails: [String!]!) {
revokeUserInvitationsByAdmin(inviteeEmails: $inviteeEmails)
}

View File

@@ -0,0 +1,3 @@
mutation UpdateUserDisplayNameByAdmin($userUID: ID!, $name: String!) {
updateUserDisplayNameByAdmin(userUID: $userUID, displayName: $name)
}

View File

@@ -0,0 +1,12 @@
query UsersListV2($searchString: String, $skip: Int, $take: Int) {
infra {
allUsersV2(searchString: $searchString, skip: $skip, take: $take) {
uid
displayName
email
isAdmin
photoURL
createdOn
}
}
}

View File

@@ -7,3 +7,13 @@ 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 ADMIN_CANNOT_BE_DELETED =
'admin/admin_can_not_be_deleted' as const;
// When trying to invite a user that is already invited
export const USER_ALREADY_INVITED =
'[GraphQL] admin/user_already_invited' as const;

View File

@@ -18,89 +18,83 @@
<div v-else-if="error">{{ t('teams.load_list_error') }}</div>
<HoppSmartTable v-else-if="teamsList.length" :list="teamsList">
<HoppSmartTable
v-else-if="teamsList.length"
:headings="headings"
:list="teamsList"
@onRowClicked="goToTeamDetails"
>
<template #head>
<tr
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
>
<th class="px-6 py-2">{{ t('teams.id') }}</th>
<th class="px-6 py-2">{{ t('teams.name') }}</th>
<th class="px-6 py-2">{{ t('teams.members') }}</th>
<!-- Empty Heading for the Action Button -->
<th class="px-6 py-2"></th>
</tr>
<th class="px-6 py-2">{{ t('teams.id') }}</th>
<th class="px-6 py-2">{{ t('teams.name') }}</th>
<th class="px-6 py-2">{{ t('teams.members') }}</th>
<!-- Empty Heading for the Action Button -->
<th class="px-6 py-2"></th>
</template>
<template #body="{ list }">
<tr
v-for="team in list"
:key="team.id"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
@click="goToTeamDetails(team.id)"
>
<td class="flex py-4 px-7 max-w-[16rem]">
<span class="truncate">
{{ team.id }}
</span>
</td>
<template #body="{ row: team }">
<td class="flex py-4 px-7 max-w-[16rem]">
<span class="truncate">
{{ team.id }}
</span>
</td>
<td class="py-4 px-7 min-w-[20rem]">
<span
class="flex items-center truncate"
:class="{ truncate: team.name }"
>
{{ team.name ?? t('teams.unnamed') }}
</span>
</td>
<td class="py-4 px-7 min-w-[20rem]">
<span
class="flex items-center truncate"
:class="{ truncate: team.name }"
>
{{ team.name ?? t('teams.unnamed') }}
</span>
</td>
<td class="py-4 px-7">
{{ team.members?.length }}
</td>
<td class="py-4 px-8">
{{ team.members?.length }}
</td>
<td @click.stop>
<div class="relative">
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconMoreHorizontal"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="IconTrash"
:label="t('teams.delete_team')"
class="!hover:bg-red-600 w-full"
@click="
() => {
deleteTeam(team.id);
hide();
}
"
/>
</div>
</template>
</tippy>
</div>
</td>
</tr>
<td @click.stop class="flex justify-end mr-10">
<div class="relative">
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconMoreHorizontal"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="IconTrash"
:label="t('teams.delete_team')"
class="!hover:bg-red-600 w-full"
@click="
() => {
deleteTeam(team.id);
hide();
}
"
/>
</div>
</template>
</tippy>
</div>
</td>
</template>
</HoppSmartTable>
<div v-else class="px-2 text-lg">
<div v-else class="px-2">
{{ t('teams.no_teams') }}
</div>
<div
v-if="hasNextPage && teamsList.length >= teamsPerPage"
class="flex justify-center my-5 px-3 py-2 cursor-pointer font-semibold rounded-3xl bg-dividerDark hover:bg-divider transition mx-auto w-38 text-secondaryDark"
class="flex items-center w-28 px-3 py-2 mt-5 mx-auto font-semibold text-secondaryDark bg-divider hover:bg-dividerDark rounded-3xl cursor-pointer"
@click="fetchNextTeams"
>
<span>{{ t('teams.show_more') }}</span>
<icon-lucide-chevron-down class="ml-2 text-lg" />
<icon-lucide-chevron-down class="ml-2" />
</div>
</div>
</div>
@@ -135,6 +129,7 @@ import {
CreateTeamDocument,
MetricsDocument,
RemoveTeamDocument,
TeamInfoQuery,
TeamListDocument,
UsersListDocument,
} from '../../helpers/backend/graphql';
@@ -149,9 +144,9 @@ const usersPerPage = computed(() => data.value?.infra.usersCount || 10000);
const { list: usersList } = usePagedQuery(
UsersListDocument,
(x) => x.infra.allUsers,
(x) => x.uid,
usersPerPage.value,
{ cursor: undefined, take: usersPerPage.value }
{ cursor: undefined, take: usersPerPage.value },
(x) => x.uid
);
const allUsersEmail = computed(() => usersList.value.map((user) => user.email));
@@ -168,11 +163,19 @@ const {
} = usePagedQuery(
TeamListDocument,
(x) => x.infra.allTeams,
(x) => x.id,
teamsPerPage,
{ cursor: undefined, take: teamsPerPage }
{ cursor: undefined, take: teamsPerPage },
(x) => x.id
);
// Table Headings
const headings = [
{ key: 'id', label: t('teams.id') },
{ key: 'name', label: t('teams.name') },
{ key: 'members', label: t('teams.members') },
{ key: 'actions', label: '' },
];
// Create Team
const showCreateTeamModal = ref(false);
const createTeamLoading = ref(false);
@@ -212,7 +215,8 @@ const createTeam = async (newTeamName: string, ownerEmail: string) => {
// Go To Individual Team Details Page
const router = useRouter();
const goToTeamDetails = (teamId: string) => router.push('/teams/' + teamId);
const goToTeamDetails = (team: TeamInfoQuery['infra']['teamInfo']) =>
router.push('/teams/' + team.id);
// Team Deletion
const confirmDeletion = ref(false);

View File

@@ -12,7 +12,7 @@
<div class="flex items-center space-x-3">
<h1 class="text-lg text-accentContrast">
{{ user.displayName }}
{{ userName }}
</h1>
<span>/</span>
<h2 class="text-lg text-accentContrast">
@@ -29,6 +29,7 @@
@delete-user="deleteUser"
@make-admin="makeUserAdmin"
@remove-admin="makeAdminToUser"
@update-user-name="(name: string) => (userName = name)"
class="py-8 px-4"
/>
</HoppSmartTab>
@@ -40,19 +41,19 @@
<HoppSmartConfirmModal
:show="confirmDeletion"
:title="t('users.confirm_user_deletion')"
:title="t('state.confirm_user_deletion')"
@hide-modal="confirmDeletion = false"
@resolve="deleteUserMutation(deleteUserUID)"
/>
<HoppSmartConfirmModal
:show="confirmUserToAdmin"
:title="t('users.confirm_user_to_admin')"
:title="t('state.confirm_user_to_admin')"
@hide-modal="confirmUserToAdmin = false"
@resolve="makeUserAdminMutation(userToAdminUID)"
/>
<HoppSmartConfirmModal
:show="confirmAdminToUser"
:title="t('users.confirm_admin_to_user')"
:title="t('state.confirm_admin_to_user')"
@hide-modal="confirmAdminToUser = false"
@resolve="makeAdminToUserMutation(adminToUserUID)"
/>
@@ -67,11 +68,12 @@ import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { useClientHandler } from '~/composables/useClientHandler';
import {
MakeUserAdminDocument,
RemoveUserAsAdminDocument,
RemoveUserByAdminDocument,
DemoteUsersByAdminDocument,
MakeUsersAdminDocument,
RemoveUsersByAdminDocument,
UserInfoDocument,
} from '~/helpers/backend/graphql';
import { ADMIN_CANNOT_BE_DELETED } from '~/helpers/errors';
const t = useI18n();
const toast = useToast();
@@ -104,6 +106,15 @@ onMounted(async () => {
await fetchData();
});
const userName = computed({
get: () => data.value?.infra.userInfo.displayName,
set: (value) => {
if (value) {
data.value!.infra.userInfo.displayName = value;
}
},
});
const user = computed({
get: () => data.value?.infra.userInfo,
set: (value) => {
@@ -113,43 +124,11 @@ const user = computed({
},
});
// User Deletion
const router = useRouter();
const userDeletion = useMutation(RemoveUserByAdminDocument);
const confirmDeletion = ref(false);
const deleteUserUID = ref<string | null>(null);
const deleteUser = (id: string) => {
confirmDeletion.value = true;
deleteUserUID.value = id;
};
const deleteUserMutation = async (id: string | null) => {
if (!id) {
confirmDeletion.value = false;
toast.error(t('state.delete_user_failure'));
return;
}
const variables = { uid: id };
const result = await userDeletion.executeMutation(variables);
if (result.error) {
toast.error(t('state.delete_user_failure'));
} else {
toast.success(t('state.delete_user_success'));
}
confirmDeletion.value = false;
deleteUserUID.value = null;
router.push('/users');
};
// Make User Admin
const userToAdmin = useMutation(MakeUserAdminDocument);
const confirmUserToAdmin = ref(false);
const userToAdminUID = ref<string | null>(null);
const usersToAdmin = useMutation(MakeUsersAdminDocument);
const makeUserAdmin = (id: string) => {
const makeUserAdmin = (id: string | null) => {
confirmUserToAdmin.value = true;
userToAdminUID.value = id;
};
@@ -160,20 +139,23 @@ const makeUserAdminMutation = async (id: string | null) => {
toast.error(t('state.admin_failure'));
return;
}
const variables = { uid: id };
const result = await userToAdmin.executeMutation(variables);
const userUIDs = [id];
const variables = { userUIDs };
const result = await usersToAdmin.executeMutation(variables);
if (result.error) {
toast.error(t('state.admin_failure'));
} else {
user.value!.isAdmin = true;
toast.success(t('state.admin_success'));
user.value!.isAdmin = true;
}
confirmUserToAdmin.value = false;
userToAdminUID.value = null;
};
// Remove Admin Status from a current admin user
const adminToUser = useMutation(RemoveUserAsAdminDocument);
const adminToUser = useMutation(DemoteUsersByAdminDocument);
const confirmAdminToUser = ref(false);
const adminToUserUID = ref<string | null>(null);
@@ -188,15 +170,56 @@ const makeAdminToUserMutation = async (id: string | null) => {
toast.error(t('state.remove_admin_failure'));
return;
}
const variables = { uid: id };
const userUIDs = [id];
const variables = { userUIDs };
const result = await adminToUser.executeMutation(variables);
if (result.error) {
toast.error(t('state.remove_admin_failure'));
} else {
toast.success(t('state.remove_admin_success'));
user.value!.isAdmin = false;
toast.error(t('state.remove_admin_success'));
}
confirmAdminToUser.value = false;
adminToUserUID.value = null;
};
// User Deletion
const router = useRouter();
const userDeletion = useMutation(RemoveUsersByAdminDocument);
const confirmDeletion = ref(false);
const deleteUserUID = ref<string | null>(null);
const deleteUser = (id: string) => {
confirmDeletion.value = true;
deleteUserUID.value = id;
};
const deleteUserMutation = async (id: string | null) => {
if (!id) {
confirmDeletion.value = false;
toast.error(t('state.delete_user_failure'));
return;
}
const userUIDs = [id];
const variables = { userUIDs };
const result = await userDeletion.executeMutation(variables);
if (result.error) {
toast.error(t('state.delete_user_failure'));
} 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'));
}
confirmDeletion.value = false;
deleteUserUID.value = null;
router.push('/users');
};
</script>

Some files were not shown because too many files have changed in this diff Show More