Compare commits

...

28 Commits

Author SHA1 Message Date
Liyas Thomas
1dcfc684ef feat: revamped header wireframe 2023-12-01 19:49:23 +05:30
Liyas Thomas
1cc845e17d fix: minor ui improvements (#3603) 2023-11-29 22:45:40 +05:30
James George
60bfb6fe2c refactor: move persistence logic into a dedicated service (#3493) 2023-11-29 22:40:26 +05:30
Joel Jacob Stephen
144d14ab5b fix: email validation failure in cases when email entered is correct when trying to create a team in admin dashboard (#3588)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-11-29 21:49:49 +05:30
Anwarul Islam
8f1ca6e282 feat: platform definition added for additional settings components (#3503) 2023-11-29 21:42:51 +05:30
Dante Calderon
a93758c6b7 chore: Remove whitespace in env variables 2023-11-22 19:40:32 +05:30
James George
1829c088cc feat: support for subpath based access in SH apps (#3449)
Co-authored-by: Balu Babu <balub997@gmail.com>
2023-11-22 19:35:35 +05:30
Akash K
ee1425d0dd fix: XML body disappearing with invalid XML (#3567)
fix: catch xmlformatter errors
2023-11-20 15:02:07 +05:30
Joel Jacob Stephen
24ae090916 refactor: allow banner service to hold multiple banners and display the banner with the highest score (#3556) 2023-11-17 20:31:34 +05:30
Nivedin
a3aa9b68fc refactor: interceptor error display in graphql response (#3553) 2023-11-17 17:03:53 +05:30
Joel Jacob Stephen
50f475334e fix: enlarged hoppscotch logo on dashboard login screen (#3559)
fix: resize the dashboard login icon
2023-11-16 22:51:08 +05:30
Andrew Bastin
7b18526f24 chore: merge hoppscotch/main into hoppscotch/release/2023.12.0 2023-11-16 13:51:12 +05:30
Andrew Bastin
23afc201a1 chore: bump version to release/2023.8.4 2023-11-14 21:26:16 +05:30
Andrew Bastin
b1982d74a6 fix: make schema more lenient while parsing public data structures 2023-11-14 21:24:25 +05:30
Andrew Bastin
e93a37c711 fix: add i18n entries for oauth errors 2023-11-14 17:44:37 +05:30
Nivedin
8d7509cdea fix: interceptor error from extension issue (#3548) 2023-11-14 17:17:23 +05:30
Akash K
e24d0ce605 fix: oauth 2.0 authentication type is breaking (#3531) 2023-11-14 00:12:04 +05:30
Andrew Bastin
de725337d6 fix: window drag taking precedence on windows 2023-11-08 20:07:13 +05:30
Andrew Bastin
9d1d369f37 fix: performance issues due to mouse on header detection 2023-11-08 18:47:40 +05:30
Andrew Bastin
2bd925d441 chore: correct version of selfhost-desktop 2023-11-08 17:18:12 +05:30
Liyas Thomas
bb8dc6f7eb chore: updated brand assets (#3500)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-11-08 15:47:35 +05:30
Anwarul Islam
be3e5ba7e7 fix: graphql query deprecation issue (#3506) 2023-11-08 14:51:39 +05:30
Andrew Bastin
663134839f feat: let platforms disable we are using cookies prompt 2023-11-07 20:49:07 +05:30
Andrew Bastin
736f83a70c fix: header inspector cookie inspection will not trigger if current interceptor supports cookies 2023-11-07 20:36:34 +05:30
Andrew Bastin
05d2175f43 fix: selfhost-desktop not running gql-codegen 2023-11-07 20:24:22 +05:30
Andrew Bastin
97bd808431 chore: set versions with a bump version 2023-11-07 16:04:02 +05:30
Andrew Bastin
a13c2fd4c1 chore: pin @codemirror/language to 6.9.0 2023-11-07 15:57:19 +05:30
Andrew Bastin
16044b5840 feat: desktop app
Co-authored-by: Vivek R <123vivekr@gmail.com>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-11-07 14:20:03 +05:30
237 changed files with 17058 additions and 1505 deletions

View File

@@ -12,8 +12,8 @@ SESSION_SECRET='add some secret here'
# Hoppscotch App Domain Config
REDIRECT_URL="http://localhost:3000"
WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
VITE_ALLOWED_AUTH_PROVIDERS = GOOGLE,GITHUB,MICROSOFT,EMAIL
WHITELISTED_ORIGINS="http://localhost:3170,http://localhost:3000,http://localhost:3100"
VITE_ALLOWED_AUTH_PROVIDERS=GOOGLE,GITHUB,MICROSOFT,EMAIL
# Google Auth Config
GOOGLE_CLIENT_ID="************************************************"
@@ -59,3 +59,6 @@ VITE_BACKEND_API_URL=http://localhost:3170/v1
# Terms Of Service And Privacy Policy Links (Optional)
VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms
VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy
# Set to `true` for subpath based access
ENABLE_SUBPATH_BASED_ACCESS=false

View File

@@ -0,0 +1,19 @@
:3000 {
try_files {path} /
root * /site/selfhost-web
file_server
}
:3100 {
try_files {path} /
root * /site/sh-admin-multiport-setup
file_server
}
:3170 {
reverse_proxy localhost:8080
}
:80 {
respond 404
}

View File

@@ -0,0 +1,37 @@
:3000 {
respond 404
}
:3100 {
respond 404
}
:3170 {
reverse_proxy localhost:8080
}
:80 {
# Serve the `selfhost-web` SPA by default
root * /site/selfhost-web
file_server
handle_path /admin* {
root * /site/sh-admin-subpath-access
file_server
# Ensures any non-existent file in the server is routed to the SPA
try_files {path} /
}
# Handle requests under `/backend*` path
handle_path /backend* {
reverse_proxy localhost:8080
}
# Catch-all route for unknown paths, serves `selfhost-web` SPA
handle {
root * /site/selfhost-web
file_server
try_files {path} /
}
}

View File

@@ -1,11 +0,0 @@
:3000 {
try_files {path} /
root * /site/selfhost-web
file_server
}
:3100 {
try_files {path} /
root * /site/sh-admin
file_server
}

View File

@@ -49,7 +49,8 @@ execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`)
fs.rmSync("build.env")
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile'
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
const backendProcess = runChildProcessWithPrefix("pnpm", ["run", "start:prod"], "Backend Server")
caddyProcess.on("exit", (code) => {

View File

@@ -17,7 +17,7 @@ services:
environment:
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=3170
- PORT=8080
volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app
@@ -26,6 +26,7 @@ services:
hoppscotch-db:
condition: service_healthy
ports:
- "3180:80"
- "3170:3170"
# The main hoppscotch app. This will be hosted at port 3000
@@ -42,7 +43,8 @@ services:
depends_on:
- hoppscotch-backend
ports:
- "3000:8080"
- "3080:80"
- "3000:3000"
# The Self Host dashboard for managing the app. This will be hosted at port 3100
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
@@ -58,7 +60,8 @@ services:
depends_on:
- hoppscotch-backend
ports:
- "3100:8080"
- "3280:80"
- "3100:3100"
# The service that spins up all 3 services at once in one container
hoppscotch-aio:
@@ -76,6 +79,7 @@ services:
- "3000:3000"
- "3100:3100"
- "3170:3170"
- "3080:80"
# The preset DB service, you can delete/comment the below lines if
# you are using an external postgres instance

View File

@@ -17,7 +17,7 @@
"types": "dist/index.d.ts",
"sideEffects": false,
"dependencies": {
"@codemirror/language": "^6.9.2",
"@codemirror/language": "6.9.0",
"@lezer/highlight": "1.1.4",
"@lezer/lr": "^1.3.13"
},

View File

@@ -0,0 +1,3 @@
:80 :3170 {
reverse_proxy localhost:8080
}

View File

@@ -1,6 +1,6 @@
{
"name": "hoppscotch-backend",
"version": "2023.8.3",
"version": "2023.8.4-1",
"description": "",
"author": "",
"private": true,

View File

@@ -0,0 +1,66 @@
#!/usr/local/bin/node
// @ts-check
import { spawn } from 'child_process';
import process from 'process';
function runChildProcessWithPrefix(command, args, prefix) {
const childProcess = spawn(command, args);
childProcess.stdout.on('data', (data) => {
const output = data.toString().trim().split('\n');
output.forEach((line) => {
console.log(`${prefix} | ${line}`);
});
});
childProcess.stderr.on('data', (data) => {
const error = data.toString().trim().split('\n');
error.forEach((line) => {
console.error(`${prefix} | ${line}`);
});
});
childProcess.on('close', (code) => {
console.log(`${prefix} Child process exited with code ${code}`);
});
childProcess.on('error', (stuff) => {
console.error('error');
console.error(stuff);
});
return childProcess;
}
const caddyProcess = runChildProcessWithPrefix(
'caddy',
['run', '--config', '/etc/caddy/backend.Caddyfile', '--adapter', 'caddyfile'],
'App/Admin Dashboard Caddy',
);
const backendProcess = runChildProcessWithPrefix(
'pnpm',
['run', 'start:prod'],
'Backend Server',
);
caddyProcess.on('exit', (code) => {
console.log(`Exiting process because Caddy Server exited with code ${code}`);
process.exit(code);
});
backendProcess.on('exit', (code) => {
console.log(
`Exiting process because Backend Server exited with code ${code}`,
);
process.exit(code);
});
process.on('SIGINT', () => {
console.log('SIGINT received, exiting...');
caddyProcess.kill('SIGINT');
backendProcess.kill('SIGINT');
process.exit(0);
});

View File

@@ -57,7 +57,7 @@ module.exports = {
{
name: "localStorage",
message:
"Do not use 'localStorage' directly. Please use localpersistence.ts functions or stores",
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
// window.localStorage block
@@ -66,7 +66,7 @@ module.exports = {
{
selector: "CallExpression[callee.object.property.name='localStorage']",
message:
"Do not use 'localStorage' directly. Please use localpersistence.ts functions or stores",
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
},

View File

@@ -1,5 +1,6 @@
{
"action": {
"add": "Add",
"autoscroll": "Autoscroll",
"cancel": "Cancel",
"choose_file": "Choose a file",
@@ -54,9 +55,28 @@
"new": "Add new",
"star": "Add star"
},
"cookies": {
"modal": {
"new_domain_name": "New domain name",
"set": "Set a cookie",
"cookie_string": "Cookie string",
"enter_cookie_string": "Enter cookie string",
"cookie_name": "Name",
"cookie_value": "Value",
"cookie_path": "Path",
"cookie_expires": "Expires",
"managed_tab": "Managed",
"raw_tab": "Raw",
"interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.",
"empty_domains": "Domain list is empty",
"empty_domain": "Domain is empty",
"no_cookies_in_domain": "No cookies set for this domain"
}
},
"app": {
"chat_with_us": "Chat with us",
"contact_us": "Contact us",
"cookies": "Cookies",
"copy": "Copy",
"copy_user_id": "Copy User Auth Token",
"developer_option": "Developer options",
@@ -119,7 +139,21 @@
"password": "Password",
"token": "Token",
"type": "Authorization Type",
"username": "Username"
"username": "Username",
"oauth": {
"token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed",
"something_went_wrong_on_token_generation": "Something went wrong on token generation",
"redirect_auth_server_returned_error": "Auth Server returned an error state",
"redirect_no_auth_code": "No Authorization Code present in the redirect",
"redirect_invalid_state": "Invalid State value present in the redirect",
"redirect_no_token_endpoint": "No Token Endpoint Defined",
"redirect_no_client_id": "No Client ID defined",
"redirect_no_client_secret": "No Client Secret Defined",
"redirect_no_code_verifier": "No Code Verifier Defined",
"redirect_auth_token_request_failed": "Request to get the auth token failed",
"redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token",
"something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect"
}
},
"collection": {
"created": "Collection created",
@@ -237,6 +271,7 @@
"error": {
"browser_support_sse": "This browser doesn't seems to have Server Sent Events support.",
"check_console_details": "Check console log for details.",
"check_how_to_add_origin": "Check how you can add an origin",
"curl_invalid_format": "cURL is not formatted properly",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
@@ -257,6 +292,7 @@
"no_environments_to_export": "No environments to export. Please create an environment to get started.",
"no_results_found": "No matches found",
"page_not_found": "This page could not be found",
"please_install_extension": "Please install the extension and add origin to the extension.",
"proxy_error": "Proxy error",
"script_fail": "Could not execute pre-request script",
"something_went_wrong": "Something went wrong",
@@ -296,9 +332,13 @@
"url": "URL"
},
"header": {
"install_pwa": "Install app",
"install_pwa": "Add to Home Screen",
"login": "Login",
"save_workspace": "Save My Workspace"
"save_workspace": "Save My Workspace",
"download_app": "Download app",
"menu": "Menu",
"go_back": "Go back",
"go_forward": "Go forward"
},
"helpers": {
"authorization": "The authorization header will be automatically generated when you send the request.",
@@ -461,7 +501,8 @@
"enter_curl": "Enter cURL command",
"generate_code": "Generate code",
"generated_code": "Generated code",
"go_to_authorization_tab": "Go to Authorization",
"go_to_authorization_tab": "Go to Authorization tab",
"go_to_body_tab": "Go to Body tab",
"header_list": "Header List",
"invalid_name": "Please provide a name for the request",
"method": "Method",
@@ -563,6 +604,7 @@
"use_experimental_url_bar": "Use experimental URL bar with environment highlighting",
"user": "User",
"verified_email": "Verified email",
"additional": "Additional Settings",
"verify_email": "Verify email"
},
"shortcodes": {
@@ -764,7 +806,7 @@
"published_error": "Something went wrong while publishing msg: {topic} to topic: {message}",
"published_message": "Published message: {message} to topic: {topic}",
"reconnection_error": "Failed to reconnect",
"show":"Show",
"show": "Show",
"subscribed_failed": "Failed to subscribe to topic: {topic}",
"subscribed_success": "Successfully subscribed to topic: {topic}",
"unsubscribed_failed": "Failed to unsubscribe from topic: {topic}",

View File

@@ -1,7 +1,7 @@
{
"name": "@hoppscotch/common",
"private": true,
"version": "2023.8.3",
"version": "2023.8.4-1",
"scripts": {
"dev": "pnpm exec npm-run-all -p -l dev:*",
"test": "vitest --run",
@@ -27,7 +27,7 @@
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "^6.9.2",
"@codemirror/language": "6.9.0",
"@codemirror/legacy-modes": "^6.3.3",
"@codemirror/lint": "^6.4.2",
"@codemirror/search": "^6.5.4",
@@ -52,6 +52,7 @@
"acorn-walk": "^8.2.0",
"axios": "^1.4.0",
"buffer": "^6.0.3",
"cookie-es": "^1.0.0",
"dioc": "workspace:^",
"esprima": "^4.0.1",
"events": "^3.3.0",
@@ -76,6 +77,8 @@
"process": "^0.11.10",
"qs": "^6.11.2",
"rxjs": "^7.8.1",
"set-cookie-parser": "^2.6.0",
"set-cookie-parser-es": "^1.0.5",
"socket.io-client-v2": "npm:socket.io-client@^2.4.0",
"socket.io-client-v3": "npm:socket.io-client@^3.1.3",
"socket.io-client-v4": "npm:socket.io-client@^4.4.1",
@@ -89,6 +92,7 @@
"url": "^0.11.1",
"util": "^0.12.5",
"uuid": "^9.0.0",
"verzod": "^0.2.0",
"vue": "^3.3.4",
"vue-i18n": "^9.2.2",
"vue-pdf-embed": "^1.1.6",
@@ -98,7 +102,8 @@
"wonka": "^6.3.4",
"workbox-window": "^7.0.0",
"xml-formatter": "^3.5.0",
"yargs-parser": "^21.1.1"
"yargs-parser": "^21.1.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
@@ -139,19 +144,19 @@
"eslint-plugin-vue": "^9.17.0",
"glob": "^10.3.10",
"npm-run-all": "^4.1.5",
"openapi-types": "^12.1.3",
"postcss": "^8.4.23",
"prettier-plugin-tailwindcss": "^0.5.6",
"tailwindcss": "^3.3.2",
"vite-plugin-fonts": "^0.6.0",
"openapi-types": "^12.1.3",
"rollup-plugin-polyfill-node": "^0.12.0",
"sass": "^1.66.0",
"tailwindcss": "^3.3.2",
"typescript": "^5.1.6",
"unplugin-fonts": "^1.0.3",
"unplugin-icons": "^0.16.5",
"unplugin-vue-components": "^0.25.1",
"vite": "^4.4.9",
"vite-plugin-checker": "^0.6.1",
"vite-plugin-fonts": "^0.6.0",
"vite-plugin-html-config": "^1.0.11",
"vite-plugin-inspect": "^0.7.38",
"vite-plugin-pages": "^0.31.0",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="156" height="32" fill="none"><rect width="156" height="32" fill="#000" rx="4"/><text xmlns="http://www.w3.org/2000/svg" x="50%" y="50%" fill="#fff" dominant-baseline="central" font-family="Helvetica,sans-serif" font-size="12" font-weight="bold" text-anchor="middle" text-rendering="geometricPrecision">▶ Run in Hoppscotch</text></svg>

After

Width:  |  Height:  |  Size: 386 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="156" height="32" fill="none"><rect width="156" height="32" fill="#fff" rx="4"/><text xmlns="http://www.w3.org/2000/svg" x="50%" y="50%" fill="#000" dominant-baseline="central" font-family="Helvetica,sans-serif" font-size="12" font-weight="bold" text-anchor="middle" text-rendering="geometricPrecision">▶ Run in Hoppscotch</text></svg>

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 926 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 624 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 KiB

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 178 KiB

View File

@@ -1 +1,50 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none"><path fill="#10B981" d="M0 0h512v512H0z"/><circle cx="197.76" cy="157.84" r="10" fill="#fff" fill-opacity=".75"/><circle cx="259.76" cy="161.84" r="12" fill="#fff" fill-opacity=".75"/><circle cx="319.76" cy="177.84" r="10" fill="#fff" fill-opacity=".75"/><path d="M344.963 235.676c2.075-12.698-38.872-29.804-90.967-38.094-52.09-8.296-96.404-4.665-98.48 8.033-.257 1.035 0 1.812.263 2.853-1.298-.521-76.714 211.212-76.714 211.212H364.14s-17.621-181.414-20.211-181.414c.515-.772 1.035-1.549 1.035-2.59Z" fill="url(#a)"/><path d="M314.902 227.386c-1.298 8.033-30.839 9.845-66.343 4.402-35.247-5.7-62.982-16.843-61.684-24.618.521-2.59 3.888-4.665 9.331-5.7-18.141.777-30.062 4.145-31.096 9.845-1.555 10.628 34.726 25.139 81.373 32.657 46.647 7.512 85.782 4.665 87.594-5.7 1.041-6.226-9.33-12.961-26.431-19.439 4.923 2.847 7.513 5.957 7.256 8.553Z" fill="#A7F3D0" fill-opacity=".5"/><path d="M333.557 157.413c-3.104-32.137-27.729-59.351-60.9-64.53-33.172-5.186-64.531 12.954-77.749 42.238 21.251 1.298 44.057 3.631 67.904 7.518 25.396 3.888 49.237 9.074 70.745 14.774Z" fill="url(#b)"/><path d="M74.142 158.002c-2.59 15.808 30.319 35.247 81.894 51.055-.257-1.04-.257-1.818-.257-2.853 2.07-12.698 46.127-16.328 98.48-8.032 52.347 8.29 93.037 25.396 90.961 38.094-.257 1.04-.514 1.818-1.035 2.589 53.645.778 90.968-7.512 93.557-23.32 3.625-24.104-74.638-56.498-174.93-72.306-100.555-15.808-185.045-9.331-188.67 14.773Zm115.586-1.298c.778-4.145 4.665-7.255 8.81-6.477 4.145.777 7.256 4.665 6.478 8.81-.52 4.145-4.665 6.998-8.81 6.478-4.145-.778-7.255-4.666-6.478-8.811Zm59.866 4.145c.777-5.7 6.22-9.587 11.92-8.547 5.7.778 9.588 6.215 8.553 11.921-1.041 5.442-6.478 9.33-11.92 8.553-5.706-.778-9.594-6.221-8.553-11.927Zm62.975 15.294c.778-4.145 4.665-7.255 8.81-6.478 4.145.778 7.255 4.666 6.478 8.811-.515 4.145-4.665 7.255-8.81 6.477-4.145-.777-7.256-4.665-6.478-8.81Z" fill="url(#c)"/><defs><radialGradient id="b" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 32.7063 -69.3245 0 264.232 124.706)"><stop stop-color="#047857"/><stop offset="1" stop-color="#064E3B"/></radialGradient><radialGradient id="c" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(255.837 186.754) scale(1389.61)"><stop stop-color="#047857"/><stop offset=".115" stop-color="#064E3B"/></radialGradient><linearGradient id="a" x1="224.998" y1="157.606" x2="224.998" y2="403.696" gradientUnits="userSpaceOnUse"><stop stop-color="#86EFAC" stop-opacity=".75"/><stop offset=".635" stop-color="#fff" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>
<svg width="824" height="824" viewBox="0 0 824 824" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="824" height="824" rx="184" fill="#08110F"/>
<rect width="824" height="824" rx="184" fill="url(#paint0_radial_0_21)" fill-opacity="0.5"/>
<path d="M435.425 463.217C429.441 476.657 411.033 481.515 394.309 474.07C377.585 466.624 368.879 449.693 374.863 436.253C380.846 422.813 399.254 417.954 415.978 425.4C432.702 432.846 441.409 449.777 435.425 463.217Z" fill="url(#paint1_linear_0_21)"/>
<path d="M435.425 463.217C429.441 476.657 411.033 481.515 394.309 474.07C377.585 466.624 368.879 449.693 374.863 436.253C380.846 422.813 399.254 417.954 415.978 425.4C432.702 432.846 441.409 449.777 435.425 463.217Z" fill="url(#paint2_radial_0_21)" style="mix-blend-mode:soft-light"/>
<path d="M535.563 521.172C553.071 526.191 570.536 518.856 574.571 504.789C578.606 490.722 567.684 475.251 550.175 470.232C532.666 465.213 515.201 472.548 511.166 486.615C507.131 500.682 518.054 516.153 535.563 521.172Z" fill="url(#paint3_linear_0_21)"/>
<path d="M535.563 521.172C553.071 526.191 570.536 518.856 574.571 504.789C578.606 490.722 567.684 475.251 550.175 470.232C532.666 465.213 515.201 472.548 511.166 486.615C507.131 500.682 518.054 516.153 535.563 521.172Z" fill="url(#paint4_radial_0_21)" style="mix-blend-mode:soft-light"/>
<path d="M292.782 355.633C308.227 365.286 314.462 383.173 306.709 395.584C298.955 407.995 280.149 410.231 264.704 400.578C249.258 390.924 243.023 373.037 250.777 360.626C258.53 348.215 277.337 345.98 292.782 355.633Z" fill="url(#paint5_linear_0_21)"/>
<path d="M292.782 355.633C308.227 365.286 314.462 383.173 306.709 395.584C298.955 407.995 280.149 410.231 264.704 400.578C249.258 390.924 243.023 373.037 250.777 360.626C258.53 348.215 277.337 345.98 292.782 355.633Z" fill="url(#paint6_radial_0_21)" style="mix-blend-mode:soft-light"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M502.355 231.325C581.373 266.506 632.095 343.263 634.119 429.03C680.633 465.639 726.858 516.883 705.36 565.168C681.25 619.319 595.382 617.091 497.781 589.689C450.767 615.718 392.444 620.168 339.689 596.68C286.934 573.192 251.229 526.908 239.1 474.517C153.428 420.321 94.3151 357.999 118.425 303.847C139.923 255.562 208.935 255.626 267.265 265.697C332.356 209.81 423.338 196.144 502.355 231.325ZM159.38 322.082C147.667 348.389 210.578 423.052 382.845 499.751C555.111 576.449 652.693 573.241 664.405 546.934C674.099 525.16 634.213 483.308 588.537 450.878C553.009 425.484 504.344 397.494 440.864 369.231C423.586 361.538 416.839 341.008 424.104 324.691C431.369 308.374 447.329 297.463 480.93 295.91C496.747 295.862 498.823 291.476 499.546 287.716C500.442 281.915 492.401 276.002 484.108 272.31C418.17 242.953 337.453 255.265 281.503 314.178C226.84 301.933 169.074 300.309 159.38 322.082Z" fill="url(#paint7_linear_0_21)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M502.355 231.325C581.373 266.506 632.095 343.263 634.119 429.03C680.633 465.639 726.858 516.883 705.36 565.168C681.25 619.319 595.382 617.091 497.781 589.689C450.767 615.718 392.444 620.168 339.689 596.68C286.934 573.192 251.229 526.908 239.1 474.517C153.428 420.321 94.3151 357.999 118.425 303.847C139.923 255.562 208.935 255.626 267.265 265.697C332.356 209.81 423.338 196.144 502.355 231.325ZM159.38 322.082C147.667 348.389 210.578 423.052 382.845 499.751C555.111 576.449 652.693 573.241 664.405 546.934C674.099 525.16 634.213 483.308 588.537 450.878C553.009 425.484 504.344 397.494 440.864 369.231C423.586 361.538 416.839 341.008 424.104 324.691C431.369 308.374 447.329 297.463 480.93 295.91C496.747 295.862 498.823 291.476 499.546 287.716C500.442 281.915 492.401 276.002 484.108 272.31C418.17 242.953 337.453 255.265 281.503 314.178C226.84 301.933 169.074 300.309 159.38 322.082Z" fill="url(#paint8_radial_0_21)" style="mix-blend-mode:soft-light"/>
<defs>
<radialGradient id="paint0_radial_0_21" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(814.524 12.36) rotate(125.613) scale(1089.59 1210.34)">
<stop stop-color="#00D196" stop-opacity="0.5"/>
<stop offset="0.996771" stop-color="#00D196" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint1_linear_0_21" x1="411.893" y1="212" x2="411.893" y2="612" gradientUnits="userSpaceOnUse">
<stop stop-color="#00D196"/>
<stop offset="1" stop-color="#00B381"/>
</linearGradient>
<radialGradient id="paint2_radial_0_21" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(644.721 344.481) rotate(159.984) scale(631.37 385.135)">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint3_linear_0_21" x1="411.893" y1="212" x2="411.893" y2="612" gradientUnits="userSpaceOnUse">
<stop stop-color="#00D196"/>
<stop offset="1" stop-color="#00B381"/>
</linearGradient>
<radialGradient id="paint4_radial_0_21" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(644.721 344.481) rotate(159.984) scale(631.37 385.135)">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint5_linear_0_21" x1="411.893" y1="212" x2="411.893" y2="612" gradientUnits="userSpaceOnUse">
<stop stop-color="#00D196"/>
<stop offset="1" stop-color="#00B381"/>
</linearGradient>
<radialGradient id="paint6_radial_0_21" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(644.721 344.481) rotate(159.984) scale(631.37 385.135)">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint7_linear_0_21" x1="411.893" y1="212" x2="411.893" y2="612" gradientUnits="userSpaceOnUse">
<stop stop-color="#00D196"/>
<stop offset="1" stop-color="#00B381"/>
</linearGradient>
<radialGradient id="paint8_radial_0_21" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(644.721 344.481) rotate(159.984) scale(631.37 385.135)">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -5,7 +5,7 @@
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module "vue" {
declare module 'vue' {
export interface GlobalComponents {
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
@@ -59,6 +59,8 @@ declare module "vue" {
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']
Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
@@ -158,7 +160,7 @@ declare module "vue" {
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['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']
@@ -204,6 +206,7 @@ declare module "vue" {
SmartSlideOver: typeof import('./../../hoppscotch-ui/src/components/smart/SlideOver.vue')['default']
SmartSpinner: typeof import('./../../hoppscotch-ui/src/components/smart/Spinner.vue')['default']
SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default']
SmartTable: typeof import('./../../hoppscotch-ui/src/components/smart/Table.vue')['default']
SmartTabs: typeof import('./../../hoppscotch-ui/src/components/smart/Tabs.vue')['default']
SmartToggle: typeof import('./../../hoppscotch-ui/src/components/smart/Toggle.vue')['default']
SmartTree: typeof import('./../../hoppscotch-ui/src/components/smart/Tree.vue')['default']

View File

@@ -8,10 +8,10 @@
<span class="text-white">
<span v-if="banner.alternateText" class="md:hidden">
{{ banner.alternateText }}
{{ banner.alternateText(t) }}
</span>
<span class="<md:hidden">
{{ banner.text }}
<span :class="banner.alternateText ? '<md:hidden' : ''">
{{ banner.text(t) }}
</span>
</span>
</div>
@@ -19,8 +19,8 @@
<script setup lang="ts">
import { computed } from "vue"
import { BannerContent, BannerType } from "~/services/banner.service"
import { useI18n } from "@composables/i18n"
import IconAlertCircle from "~icons/lucide/alert-circle"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
@@ -30,6 +30,8 @@ const props = defineProps<{
banner: BannerContent
}>()
const t = useI18n()
const ariaRoles: Record<BannerType, string> = {
error: "alert",
warning: "status",

View File

@@ -20,6 +20,12 @@
<AppInterceptor />
</template>
</tippy>
<HoppButtonSecondary
v-if="platform.platformFeatureFlags.cookiesEnabled ?? false"
:label="t('app.cookies')"
:icon="IconCookie"
@click="showCookiesModal = true"
/>
</div>
<div class="flex">
<tippy
@@ -195,12 +201,17 @@
:show="showDeveloperOptions"
@hide-modal="showDeveloperOptions = false"
/>
<CookiesAllModal
:show="showCookiesModal"
@hide-modal="showCookiesModal = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { version } from "~/../package.json"
import IconCookie from "~icons/lucide/cookie"
import IconSidebar from "~icons/lucide/sidebar"
import IconZap from "~icons/lucide/zap"
import IconShare2 from "~icons/lucide/share-2"
@@ -223,7 +234,9 @@ import { invokeAction } from "@helpers/actions"
import { HoppSmartItem } from "@hoppscotch/ui"
const t = useI18n()
const showDeveloperOptions = ref(false)
const showCookiesModal = ref(false)
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
const SIDEBAR = useSetting("SIDEBAR")

View File

@@ -1,24 +1,50 @@
<template>
<div>
<header
class="flex flex-1 flex-shrink-0 items-center justify-between space-x-2 overflow-x-auto overflow-y-hidden px-2 py-2"
ref="headerRef"
class="grid grid-cols-5 grid-rows-1 gap-2 overflow-x-auto overflow-y-hidden p-2"
@mousedown.prevent="platform.ui?.appHeader?.onHeaderAreaClick?.()"
>
<div
class="inline-flex flex-1 items-center justify-start space-x-2"
class="col-span-2 flex items-center justify-between space-x-2"
:style="{
paddingTop: platform.ui?.appHeader?.paddingTop?.value,
paddingLeft: platform.ui?.appHeader?.paddingLeft?.value,
}"
>
<HoppButtonSecondary
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')"
to="/"
/>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('header.menu')"
:icon="IconMenu"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
/>
<HoppButtonSecondary
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')"
to="/"
/>
</div>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('header.go_back')"
:icon="IconArrowLeft"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="router.back()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('header.go_forward')"
:icon="IconArrowRight"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="router.forward()"
/>
</div>
</div>
<div class="inline-flex flex-1 items-center justify-center space-x-2">
<div class="col-span-1 flex items-center justify-between space-x-2">
<button
class="flex max-w-[15rem] flex-1 cursor-text items-center justify-between self-stretch rounded border border-dividerDark bg-primaryDark px-2 py-1 text-secondaryLight transition hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
class="flex h-full flex-1 cursor-text items-center justify-between rounded border border-dividerDark bg-primaryDark px-2 text-secondaryLight transition hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
@click="invokeAction('modals.search.toggle')"
>
<span class="inline-flex flex-1 items-center">
@@ -30,192 +56,224 @@
<kbd class="shortcut-key">K</kbd>
</span>
</button>
<HoppButtonSecondary
v-if="showInstallButton"
v-tippy="{ theme: 'tooltip' }"
:title="t('header.install_pwa')"
:icon="IconDownload"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="installPWA()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${
mdAndLarger ? t('support.title') : t('app.options')
} <kbd>?</kbd>`"
:icon="IconLifeBuoy"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.support.toggle')"
/>
</div>
<div class="inline-flex flex-1 items-center justify-end space-x-2">
<div
v-if="currentUser === null"
class="inline-flex items-center space-x-2"
>
<div class="col-span-2 flex items-center justify-between space-x-2">
<div class="flex">
<HoppButtonSecondary
:icon="IconUploadCloud"
:label="t('header.save_workspace')"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 hidden border border-green-600/25 bg-green-500/[.15] !text-green-500 hover:border-green-800/50 hover:bg-green-400/10 focus-visible:border-green-800/50 focus-visible:bg-green-400/10 md:flex"
@click="invokeAction('modals.login.toggle')"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${
mdAndLarger ? t('support.title') : t('app.options')
} <kbd>?</kbd>`"
:icon="IconLifeBuoy"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.support.toggle')"
/>
<HoppButtonPrimary
:label="t('header.login')"
@click="invokeAction('modals.login.toggle')"
/>
</div>
<div v-else class="inline-flex items-center space-x-2">
<TeamsMemberStack
v-if="
workspace.type === 'team' &&
selectedTeam &&
selectedTeam.teamMembers.length > 1
"
:team-members="selectedTeam.teamMembers"
show-count
class="mx-2"
@handle-click="handleTeamEdit()"
/>
<div
class="flex divide-x divide-green-600/25 rounded border border-green-600/25 bg-green-500/[.15] focus-within:divide-green-800/50 focus-within:border-green-800/50 focus-within:bg-green-400/10 hover:divide-green-800/50 hover:border-green-800/50 hover:bg-green-400/10"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('team.invite_tooltip')"
:icon="IconUserPlus"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 !text-green-500"
@click="handleInvite()"
/>
<HoppButtonSecondary
v-if="
workspace.type === 'team' &&
selectedTeam &&
selectedTeam?.myRole === 'OWNER'
"
v-tippy="{ theme: 'tooltip' }"
:title="t('team.edit')"
:icon="IconSettings"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 !text-green-500"
@click="handleTeamEdit()"
/>
</div>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => accountActions.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('workspace.change')"
:label="mdAndLarger ? workspaceName : ``"
:icon="workspace.type === 'personal' ? IconUser : IconUsers"
class="select-wrapper !focus-visible:text-blue-600 !hover:text-blue-600 rounded border border-blue-600/25 bg-blue-500/[.15] py-[0.4375rem] pr-8 !text-blue-500 hover:border-blue-800/50 hover:bg-blue-400/10 focus-visible:border-blue-800/50 focus-visible:bg-blue-400/10"
/>
<template #content="{ hide }">
<div
ref="accountActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
@click="hide()"
>
<WorkspaceSelector />
</div>
</template>
</tippy>
<span class="px-2">
<span>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
:on-shown="() => downloadActions.focus()"
>
<HoppSmartPicture
v-if="currentUser.photoURL"
v-tippy="{
theme: 'tooltip',
}"
:url="currentUser.photoURL"
:alt="
currentUser.displayName ||
t('profile.default_hopp_displayname')
"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
indicator
:indicator-styles="
network.isOnline ? 'bg-green-500' : 'bg-red-500'
"
/>
<HoppSmartPicture
v-else
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
:initial="currentUser.displayName || currentUser.email"
indicator
:indicator-styles="
network.isOnline ? 'bg-green-500' : 'bg-red-500'
"
:title="t('header.download_app')"
:icon="IconDownload"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
ref="downloadActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.p="profile.$el.click()"
@keyup.s="settings.$el.click()"
@keyup.l="logout.$el.click()"
@keyup.escape="hide()"
@click="hide()"
>
<div class="flex flex-col px-2 text-tiny">
<span class="inline-flex truncate font-semibold">
{{
currentUser.displayName ||
t("profile.default_hopp_displayname")
}}
</span>
<span class="inline-flex truncate text-secondaryLight">
{{ currentUser.email }}
</span>
</div>
<hr />
<HoppSmartItem
ref="profile"
to="/profile"
:icon="IconUser"
:label="t('navigation.profile')"
:shortcut="['P']"
@click="hide()"
:label="t('header.download_app')"
:icon="IconDownload"
/>
<HoppSmartItem
ref="settings"
to="/settings"
:icon="IconSettings"
:label="t('navigation.settings')"
:shortcut="['S']"
@click="hide()"
/>
<FirebaseLogout
ref="logout"
:shortcut="['L']"
@confirm-logout="hide()"
v-if="showInstallButton"
:label="t('header.install_pwa')"
:icon="IconPlusSquare"
@click="installPWA()"
/>
</div>
</template>
</tippy>
</span>
</div>
<div class="flex">
<div
v-if="currentUser === null"
class="inline-flex items-center space-x-2"
>
<HoppButtonSecondary
:icon="IconUploadCloud"
:label="t('header.save_workspace')"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 hidden h-8 border border-emerald-600/25 bg-emerald-500/10 !text-emerald-500 hover:border-emerald-600/20 hover:bg-emerald-600/20 focus-visible:border-emerald-600/20 focus-visible:bg-emerald-600/20 md:flex"
@click="invokeAction('modals.login.toggle')"
/>
<HoppButtonPrimary
:label="t('header.login')"
class="h-8"
@click="invokeAction('modals.login.toggle')"
/>
</div>
<div v-else class="inline-flex items-center space-x-2">
<TeamsMemberStack
v-if="
workspace.type === 'team' &&
selectedTeam &&
selectedTeam.teamMembers.length > 1
"
:team-members="selectedTeam.teamMembers"
show-count
class="mx-2"
@handle-click="handleTeamEdit()"
/>
<div
class="flex h-8 divide-x divide-emerald-600/25 rounded border border-emerald-600/25 bg-emerald-500/10 focus-within:divide-emerald-600/20 focus-within:border-emerald-600/20 focus-within:bg-emerald-600/20 hover:divide-emerald-600/20 hover:border-emerald-600/20 hover:bg-emerald-600/20"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('team.invite_tooltip')"
:icon="IconUserPlus"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 !text-emerald-500"
@click="handleInvite()"
/>
<HoppButtonSecondary
v-if="
workspace.type === 'team' &&
selectedTeam &&
selectedTeam?.myRole === 'OWNER'
"
v-tippy="{ theme: 'tooltip' }"
:title="t('team.edit')"
:icon="IconSettings"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 !text-emerald-500"
@click="handleTeamEdit()"
/>
</div>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => accountActions.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('workspace.change')"
:label="mdAndLarger ? workspaceName : ``"
:icon="workspace.type === 'personal' ? IconUser : IconUsers"
class="select-wrapper !focus-visible:text-blue-600 !hover:text-blue-600 h-8 rounded border border-blue-600/25 bg-blue-500/10 pr-8 !text-blue-500 hover:border-blue-600/20 hover:bg-blue-600/20 focus-visible:border-blue-600/20 focus-visible:bg-blue-600/20"
/>
<template #content="{ hide }">
<div
ref="accountActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
@click="hide()"
>
<WorkspaceSelector />
</div>
</template>
</tippy>
<span class="px-2">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<HoppSmartPicture
v-if="currentUser.photoURL"
v-tippy="{
theme: 'tooltip',
}"
:url="currentUser.photoURL"
:alt="
currentUser.displayName ||
t('profile.default_hopp_displayname')
"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
indicator
:indicator-styles="
network.isOnline ? 'bg-emerald-500' : 'bg-red-500'
"
/>
<HoppSmartPicture
v-else
v-tippy="{ theme: 'tooltip' }"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
:initial="currentUser.displayName || currentUser.email"
indicator
:indicator-styles="
network.isOnline ? 'bg-emerald-500' : 'bg-red-500'
"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.p="profile.$el.click()"
@keyup.s="settings.$el.click()"
@keyup.l="logout.$el.click()"
@keyup.escape="hide()"
>
<div class="flex flex-col px-2 text-tiny">
<span class="inline-flex truncate font-semibold">
{{
currentUser.displayName ||
t("profile.default_hopp_displayname")
}}
</span>
<span class="inline-flex truncate text-secondaryLight">
{{ currentUser.email }}
</span>
</div>
<hr />
<HoppSmartItem
ref="profile"
to="/profile"
:icon="IconUser"
:label="t('navigation.profile')"
:shortcut="['P']"
@click="hide()"
/>
<HoppSmartItem
ref="settings"
to="/settings"
:icon="IconSettings"
:label="t('navigation.settings')"
:shortcut="['S']"
@click="hide()"
/>
<FirebaseLogout
ref="logout"
:shortcut="['L']"
@confirm-logout="hide()"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
</div>
</header>
<AppBanner v-if="banner" :banner="banner" />
<AppBanner v-if="bannerContent" :banner="bannerContent" />
<TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" />
<TeamsInvite
v-if="workspace.type === 'team' && workspace.teamID"
@@ -231,7 +289,6 @@
@invite-team="inviteTeam(editingTeamName, editingTeamID)"
@refetch-teams="refetchTeams"
/>
<HoppSmartConfirmModal
:show="confirmRemove"
:title="t('confirm.remove_team')"
@@ -261,13 +318,23 @@ import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUser from "~icons/lucide/user"
import IconUserPlus from "~icons/lucide/user-plus"
import IconUsers from "~icons/lucide/users"
import IconPlusSquare from "~icons/lucide/plus-square"
import IconArrowLeft from "~icons/lucide/arrow-left"
import IconArrowRight from "~icons/lucide/arrow-right"
import IconMenu from "~icons/lucide/align-left"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { deleteTeam as backendDeleteTeam } from "~/helpers/backend/mutations/Team"
import { BannerService } from "~/services/banner.service"
import {
BannerService,
BannerContent,
BANNER_PRIORITY_HIGH,
} from "~/services/banner.service"
import { useRouter } from "vue-router"
const t = useI18n()
const toast = useToast()
const router = useRouter()
/**
* Once the PWA code is initialized, this holds a method
@@ -282,18 +349,29 @@ const showTeamsModal = ref(false)
const breakpoints = useBreakpoints(breakpointsTailwind)
const mdAndLarger = breakpoints.greater("md")
const { content: banner } = useService(BannerService)
const network = reactive(useNetwork())
const banner = useService(BannerService)
const bannerContent = computed(() => banner.content.value?.content)
let bannerID: number | null = null
watch(network, () => {
if (network.isOnline) {
banner.value = null
const offlineBanner: BannerContent = {
type: "info",
text: (t) => t("helpers.offline"),
alternateText: (t) => t("helpers.offline_short"),
score: BANNER_PRIORITY_HIGH,
}
const network = reactive(useNetwork())
const isOnline = computed(() => network.isOnline)
// Show the offline banner if the user is offline
watch(isOnline, () => {
if (!isOnline.value) {
bannerID = banner.showBanner(offlineBanner)
return
}
banner.value = {
type: "info",
text: t("helpers.offline"),
alternateText: t("helpers.offline_short"),
} else {
if (banner.content && bannerID) {
banner.removeBanner(bannerID)
}
}
})
@@ -428,6 +506,7 @@ const profile = ref<any | null>(null)
const settings = ref<any | null>(null)
const logout = ref<any | null>(null)
const accountActions = ref<any | null>(null)
const downloadActions = ref<any | null>(null)
defineActionHandler("modals.team.edit", handleTeamEdit)

View File

@@ -47,14 +47,15 @@
</template>
<script setup lang="ts">
import { Splitpanes, Pane } from "splitpanes"
import { Pane, Splitpanes } from "splitpanes"
import "splitpanes/dist/splitpanes.css"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { computed, useSlots, ref } from "vue"
import { useSetting } from "@composables/settings"
import { setLocalConfig, getLocalConfig } from "~/newstore/localpersistence"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { useService } from "dioc/vue"
import { computed, ref, useSlots } from "vue"
import { PersistenceService } from "~/services/persistence"
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
@@ -67,6 +68,8 @@ const SIDEBAR = useSetting("SIDEBAR")
const slots = useSlots()
const persistenceService = useService(PersistenceService)
const hasSidebar = computed(() => !!slots.sidebar)
const hasSecondary = computed(() => !!slots.secondary)
@@ -96,7 +99,7 @@ if (!COLUMN_LAYOUT.value) {
function setPaneEvent(event: PaneEvent[], type: "vertical" | "horizontal") {
if (!props.layoutId) return
const storageKey = `${props.layoutId}-pane-config-${type}`
setLocalConfig(storageKey, JSON.stringify(event))
persistenceService.setLocalConfig(storageKey, JSON.stringify(event))
}
function populatePaneEvent() {
@@ -119,7 +122,7 @@ function populatePaneEvent() {
function getPaneData(type: "vertical" | "horizontal"): PaneEvent[] | null {
const storageKey = `${props.layoutId}-pane-config-${type}`
const paneEvent = getLocalConfig(storageKey)
const paneEvent = persistenceService.getLocalConfig(storageKey)
if (!paneEvent) return null
return JSON.parse(paneEvent)
}

View File

@@ -258,7 +258,7 @@ const importFromJSON = () => {
inputChooseFileToImportFrom.value.value = ""
}
const exportJSON = () => {
const exportJSON = async () => {
const dataToWrite = collectionJson.value
const parsedCollections = JSON.parse(dataToWrite)
@@ -268,24 +268,32 @@ const exportJSON = () => {
}
const file = new Blob([dataToWrite], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
platform?.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "gql",
const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/json",
suggestedFilename: filename,
filters: [
{
name: "Hoppscotch Collection JSON file",
extensions: ["json"],
},
],
})
// TODO: get uri from meta
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
document.body.appendChild(a)
a.click()
toast.success(t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
if (result.type === "unknown" || result.type === "saved") {
platform?.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "gql",
})
toast.success(t("state.download_started").toString())
}
}
</script>

View File

@@ -1866,28 +1866,25 @@ const getJSONCollection = async () => {
* @param collectionJSON - JSON string of the collection
* @param name - Name of the collection set as the file name
*/
const initializeDownloadCollection = (
const initializeDownloadCollection = async (
collectionJSON: string,
name: string | null
) => {
const file = new Blob([collectionJSON], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
const result = await platform.io.saveFileWithDialog({
data: collectionJSON,
contentType: "application/json",
suggestedFilename: `${name ?? "collection"}.json`,
filters: [
{
name: "Hoppscotch Collection JSON file",
extensions: ["json"],
},
],
})
if (name) {
a.download = `${name}.json`
} else {
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
if (result.type === "unknown" || result.type === "saved") {
toast.success(t("state.download_started").toString())
}
document.body.appendChild(a)
a.click()
toast.success(t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
}
/**
@@ -1916,11 +1913,14 @@ const exportData = async (
exportLoading.value = false
return
},
(coll) => {
async (coll) => {
const hoppColl = teamCollToHoppRESTColl(coll)
const collectionJSONString = JSON.stringify(hoppColl)
initializeDownloadCollection(collectionJSONString, hoppColl.name)
await initializeDownloadCollection(
collectionJSONString,
hoppColl.name
)
exportLoading.value = false
}
)

View File

@@ -0,0 +1,269 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('app.cookies')"
aria-modal="true"
@close="hideModal"
>
<template #body>
<HoppSmartPlaceholder
v-if="!currentInterceptorSupportsCookies"
:text="t('cookies.modal.interceptor_no_support')"
>
<AppInterceptor class="rounded border border-dividerLight p-2" />
</HoppSmartPlaceholder>
<div v-else class="flex flex-col">
<div
class="sticky -mx-4 -mt-4 flex space-x-2 border-b border-dividerLight bg-primary px-4 py-4"
style="top: calc(-1 * var(--line-height-body))"
>
<HoppSmartInput
v-model="newDomainText"
class="flex-1"
:placeholder="t('cookies.modal.new_domain_name')"
@keyup.enter="addNewDomain"
/>
<HoppButtonSecondary
outline
filled
:label="t('action.add')"
@click="addNewDomain"
/>
</div>
<div class="flex flex-col space-y-4">
<HoppSmartPlaceholder
v-if="workingCookieJar.size === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('cookies.modal.empty_domains')}`"
:text="t('cookies.modal.empty_domains')"
class="mt-6"
>
</HoppSmartPlaceholder>
<div
v-for="[domain, entries] in workingCookieJar.entries()"
v-else
:key="domain"
class="flex flex-col"
>
<div class="flex flex-1 items-center justify-between">
<label for="cookiesList" class="p-4">
{{ domain }}
</label>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.delete')"
:icon="IconTrash2"
@click="deleteDomain(domain)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('add.new')"
:icon="IconPlus"
@click="addCookieToDomain(domain)"
/>
</div>
</div>
<div class="rounded border border-divider">
<div class="divide-y divide-dividerLight">
<div
v-if="entries.length === 0"
class="flex flex-col items-center gap-2 p-4"
>
{{ t("cookies.modal.no_cookies_in_domain") }}
</div>
<template v-else>
<div
v-for="(entry, entryIndex) in entries"
:key="`${entry}-${entryIndex}`"
class="flex divide-x divide-dividerLight"
>
<input
class="flex flex-1 bg-transparent px-4 py-2"
:value="entry"
readonly
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.edit')"
:icon="IconEdit"
@click="editCookie(domain, entryIndex, entry)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="deleteCookie(domain, entryIndex)"
/>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-if="currentInterceptorSupportsCookies" #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
v-focus
:label="t('action.save')"
outline
@click="saveCookieChanges"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="cancelCookieChanges"
/>
</span>
<HoppButtonSecondary
:label="t('action.clear_all')"
outline
filled
@click="clearAllDomains"
/>
</template>
</HoppSmartModal>
<CookiesEditCookie
:show="!!showEditModalFor"
:entry="showEditModalFor"
@save-cookie="saveCookie"
@hide-modal="showEditModalFor = null"
/>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useService } from "dioc/vue"
import { CookieJarService } from "~/services/cookie-jar.service"
import IconTrash from "~icons/lucide/trash"
import IconEdit from "~icons/lucide/edit"
import IconTrash2 from "~icons/lucide/trash-2"
import IconPlus from "~icons/lucide/plus"
import { cloneDeep } from "lodash-es"
import { ref, watch, computed } from "vue"
import { InterceptorService } from "~/services/interceptor.service"
import { EditCookieConfig } from "./EditCookie.vue"
import { useColorMode } from "@composables/theming"
import { useToast } from "@composables/toast"
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const t = useI18n()
const colorMode = useColorMode()
const toast = useToast()
const newDomainText = ref("")
const interceptorService = useService(InterceptorService)
const cookieJarService = useService(CookieJarService)
const workingCookieJar = ref(cloneDeep(cookieJarService.cookieJar.value))
const currentInterceptorSupportsCookies = computed(() => {
const currentInterceptor = interceptorService.currentInterceptor.value
if (!currentInterceptor) return true
return currentInterceptor.supportsCookies ?? false
})
function addNewDomain() {
if (newDomainText.value === "" || /^\s+$/.test(newDomainText.value)) {
toast.error(`${t("cookies.modal.empty_domain")}`)
return
}
workingCookieJar.value.set(newDomainText.value, [])
newDomainText.value = ""
}
function deleteDomain(domain: string) {
workingCookieJar.value.delete(domain)
}
function addCookieToDomain(domain: string) {
showEditModalFor.value = { type: "create", domain }
}
function clearAllDomains() {
workingCookieJar.value = new Map()
toast.success(`${t("state.cleared")}`)
}
watch(
() => props.show,
(show) => {
if (show) {
workingCookieJar.value = cloneDeep(cookieJarService.cookieJar.value)
}
}
)
const showEditModalFor = ref<EditCookieConfig | null>(null)
function saveCookieChanges() {
cookieJarService.cookieJar.value = workingCookieJar.value
hideModal()
}
function cancelCookieChanges() {
hideModal()
}
function editCookie(domain: string, entryIndex: number, cookieEntry: string) {
showEditModalFor.value = {
type: "edit",
domain,
entryIndex,
currentCookieEntry: cookieEntry,
}
}
function deleteCookie(domain: string, entryIndex: number) {
const entry = workingCookieJar.value.get(domain)
if (entry) {
entry.splice(entryIndex, 1)
}
}
function saveCookie(cookie: string) {
if (showEditModalFor.value?.type === "create") {
const { domain } = showEditModalFor.value
const entry = workingCookieJar.value.get(domain)!
entry.push(cookie)
showEditModalFor.value = null
return
}
if (showEditModalFor.value?.type !== "edit") return
const { domain, entryIndex } = showEditModalFor.value!
const entry = workingCookieJar.value.get(domain)
if (entry) {
entry[entryIndex] = cookie
}
showEditModalFor.value = null
}
const hideModal = () => {
emit("hide-modal")
}
</script>

View File

@@ -0,0 +1,195 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('cookies.modal.set')"
@close="hideModal"
>
<template #body>
<div class="rounded border border-dividerLight">
<div class="flex flex-col">
<div class="flex items-center justify-between pl-4">
<label class="truncate font-semibold text-secondaryLight">
{{ t("cookies.modal.cookie_string") }}
</label>
<div class="flex items-center">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="IconTrash2"
@click="clearContent()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="t('action.download_file')"
:icon="downloadIcon"
@click="downloadResponse"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="t('action.copy')"
:icon="copyIcon"
@click="copyResponse"
/>
</div>
</div>
<div class="h-46">
<div
ref="cookieEditor"
class="h-full rounded-b border-t border-dividerLight"
></div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex space-x-2">
<HoppButtonPrimary
v-focus
:label="t('action.save')"
outline
@click="saveCookieChange"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="cancelCookieChange"
/>
</div>
<span class="flex">
<HoppButtonSecondary
:icon="pasteIcon"
:label="`${t('action.paste')}`"
filled
outline
@click="handlePaste"
/>
</span>
</template>
</HoppSmartModal>
</template>
<script lang="ts">
export type EditCookieConfig =
| { type: "create"; domain: string }
| {
type: "edit"
domain: string
entryIndex: number
currentCookieEntry: string
}
</script>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useCodemirror } from "~/composables/codemirror"
import { watch, ref, reactive } from "vue"
import { refAutoReset } from "@vueuse/core"
import IconWrapText from "~icons/lucide/wrap-text"
import IconClipboard from "~icons/lucide/clipboard"
import IconCheck from "~icons/lucide/check"
import IconTrash2 from "~icons/lucide/trash-2"
import { useToast } from "~/composables/toast"
import {
useCopyResponse,
useDownloadResponse,
} from "~/composables/lens-actions"
// TODO: Build Managed Mode!
const props = defineProps<{
show: boolean
entry: EditCookieConfig | null
}>()
const emit = defineEmits<{
(e: "save-cookie", cookie: string): void
(e: "hide-modal"): void
}>()
const t = useI18n()
const toast = useToast()
const cookieEditor = ref<HTMLElement>()
const rawCookieString = ref("")
const linewrapEnabled = ref(true)
useCodemirror(
cookieEditor,
rawCookieString,
reactive({
extendedEditorConfig: {
mode: "text/plain",
placeholder: `${t("cookies.modal.enter_cookie_string")}`,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,
environmentHighlights: false,
})
)
const pasteIcon = refAutoReset<typeof IconClipboard | typeof IconCheck>(
IconClipboard,
1000
)
watch(
() => props.entry,
() => {
if (!props.entry) return
if (props.entry.type === "create") {
rawCookieString.value = ""
return
}
rawCookieString.value = props.entry.currentCookieEntry
}
)
function hideModal() {
emit("hide-modal")
}
function cancelCookieChange() {
hideModal()
}
async function handlePaste() {
try {
const text = await navigator.clipboard.readText()
if (text) {
rawCookieString.value = text
pasteIcon.value = IconCheck
}
} catch (e) {
console.error("Failed to copy: ", e)
toast.error(t("profile.no_permission").toString())
}
}
function saveCookieChange() {
emit("save-cookie", rawCookieString.value)
}
const { copyIcon, copyResponse } = useCopyResponse(rawCookieString)
const { downloadIcon, downloadResponse } = useDownloadResponse(
"",
rawCookieString
)
function clearContent() {
rawCookieString.value = ""
}
</script>

View File

@@ -375,7 +375,7 @@ const importFromPostman = ({
importFromHoppscotch(environments)
}
const exportJSON = () => {
const exportJSON = async () => {
const dataToWrite = environmentJson.value
const parsedCollections = JSON.parse(dataToWrite)
@@ -385,19 +385,27 @@ const exportJSON = () => {
}
const file = new Blob([dataToWrite], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
// TODO: get uri from meta
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
document.body.appendChild(a)
a.click()
toast.success(t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/json",
suggestedFilename: filename,
filters: [
{
name: "JSON file",
extensions: ["json"],
},
],
})
if (result.type === "unknown" || result.type === "saved") {
toast.success(t("state.download_started").toString())
}
}
const getErrorMessage = (err: GQLError<string>) => {

View File

@@ -111,20 +111,21 @@
<script setup lang="ts">
import { Ref, computed, onMounted, ref } from "vue"
import { useI18n } from "@composables/i18n"
import { useStreamSubscriber } from "@composables/stream"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { platform } from "~/platform"
import { setLocalConfig } from "~/newstore/localpersistence"
import IconEmail from "~icons/auth/email"
import IconGithub from "~icons/auth/github"
import IconGoogle from "~icons/auth/google"
import IconEmail from "~icons/auth/email"
import IconMicrosoft from "~icons/auth/microsoft"
import IconArrowLeft from "~icons/lucide/arrow-left"
import { useService } from "dioc/vue"
import { LoginItemDef } from "~/platform/auth"
import { PersistenceService } from "~/services/persistence"
defineProps<{
show: boolean
@@ -138,6 +139,8 @@ const { subscribeToStream } = useStreamSubscriber()
const t = useI18n()
const toast = useToast()
const persistenceService = useService(PersistenceService)
const form = {
email: "",
}
@@ -260,7 +263,7 @@ const signInWithEmail = async () => {
.signInWithEmail(form.email)
.then(() => {
mode.value = "email-sent"
setLocalConfig("emailForSignIn", form.email)
persistenceService.setLocalConfig("emailForSignIn", form.email)
})
.catch((e) => {
console.error(e)

View File

@@ -1,23 +1,34 @@
<template>
<div>
<div class="field-title" :class="{ 'field-highlighted': isHighlighted }">
{{ fieldName }}
<span v-if="fieldArgs.length > 0">
(
<span v-for="(field, index) in fieldArgs" :key="`field-${index}`">
{{ field.name }}:
<GraphqlTypeLink
:gql-type="field.type"
:jump-type-callback="jumpTypeCallback"
/>
<span v-if="index !== fieldArgs.length - 1">, </span>
<div class="flex justify-between gap-2">
<div
class="field-title flex-1"
:class="{ 'field-highlighted': isHighlighted }"
>
{{ fieldName }}
<span v-if="fieldArgs.length > 0">
(
<span v-for="(field, index) in fieldArgs" :key="`field-${index}`">
{{ field.name }}:
<GraphqlTypeLink
:gql-type="field.type"
@jump-to-type="jumpToType"
/>
<span v-if="index !== fieldArgs.length - 1">, </span>
</span>
) </span
>:
<GraphqlTypeLink :gql-type="gqlField.type" @jump-to-type="jumpToType" />
</div>
<div v-if="gqlField.deprecationReason">
<span
v-tippy="{ theme: 'tomato' }"
class="flex cursor-pointer items-center gap-2 text-xs !text-red-500 hover:!text-red-600"
:title="gqlField.deprecationReason"
>
<IconAlertTriangle /> {{ t("state.deprecated") }}
</span>
) </span
>:
<GraphqlTypeLink
:gql-type="gqlField.type"
:jump-type-callback="jumpTypeCallback"
/>
</div>
</div>
<div
v-if="gqlField.description"
@@ -25,12 +36,6 @@
>
{{ gqlField.description }}
</div>
<div
v-if="gqlField.isDeprecated"
class="field-deprecated my-1 inline-block rounded bg-yellow-200 px-2 py-1 text-black"
>
{{ t("state.deprecated") }}
</div>
<div v-if="fieldArgs.length > 0">
<h5 class="my-2">Arguments:</h5>
<div class="border-l-2 border-divider pl-4">
@@ -39,7 +44,7 @@
{{ field.name }}:
<GraphqlTypeLink
:gql-type="field.type"
:jump-type-callback="jumpTypeCallback"
@jump-to-type="jumpToType"
/>
</span>
<div
@@ -54,32 +59,36 @@
</div>
</template>
<script>
// TypeScript + Script Setup this :)
import { defineComponent } from "vue"
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { GraphQLType } from "graphql"
import { computed } from "vue"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
export default defineComponent({
props: {
gqlField: { type: Object, default: () => ({}) },
jumpTypeCallback: { type: Function, default: () => ({}) },
isHighlighted: { type: Boolean, default: false },
},
setup() {
return {
t: useI18n(),
}
},
computed: {
fieldName() {
return this.gqlField.name
},
const t = useI18n()
fieldArgs() {
return this.gqlField.args || []
},
},
})
const props = withDefaults(
defineProps<{
gqlField: any
isHighlighted: boolean
}>(),
{
gqlField: {},
isHighlighted: false,
}
)
const emit = defineEmits<{
(e: "jump-to-type", type: GraphQLType): void
}>()
const fieldName = computed(() => props.gqlField.name)
const fieldArgs = computed(() => props.gqlField.args || [])
const jumpToType = (type: GraphQLType) => {
emit("jump-to-type", type)
}
</script>
<style lang="scss" scoped>

View File

@@ -69,9 +69,9 @@
: null,
}"
:icon="IconGripVertical"
class="cursor-auto text-primary hover:text-primary"
class="opacity-0"
:class="{
'draggable-handle !cursor-grab group-hover:text-secondaryLight':
'draggable-handle cursor-grab group-hover:opacity-100':
index !== workingHeaders?.length - 1,
}"
tabindex="-1"

View File

@@ -51,7 +51,7 @@
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { completePageProgress, startPageProgress } from "@modules/loadingbar"
import { completePageProgress, startPageProgress } from "~/modules/loadingbar"
import * as gql from "graphql"
import { clone } from "lodash-es"
import { computed, ref, watch } from "vue"
@@ -63,6 +63,7 @@ import {
GQLResponseEvent,
runGQLOperation,
gqlMessageEvent,
connection,
} from "~/helpers/graphql/connection"
import { useService } from "dioc/vue"
import { InterceptorService } from "~/services/interceptor.service"
@@ -152,13 +153,7 @@ const runQuery = async (
toast.success(t("authorization.graphql_headers"))
}
} catch (e: any) {
console.log(e)
// response.value = [`${e}`]
completePageProgress()
toast.error(
`${t("error.something_went_wrong")}. ${t("error.check_console_details")}`,
{}
)
console.error(e)
}
platform.analytics?.logEvent({
@@ -177,7 +172,10 @@ watch(
}
try {
if (event?.operationType !== "subscription") {
if (
event?.type === "response" &&
event?.operationType !== "subscription"
) {
// response.value = [event]
emit("update:response", [event])
} else {
@@ -192,6 +190,26 @@ watch(
{ deep: true }
)
watch(
() => connection,
(newVal) => {
if (newVal.error && newVal.state === "DISCONNECTED") {
const response = [
{
type: "error",
error: {
message: newVal.error.message(t),
type: newVal.error.type,
component: newVal.error.component,
},
},
]
emit("update:response", response)
}
},
{ deep: true }
)
const hideRequestModal = () => {
showSaveRequestModal.value = false
}

View File

@@ -1,6 +1,11 @@
<template>
<div class="flex flex-1 flex-col overflow-auto whitespace-nowrap">
<div v-if="response?.length === 1" class="flex flex-1 flex-col">
<div
v-if="
response && response.length === 1 && response[0].type === 'response'
"
class="flex flex-1 flex-col"
>
<div
class="sticky top-0 z-10 flex flex-shrink-0 items-center justify-between overflow-x-auto border-b border-dividerLight bg-primary pl-4"
>
@@ -35,6 +40,13 @@
</div>
<div ref="schemaEditor" class="flex flex-1 flex-col"></div>
</div>
<component
:is="response[0].error.component"
v-else-if="
response && response[0].type === 'error' && response[0].error.component
"
class="flex-1"
/>
<div
v-else-if="response && response?.length > 1"
class="flex flex-1 flex-col"
@@ -59,6 +71,7 @@ import { useToast } from "@composables/toast"
import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { GQLResponseEvent } from "~/helpers/graphql/connection"
import { platform } from "~/platform"
const t = useI18n()
const toast = useToast()
@@ -73,8 +86,16 @@ const props = withDefaults(
)
const responseString = computed(() => {
if (props.response?.length === 1) {
return JSON.stringify(JSON.parse(props.response[0].data), null, 2)
const response = props.response
if (response && response[0].type === "error") {
return ""
} else if (
response &&
response.length === 1 &&
response[0].type === "response" &&
response[0].data
) {
return JSON.stringify(JSON.parse(response[0].data), null, 2)
}
return ""
})
@@ -111,21 +132,31 @@ const copyResponse = (str: string) => {
toast.success(`${t("state.copied_to_clipboard")}`)
}
const downloadResponse = (str: string) => {
const downloadResponse = async (str: string) => {
const dataToWrite = str
const file = new Blob([dataToWrite!], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}`
document.body.appendChild(a)
a.click()
downloadResponseIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/json",
suggestedFilename: filename,
filters: [
{
name: "JSON file",
extensions: ["json"],
},
],
})
if (result.type === "unknown" || result.type === "saved") {
downloadResponseIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
}
}
defineActionHandler(

View File

@@ -58,8 +58,8 @@
v-for="(field, index) in filteredQueryFields"
:key="`field-${index}`"
:gql-field="field"
:jump-type-callback="handleJumpToType"
class="p-4"
@jump-to-type="handleJumpToType"
/>
</HoppSmartTab>
<HoppSmartTab
@@ -72,8 +72,8 @@
v-for="(field, index) in filteredMutationFields"
:key="`field-${index}`"
:gql-field="field"
:jump-type-callback="handleJumpToType"
class="p-4"
@jump-to-type="handleJumpToType"
/>
</HoppSmartTab>
<HoppSmartTab
@@ -86,8 +86,8 @@
v-for="(field, index) in filteredSubscriptionFields"
:key="`field-${index}`"
:gql-field="field"
:jump-type-callback="handleJumpToType"
class="p-4"
@jump-to-type="handleJumpToType"
/>
</HoppSmartTab>
<HoppSmartTab
@@ -103,7 +103,7 @@
:gql-types="graphqlTypes"
:is-highlighted="isGqlTypeHighlighted(type)"
:highlighted-fields="getGqlTypeHighlightedFields(type)"
:jump-type-callback="handleJumpToType"
@jump-to-type="handleJumpToType"
/>
</HoppSmartTab>
</HoppSmartTabs>
@@ -202,6 +202,7 @@ import {
schemaString,
subscriptionFields,
} from "~/helpers/graphql/connection"
import { platform } from "~/platform"
type NavigationTabs = "history" | "collection" | "docs" | "schema"
type GqlTabs = "queries" | "mutations" | "subscriptions" | "types"
@@ -372,21 +373,33 @@ useCodemirror(
})
)
const downloadSchema = () => {
const dataToWrite = JSON.stringify(schemaString.value, null, 2)
const downloadSchema = async () => {
const dataToWrite = schemaString.value
const file = new Blob([dataToWrite], { type: "application/graphql" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.graphql`
document.body.appendChild(a)
a.click()
downloadSchemaIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
const filename = `${
url.split("/").pop()!.split("#")[0].split("?")[0]
}.graphql`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/graphql",
suggestedFilename: filename,
filters: [
{
name: "GraphQL Schema File",
extensions: ["graphql"],
},
],
})
if (result.type === "unknown" || result.type === "saved") {
downloadSchemaIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
}
}
const copySchema = () => {

View File

@@ -49,7 +49,11 @@
v-for="(entry, index) in log"
:key="`entry-${index}`"
:is-open="log.length - 1 === index"
:entry="{ ts: entry.time, source: 'info', payload: entry.data }"
:entry="{
ts: entry.type === 'response' ? entry.time : undefined,
source: 'info',
payload: entry.type === 'response' ? entry.data : '',
}"
/>
</div>
</div>

View File

@@ -7,38 +7,31 @@
</span>
</template>
<script lang="ts">
import { defineComponent } from "vue"
import { GraphQLScalarType } from "graphql"
<script setup lang="ts">
import { GraphQLScalarType, GraphQLType } from "graphql"
import { computed } from "vue"
export default defineComponent({
props: {
// eslint-disable-next-line vue/require-default-prop
gqlType: null,
// (typeName: string) => void
// eslint-disable-next-line vue/require-default-prop
jumpTypeCallback: Function,
},
const props = defineProps<{
gqlType: GraphQLType
}>()
computed: {
typeString() {
return `${this.gqlType}`
},
isScalar() {
return this.resolveRootType(this.gqlType) instanceof GraphQLScalarType
},
},
const emit = defineEmits<{
(e: "jump-to-type", type: GraphQLType): void
}>()
methods: {
jumpToType() {
if (this.isScalar) return
this.jumpTypeCallback(this.gqlType)
},
resolveRootType(type) {
let t = type
while (t.ofType != null) t = t.ofType
return t
},
},
const typeString = computed(() => `${props.gqlType}`)
const isScalar = computed(() => {
return resolveRootType(props.gqlType) instanceof GraphQLScalarType
})
function resolveRootType(type: GraphQLType) {
let t = type as any
while (t.ofType != null) t = t.ofType
return t
}
function jumpToType() {
if (isScalar.value) return
emit("jump-to-type", props.gqlType)
}
</script>

View File

@@ -54,9 +54,9 @@
: null,
}"
:icon="IconGripVertical"
class="cursor-auto text-primary hover:text-primary"
class="opacity-0"
:class="{
'draggable-handle !cursor-grab group-hover:text-secondaryLight':
'draggable-handle cursor-grab group-hover:opacity-100':
index !== workingParams?.length - 1,
}"
tabindex="-1"
@@ -75,7 +75,7 @@
"
/>
<div v-if="entry.isFile" class="file-chips-container">
<div class="file-chips-wrapper space-x-2">
<div class="file-chips-wrapper space-x-1">
<HoppSmartFileChip
v-for="(file, fileIndex) in entry.value"
:key="`param-${index}-file-${fileIndex}`"

View File

@@ -71,9 +71,9 @@
: null,
}"
:icon="IconGripVertical"
class="cursor-auto text-primary hover:text-primary"
class="opacity-0"
:class="{
'draggable-handle !cursor-grab group-hover:text-secondaryLight':
'draggable-handle cursor-grab group-hover:opacity-100':
index !== workingHeaders?.length - 1,
}"
tabindex="-1"
@@ -190,19 +190,13 @@
:icon="masking ? IconEye : IconEyeOff"
@click="toggleMask()"
/>
<HoppButtonSecondary
v-else
v-tippy="{ theme: 'tooltip' }"
:icon="IconArrowUpRight"
:title="t('request.go_to_authorization_tab')"
class="cursor-auto text-primary hover:text-primary"
/>
<div v-else class="aspect-square w-8"></div>
</span>
<span>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconArrowUpRight"
:title="t('request.go_to_authorization_tab')"
:title="changeTabTooltip(header.source)"
@click="changeTab(header.source)"
/>
</span>
@@ -510,6 +504,15 @@ const mask = (header: ComputedHeader) => {
return header.header.value
}
const changeTabTooltip = (tab: ComputedHeader["source"]) => {
switch (tab) {
case "auth":
return t("request.go_to_authorization_tab")
case "body":
return t("request.go_to_body_tab")
}
}
const changeTab = (tab: ComputedHeader["source"]) => {
if (tab === "auth") emit("change-tab", "authorization")
else emit("change-tab", "bodyParams")

View File

@@ -43,6 +43,7 @@ import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { tokenRequest } from "~/helpers/oauth"
import { getCombinedEnvVariables } from "~/helpers/preRequest"
import * as E from "fp-ts/Either"
const t = useI18n()
const toast = useToast()
@@ -77,6 +78,15 @@ const clientSecret = pluckRef(auth, "clientSecret" as any)
const scope = pluckRef(auth, "scope")
function translateTokenRequestError(error: string) {
switch (error) {
case "OIDC_DISCOVERY_FAILED":
return t("authorization.oauth.token_generation_oidc_discovery_failed")
default:
return t("authorization.oauth.something_went_wrong_on_token_generation")
}
}
const handleAccessTokenRequest = async () => {
if (
oidcDiscoveryURL.value === "" &&
@@ -98,7 +108,11 @@ const handleAccessTokenRequest = async () => {
clientSecret: parseTemplateString(clientSecret.value, envVars),
scope: parseTemplateString(scope.value, envVars),
}
await tokenRequest(tokenReqParams)
const res = await tokenRequest(tokenReqParams)
if (res && E.isLeft(res)) {
toast.error(translateTokenRequestError(res.left))
}
} catch (e) {
toast.error(`${e}`)
}

View File

@@ -71,9 +71,9 @@
: null,
}"
:icon="IconGripVertical"
class="cursor-auto text-primary hover:text-primary"
class="opacity-0"
:class="{
'draggable-handle !cursor-grab group-hover:text-secondaryLight':
'draggable-handle cursor-grab group-hover:opacity-100':
index !== workingParams?.length - 1,
}"
tabindex="-1"

View File

@@ -350,7 +350,6 @@ const newSendRequest = async () => {
const streamResult = await streamPromise
requestCancelFunc.value = cancel
if (E.isRight(streamResult)) {
subscribeToStream(
streamResult.right,
@@ -365,6 +364,20 @@ const newSendRequest = async () => {
loading.value = false
},
() => {
// TODO: Change this any to a proper type
const result = (streamResult.right as any).value
if (
result.type === "network_fail" &&
result.error?.error === "NO_PW_EXT_HOOK"
) {
const errorResponse: HoppRESTResponse = {
type: "extension_error",
error: result.error.humanMessage.heading,
component: result.error.component,
req: result.req,
}
updateRESTResponse(errorResponse)
}
loading.value = false
}
)

View File

@@ -11,6 +11,12 @@
<HoppSmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<component
:is="response.component"
v-if="response.type === 'extension_error'"
class="flex-1"
/>
<HoppSmartPlaceholder
v-if="response.type === 'network_fail'"
:src="`/images/states/${colorMode.value}/youre_lost.svg`"

View File

@@ -71,9 +71,9 @@
: null,
}"
:icon="IconGripVertical"
class="cursor-auto text-primary hover:text-primary"
class="opacity-0"
:class="{
'draggable-handle !cursor-grab group-hover:text-secondaryLight':
'draggable-handle cursor-grab group-hover:opacity-100':
index !== workingUrlEncodedParams?.length - 1,
}"
tabindex="-1"

View File

@@ -0,0 +1,98 @@
<template>
<HoppSmartPlaceholder
:src="`/images/states/${colorMode.value}/youre_lost.svg`"
:alt="`${t('error.network_fail')}`"
:heading="t('error.network_fail')"
large
>
<div class="my-1 flex flex-col items-center text-secondaryLight">
<span>
{{ t("error.please_install_extension") }}
</span>
<span>
{{ t("error.check_how_to_add_origin") }}
<HoppSmartLink
blank
to="https://docs.hoppscotch.io/documentation/features/interceptor#browser-extension"
class="text-accent hover:text-accentDark"
>
here
</HoppSmartLink>
</span>
</div>
<div class="flex flex-col space-y-2 py-4">
<span>
<HoppSmartItem
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
blank
:icon="IconChrome"
label="Chrome"
:info-icon="hasChromeExtInstalled ? IconCheckCircle : null"
:active-info-icon="hasChromeExtInstalled"
outline
/>
</span>
<span>
<HoppSmartItem
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
blank
:icon="IconFirefox"
label="Firefox"
:info-icon="hasFirefoxExtInstalled ? IconCheckCircle : null"
:active-info-icon="hasFirefoxExtInstalled"
outline
/>
</span>
</div>
<div class="space-y-4 py-4">
<div class="flex items-center">
<HoppSmartToggle
:on="extensionEnabled"
@change="extensionEnabled = !extensionEnabled"
>
{{ t("settings.extensions_use_toggle") }}
</HoppSmartToggle>
</div>
</div>
</HoppSmartPlaceholder>
</template>
<script setup lang="ts">
import IconChrome from "~icons/brands/chrome"
import IconFirefox from "~icons/brands/firefox"
import IconCheckCircle from "~icons/lucide/check-circle"
import { useI18n } from "@composables/i18n"
import { ExtensionInterceptorService } from "~/platform/std/interceptors/extension"
import { useService } from "dioc/vue"
import { computed } from "vue"
import { InterceptorService } from "~/services/interceptor.service"
import { platform } from "~/platform"
import { useColorMode } from "~/composables/theming"
const colorMode = useColorMode()
const t = useI18n()
const interceptorService = useService(InterceptorService)
const extensionService = useService(ExtensionInterceptorService)
const hasChromeExtInstalled = extensionService.chromeExtensionInstalled
const hasFirefoxExtInstalled = extensionService.firefoxExtensionInstalled
const extensionEnabled = computed({
get() {
return (
interceptorService.currentInterceptorID.value ===
extensionService.interceptorID
)
},
set(active) {
if (active) {
interceptorService.currentInterceptorID.value =
extensionService.interceptorID
} else {
interceptorService.currentInterceptorID.value =
platform.interceptors.default
}
},
})
</script>

View File

@@ -44,6 +44,8 @@ import { invokeAction } from "~/helpers/actions"
import { useDebounceFn } from "@vueuse/core"
// TODO: Migrate from legacy mode
import * as E from "fp-ts/Either"
type ExtendedEditorConfig = {
mode: string
placeholder: string
@@ -160,6 +162,21 @@ const getLanguage = (langMime: string): Language | null => {
return null
}
const formatXML = (doc: string) => {
try {
const formatted = xmlFormat(doc, {
indentation: " ",
collapseContent: true,
lineSeparator: "\n",
whiteSpaceAtEndOfSelfclosingTag: true,
})
return E.right(formatted)
} catch (e) {
return E.left(e)
}
}
/**
* Uses xml-formatter to format the XML document
* @param doc Document to parse
@@ -171,14 +188,11 @@ const parseDoc = (
langMime: string
): string | undefined => {
if (langMime === "application/xml" && doc) {
return xmlFormat(doc, {
indentation: " ",
collapseContent: true,
lineSeparator: "\n",
})
} else {
return doc
const xmlFormatingResult = formatXML(doc)
if (E.isRight(xmlFormatingResult)) return xmlFormatingResult.right
}
return doc
}
const getEditorLanguage = (

View File

@@ -10,6 +10,7 @@ import { useI18n } from "./i18n"
import { refAutoReset } from "@vueuse/core"
import { copyToClipboard } from "@helpers/utils/clipboard"
import { HoppRESTResponse } from "@helpers/types/HoppRESTResponse"
import { platform } from "~/platform"
export function useCopyResponse(responseBodyText: Ref<any>) {
const toast = useToast()
@@ -40,15 +41,14 @@ export function useDownloadResponse(
const toast = useToast()
const t = useI18n()
const downloadResponse = () => {
const downloadResponse = async () => {
const dataToWrite = responseBody.value
const file = new Blob([dataToWrite], { type: contentType })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
// TODO: get uri from meta
a.download = pipe(
// Guess extension and filename
const file = new Blob([dataToWrite], { type: contentType })
const url = URL.createObjectURL(file)
const filename = pipe(
url,
S.split("/"),
RNEA.last,
@@ -58,15 +58,24 @@ export function useDownloadResponse(
RNEA.head
)
document.body.appendChild(a)
a.click()
downloadIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
URL.revokeObjectURL(url)
console.log(filename)
// TODO: Look at the mime type and determine extension ?
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: contentType,
suggestedFilename: filename,
})
// Assume success if unknown as we cannot determine
if (result.type === "unknown" || result.type === "saved") {
downloadIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
}
}
return {
downloadIcon,
downloadResponse,

View File

@@ -152,12 +152,14 @@ export function useStreamSubscriber(): {
error?: (e: any) => void,
complete?: () => void
) => {
const sub = stream.subscribe({
let sub: Subscription | null = null
sub = stream.subscribe({
next,
error,
complete: () => {
if (complete) complete()
subs.splice(subs.indexOf(sub), 1)
if (sub) subs.splice(subs.indexOf(sub), 1)
},
})

View File

@@ -11,8 +11,9 @@ import {
getIntrospectionQuery,
printSchema,
} from "graphql"
import { computed, reactive, ref } from "vue"
import { Component, computed, reactive, ref } from "vue"
import { getService } from "~/modules/dioc"
import { getI18n } from "~/modules/i18n"
import { addGraphqlHistoryEntry, makeGQLHistoryEntry } from "~/newstore/history"
@@ -32,13 +33,23 @@ type RunQueryOptions = {
operationType: OperationType
}
export type GQLResponseEvent = {
time: number
operationName: string | undefined
operationType: OperationType
data: string
rawQuery?: RunQueryOptions
}
export type GQLResponseEvent =
| {
type: "response"
time: number
operationName: string | undefined
operationType: OperationType
data: string
rawQuery?: RunQueryOptions
}
| {
type: "error"
error: {
type: string
message: string
component?: Component
}
}
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
export type SubscriptionState = "SUBSCRIBING" | "SUBSCRIBED" | "UNSUBSCRIBED"
@@ -61,6 +72,11 @@ type Connection = {
subscriptionState: Map<string, SubscriptionState>
socket: WebSocket | undefined
schema: GraphQLSchema | null
error?: {
type: string
message: (t: ReturnType<typeof getI18n>) => string
component?: Component
} | null
}
const tabs = getService(GQLTabService)
@@ -71,6 +87,7 @@ export const connection = reactive<Connection>({
subscriptionState: new Map<string, SubscriptionState>(),
socket: undefined,
schema: null,
error: null,
})
export const schema = computed(() => connection.schema)
@@ -202,7 +219,19 @@ const getSchema = async (url: string, headers: GQLHeader[]) => {
const res = await interceptorService.runRequest(reqOptions).response
if (E.isLeft(res)) {
console.error(res.left)
if (
res.left !== "cancellation" &&
res.left.error === "NO_PW_EXT_HOOK" &&
res.left.humanMessage
) {
connection.error = {
type: res.left.error,
message: (t: ReturnType<typeof getI18n>) =>
res.left.humanMessage.description(t),
component: res.left.component,
}
}
throw new Error(res.left.toString())
}
@@ -218,6 +247,7 @@ const getSchema = async (url: string, headers: GQLHeader[]) => {
const schema = buildClientSchema(introspectResponse.data)
connection.schema = schema
connection.error = null
} catch (e: any) {
console.error(e)
disconnect()
@@ -280,7 +310,18 @@ export const runGQLOperation = async (options: RunQueryOptions) => {
const result = await interceptorService.runRequest(reqOptions).response
if (E.isLeft(result)) {
console.error(result.left)
if (
result.left !== "cancellation" &&
result.left.error === "NO_PW_EXT_HOOK" &&
result.left.humanMessage
) {
connection.error = {
type: result.left.error,
message: (t: ReturnType<typeof getI18n>) =>
result.left.humanMessage.description(t),
component: result.left.component,
}
}
throw new Error(result.left.toString())
}
@@ -292,6 +333,7 @@ export const runGQLOperation = async (options: RunQueryOptions) => {
.replace(/\0+$/, "")
gqlMessageEvent.value = {
type: "response",
time: Date.now(),
operationName: operationName ?? "query",
data: responseText,
@@ -299,6 +341,10 @@ export const runGQLOperation = async (options: RunQueryOptions) => {
operationType,
}
if (connection.state !== "CONNECTED") {
connection.state = "CONNECTED"
}
addQueryToHistory(options, responseText)
return responseText
@@ -352,6 +398,7 @@ export const runSubscription = (
}
case GQL.DATA: {
gqlMessageEvent.value = {
type: "response",
time: Date.now(),
operationName,
data: JSON.stringify(data.payload),

View File

@@ -1,6 +1,7 @@
import { Environment } from "@hoppscotch/data"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import { cloneDeep } from "lodash-es"
import { platform } from "~/platform"
const getEnvironmentJson = (
environmentObj: TeamEnvironment | Environment,
@@ -32,17 +33,24 @@ export const exportAsJSON = (
if (!dataToWrite) return false
const file = new Blob([dataToWrite], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
// Extracts the path from url, removes fragment identifier and query parameters if any, appends the ".json" extension, and assigns it
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
document.body.appendChild(a)
a.click()
setTimeout(() => {
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}, 0)
URL.revokeObjectURL(url)
platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/json",
// Extracts the path from url, removes fragment identifier and query parameters if any, appends the ".json" extension, and assigns it
suggestedFilename: `${
url.split("/").pop()!.split("#")[0].split("?")[0]
}.json`,
filters: [
{
name: "JSON file",
extensions: ["json"],
},
],
})
return true
}

View File

@@ -1,245 +0,0 @@
import {
getLocalConfig,
setLocalConfig,
removeLocalConfig,
} from "~/newstore/localpersistence"
const redirectUri = `${window.location.origin}/`
// GENERAL HELPER FUNCTIONS
/**
* Makes a POST request and parse the response as JSON
*
* @param {String} url - The resource
* @param {Object} params - Configuration options
* @returns {Object}
*/
const sendPostRequest = async (url, params) => {
const body = Object.keys(params)
.map((key) => `${key}=${params[key]}`)
.join("&")
const options = {
method: "POST",
headers: {
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
},
body,
}
try {
const response = await fetch(url, options)
const data = await response.json()
return data
} catch (e) {
console.error(e)
}
}
/**
* Parse a query string into an object
*
* @param {String} searchQuery - The search query params
* @returns {Object}
*/
const parseQueryString = (searchQuery) => {
if (searchQuery === "") {
return {}
}
const segments = searchQuery.split("&").map((s) => s.split("="))
const queryString = segments.reduce(
(obj, el) => ({ ...obj, [el[0]]: el[1] }),
{}
)
return queryString
}
/**
* Get OAuth configuration from OpenID Discovery endpoint
*
* @returns {Object}
*/
const getTokenConfiguration = async (endpoint) => {
const options = {
method: "GET",
headers: {
"Content-type": "application/json",
},
}
try {
const response = await fetch(endpoint, options)
const config = await response.json()
return config
} catch (e) {
console.error(e)
}
}
// PKCE HELPER FUNCTIONS
/**
* Generates a secure random string using the browser crypto functions
*
* @returns {Object}
*/
const generateRandomString = () => {
const array = new Uint32Array(28)
window.crypto.getRandomValues(array)
return Array.from(array, (dec) => `0${dec.toString(16)}`.slice(-2)).join("")
}
/**
* Calculate the SHA256 hash of the input text
*
* @returns {Promise<ArrayBuffer>}
*/
const sha256 = (plain) => {
const encoder = new TextEncoder()
const data = encoder.encode(plain)
return window.crypto.subtle.digest("SHA-256", data)
}
/**
* Encodes the input string into Base64 format
*
* @param {String} str - The string to be converted
* @returns {Promise<ArrayBuffer>}
*/
const base64urlencode = (
str // Converts the ArrayBuffer to string using Uint8 array to convert to what btoa accepts.
) =>
// btoa accepts chars only within ascii 0-255 and base64 encodes them.
// Then convert the base64 encoded to base64url encoded
// (replace + with -, replace / with _, trim trailing =)
btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
/**
* Return the base64-urlencoded sha256 hash for the PKCE challenge
*
* @param {String} v - The randomly generated string
* @returns {String}
*/
const pkceChallengeFromVerifier = async (v) => {
const hashed = await sha256(v)
return base64urlencode(hashed)
}
// OAUTH REQUEST
/**
* Initiates PKCE Auth Code flow when requested
*
* @param {Object} - The necessary params
* @returns {Void}
*/
const tokenRequest = async ({
oidcDiscoveryUrl,
grantType,
authUrl,
accessTokenUrl,
clientId,
clientSecret,
scope,
}) => {
// Check oauth configuration
if (oidcDiscoveryUrl !== "") {
// eslint-disable-next-line camelcase
const { authorization_endpoint, token_endpoint } =
await getTokenConfiguration(oidcDiscoveryUrl)
// eslint-disable-next-line camelcase
authUrl = authorization_endpoint
// eslint-disable-next-line camelcase
accessTokenUrl = token_endpoint
}
// Store oauth information
setLocalConfig("tokenEndpoint", accessTokenUrl)
setLocalConfig("client_id", clientId)
setLocalConfig("client_secret", clientSecret)
// Create and store a random state value
const state = generateRandomString()
setLocalConfig("pkce_state", state)
// Create and store a new PKCE codeVerifier (the plaintext random secret)
const codeVerifier = generateRandomString()
setLocalConfig("pkce_codeVerifier", codeVerifier)
// Hash and base64-urlencode the secret to use as the challenge
const codeChallenge = await pkceChallengeFromVerifier(codeVerifier)
// Build the authorization URL
const buildUrl = () =>
`${authUrl + `?response_type=${grantType}`}&client_id=${encodeURIComponent(
clientId
)}&state=${encodeURIComponent(state)}&scope=${encodeURIComponent(
scope
)}&redirect_uri=${encodeURIComponent(
redirectUri
)}&code_challenge=${encodeURIComponent(
codeChallenge
)}&code_challenge_method=S256`
// Redirect to the authorization server
window.location = buildUrl()
}
// OAUTH REDIRECT HANDLING
/**
* Handle the redirect back from the authorization server and
* get an access token from the token endpoint
*
* @returns {Promise<any | void>}
*/
const oauthRedirect = () => {
let tokenResponse = ""
const q = parseQueryString(window.location.search.substring(1))
// Check if the server returned an error string
if (q.error) {
alert(`Error returned from authorization server: ${q.error}`)
}
// If the server returned an authorization code, attempt to exchange it for an access token
if (q.code) {
// Verify state matches what we set at the beginning
if (getLocalConfig("pkce_state") !== q.state) {
alert("Invalid state")
Promise.reject(tokenResponse)
} else {
try {
// Exchange the authorization code for an access token
tokenResponse = sendPostRequest(getLocalConfig("tokenEndpoint"), {
grant_type: "authorization_code",
code: q.code,
client_id: getLocalConfig("client_id"),
client_secret: getLocalConfig("client_secret"),
redirect_uri: redirectUri,
code_verifier: getLocalConfig("pkce_codeVerifier"),
})
} catch (e) {
console.error(e)
return Promise.reject(tokenResponse)
}
}
// Clean these up since we don't need them anymore
removeLocalConfig("pkce_state")
removeLocalConfig("pkce_codeVerifier")
removeLocalConfig("tokenEndpoint")
removeLocalConfig("client_id")
removeLocalConfig("client_secret")
return tokenResponse
}
return Promise.reject(tokenResponse)
}
export { tokenRequest, oauthRedirect }

View File

@@ -0,0 +1,312 @@
import { getService } from "~/modules/dioc"
import { PersistenceService } from "~/services/persistence"
import * as E from "fp-ts/Either"
import { z } from "zod"
const redirectUri = `${window.location.origin}/oauth`
const persistenceService = getService(PersistenceService)
// GENERAL HELPER FUNCTIONS
/**
* Makes a POST request and parse the response as JSON
*
* @param {String} url - The resource
* @param {Object} params - Configuration options
* @returns {Object}
*/
const sendPostRequest = async (url: string, params: Record<string, string>) => {
const body = Object.keys(params)
.map((key) => `${key}=${params[key]}`)
.join("&")
const options = {
method: "POST",
headers: {
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
},
body,
}
try {
const response = await fetch(url, options)
const data = await response.json()
return E.right(data)
} catch (e) {
return E.left("AUTH_TOKEN_REQUEST_FAILED")
}
}
/**
* Parse a query string into an object
*
* @param {String} searchQuery - The search query params
* @returns {Object}
*/
const parseQueryString = (searchQuery: string): Record<string, string> => {
if (searchQuery === "") {
return {}
}
const segments = searchQuery.split("&").map((s) => s.split("="))
const queryString = segments.reduce(
(obj, el) => ({ ...obj, [el[0]]: el[1] }),
{}
)
return queryString
}
/**
* Get OAuth configuration from OpenID Discovery endpoint
*
* @returns {Object}
*/
const getTokenConfiguration = async (endpoint: string) => {
const options = {
method: "GET",
headers: {
"Content-type": "application/json",
},
}
try {
const response = await fetch(endpoint, options)
const config = await response.json()
return E.right(config)
} catch (e) {
return E.left("OIDC_DISCOVERY_FAILED")
}
}
// PKCE HELPER FUNCTIONS
/**
* Generates a secure random string using the browser crypto functions
*
* @returns {Object}
*/
const generateRandomString = () => {
const array = new Uint32Array(28)
window.crypto.getRandomValues(array)
return Array.from(array, (dec) => `0${dec.toString(16)}`.slice(-2)).join("")
}
/**
* Calculate the SHA256 hash of the input text
*
* @returns {Promise<ArrayBuffer>}
*/
const sha256 = (plain: string) => {
const encoder = new TextEncoder()
const data = encoder.encode(plain)
return window.crypto.subtle.digest("SHA-256", data)
}
/**
* Encodes the input string into Base64 format
*
* @param {String} str - The string to be converted
* @returns {Promise<ArrayBuffer>}
*/
const base64urlencode = (
str: ArrayBuffer // Converts the ArrayBuffer to string using Uint8 array to convert to what btoa accepts.
) => {
const hashArray = Array.from(new Uint8Array(str))
// btoa accepts chars only within ascii 0-255 and base64 encodes them.
// Then convert the base64 encoded to base64url encoded
// (replace + with -, replace / with _, trim trailing =)
return btoa(String.fromCharCode.apply(null, hashArray))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
}
/**
* Return the base64-urlencoded sha256 hash for the PKCE challenge
*
* @param {String} v - The randomly generated string
* @returns {String}
*/
const pkceChallengeFromVerifier = async (v: string) => {
const hashed = await sha256(v)
return base64urlencode(hashed)
}
// OAUTH REQUEST
type TokenRequestParams = {
oidcDiscoveryUrl: string
grantType: string
authUrl: string
accessTokenUrl: string
clientId: string
clientSecret: string
scope: string
}
/**
* Initiates PKCE Auth Code flow when requested
*
* @param {Object} - The necessary params
* @returns {Void}
*/
const tokenRequest = async ({
oidcDiscoveryUrl,
grantType,
authUrl,
accessTokenUrl,
clientId,
clientSecret,
scope,
}: TokenRequestParams) => {
// Check oauth configuration
if (oidcDiscoveryUrl !== "") {
const res = await getTokenConfiguration(oidcDiscoveryUrl)
const OIDCConfigurationSchema = z.object({
authorization_endpoint: z.string(),
token_endpoint: z.string(),
})
if (E.isLeft(res)) {
return E.left("OIDC_DISCOVERY_FAILED" as const)
}
const parsedOIDCConfiguration = OIDCConfigurationSchema.safeParse(res.right)
if (!parsedOIDCConfiguration.success) {
return E.left("OIDC_DISCOVERY_FAILED" as const)
}
authUrl = parsedOIDCConfiguration.data.authorization_endpoint
accessTokenUrl = parsedOIDCConfiguration.data.token_endpoint
}
// Store oauth information
persistenceService.setLocalConfig("tokenEndpoint", accessTokenUrl)
persistenceService.setLocalConfig("client_id", clientId)
persistenceService.setLocalConfig("client_secret", clientSecret)
// Create and store a random state value
const state = generateRandomString()
persistenceService.setLocalConfig("pkce_state", state)
// Create and store a new PKCE codeVerifier (the plaintext random secret)
const codeVerifier = generateRandomString()
persistenceService.setLocalConfig("pkce_codeVerifier", codeVerifier)
// Hash and base64-urlencode the secret to use as the challenge
const codeChallenge = await pkceChallengeFromVerifier(codeVerifier)
// Build the authorization URL
const buildUrl = () =>
`${authUrl + `?response_type=${grantType}`}&client_id=${encodeURIComponent(
clientId
)}&state=${encodeURIComponent(state)}&scope=${encodeURIComponent(
scope
)}&redirect_uri=${encodeURIComponent(
redirectUri
)}&code_challenge=${encodeURIComponent(
codeChallenge
)}&code_challenge_method=S256`
// Redirect to the authorization server
window.location.assign(buildUrl())
}
// OAUTH REDIRECT HANDLING
/**
* Handle the redirect back from the authorization server and
* get an access token from the token endpoint
*
* @returns {Promise<any | void>}
*/
const handleOAuthRedirect = async () => {
const queryParams = parseQueryString(window.location.search.substring(1))
// Check if the server returned an error string
if (queryParams.error) {
return E.left("AUTH_SERVER_RETURNED_ERROR" as const)
}
if (!queryParams.code) {
return E.left("NO_AUTH_CODE" as const)
}
// If the server returned an authorization code, attempt to exchange it for an access token
// Verify state matches what we set at the beginning
if (persistenceService.getLocalConfig("pkce_state") !== queryParams.state) {
return E.left("INVALID_STATE" as const)
}
const tokenEndpoint = persistenceService.getLocalConfig("tokenEndpoint")
const clientID = persistenceService.getLocalConfig("client_id")
const clientSecret = persistenceService.getLocalConfig("client_secret")
const codeVerifier = persistenceService.getLocalConfig("pkce_codeVerifier")
if (!tokenEndpoint) {
return E.left("NO_TOKEN_ENDPOINT" as const)
}
if (!clientID) {
return E.left("NO_CLIENT_ID" as const)
}
if (!clientSecret) {
return E.left("NO_CLIENT_SECRET" as const)
}
if (!codeVerifier) {
return E.left("NO_CODE_VERIFIER" as const)
}
// Exchange the authorization code for an access token
const tokenResponse: E.Either<string, any> = await sendPostRequest(
tokenEndpoint,
{
grant_type: "authorization_code",
code: queryParams.code,
client_id: clientID,
client_secret: clientSecret,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}
)
// Clean these up since we don't need them anymore
clearPKCEState()
if (E.isLeft(tokenResponse)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const withAccessTokenSchema = z.object({
access_token: z.string(),
})
const parsedTokenResponse = withAccessTokenSchema.safeParse(
tokenResponse.right
)
return parsedTokenResponse.success
? E.right(parsedTokenResponse.data)
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
}
const clearPKCEState = () => {
persistenceService.removeLocalConfig("pkce_state")
persistenceService.removeLocalConfig("pkce_codeVerifier")
persistenceService.removeLocalConfig("tokenEndpoint")
persistenceService.removeLocalConfig("client_id")
persistenceService.removeLocalConfig("client_secret")
}
export { tokenRequest, handleOAuthRedirect }

View File

@@ -1,4 +1,5 @@
import { HoppRESTRequest } from "@hoppscotch/data"
import { Component } from "vue"
export type HoppRESTResponseHeader = { key: string; value: string }
@@ -39,3 +40,9 @@ export type HoppRESTResponse =
req: HoppRESTRequest
}
| {
type: "extension_error"
error: string
component: Component
req: HoppRESTRequest
}

View File

@@ -1,20 +1,21 @@
import { HOPP_MODULES } from "@modules/."
import { createApp } from "vue"
import { PlatformDef, setPlatformDef } from "./platform"
import { setupLocalPersistence } from "./newstore/localpersistence"
import { performMigrations } from "./helpers/migrations"
import { initializeApp } from "./helpers/app"
import { initBackendGQLClient } from "./helpers/backend/GQLClient"
import { HOPP_MODULES } from "@modules/."
import { performMigrations } from "./helpers/migrations"
import { PlatformDef, setPlatformDef } from "./platform"
import "../assets/scss/tailwind.scss"
import "../assets/themes/themes.scss"
import "../assets/scss/styles.scss"
import "nprogress/nprogress.css"
import "@fontsource-variable/inter"
import "@fontsource-variable/material-symbols-rounded"
import "@fontsource-variable/roboto-mono"
import "nprogress/nprogress.css"
import "../assets/scss/styles.scss"
import "../assets/scss/tailwind.scss"
import "../assets/themes/themes.scss"
import App from "./App.vue"
import { getService } from "./modules/dioc"
import { PersistenceService } from "./services/persistence"
export function createHoppApp(el: string | Element, platformDef: PlatformDef) {
setPlatformDef(platformDef)
@@ -24,12 +25,15 @@ export function createHoppApp(el: string | Element, platformDef: PlatformDef) {
// Some basic work that needs to be done before module inits even
initBackendGQLClient()
initializeApp()
setupLocalPersistence()
performMigrations()
HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app))
platformDef.addedHoppModules?.forEach((mod) => mod.onVueAppInit?.(app))
// TODO: Explore possibilities of moving this invocation to the service constructor
// `toast` was coming up as `null` in the previous attempts
getService(PersistenceService).setupLocalPersistence()
performMigrations()
app.mount(el)
console.info(

View File

@@ -61,18 +61,21 @@
</template>
<script setup lang="ts">
import { computed, onBeforeMount, onMounted, ref, watch } from "vue"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { Splitpanes, Pane } from "splitpanes"
import "splitpanes/dist/splitpanes.css"
import { RouterView, useRouter } from "vue-router"
import { useSetting } from "@composables/settings"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { useService } from "dioc/vue"
import { Pane, Splitpanes } from "splitpanes"
import "splitpanes/dist/splitpanes.css"
import { computed, onBeforeMount, onMounted, ref, watch } from "vue"
import { RouterView, useRouter } from "vue-router"
import { defineActionHandler } from "~/helpers/actions"
import { hookKeybindingsListener } from "~/helpers/keybindings"
import { applySetting } from "~/newstore/settings"
import { getLocalConfig, setLocalConfig } from "~/newstore/localpersistence"
import { useToast } from "~/composables/toast"
import { useI18n } from "~/composables/i18n"
import { platform } from "~/platform"
import { PersistenceService } from "~/services/persistence"
const router = useRouter()
@@ -89,6 +92,8 @@ const mdAndLarger = breakpoints.greater("md")
const toast = useToast()
const t = useI18n()
const persistenceService = useService(PersistenceService)
onBeforeMount(() => {
if (!mdAndLarger.value) {
rightSidebar.value = false
@@ -97,15 +102,19 @@ onBeforeMount(() => {
})
onMounted(() => {
const cookiesAllowed = getLocalConfig("cookiesAllowed") === "yes"
if (!cookiesAllowed) {
const cookiesAllowed =
persistenceService.getLocalConfig("cookiesAllowed") === "yes"
const platformAllowsCookiePrompts =
platform.platformFeatureFlags.promptAsUsingCookies ?? true
if (!cookiesAllowed && platformAllowsCookiePrompts) {
toast.show(`${t("app.we_use_cookies")}`, {
duration: 0,
action: [
{
text: `${t("action.learn_more")}`,
onClick: (_, toastObject) => {
setLocalConfig("cookiesAllowed", "yes")
persistenceService.setLocalConfig("cookiesAllowed", "yes")
toastObject.goAway(0)
window
.open("https://docs.hoppscotch.io/support/privacy", "_blank")
@@ -115,7 +124,7 @@ onMounted(() => {
{
text: `${t("action.dismiss")}`,
onClick: (_, toastObject) => {
setLocalConfig("cookiesAllowed", "yes")
persistenceService.setLocalConfig("cookiesAllowed", "yes")
toastObject.goAway(0)
},
},

View File

@@ -1,14 +1,15 @@
import * as R from "fp-ts/Record"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as R from "fp-ts/Record"
import { createI18n, I18n, I18nOptions } from "vue-i18n"
import { HoppModule } from "."
import languages from "../../languages.json"
import { throwError } from "~/helpers/functional/error"
import { getLocalConfig, setLocalConfig } from "~/newstore/localpersistence"
import { PersistenceService } from "~/services/persistence"
import { getService } from "./dioc"
/*
In context of this file, we have 2 main kinds of things.
@@ -44,6 +45,8 @@ type LanguagesDef = {
const FALLBACK_LANG_CODE = "en"
const persistenceService = getService(PersistenceService)
// TypeScript cannot understand dir is restricted to "ltr" or "rtl" yet, hence assertion
export const APP_LANGUAGES: LanguagesDef[] = languages as LanguagesDef[]
@@ -69,7 +72,7 @@ let i18nInstance: I18n<
const resolveCurrentLocale = () =>
pipe(
// Resolve from locale and make sure it is in languages
getLocalConfig("locale"),
persistenceService.getLocalConfig("locale"),
O.fromNullable,
O.filter((locale) =>
pipe(
@@ -118,7 +121,7 @@ export const changeAppLanguage = async (locale: string) => {
// TODO: Look into the type issues here
i18nInstance.global.locale.value = locale
setLocalConfig("locale", locale)
persistenceService.setLocalConfig("locale", locale)
}
/**
@@ -145,7 +148,7 @@ export default <HoppModule>{
const currentLocale = resolveCurrentLocale()
changeAppLanguage(currentLocale)
setLocalConfig("locale", currentLocale)
persistenceService.setLocalConfig("locale", currentLocale)
},
onBeforeRouteChange(to, _, router) {
// Convert old locale path format to new format

View File

@@ -1,22 +1,26 @@
import { usePreferredDark, useStorage } from "@vueuse/core"
import { App, computed, reactive, Ref, watch } from "vue"
import type { HoppBgColor } from "~/newstore/settings"
import { useSettingStatic } from "@composables/settings"
import { usePreferredDark, useStorage } from "@vueuse/core"
import { App, Ref, computed, reactive, watch } from "vue"
import type { HoppBgColor } from "~/newstore/settings"
import { PersistenceService } from "~/services/persistence"
import { HoppModule } from "."
import { hoppLocalConfigStorage } from "~/newstore/localpersistence"
import { getService } from "./dioc"
export type HoppColorMode = {
preference: HoppBgColor
value: Readonly<Exclude<HoppBgColor, "system">>
}
const persistenceService = getService(PersistenceService)
const applyColorMode = (app: App) => {
const [settingPref] = useSettingStatic("BG_COLOR")
const currentLocalPreference = useStorage<HoppBgColor>(
"nuxt-color-mode",
"system",
hoppLocalConfigStorage,
persistenceService.hoppLocalConfigStorage,
{
listenToStorageChanges: true,
}

View File

@@ -1,423 +0,0 @@
/* eslint-disable no-restricted-globals, no-restricted-syntax */
import { clone, assign, isEmpty } from "lodash-es"
import {
translateToNewRESTCollection,
translateToNewGQLCollection,
Environment,
} from "@hoppscotch/data"
import {
settingsStore,
bulkApplySettings,
getDefaultSettings,
applySetting,
HoppAccentColor,
HoppBgColor,
performSettingsDataMigrations,
} from "./settings"
import {
restHistoryStore,
graphqlHistoryStore,
setRESTHistoryEntries,
setGraphqlHistoryEntries,
translateToNewRESTHistory,
translateToNewGQLHistory,
} from "./history"
import {
restCollectionStore,
graphqlCollectionStore,
setGraphqlCollections,
setRESTCollections,
} from "./collections"
import {
replaceEnvironments,
environments$,
addGlobalEnvVariable,
setGlobalEnvVariables,
globalEnv$,
setSelectedEnvironmentIndex,
selectedEnvironmentIndex$,
} from "./environments"
import { WSRequest$, setWSRequest } from "./WebSocketSession"
import { SIORequest$, setSIORequest } from "./SocketIOSession"
import { SSERequest$, setSSERequest } from "./SSESession"
import { MQTTRequest$, setMQTTRequest } from "./MQTTSession"
import { bulkApplyLocalState, localStateStore } from "./localstate"
import { StorageLike, watchDebounced } from "@vueuse/core"
import { getService } from "~/modules/dioc"
import { RESTTabService } from "~/services/tab/rest"
import { GQLTabService } from "~/services/tab/graphql"
function checkAndMigrateOldSettings() {
if (window.localStorage.getItem("selectedEnvIndex")) {
const index = window.localStorage.getItem("selectedEnvIndex")
if (index) {
if (index === "-1") {
window.localStorage.setItem(
"selectedEnvIndex",
JSON.stringify({
type: "NO_ENV_SELECTED",
})
)
} else if (Number(index) >= 0) {
window.localStorage.setItem(
"selectedEnvIndex",
JSON.stringify({
type: "MY_ENV",
index: parseInt(index),
})
)
}
}
}
const vuexData = JSON.parse(window.localStorage.getItem("vuex") || "{}")
if (isEmpty(vuexData)) return
const { postwoman } = vuexData
if (!isEmpty(postwoman?.settings)) {
const settingsData = assign(clone(getDefaultSettings()), postwoman.settings)
window.localStorage.setItem("settings", JSON.stringify(settingsData))
delete postwoman.settings
window.localStorage.setItem("vuex", JSON.stringify(vuexData))
}
if (postwoman?.collections) {
window.localStorage.setItem(
"collections",
JSON.stringify(postwoman.collections)
)
delete postwoman.collections
window.localStorage.setItem("vuex", JSON.stringify(vuexData))
}
if (postwoman?.collectionsGraphql) {
window.localStorage.setItem(
"collectionsGraphql",
JSON.stringify(postwoman.collectionsGraphql)
)
delete postwoman.collectionsGraphql
window.localStorage.setItem("vuex", JSON.stringify(vuexData))
}
if (postwoman?.environments) {
window.localStorage.setItem(
"environments",
JSON.stringify(postwoman.environments)
)
delete postwoman.environments
window.localStorage.setItem("vuex", JSON.stringify(vuexData))
}
if (window.localStorage.getItem("THEME_COLOR")) {
const themeColor = window.localStorage.getItem("THEME_COLOR")
applySetting("THEME_COLOR", themeColor as HoppAccentColor)
window.localStorage.removeItem("THEME_COLOR")
}
if (window.localStorage.getItem("nuxt-color-mode")) {
const color = window.localStorage.getItem("nuxt-color-mode") as HoppBgColor
applySetting("BG_COLOR", color)
window.localStorage.removeItem("nuxt-color-mode")
}
}
function setupLocalStatePersistence() {
const localStateData = JSON.parse(
window.localStorage.getItem("localState") ?? "{}"
)
if (localStateData) bulkApplyLocalState(localStateData)
localStateStore.subject$.subscribe((state) => {
window.localStorage.setItem("localState", JSON.stringify(state))
})
}
function setupSettingsPersistence() {
const settingsData = JSON.parse(
window.localStorage.getItem("settings") || "{}"
)
const updatedSettings = settingsData
? performSettingsDataMigrations(settingsData)
: settingsData
if (updatedSettings) {
bulkApplySettings(updatedSettings)
}
settingsStore.subject$.subscribe((settings) => {
window.localStorage.setItem("settings", JSON.stringify(settings))
})
}
function setupHistoryPersistence() {
const restHistoryData = JSON.parse(
window.localStorage.getItem("history") || "[]"
).map(translateToNewRESTHistory)
const graphqlHistoryData = JSON.parse(
window.localStorage.getItem("graphqlHistory") || "[]"
).map(translateToNewGQLHistory)
setRESTHistoryEntries(restHistoryData)
setGraphqlHistoryEntries(graphqlHistoryData)
restHistoryStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem("history", JSON.stringify(state))
})
graphqlHistoryStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem("graphqlHistory", JSON.stringify(state))
})
}
function setupCollectionsPersistence() {
const restCollectionData = JSON.parse(
window.localStorage.getItem("collections") || "[]"
).map(translateToNewRESTCollection)
const graphqlCollectionData = JSON.parse(
window.localStorage.getItem("collectionsGraphql") || "[]"
).map(translateToNewGQLCollection)
setRESTCollections(restCollectionData)
setGraphqlCollections(graphqlCollectionData)
restCollectionStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem("collections", JSON.stringify(state))
})
graphqlCollectionStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem("collectionsGraphql", JSON.stringify(state))
})
}
function setupEnvironmentsPersistence() {
const environmentsData: Environment[] = JSON.parse(
window.localStorage.getItem("environments") || "[]"
)
// Check if a global env is defined and if so move that to globals
const globalIndex = environmentsData.findIndex(
(x) => x.name.toLowerCase() === "globals"
)
if (globalIndex !== -1) {
const globalEnv = environmentsData[globalIndex]
globalEnv.variables.forEach((variable) => addGlobalEnvVariable(variable))
// Remove global from environments
environmentsData.splice(globalIndex, 1)
// Just sync the changes manually
window.localStorage.setItem(
"environments",
JSON.stringify(environmentsData)
)
}
replaceEnvironments(environmentsData)
environments$.subscribe((envs) => {
window.localStorage.setItem("environments", JSON.stringify(envs))
})
}
function setupSelectedEnvPersistence() {
const selectedEnvIndex = JSON.parse(
window.localStorage.getItem("selectedEnvIndex") ?? "null"
)
// If there is a selected env index, set it to the store else set it to null
if (selectedEnvIndex) {
setSelectedEnvironmentIndex(selectedEnvIndex)
} else {
setSelectedEnvironmentIndex({
type: "NO_ENV_SELECTED",
})
}
selectedEnvironmentIndex$.subscribe((envIndex) => {
window.localStorage.setItem("selectedEnvIndex", JSON.stringify(envIndex))
})
}
function setupWebsocketPersistence() {
const request = JSON.parse(
window.localStorage.getItem("WebsocketRequest") || "null"
)
setWSRequest(request)
WSRequest$.subscribe((req) => {
window.localStorage.setItem("WebsocketRequest", JSON.stringify(req))
})
}
function setupSocketIOPersistence() {
const request = JSON.parse(
window.localStorage.getItem("SocketIORequest") || "null"
)
setSIORequest(request)
SIORequest$.subscribe((req) => {
window.localStorage.setItem("SocketIORequest", JSON.stringify(req))
})
}
function setupSSEPersistence() {
const request = JSON.parse(
window.localStorage.getItem("SSERequest") || "null"
)
setSSERequest(request)
SSERequest$.subscribe((req) => {
window.localStorage.setItem("SSERequest", JSON.stringify(req))
})
}
function setupMQTTPersistence() {
const request = JSON.parse(
window.localStorage.getItem("MQTTRequest") || "null"
)
setMQTTRequest(request)
MQTTRequest$.subscribe((req) => {
window.localStorage.setItem("MQTTRequest", JSON.stringify(req))
})
}
function setupGlobalEnvsPersistence() {
const globals: Environment["variables"] = JSON.parse(
window.localStorage.getItem("globalEnv") || "[]"
)
setGlobalEnvVariables(globals)
globalEnv$.subscribe((vars) => {
window.localStorage.setItem("globalEnv", JSON.stringify(vars))
})
}
// TODO: Graceful error handling ?
export function setupRESTTabsPersistence() {
const tabService = getService(RESTTabService)
try {
const state = window.localStorage.getItem("restTabState")
if (state) {
const data = JSON.parse(state)
tabService.loadTabsFromPersistedState(data)
}
} catch (e) {
console.error(
`Failed parsing persisted tab state, state:`,
window.localStorage.getItem("restTabState")
)
}
watchDebounced(
tabService.persistableTabState,
(state) => {
window.localStorage.setItem("restTabState", JSON.stringify(state))
},
{ debounce: 500, deep: true }
)
}
function setupGQLTabsPersistence() {
const tabService = getService(GQLTabService)
try {
const state = window.localStorage.getItem("gqlTabState")
if (state) {
const data = JSON.parse(state)
tabService.loadTabsFromPersistedState(data)
}
} catch (e) {
console.error(
`Failed parsing persisted tab state, state:`,
window.localStorage.getItem("gqlTabState")
)
}
watchDebounced(
tabService.persistableTabState,
(state) => {
window.localStorage.setItem("gqlTabState", JSON.stringify(state))
},
{ debounce: 500, deep: true }
)
}
export function setupLocalPersistence() {
checkAndMigrateOldSettings()
setupLocalStatePersistence()
setupSettingsPersistence()
setupRESTTabsPersistence()
setupGQLTabsPersistence()
setupHistoryPersistence()
setupCollectionsPersistence()
setupGlobalEnvsPersistence()
setupEnvironmentsPersistence()
setupSelectedEnvPersistence()
setupWebsocketPersistence()
setupSocketIOPersistence()
setupSSEPersistence()
setupMQTTPersistence()
}
/**
* Gets a value in LocalStorage.
*
* NOTE: Use LocalStorage to only store non-reactive simple data
* For more complex data, use stores and connect it to localpersistence
*/
export function getLocalConfig(name: string) {
return window.localStorage.getItem(name)
}
/**
* Sets a value in LocalStorage.
*
* NOTE: Use LocalStorage to only store non-reactive simple data
* For more complex data, use stores and connect it to localpersistence
*/
export function setLocalConfig(key: string, value: string) {
window.localStorage.setItem(key, value)
}
/**
* Clear config value in LocalStorage.
* @param key Key to be cleared
*/
export function removeLocalConfig(key: string) {
window.localStorage.removeItem(key)
}
/**
* The storage system we are using in the application.
* NOTE: This is a placeholder for being used in app.
* This entire redirection of localStorage is to allow for
* not refactoring the entire app code when we refactor when
* we are building the native (which may lack localStorage,
* or use a custom system)
*/
export const hoppLocalConfigStorage: StorageLike = localStorage

View File

@@ -94,7 +94,7 @@
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount, onBeforeMount } from "vue"
import { ref, onMounted, onBeforeUnmount } from "vue"
import { safelyExtractRESTRequest } from "@hoppscotch/data"
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
import { useRoute } from "vue-router"
@@ -114,7 +114,6 @@ import {
} from "rxjs"
import { useToast } from "~/composables/toast"
import { watchDebounced } from "@vueuse/core"
import { oauthRedirect } from "~/helpers/oauth"
import { useReadonlyStream } from "~/composables/stream"
import {
changeCurrentSyncStatus,
@@ -414,28 +413,6 @@ function setupTabStateSync() {
})
}
function oAuthURL() {
onBeforeMount(async () => {
try {
const tokenInfo = await oauthRedirect()
if (
typeof tokenInfo === "object" &&
tokenInfo.hasOwnProperty("access_token")
) {
if (
tabs.currentActiveTab.value.document.request.auth.authType ===
"oauth-2"
) {
tabs.currentActiveTab.value.document.request.auth.token =
tokenInfo.access_token
}
}
// eslint-disable-next-line no-empty
} catch (_) {}
})
}
defineActionHandler("contextmenu.open", ({ position, text }) => {
if (text) {
contextMenu.value = {
@@ -454,7 +431,6 @@ defineActionHandler("contextmenu.open", ({ position, text }) => {
setupTabStateSync()
bindRequestToURLParams()
oAuthURL()
defineActionHandler("rest.request.open", ({ doc }) => {
tabs.createNewTab(doc)

View File

@@ -0,0 +1,81 @@
<template>
<div class="flex items-center justify-center">
<HoppSmartSpinner />
</div>
</template>
<script setup lang="ts">
import { handleOAuthRedirect } from "~/helpers/oauth"
import { useToast } from "~/composables/toast"
import { useI18n } from "~/composables/i18n"
import * as E from "fp-ts/Either"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
import { onMounted } from "vue"
import { useRouter } from "vue-router"
const t = useI18n()
const router = useRouter()
const toast = useToast()
const tabs = useService(RESTTabService)
function translateOAuthRedirectError(error: string) {
switch (error) {
case "AUTH_SERVER_RETURNED_ERROR":
return t("authorization.oauth.redirect_auth_server_returned_error")
case "NO_AUTH_CODE":
return t("authorization.oauth.redirect_no_auth_code")
case "INVALID_STATE":
return t("authorization.oauth.redirect_invalid_state")
case "NO_TOKEN_ENDPOINT":
return t("authorization.oauth.redirect_no_token_endpoint")
case "NO_CLIENT_ID":
return t("authorization.oauth.redirect_no_client_id")
case "NO_CLIENT_SECRET":
return t("authorization.oauth.redirect_no_client_secret")
case "NO_CODE_VERIFIER":
return t("authorization.oauth.redirect_no_code_verifier")
case "AUTH_TOKEN_REQUEST_FAILED":
return t("authorization.oauth.redirect_auth_token_request_failed")
case "AUTH_TOKEN_REQUEST_INVALID_RESPONSE":
return t(
"authorization.oauth.redirect_auth_token_request_invalid_response"
)
default:
return t("authorization.oauth.something_went_wrong_on_oauth_redirect")
}
}
onMounted(async () => {
const tokenInfo = await handleOAuthRedirect()
if (E.isLeft(tokenInfo)) {
toast.error(translateOAuthRedirectError(tokenInfo.left))
router.push("/")
return
}
if (
tabs.currentActiveTab.value.document.request.auth.authType === "oauth-2"
) {
tabs.currentActiveTab.value.document.request.auth.token =
tokenInfo.right.access_token
router.push("/")
return
}
})
</script>

View File

@@ -143,8 +143,6 @@
</div>
</section>
<ProfileUserDelete />
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.sync") }}
@@ -180,7 +178,18 @@
</div>
</section>
<template v-if="platform.ui?.additionalProfileSections?.length">
<template
v-for="item in platform.ui?.additionalProfileSections"
:key="item.id"
>
<component :is="item" />
</template>
</template>
<ProfileShortcodes />
<ProfileUserDelete />
</div>
</HoppSmartTab>
<HoppSmartTab :id="'teams'" :label="t('team.title')">

View File

@@ -98,9 +98,9 @@
: null,
}"
:icon="IconGripVertical"
class="cursor-auto text-primary hover:text-primary"
class="opacity-0"
:class="{
'draggable-handle !cursor-grab group-hover:text-secondaryLight':
'draggable-handle cursor-grab group-hover:opacity-100':
index !== protocols?.length - 1,
}"
tabindex="-1"

View File

@@ -106,6 +106,15 @@
</section>
</div>
</div>
<template v-if="platform.ui?.additionalSettingsSections?.length">
<template
v-for="item in platform.ui?.additionalSettingsSections"
:key="item.id"
>
<component :is="item" />
</template>
</template>
</div>
<HoppSmartConfirmModal
:show="confirmRemove"

View File

@@ -10,6 +10,7 @@ import { InterceptorsPlatformDef } from "./interceptors"
import { HoppModule } from "~/modules"
import { InspectorsPlatformDef } from "./inspectors"
import { Service } from "dioc"
import { IOPlatformDef } from "./io"
export type PlatformDef = {
ui?: UIPlatformDef
@@ -17,6 +18,7 @@ export type PlatformDef = {
addedServices?: Array<typeof Service<unknown> & { ID: string }>
auth: AuthPlatformDef
analytics?: AnalyticsPlatformDef
io: IOPlatformDef
sync: {
environments: EnvironmentsPlatformDef
collections: CollectionsPlatformDef
@@ -29,6 +31,20 @@ export type PlatformDef = {
platformFeatureFlags: {
exportAsGIST: boolean
hasTelemetry: boolean
/**
* Whether the platform supports cookies (affects whether the cookies footer item is shown)
* If a value is not given, then the value is assumed to be false
*/
cookiesEnabled?: boolean
/**
* Whether the platform should prompt the user that cookies are being used.
* This will result in the user being notified a cookies advisory and is meant for web apps.
*
* If a value is not given, then the value is assumed to be true
*/
promptAsUsingCookies?: boolean
}
}

View File

@@ -0,0 +1,84 @@
/**
* Defines how to save a file to the user's filesystem.
*/
export type SaveFileWithDialogOptions = {
/**
* The data to be saved
*/
data: string | ArrayBuffer
/**
* The suggested filename for the file. This name will be shown in the
* save dialog by default when a save is initiated.
*/
suggestedFilename: string
/**
* The content type mime type of the data to be saved.
*
* NOTE: The usage of this data might be platform dependent.
* For example, this field is used in the web, but not in the desktop app.
*/
contentType: string
/**
* Defines the filters (like in Windows, on the right side, where you can
* select the file type) for the file dialog.
*
* NOTE: The usage of this data might be platform dependent.
* For example, this field is used in the web, but not in the desktop app.
*/
filters?: Array<{
/**
* The name of the filter (in Windows, if the filter looks
* like "Images (*.png, *.jpg)", the name would be "Images")
*/
name: string
/**
* The array of extensions that are supported, without the dot.
*/
extensions: string[]
}>
}
export type SaveFileResponse =
| {
/**
* The implementation was unable to determine the status of the save operation.
* This cannot be considered a success or a failure and should be handled as an uncertainity.
* The browser standard implementation (std) returns this value as there is no way to
* check if the user downloaded the file or not.
*/
type: "unknown"
}
| {
/**
* The result is known and the user cancelled the save.
*/
type: "cancelled"
}
| {
/**
* The result is known and the user saved the file.
*/
type: "saved"
/**
* The full path of where the file was saved
*/
path: string
}
/**
* Platform definitions for how to handle IO operations.
*/
export type IOPlatformDef = {
/**
* Defines how to save a file to the user's filesystem.
* The expected behaviour is for the browser to show a prompt to save the file.
*/
saveFileWithDialog: (
opts: SaveFileWithDialogOptions
) => Promise<SaveFileResponse>
}

View File

@@ -12,6 +12,7 @@ import { computed, readonly, ref } from "vue"
import { browserIsChrome, browserIsFirefox } from "~/helpers/utils/userAgent"
import SettingsExtension from "~/components/settings/Extension.vue"
import InterceptorsExtensionSubtitle from "~/components/interceptors/ExtensionSubtitle.vue"
import InterceptorsErrorPlaceholder from "~/components/interceptors/ErrorPlaceholder.vue"
export const defineSubscribableObject = <T extends object>(obj: T) => {
const proxyObject = {
@@ -208,7 +209,6 @@ export class ExtensionInterceptorService
req: AxiosRequestConfig
): RequestRunResult["response"] {
const extensionHook = window.__POSTWOMAN_EXTENSION_HOOK__
if (!extensionHook) {
return E.left(<InterceptorError>{
// TODO: i18n this
@@ -217,6 +217,7 @@ export class ExtensionInterceptorService
description: () => "Heading not found",
},
error: "NO_PW_EXT_HOOK",
component: InterceptorsErrorPlaceholder,
})
}
@@ -228,6 +229,7 @@ export class ExtensionInterceptorService
return E.right(result)
} catch (e) {
console.error(e)
// TODO: improve type checking
if ((e as any).response) {
return E.right((e as any).response)

View File

@@ -0,0 +1,37 @@
import { IOPlatformDef } from "../io"
import { pipe } from "fp-ts/function"
import * as S from "fp-ts/string"
import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
/**
* Implementation for how to handle IO operations in the browser.
*/
export const browserIODef: IOPlatformDef = {
saveFileWithDialog(opts) {
const file = new Blob([opts.data], { type: opts.contentType })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = pipe(
url,
S.split("/"),
RNEA.last,
S.split("#"),
RNEA.head,
S.split("?"),
RNEA.head
)
document.body.appendChild(a)
a.click()
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
// Browsers provide no way for us to know the save went successfully.
return Promise.resolve({ type: "unknown" })
},
}

View File

@@ -20,6 +20,11 @@ export type UIPlatformDef = {
appHeader?: {
paddingTop?: Ref<string>
paddingLeft?: Ref<string>
/**
* A function which is called when the header area of the app receives a click event
*/
onHeaderAreaClick?: () => void
}
onCodemirrorInstanceMount?: (element: HTMLElement) => void
@@ -33,4 +38,14 @@ export type UIPlatformDef = {
* Additional Support Options menu items shown in the app header
*/
additionalSupportOptionsMenuItems?: HoppSupportOptionsMenuItem[]
/**
* Additional Settings Section components in the settings page
*/
additionalSettingsSections?: Component[]
/**
* Additional profile Section components in the profile page
*/
additionalProfileSections?: Component[]
}

View File

@@ -1,38 +1,53 @@
import { describe, expect, it } from "vitest"
import { BannerContent, BannerService } from "../banner.service"
import { TestContainer } from "dioc/testing"
import { getI18n } from "~/modules/i18n"
import {
BannerService,
BANNER_PRIORITY_LOW,
BANNER_PRIORITY_HIGH,
BannerContent,
} from "../banner.service"
describe("BannerService", () => {
const container = new TestContainer()
const service = container.bind(BannerService)
const banner = container.bind(BannerService)
it("initally there are no banners defined", () => {
expect(service.content.value).toEqual(null)
})
it("should be able to set and retrieve banner content", () => {
const sampleBanner: BannerContent = {
it("should be able to show and remove a banner", () => {
const bannerContent: BannerContent = {
type: "info",
text: "Info Banner",
text: (t: ReturnType<typeof getI18n>) => t("Info Banner"),
score: BANNER_PRIORITY_LOW,
}
const banner = service.content
banner.value = sampleBanner
const retrievedBanner = service.content.value
const bannerId = banner.showBanner(bannerContent)
expect(banner.content.value).toEqual({
id: bannerId,
content: bannerContent,
})
expect(retrievedBanner).toEqual(sampleBanner)
banner.removeBanner(bannerId)
expect(banner.content.value).toBeNull()
})
it("should be able to update the banner content", () => {
const updatedBanner: BannerContent = {
type: "warning",
text: "Updated Banner Content",
alternateText: "Updated Banner",
it("should show the banner with the highest score", () => {
const lowPriorityBanner: BannerContent = {
type: "info",
text: (t: ReturnType<typeof getI18n>) => t("Low Priority Banner"),
score: BANNER_PRIORITY_LOW,
}
service.content.value = updatedBanner
const retrievedBanner = service.content.value
const highPriorityBanner: BannerContent = {
type: "warning",
text: (t: ReturnType<typeof getI18n>) => t("High Priority Banner"),
score: BANNER_PRIORITY_HIGH,
}
expect(retrievedBanner).toEqual(updatedBanner)
banner.showBanner(lowPriorityBanner)
const highPriorityBannerID = banner.showBanner(highPriorityBanner)
expect(banner.content.value).toEqual({
id: highPriorityBannerID,
content: highPriorityBanner,
})
})
})

View File

@@ -72,6 +72,61 @@ describe("InterceptorService", () => {
expect(service.currentInterceptorID.value).not.toEqual("unknown")
})
it("currentInterceptor points to the instance of the currently selected interceptor", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
const interceptor = {
interceptorID: "test",
name: () => "test interceptor",
selectable: { type: "selectable" as const },
runRequest: () => {
throw new Error("not implemented")
},
}
service.registerInterceptor(interceptor)
service.currentInterceptorID.value = "test"
expect(service.currentInterceptor.value).toBe(interceptor)
})
it("currentInterceptor updates when the currentInterceptorID changes", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
const interceptor = {
interceptorID: "test",
name: () => "test interceptor",
selectable: { type: "selectable" as const },
runRequest: () => {
throw new Error("not implemented")
},
}
const interceptor_2 = {
interceptorID: "test2",
name: () => "test interceptor",
selectable: { type: "selectable" as const },
runRequest: () => {
throw new Error("not implemented")
},
}
service.registerInterceptor(interceptor)
service.registerInterceptor(interceptor_2)
service.currentInterceptorID.value = "test"
expect(service.currentInterceptor.value).toBe(interceptor)
service.currentInterceptorID.value = "test2"
expect(service.currentInterceptor.value).not.toBe(interceptor)
expect(service.currentInterceptor.value).toBe(interceptor_2)
})
describe("registerInterceptor", () => {
it("should register the interceptor", () => {
const container = new TestContainer()

View File

@@ -1,16 +1,35 @@
import { Service } from "dioc"
import { ref } from "vue"
import { computed, ref } from "vue"
import { getI18n } from "~/modules/i18n"
export const BANNER_PRIORITY_LOW = 1
export const BANNER_PRIORITY_MEDIUM = 3
export const BANNER_PRIORITY_HIGH = 5
/**
* The different types of banners that can be used.
*/
export type BannerType = "info" | "warning" | "error"
export type BannerContent = {
type: BannerType
text: string
text: (t: ReturnType<typeof getI18n>) => string
// Can be used to display an alternate text when display size is small
alternateText?: string
alternateText?: (t: ReturnType<typeof getI18n>) => string
// Used to determine which banner should be displayed when multiple banners are present
score: number
}
export type Banner = {
id: number
content: BannerContent
}
// Returns the banner with the highest score
const getBannerWithHighestScore = (list: Banner[]) => {
if (list.length === 0) return null
else if (list.length === 1) return list[0]
else {
const highestScore = Math.max(...list.map((banner) => banner.content.score))
return list.find((banner) => banner.content.score === highestScore)
}
}
/**
@@ -20,9 +39,22 @@ export type BannerContent = {
export class BannerService extends Service {
public static readonly ID = "BANNER_SERVICE"
/**
* This is a reactive variable that can be used to set the contents of the banner
* and use it to render the banner on components.
*/
public content = ref<BannerContent | null>(null)
private bannerID = 0
private bannerList = ref<Banner[]>([])
public content = computed(() =>
getBannerWithHighestScore(this.bannerList.value)
)
public showBanner(banner: BannerContent) {
this.bannerID = this.bannerID + 1
this.bannerList.value.push({ id: this.bannerID, content: banner })
return this.bannerID
}
public removeBanner(id: number) {
this.bannerList.value = this.bannerList.value.filter(
(banner) => id !== banner.id
)
}
}

View File

@@ -0,0 +1,69 @@
import { Service } from "dioc"
import { ref } from "vue"
import { parseString as setCookieParse } from "set-cookie-parser-es"
export type CookieDef = {
name: string
value: string
domain: string
path: string
expires: string
}
export class CookieJarService extends Service {
public static readonly ID = "COOKIE_JAR_SERVICE"
/**
* The cookie jar that stores all relevant cookie info.
* The keys correspond to the domain of the cookie.
* The cookie strings are stored as an array of strings corresponding to the domain
*/
public cookieJar = ref(new Map<string, string[]>())
constructor() {
super()
}
public parseSetCookieString(setCookieString: string) {
return setCookieParse(setCookieString)
}
public bulkApplyCookiesToDomain(cookies: string[], domain: string) {
const existingDomainEntries = this.cookieJar.value.get(domain) ?? []
existingDomainEntries.push(...cookies)
this.cookieJar.value.set(domain, existingDomainEntries)
}
public getCookiesForURL(url: URL) {
const relevantDomains = Array.from(this.cookieJar.value.keys()).filter(
(domain) => url.hostname.endsWith(domain)
)
return relevantDomains
.flatMap((domain) => {
// Assemble the list of cookie entries from all the relevant domains
const cookieStrings = this.cookieJar.value.get(domain)! // We know not nullable from how we filter above
return cookieStrings.map((cookieString) =>
this.parseSetCookieString(cookieString)
)
})
.filter((cookie) => {
// Perform the required checks on the cookies
const passesPathCheck = url.pathname.startsWith(cookie.path ?? "/")
const passesExpiresCheck = !cookie.expires
? true
: cookie.expires.getTime() >= new Date().getTime()
const passesSecureCheck = !cookie.secure
? true
: url.protocol === "https:"
return passesPathCheck && passesExpiresCheck && passesSecureCheck
})
}
}

View File

@@ -4,6 +4,7 @@ import { HeaderInspectorService } from "../header.inspector"
import { InspectionService } from "../../index"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { ref } from "vue"
import { InterceptorService } from "~/services/interceptor.service"
vi.mock("~/modules/i18n", () => ({
__esModule: true,
@@ -58,5 +59,48 @@ describe("HeaderInspectorService", () => {
expect(result.value).toHaveLength(0)
})
it("should return an empty array when headers contain cookies but interceptor supports cookies", () => {
const container = new TestContainer()
container.bindMock(InterceptorService, {
currentInterceptor: ref({ supportsCookies: true }) as any,
})
const headerInspector = container.bind(HeaderInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [{ key: "Cookie", value: "some-cookie", active: true }],
})
const result = headerInspector.getInspections(req)
expect(result.value).toHaveLength(0)
})
it("should return an inspector result when headers contain cookies and the current interceptor doesn't support cookies", () => {
const container = new TestContainer()
container.bindMock(InterceptorService, {
currentInterceptor: ref({ supportsCookies: false }) as any,
})
const headerInspector = container.bind(HeaderInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [{ key: "Cookie", value: "some-cookie", active: true }],
})
const result = headerInspector.getInspections(req)
expect(result.value).not.toHaveLength(0)
expect(result.value).toContainEqual(
expect.objectContaining({ id: "header", isApplicable: true })
)
})
})
})

View File

@@ -4,6 +4,7 @@ import { getI18n } from "~/modules/i18n"
import { HoppRESTRequest } from "@hoppscotch/data"
import { Ref, computed, markRaw } from "vue"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
import { InterceptorService } from "~/services/interceptor.service"
/**
* This inspector is responsible for inspecting the header of a request.
@@ -19,6 +20,7 @@ export class HeaderInspectorService extends Service implements Inspector {
public readonly inspectorID = "header"
private readonly inspection = this.bind(InspectionService)
private readonly interceptorService = this.bind(InterceptorService)
constructor() {
super()
@@ -42,7 +44,10 @@ export class HeaderInspectorService extends Service implements Inspector {
const isContainCookies = headerKeys.includes("Cookie")
if (isContainCookies) {
if (
isContainCookies &&
!this.interceptorService.currentInterceptor.value?.supportsCookies
) {
headerKeys.forEach((headerKey, index) => {
if (this.cookiesCheck(headerKey)) {
results.push({

View File

@@ -29,6 +29,7 @@ export type InterceptorError =
description: (t: ReturnType<typeof getI18n>) => string
}
error?: unknown
component?: Component
}
/**
@@ -85,6 +86,12 @@ export type Interceptor<Err extends InterceptorError = InterceptorError> = {
*/
name: (t: ReturnType<typeof getI18n>) => MaybeRef<string>
/**
* Defines whether the interceptor has support for cookies.
* If this field is undefined, it is assumed as not supporting cookies.
*/
supportsCookies?: boolean
/**
* Defines what to render in the Interceptor section of the Settings page.
* Use this space to define interceptor specific settings.
@@ -161,6 +168,16 @@ export class InterceptorService extends Service {
Array.from(this.interceptors.values())
)
/**
* Gives an instance to the current interceptor.
* NOTE: Do not update from here, this is only for reading.
*/
public currentInterceptor = computed(() => {
if (this.currentInterceptorID.value === null) return null
return this.interceptors.get(this.currentInterceptorID.value)
})
constructor() {
super()

View File

@@ -0,0 +1,208 @@
import {
Environment,
HoppCollection,
HoppGQLRequest,
HoppRESTRequest,
} from "@hoppscotch/data"
import { HoppGQLDocument } from "~/helpers/graphql/document"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history"
import { SettingsDef, getDefaultSettings } from "~/newstore/settings"
import { PersistableTabState } from "~/services/tab"
type VUEX_DATA = {
postwoman: {
settings?: SettingsDef
collections?: HoppCollection<HoppRESTRequest>[]
collectionsGraphql?: HoppCollection<HoppGQLRequest>[]
environments?: Environment[]
}
}
const DEFAULT_SETTINGS = getDefaultSettings()
export const REST_COLLECTIONS_MOCK: HoppCollection<HoppRESTRequest>[] = [
{
v: 1,
name: "Echo",
folders: [],
requests: [
{
v: "1",
endpoint: "https://echo.hoppscotch.io",
name: "Echo test",
params: [],
headers: [],
method: "GET",
auth: { authType: "none", authActive: true },
preRequestScript: "",
testScript: "",
body: { contentType: null, body: null },
},
],
},
]
export const GQL_COLLECTIONS_MOCK: HoppCollection<HoppGQLRequest>[] = [
{
v: 1,
name: "Echo",
folders: [],
requests: [
{
v: 2,
name: "Echo test",
url: "https://echo.hoppscotch.io/graphql",
headers: [],
variables: '{\n "id": "1"\n}',
query: "query Request { url }",
auth: { authType: "none", authActive: true },
},
],
},
]
export const ENVIRONMENTS_MOCK: Environment[] = [
{
name: "globals",
variables: [
{
key: "test-global-key",
value: "test-global-value",
},
],
},
{ name: "Test", variables: [{ key: "test-key", value: "test-value" }] },
]
export const SELECTED_ENV_INDEX_MOCK = {
type: "MY_ENV",
index: 1,
}
export const WEBSOCKET_REQUEST_MOCK = {
endpoint: "wss://echo-websocket.hoppscotch.io",
protocols: [],
}
export const SOCKET_IO_REQUEST_MOCK = {
endpoint: "wss://echo-socketio.hoppscotch.io",
path: "/socket.io",
version: "v4",
}
export const SSE_REQUEST_MOCK = {
endpoint: "https://express-eventsource.herokuapp.com/events",
eventType: "data",
}
export const MQTT_REQUEST_MOCK = {
endpoint: "wss://test.mosquitto.org:8081",
clientID: "hoppscotch",
}
export const GLOBAL_ENV_MOCK: Environment["variables"] = [
{ key: "test-key", value: "test-value" },
]
export const VUEX_DATA_MOCK: VUEX_DATA = {
postwoman: {
settings: { ...DEFAULT_SETTINGS, THEME_COLOR: "purple" },
collections: REST_COLLECTIONS_MOCK,
collectionsGraphql: GQL_COLLECTIONS_MOCK,
environments: ENVIRONMENTS_MOCK,
},
}
export const REST_HISTORY_MOCK: RESTHistoryEntry[] = [
{
v: 1,
request: {
auth: { authType: "none", authActive: true },
body: { contentType: null, body: null },
endpoint: "https://echo.hoppscotch.io",
headers: [],
method: "GET",
name: "Untitled",
params: [],
preRequestScript: "",
testScript: "",
v: "1",
},
responseMeta: { duration: 807, statusCode: 200 },
star: false,
updatedOn: new Date("2023-11-07T05:27:32.951Z"),
},
]
export const GQL_HISTORY_MOCK: GQLHistoryEntry[] = [
{
v: 1,
request: {
v: 2,
name: "Untitled",
url: "https://echo.hoppscotch.io/graphql",
query: "query Request { url }",
headers: [],
variables: "",
auth: { authType: "none", authActive: true },
},
response: '{"data":{"url":"/graphql"}}',
star: false,
updatedOn: new Date("2023-11-07T05:28:21.073Z"),
},
]
export const GQL_TAB_STATE_MOCK: PersistableTabState<HoppGQLDocument> = {
lastActiveTabID: "5edbe8d4-65c9-4381-9354-5f1bf05d8ccc",
orderedDocs: [
{
tabID: "5edbe8d4-65c9-4381-9354-5f1bf05d8ccc",
doc: {
request: {
v: 2,
name: "Untitled",
url: "https://echo.hoppscotch.io/graphql",
headers: [],
variables: '{\n "id": "1"\n}',
query: "query Request { url }",
auth: { authType: "none", authActive: true },
},
isDirty: true,
optionTabPreference: "query",
response: null,
},
},
],
}
export const REST_TAB_STATE_MOCK: PersistableTabState<HoppRESTDocument> = {
lastActiveTabID: "e6e8d800-caa8-44a2-a6a6-b4765a3167aa",
orderedDocs: [
{
tabID: "e6e8d800-caa8-44a2-a6a6-b4765a3167aa",
doc: {
request: {
v: "1",
endpoint: "https://echo.hoppscotch.io",
name: "Echo test",
params: [],
headers: [],
method: "GET",
auth: { authType: "none", authActive: true },
preRequestScript: "",
testScript: "",
body: { contentType: null, body: null },
},
isDirty: false,
saveContext: {
originLocation: "user-collection",
folderPath: "0",
requestIndex: 0,
},
response: null,
},
},
],
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,724 @@
/* eslint-disable no-restricted-globals, no-restricted-syntax */
import {
Environment,
translateToNewGQLCollection,
translateToNewRESTCollection,
} from "@hoppscotch/data"
import { StorageLike, watchDebounced } from "@vueuse/core"
import { Service } from "dioc"
import { assign, clone, isEmpty } from "lodash-es"
import { z } from "zod"
import { GQLTabService } from "~/services/tab/graphql"
import { RESTTabService } from "~/services/tab/rest"
import { useToast } from "~/composables/toast"
import { MQTTRequest$, setMQTTRequest } from "../../newstore/MQTTSession"
import { SSERequest$, setSSERequest } from "../../newstore/SSESession"
import { SIORequest$, setSIORequest } from "../../newstore/SocketIOSession"
import { WSRequest$, setWSRequest } from "../../newstore/WebSocketSession"
import {
graphqlCollectionStore,
restCollectionStore,
setGraphqlCollections,
setRESTCollections,
} from "../../newstore/collections"
import {
addGlobalEnvVariable,
environments$,
globalEnv$,
replaceEnvironments,
selectedEnvironmentIndex$,
setGlobalEnvVariables,
setSelectedEnvironmentIndex,
} from "../../newstore/environments"
import {
graphqlHistoryStore,
restHistoryStore,
setGraphqlHistoryEntries,
setRESTHistoryEntries,
translateToNewGQLHistory,
translateToNewRESTHistory,
} from "../../newstore/history"
import { bulkApplyLocalState, localStateStore } from "../../newstore/localstate"
import {
HoppAccentColor,
HoppBgColor,
applySetting,
bulkApplySettings,
getDefaultSettings,
performSettingsDataMigrations,
settingsStore,
} from "../../newstore/settings"
import {
ENVIRONMENTS_SCHEMA,
GLOBAL_ENV_SCHEMA,
GQL_COLLECTION_SCHEMA,
GQL_HISTORY_ENTRY_SCHEMA,
GQL_TAB_STATE_SCHEMA,
LOCAL_STATE_SCHEMA,
MQTT_REQUEST_SCHEMA,
NUXT_COLOR_MODE_SCHEMA,
REST_COLLECTION_SCHEMA,
REST_HISTORY_ENTRY_SCHEMA,
REST_TAB_STATE_SCHEMA,
SELECTED_ENV_INDEX_SCHEMA,
SETTINGS_SCHEMA,
SOCKET_IO_REQUEST_SCHEMA,
SSE_REQUEST_SCHEMA,
THEME_COLOR_SCHEMA,
VUEX_SCHEMA,
WEBSOCKET_REQUEST_SCHEMA,
} from "./validation-schemas"
/**
* This service compiles persistence logic across the codebase
*/
export class PersistenceService extends Service {
public static readonly ID = "PERSISTENCE_SERVICE"
private readonly restTabService = this.bind(RESTTabService)
private readonly gqlTabService = this.bind(GQLTabService)
public hoppLocalConfigStorage: StorageLike = localStorage
constructor() {
super()
}
private showErrorToast(localStorageKey: string) {
const toast = useToast()
toast.error(
`There's a mismatch with the expected schema for the value corresponding to ${localStorageKey} read from localStorage, keeping a backup in ${localStorageKey}-backup`
)
}
private checkAndMigrateOldSettings() {
if (window.localStorage.getItem("selectedEnvIndex")) {
const index = window.localStorage.getItem("selectedEnvIndex")
if (index) {
if (index === "-1") {
window.localStorage.setItem(
"selectedEnvIndex",
JSON.stringify({
type: "NO_ENV_SELECTED",
})
)
} else if (Number(index) >= 0) {
window.localStorage.setItem(
"selectedEnvIndex",
JSON.stringify({
type: "MY_ENV",
index: parseInt(index),
})
)
}
}
}
const vuexKey = "vuex"
let vuexData = JSON.parse(window.localStorage.getItem(vuexKey) || "{}")
if (isEmpty(vuexData)) return
// Validate data read from localStorage
const result = VUEX_SCHEMA.safeParse(vuexData)
if (result.success) {
vuexData = result.data
} else {
this.showErrorToast(vuexKey)
window.localStorage.setItem(`${vuexKey}-backup`, JSON.stringify(vuexData))
}
const { postwoman } = vuexData
if (!isEmpty(postwoman?.settings)) {
const settingsData = assign(
clone(getDefaultSettings()),
postwoman.settings
)
window.localStorage.setItem("settings", JSON.stringify(settingsData))
delete postwoman.settings
window.localStorage.setItem(vuexKey, JSON.stringify(vuexData))
}
if (postwoman?.collections) {
window.localStorage.setItem(
"collections",
JSON.stringify(postwoman.collections)
)
delete postwoman.collections
window.localStorage.setItem(vuexKey, JSON.stringify(vuexData))
}
if (postwoman?.collectionsGraphql) {
window.localStorage.setItem(
"collectionsGraphql",
JSON.stringify(postwoman.collectionsGraphql)
)
delete postwoman.collectionsGraphql
window.localStorage.setItem(vuexKey, JSON.stringify(vuexData))
}
if (postwoman?.environments) {
window.localStorage.setItem(
"environments",
JSON.stringify(postwoman.environments)
)
delete postwoman.environments
window.localStorage.setItem(vuexKey, JSON.stringify(vuexData))
}
const themeColorKey = "THEME_COLOR"
let themeColorValue = window.localStorage.getItem(themeColorKey)
if (themeColorValue) {
// Validate data read from localStorage
const result = THEME_COLOR_SCHEMA.safeParse(themeColorValue)
if (result.success) {
themeColorValue = result.data
} else {
this.showErrorToast(themeColorKey)
window.localStorage.setItem(`${themeColorKey}-backup`, themeColorValue)
}
applySetting(themeColorKey, themeColorValue as HoppAccentColor)
window.localStorage.removeItem(themeColorKey)
}
const nuxtColorModeKey = "nuxt-color-mode"
let nuxtColorModeValue = window.localStorage.getItem(nuxtColorModeKey)
if (nuxtColorModeValue) {
// Validate data read from localStorage
const result = NUXT_COLOR_MODE_SCHEMA.safeParse(nuxtColorModeValue)
if (result.success) {
nuxtColorModeValue = result.data
} else {
this.showErrorToast(nuxtColorModeKey)
window.localStorage.setItem(
`${nuxtColorModeKey}-backup`,
nuxtColorModeValue
)
}
applySetting("BG_COLOR", nuxtColorModeValue as HoppBgColor)
window.localStorage.removeItem(nuxtColorModeKey)
}
}
public setupLocalStatePersistence() {
const localStateKey = "localState"
let localStateData = JSON.parse(
window.localStorage.getItem(localStateKey) ?? "{}"
)
// Validate data read from localStorage
const result = LOCAL_STATE_SCHEMA.safeParse(localStateData)
if (result.success) {
localStateData = result.data
} else {
this.showErrorToast(localStateKey)
window.localStorage.setItem(
`${localStateKey}-backup`,
JSON.stringify(localStateData)
)
}
if (localStateData) bulkApplyLocalState(localStateData)
localStateStore.subject$.subscribe((state) => {
window.localStorage.setItem(localStateKey, JSON.stringify(state))
})
}
private setupSettingsPersistence() {
const settingsKey = "settings"
let settingsData = JSON.parse(
window.localStorage.getItem(settingsKey) || "{}"
)
// Validate data read from localStorage
const result = SETTINGS_SCHEMA.safeParse(settingsData)
if (result.success) {
settingsData = result.data
} else {
this.showErrorToast(settingsKey)
window.localStorage.setItem(
`${settingsKey}-backup`,
JSON.stringify(settingsData)
)
}
const updatedSettings = settingsData
? performSettingsDataMigrations(settingsData)
: settingsData
if (updatedSettings) {
bulkApplySettings(updatedSettings)
}
settingsStore.subject$.subscribe((settings) => {
window.localStorage.setItem(settingsKey, JSON.stringify(settings))
})
}
private setupHistoryPersistence() {
const restHistoryKey = "history"
let restHistoryData = JSON.parse(
window.localStorage.getItem(restHistoryKey) || "[]"
)
const graphqlHistoryKey = "graphqlHistory"
let graphqlHistoryData = JSON.parse(
window.localStorage.getItem(graphqlHistoryKey) || "[]"
)
// Validate data read from localStorage
const restHistorySchemaParsedresult = z
.array(REST_HISTORY_ENTRY_SCHEMA)
.safeParse(restHistoryData)
if (restHistorySchemaParsedresult.success) {
restHistoryData = restHistorySchemaParsedresult.data
} else {
this.showErrorToast(restHistoryKey)
window.localStorage.setItem(
`${restHistoryKey}-backup`,
JSON.stringify(restHistoryData)
)
}
const gqlHistorySchemaParsedresult = z
.array(GQL_HISTORY_ENTRY_SCHEMA)
.safeParse(graphqlHistoryData)
if (gqlHistorySchemaParsedresult.success) {
graphqlHistoryData = gqlHistorySchemaParsedresult.data
} else {
this.showErrorToast(graphqlHistoryKey)
window.localStorage.setItem(
`${graphqlHistoryKey}-backup`,
JSON.stringify(graphqlHistoryData)
)
}
const translatedRestHistoryData = restHistoryData.map(
translateToNewRESTHistory
)
const translatedGraphqlHistoryData = graphqlHistoryData.map(
translateToNewGQLHistory
)
setRESTHistoryEntries(translatedRestHistoryData)
setGraphqlHistoryEntries(translatedGraphqlHistoryData)
restHistoryStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem(restHistoryKey, JSON.stringify(state))
})
graphqlHistoryStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem(graphqlHistoryKey, JSON.stringify(state))
})
}
private setupCollectionsPersistence() {
const restCollectionsKey = "collections"
let restCollectionsData = JSON.parse(
window.localStorage.getItem(restCollectionsKey) || "[]"
)
const graphqlCollectionsKey = "collectionsGraphql"
let graphqlCollectionsData = JSON.parse(
window.localStorage.getItem(graphqlCollectionsKey) || "[]"
)
// Validate data read from localStorage
const restCollectionsSchemaParsedresult = z
.array(REST_COLLECTION_SCHEMA)
.safeParse(restCollectionsData)
if (restCollectionsSchemaParsedresult.success) {
restCollectionsData = restCollectionsSchemaParsedresult.data
} else {
this.showErrorToast(restCollectionsKey)
window.localStorage.setItem(
`${restCollectionsKey}-backup`,
JSON.stringify(restCollectionsData)
)
}
const gqlCollectionsSchemaParsedresult = z
.array(GQL_COLLECTION_SCHEMA)
.safeParse(graphqlCollectionsData)
if (gqlCollectionsSchemaParsedresult.success) {
graphqlCollectionsData = gqlCollectionsSchemaParsedresult.data
} else {
this.showErrorToast(graphqlCollectionsKey)
window.localStorage.setItem(
`${graphqlCollectionsKey}-backup`,
JSON.stringify(graphqlCollectionsData)
)
}
const translatedRestCollectionsData = restCollectionsData.map(
translateToNewRESTCollection
)
const translatedGraphqlCollectionsData = graphqlCollectionsData.map(
translateToNewGQLCollection
)
setRESTCollections(translatedRestCollectionsData)
setGraphqlCollections(translatedGraphqlCollectionsData)
restCollectionStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem(restCollectionsKey, JSON.stringify(state))
})
graphqlCollectionStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem(graphqlCollectionsKey, JSON.stringify(state))
})
}
private setupEnvironmentsPersistence() {
const environmentsKey = "environments"
let environmentsData: Environment[] = JSON.parse(
window.localStorage.getItem(environmentsKey) || "[]"
)
// Validate data read from localStorage
const result = ENVIRONMENTS_SCHEMA.safeParse(environmentsData)
if (result.success) {
environmentsData = result.data
} else {
this.showErrorToast(environmentsKey)
window.localStorage.setItem(
`${environmentsKey}-backup`,
JSON.stringify(environmentsData)
)
}
// Check if a global env is defined and if so move that to globals
const globalIndex = environmentsData.findIndex(
(x) => x.name.toLowerCase() === "globals"
)
if (globalIndex !== -1) {
const globalEnv = environmentsData[globalIndex]
globalEnv.variables.forEach((variable) => addGlobalEnvVariable(variable))
// Remove global from environments
environmentsData.splice(globalIndex, 1)
// Just sync the changes manually
window.localStorage.setItem(
environmentsKey,
JSON.stringify(environmentsData)
)
}
replaceEnvironments(environmentsData)
environments$.subscribe((envs) => {
window.localStorage.setItem(environmentsKey, JSON.stringify(envs))
})
}
private setupSelectedEnvPersistence() {
const selectedEnvIndexKey = "selectedEnvIndex"
let selectedEnvIndexValue = JSON.parse(
window.localStorage.getItem(selectedEnvIndexKey) ?? "null"
)
// Validate data read from localStorage
const result = SELECTED_ENV_INDEX_SCHEMA.safeParse(selectedEnvIndexValue)
if (result.success) {
selectedEnvIndexValue = result.data
} else {
this.showErrorToast(selectedEnvIndexKey)
window.localStorage.setItem(
`${selectedEnvIndexKey}-backup`,
JSON.stringify(selectedEnvIndexValue)
)
}
// If there is a selected env index, set it to the store else set it to null
if (selectedEnvIndexValue) {
setSelectedEnvironmentIndex(selectedEnvIndexValue)
} else {
setSelectedEnvironmentIndex({
type: "NO_ENV_SELECTED",
})
}
selectedEnvironmentIndex$.subscribe((envIndex) => {
window.localStorage.setItem(selectedEnvIndexKey, JSON.stringify(envIndex))
})
}
private setupWebsocketPersistence() {
const wsRequestKey = "WebsocketRequest"
let wsRequestData = JSON.parse(
window.localStorage.getItem(wsRequestKey) || "null"
)
// Validate data read from localStorage
const result = WEBSOCKET_REQUEST_SCHEMA.safeParse(wsRequestData)
if (result.success) {
wsRequestData = result.data
} else {
this.showErrorToast(wsRequestKey)
window.localStorage.setItem(
`${wsRequestKey}-backup`,
JSON.stringify(wsRequestData)
)
}
setWSRequest(wsRequestData)
WSRequest$.subscribe((req) => {
window.localStorage.setItem(wsRequestKey, JSON.stringify(req))
})
}
private setupSocketIOPersistence() {
const sioRequestKey = "SocketIORequest"
let sioRequestData = JSON.parse(
window.localStorage.getItem(sioRequestKey) || "null"
)
// Validate data read from localStorage
const result = SOCKET_IO_REQUEST_SCHEMA.safeParse(sioRequestData)
if (result.success) {
sioRequestData = result.data
} else {
this.showErrorToast(sioRequestKey)
window.localStorage.setItem(
`${sioRequestKey}-backup`,
JSON.stringify(sioRequestData)
)
}
setSIORequest(sioRequestData)
SIORequest$.subscribe((req) => {
window.localStorage.setItem(sioRequestKey, JSON.stringify(req))
})
}
private setupSSEPersistence() {
const sseRequestKey = "SSERequest"
let sseRequestData = JSON.parse(
window.localStorage.getItem(sseRequestKey) || "null"
)
// Validate data read from localStorage
const result = SSE_REQUEST_SCHEMA.safeParse(sseRequestData)
if (result.success) {
sseRequestData = result.data
} else {
this.showErrorToast(sseRequestKey)
window.localStorage.setItem(
`${sseRequestKey}-backup`,
JSON.stringify(sseRequestData)
)
}
setSSERequest(sseRequestData)
SSERequest$.subscribe((req) => {
window.localStorage.setItem(sseRequestKey, JSON.stringify(req))
})
}
private setupMQTTPersistence() {
const mqttRequestKey = "MQTTRequest"
let mqttRequestData = JSON.parse(
window.localStorage.getItem(mqttRequestKey) || "null"
)
// Validate data read from localStorage
const result = MQTT_REQUEST_SCHEMA.safeParse(mqttRequestData)
if (result.success) {
mqttRequestData = result.data
} else {
this.showErrorToast(mqttRequestKey)
window.localStorage.setItem(
`${mqttRequestKey}-backup`,
JSON.stringify(mqttRequestData)
)
}
setMQTTRequest(mqttRequestData)
MQTTRequest$.subscribe((req) => {
window.localStorage.setItem(mqttRequestKey, JSON.stringify(req))
})
}
private setupGlobalEnvsPersistence() {
const globalEnvKey = "globalEnv"
let globalEnvData: Environment["variables"] = JSON.parse(
window.localStorage.getItem(globalEnvKey) || "[]"
)
// Validate data read from localStorage
const result = GLOBAL_ENV_SCHEMA.safeParse(globalEnvData)
if (result.success) {
globalEnvData = result.data
} else {
this.showErrorToast(globalEnvKey)
window.localStorage.setItem(
`${globalEnvKey}-backup`,
JSON.stringify(globalEnvData)
)
}
setGlobalEnvVariables(globalEnvData)
globalEnv$.subscribe((vars) => {
window.localStorage.setItem(globalEnvKey, JSON.stringify(vars))
})
}
private setupGQLTabsPersistence() {
const gqlTabStateKey = "gqlTabState"
const gqlTabStateData = window.localStorage.getItem(gqlTabStateKey)
try {
if (gqlTabStateData) {
let parsedGqlTabStateData = JSON.parse(gqlTabStateData)
// Validate data read from localStorage
const result = GQL_TAB_STATE_SCHEMA.safeParse(parsedGqlTabStateData)
if (result.success) {
parsedGqlTabStateData = result.data
} else {
this.showErrorToast(gqlTabStateKey)
window.localStorage.setItem(
`${gqlTabStateKey}-backup`,
JSON.stringify(parsedGqlTabStateData)
)
}
this.gqlTabService.loadTabsFromPersistedState(parsedGqlTabStateData)
}
} catch (e) {
console.error(
`Failed parsing persisted tab state, state:`,
gqlTabStateData
)
}
watchDebounced(
this.gqlTabService.persistableTabState,
(newGqlTabStateData) => {
window.localStorage.setItem(
gqlTabStateKey,
JSON.stringify(newGqlTabStateData)
)
},
{ debounce: 500, deep: true }
)
}
private setupRESTTabsPersistence() {
const restTabStateKey = "restTabState"
const restTabStateData = window.localStorage.getItem(restTabStateKey)
try {
if (restTabStateData) {
let parsedGqlTabStateData = JSON.parse(restTabStateData)
// Validate data read from localStorage
const result = REST_TAB_STATE_SCHEMA.safeParse(parsedGqlTabStateData)
if (result.success) {
parsedGqlTabStateData = result.data
} else {
this.showErrorToast(restTabStateKey)
window.localStorage.setItem(
`${restTabStateKey}-backup`,
JSON.stringify(parsedGqlTabStateData)
)
}
this.restTabService.loadTabsFromPersistedState(parsedGqlTabStateData)
}
} catch (e) {
console.error(
`Failed parsing persisted tab state, state:`,
restTabStateData
)
}
watchDebounced(
this.restTabService.persistableTabState,
(newRestTabStateData) => {
window.localStorage.setItem(
restTabStateKey,
JSON.stringify(newRestTabStateData)
)
},
{ debounce: 500, deep: true }
)
}
public setupLocalPersistence() {
this.checkAndMigrateOldSettings()
this.setupLocalStatePersistence()
this.setupSettingsPersistence()
this.setupRESTTabsPersistence()
this.setupGQLTabsPersistence()
this.setupHistoryPersistence()
this.setupCollectionsPersistence()
this.setupGlobalEnvsPersistence()
this.setupEnvironmentsPersistence()
this.setupSelectedEnvPersistence()
this.setupWebsocketPersistence()
this.setupSocketIOPersistence()
this.setupSSEPersistence()
this.setupMQTTPersistence()
}
/**
* Gets a value from localStorage
*
* NOTE: Use localStorage to only store non-reactive simple data
* For more complex data, use stores and connect it to `PersistenceService`
*/
public getLocalConfig(name: string) {
return window.localStorage.getItem(name)
}
/**
* Sets a value in localStorage
*
* NOTE: Use localStorage to only store non-reactive simple data
* For more complex data, use stores and connect it to `PersistenceService`
*/
public setLocalConfig(key: string, value: string) {
window.localStorage.setItem(key, value)
}
/**
* Clear config value in localStorage
*/
public removeLocalConfig(key: string) {
window.localStorage.removeItem(key)
}
}

View File

@@ -0,0 +1,470 @@
import {
Environment,
GQLHeader,
HoppGQLAuth,
HoppGQLRequest,
HoppRESTRequest,
} from "@hoppscotch/data"
import { entityReference } from "verzod"
import { z } from "zod"
import { HoppAccentColors, HoppBgColors } from "~/newstore/settings"
const ThemeColorSchema = z.enum([
"green",
"teal",
"blue",
"indigo",
"purple",
"yellow",
"orange",
"red",
"pink",
])
const BgColorSchema = z.enum(["system", "light", "dark", "black"])
const SettingsDefSchema = z.object({
syncCollections: z.boolean(),
syncHistory: z.boolean(),
syncEnvironments: z.boolean(),
PROXY_URL: z.string(),
CURRENT_INTERCEPTOR_ID: z.string(),
URL_EXCLUDES: z.object({
auth: z.boolean(),
httpUser: z.boolean(),
httpPassword: z.boolean(),
bearerToken: z.boolean(),
oauth2Token: z.boolean(),
}),
THEME_COLOR: ThemeColorSchema,
BG_COLOR: BgColorSchema,
TELEMETRY_ENABLED: z.boolean(),
EXPAND_NAVIGATION: z.boolean(),
SIDEBAR: z.boolean(),
SIDEBAR_ON_LEFT: z.boolean(),
COLUMN_LAYOUT: z.boolean(),
})
// Common properties shared across REST & GQL collections
const HoppCollectionSchemaCommonProps = z
.object({
v: z.number(),
name: z.string(),
id: z.optional(z.string()),
})
.strict()
const HoppRESTRequestSchema = entityReference(HoppRESTRequest)
const HoppGQLRequestSchema = entityReference(HoppGQLRequest)
// @ts-expect-error recursive schema
const HoppRESTCollectionSchema = HoppCollectionSchemaCommonProps.extend({
folders: z.array(z.lazy(() => HoppRESTCollectionSchema)),
requests: z.optional(z.array(HoppRESTRequestSchema)),
}).strict()
// @ts-expect-error recursive schema
const HoppGQLCollectionSchema = HoppCollectionSchemaCommonProps.extend({
folders: z.array(z.lazy(() => HoppGQLCollectionSchema)),
requests: z.optional(z.array(HoppGQLRequestSchema)),
}).strict()
export const VUEX_SCHEMA = z.object({
postwoman: z.optional(
z.object({
settings: z.optional(SettingsDefSchema),
//! Versioned entities
collections: z.optional(z.array(HoppRESTCollectionSchema)),
collectionsGraphql: z.optional(z.array(HoppGQLCollectionSchema)),
environments: z.optional(z.array(entityReference(Environment))),
})
),
})
export const THEME_COLOR_SCHEMA = z.enum(HoppAccentColors)
export const NUXT_COLOR_MODE_SCHEMA = z.enum(HoppBgColors)
export const LOCAL_STATE_SCHEMA = z.union([
z.object({}).strict(),
z
.object({
REMEMBERED_TEAM_ID: z.optional(z.string()),
})
.strict(),
])
export const SETTINGS_SCHEMA = z.union([
z.object({}).strict(),
SettingsDefSchema.extend({
EXTENSIONS_ENABLED: z.optional(z.boolean()),
PROXY_ENABLED: z.optional(z.boolean()),
}),
])
export const REST_HISTORY_ENTRY_SCHEMA = z
.object({
v: z.number(),
//! Versioned entity
request: HoppRESTRequestSchema,
responseMeta: z
.object({
duration: z.nullable(z.number()),
statusCode: z.nullable(z.number()),
})
.strict(),
star: z.boolean(),
id: z.optional(z.string()),
updatedOn: z.optional(z.union([z.date(), z.string()])),
})
.strict()
export const GQL_HISTORY_ENTRY_SCHEMA = z
.object({
v: z.number(),
//! Versioned entity
request: HoppGQLRequestSchema,
response: z.string(),
star: z.boolean(),
id: z.optional(z.string()),
updatedOn: z.optional(z.union([z.date(), z.string()])),
})
.strict()
export const REST_COLLECTION_SCHEMA = HoppRESTCollectionSchema
export const GQL_COLLECTION_SCHEMA = HoppGQLCollectionSchema
export const ENVIRONMENTS_SCHEMA = z.array(entityReference(Environment))
export const SELECTED_ENV_INDEX_SCHEMA = z.nullable(
z.discriminatedUnion("type", [
z
.object({
type: z.literal("NO_ENV_SELECTED"),
})
.strict(),
z
.object({
type: z.literal("MY_ENV"),
index: z.number(),
})
.strict(),
z.object({
type: z.literal("TEAM_ENV"),
teamID: z.string(),
teamEnvID: z.string(),
// ! Versioned entity
environment: entityReference(Environment),
}),
])
)
export const WEBSOCKET_REQUEST_SCHEMA = z.nullable(
z
.object({
endpoint: z.string(),
protocols: z.array(
z
.object({
value: z.string(),
active: z.boolean(),
})
.strict()
),
})
.strict()
)
export const SOCKET_IO_REQUEST_SCHEMA = z.nullable(
z
.object({
endpoint: z.string(),
path: z.string(),
version: z.union([z.literal("v4"), z.literal("v3"), z.literal("v2")]),
})
.strict()
)
export const SSE_REQUEST_SCHEMA = z.nullable(
z
.object({
endpoint: z.string(),
eventType: z.string(),
})
.strict()
)
export const MQTT_REQUEST_SCHEMA = z.nullable(
z
.object({
endpoint: z.string(),
clientID: z.string(),
})
.strict()
)
export const GLOBAL_ENV_SCHEMA = z.union([
z.array(z.never()),
z.array(
z
.object({
key: z.string(),
value: z.string(),
})
.strict()
),
])
const OperationTypeSchema = z.enum([
"subscription",
"query",
"mutation",
"teardown",
])
const RunQueryOptionsSchema = z
.object({
name: z.optional(z.string()),
url: z.string(),
headers: z.array(GQLHeader),
query: z.string(),
variables: z.string(),
auth: HoppGQLAuth,
operationName: z.optional(z.string()),
operationType: OperationTypeSchema,
})
.strict()
const HoppGQLSaveContextSchema = z.nullable(
z.discriminatedUnion("originLocation", [
z
.object({
originLocation: z.literal("user-collection"),
folderPath: z.string(),
requestIndex: z.number(),
})
.strict(),
z
.object({
originLocation: z.literal("team-collection"),
requestID: z.string(),
teamID: z.optional(z.string()),
collectionID: z.optional(z.string()),
})
.strict(),
])
)
const GQLResponseEventSchema = z.array(
z
.object({
time: z.number(),
operationName: z.optional(z.string()),
operationType: OperationTypeSchema,
data: z.string(),
rawQuery: z.optional(RunQueryOptionsSchema),
})
.strict()
)
const validGqlOperations = [
"query",
"headers",
"variables",
"authorization",
] as const
export const GQL_TAB_STATE_SCHEMA = z
.object({
lastActiveTabID: z.string(),
orderedDocs: z.array(
z.object({
tabID: z.string(),
doc: z
.object({
// Versioned entity
request: entityReference(HoppGQLRequest),
isDirty: z.boolean(),
saveContext: z.optional(HoppGQLSaveContextSchema),
response: z.optional(z.nullable(GQLResponseEventSchema)),
responseTabPreference: z.optional(z.string()),
optionTabPreference: z.optional(z.enum(validGqlOperations)),
})
.strict(),
})
),
})
.strict()
const HoppTestExpectResultSchema = z
.object({
status: z.enum(["fail", "pass", "error"]),
message: z.string(),
})
.strict()
// @ts-expect-error recursive schema
const HoppTestDataSchema = z.lazy(() =>
z
.object({
description: z.string(),
expectResults: z.array(HoppTestExpectResultSchema),
tests: z.array(HoppTestDataSchema),
})
.strict()
)
const EnvironmentVariablesSchema = z
.object({
key: z.string(),
value: z.string(),
})
.strict()
const HoppTestResultSchema = z
.object({
tests: z.array(HoppTestDataSchema),
expectResults: z.array(HoppTestExpectResultSchema),
description: z.string(),
scriptError: z.boolean(),
envDiff: z
.object({
global: z
.object({
additions: z.array(EnvironmentVariablesSchema),
updations: z.array(
EnvironmentVariablesSchema.extend({ previousValue: z.string() })
),
deletions: z.array(EnvironmentVariablesSchema),
})
.strict(),
selected: z
.object({
additions: z.array(EnvironmentVariablesSchema),
updations: z.array(
EnvironmentVariablesSchema.extend({ previousValue: z.string() })
),
deletions: z.array(EnvironmentVariablesSchema),
})
.strict(),
})
.strict(),
})
.strict()
const HoppRESTResponseHeaderSchema = z
.object({
key: z.string(),
value: z.string(),
})
.strict()
const HoppRESTResponseSchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("loading"),
// !Versioned entity
req: HoppRESTRequestSchema,
})
.strict(),
z
.object({
type: z.literal("fail"),
headers: z.array(HoppRESTResponseHeaderSchema),
body: z.instanceof(ArrayBuffer),
statusCode: z.number(),
meta: z
.object({
responseSize: z.number(),
responseDuration: z.number(),
})
.strict(),
// !Versioned entity
req: HoppRESTRequestSchema,
})
.strict(),
z
.object({
type: z.literal("network_fail"),
error: z.unknown(),
// !Versioned entity
req: HoppRESTRequestSchema,
})
.strict(),
z
.object({
type: z.literal("script_fail"),
error: z.instanceof(Error),
})
.strict(),
z
.object({
type: z.literal("success"),
headers: z.array(HoppRESTResponseHeaderSchema),
body: z.instanceof(ArrayBuffer),
statusCode: z.number(),
meta: z
.object({
responseSize: z.number(),
responseDuration: z.number(),
})
.strict(),
// !Versioned entity
req: HoppRESTRequestSchema,
})
.strict(),
])
const HoppRESTSaveContextSchema = z.nullable(
z.discriminatedUnion("originLocation", [
z
.object({
originLocation: z.literal("user-collection"),
folderPath: z.string(),
requestIndex: z.number(),
})
.strict(),
z
.object({
originLocation: z.literal("team-collection"),
requestID: z.string(),
teamID: z.optional(z.string()),
collectionID: z.optional(z.string()),
})
.strict(),
])
)
const validRestOperations = [
"params",
"bodyParams",
"headers",
"authorization",
"preRequestScript",
"tests",
] as const
export const REST_TAB_STATE_SCHEMA = z
.object({
lastActiveTabID: z.string(),
orderedDocs: z.array(
z.object({
tabID: z.string(),
doc: z
.object({
// !Versioned entity
request: entityReference(HoppRESTRequest),
isDirty: z.boolean(),
saveContext: z.optional(HoppRESTSaveContextSchema),
response: z.optional(z.nullable(HoppRESTResponseSchema)),
testResults: z.optional(z.nullable(HoppTestResultSchema)),
responseTabPreference: z.optional(z.string()),
optionTabPreference: z.optional(z.enum(validRestOperations)),
})
.strict(),
})
),
})
.strict()

View File

@@ -43,7 +43,7 @@
"io-ts": "^2.2.20",
"lodash": "^4.17.21",
"parser-ts": "^0.7.0",
"verzod": "^0.1.1",
"verzod": "^0.2.2",
"zod": "^3.22.4"
}
}

View File

@@ -2,9 +2,9 @@ import { z } from "zod"
import { defineVersion } from "verzod"
export const GQLHeader = z.object({
key: z.string(),
value: z.string(),
active: z.boolean()
key: z.string().catch(""),
value: z.string().catch(""),
active: z.boolean().catch(true)
})
export type GQLHeader = z.infer<typeof GQLHeader>
@@ -13,7 +13,7 @@ export const V1_SCHEMA = z.object({
v: z.literal(1),
name: z.string(),
url: z.string(),
headers: z.array(GQLHeader),
headers: z.array(GQLHeader).catch([]),
query: z.string(),
variables: z.string(),
})

View File

@@ -11,8 +11,8 @@ export type HoppGQLAuthNone = z.infer<typeof HoppGQLAuthNone>
export const HoppGQLAuthBasic = z.object({
authType: z.literal("basic"),
username: z.string(),
password: z.string()
username: z.string().catch(""),
password: z.string().catch("")
})
export type HoppGQLAuthBasic = z.infer<typeof HoppGQLAuthBasic>
@@ -20,7 +20,7 @@ export type HoppGQLAuthBasic = z.infer<typeof HoppGQLAuthBasic>
export const HoppGQLAuthBearer = z.object({
authType: z.literal("bearer"),
token: z.string()
token: z.string().catch("")
})
export type HoppGQLAuthBearer = z.infer<typeof HoppGQLAuthBearer>
@@ -28,12 +28,12 @@ export type HoppGQLAuthBearer = z.infer<typeof HoppGQLAuthBearer>
export const HoppGQLAuthOAuth2 = z.object({
authType: z.literal("oauth-2"),
token: z.string(),
oidcDiscoveryURL: z.string(),
authURL: z.string(),
accessTokenURL: z.string(),
clientID: z.string(),
scope: z.string()
token: z.string().catch(""),
oidcDiscoveryURL: z.string().catch(""),
authURL: z.string().catch(""),
accessTokenURL: z.string().catch(""),
clientID: z.string().catch(""),
scope: z.string().catch("")
})
export type HoppGQLAuthOAuth2 = z.infer<typeof HoppGQLAuthOAuth2>
@@ -41,9 +41,9 @@ export type HoppGQLAuthOAuth2 = z.infer<typeof HoppGQLAuthOAuth2>
export const HoppGQLAuthAPIKey = z.object({
authType: z.literal("api-key"),
key: z.string(),
value: z.string(),
addTo: z.string()
key: z.string().catch(""),
value: z.string().catch(""),
addTo: z.string().catch("Headers")
})
export type HoppGQLAuthAPIKey = z.infer<typeof HoppGQLAuthAPIKey>
@@ -68,7 +68,7 @@ const V2_SCHEMA = z.object({
name: z.string(),
url: z.string(),
headers: z.array(GQLHeader),
headers: z.array(GQLHeader).catch([]),
query: z.string(),
variables: z.string(),

View File

@@ -52,6 +52,7 @@ export const HoppRESTRequest = createVersionedEntity({
export type HoppRESTRequest = InferredEntity<typeof HoppRESTRequest>
// TODO: Handle the issue with the preRequestScript and testScript type check failures on pre-commit
const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
id: undefinedEq(S.Eq),
v: S.Eq,
@@ -59,11 +60,11 @@ const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
body: lodashIsEqualEq,
endpoint: S.Eq,
headers: mapThenEq(
(arr) => arr.filter((h) => h.key !== "" && h.value !== ""),
(arr) => arr.filter((h: any) => h.key !== "" && h.value !== ""),
lodashIsEqualEq
),
params: mapThenEq(
(arr) => arr.filter((p) => p.key !== "" && p.value !== ""),
(arr) => arr.filter((p: any) => p.key !== "" && p.value !== ""),
lodashIsEqualEq
),
method: S.Eq,

View File

@@ -10,7 +10,7 @@ export const FormDataKeyValue = z.object({
z.union([
z.object({
isFile: z.literal(true),
value: z.array(z.instanceof(Blob))
value: z.array(z.instanceof(Blob).nullable())
}),
z.object({
isFile: z.literal(false),
@@ -31,11 +31,11 @@ export type HoppRESTReqBodyFormData = z.infer<typeof HoppRESTReqBodyFormData>
export const HoppRESTReqBody = z.union([
z.object({
contentType: z.literal(null),
body: z.literal(null)
body: z.literal(null).catch(null)
}),
z.object({
contentType: z.literal("multipart/form-data"),
body: FormDataKeyValue
body: z.array(FormDataKeyValue).catch([])
}),
z.object({
contentType: z.union([
@@ -48,7 +48,7 @@ export const HoppRESTReqBody = z.union([
z.literal("text/html"),
z.literal("text/plain"),
]),
body: z.string()
body: z.string().catch("")
})
])
@@ -62,36 +62,36 @@ export type HoppRESTAuthNone = z.infer<typeof HoppRESTAuthNone>
export const HoppRESTAuthBasic = z.object({
authType: z.literal("basic"),
username: z.string(),
password: z.string(),
username: z.string().catch(""),
password: z.string().catch(""),
})
export type HoppRESTAuthBasic = z.infer<typeof HoppRESTAuthBasic>
export const HoppRESTAuthBearer = z.object({
authType: z.literal("bearer"),
token: z.string(),
token: z.string().catch(""),
})
export type HoppRESTAuthBearer = z.infer<typeof HoppRESTAuthBearer>
export const HoppRESTAuthOAuth2 = z.object({
authType: z.literal("oauth-2"),
token: z.string(),
oidcDiscoveryURL: z.string(),
authURL: z.string(),
accessTokenURL: z.string(),
clientID: z.string(),
scope: z.string(),
token: z.string().catch(""),
oidcDiscoveryURL: z.string().catch(""),
authURL: z.string().catch(""),
accessTokenURL: z.string().catch(""),
clientID: z.string().catch(""),
scope: z.string().catch(""),
})
export type HoppRESTAuthOAuth2 = z.infer<typeof HoppRESTAuthOAuth2>
export const HoppRESTAuthAPIKey = z.object({
authType: z.literal("api-key"),
key: z.string(),
value: z.string(),
addTo: z.string(),
key: z.string().catch(""),
value: z.string().catch(""),
addTo: z.string().catch("Headers"),
})
export type HoppRESTAuthAPIKey = z.infer<typeof HoppRESTAuthAPIKey>
@@ -112,9 +112,9 @@ export type HoppRESTAuth = z.infer<typeof HoppRESTAuth>
export const HoppRESTParams = z.array(
z.object({
key: z.string(),
value: z.string(),
active: z.boolean()
key: z.string().catch(""),
value: z.string().catch(""),
active: z.boolean().catch(true)
})
)
@@ -122,9 +122,9 @@ export type HoppRESTParams = z.infer<typeof HoppRESTParams>
export const HoppRESTHeaders = z.array(
z.object({
key: z.string(),
value: z.string(),
active: z.boolean()
key: z.string().catch(""),
value: z.string().catch(""),
active: z.boolean().catch(true)
})
)
@@ -139,8 +139,8 @@ const V1_SCHEMA = z.object({
endpoint: z.string(),
params: HoppRESTParams,
headers: HoppRESTHeaders,
preRequestScript: z.string(),
testScript: z.string(),
preRequestScript: z.string().catch(""),
testScript: z.string().catch(""),
auth: HoppRESTAuth,

View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Sitemap
.sitemap-gen
# Backend Code generation
src/api/generated

View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

View File

@@ -0,0 +1,16 @@
# Tauri + Vue 3 + TypeScript
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
## Type Support For `.vue` Imports in TS
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type by default. In most cases this is fine if you don't really care about component prop types outside of templates. However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using manual `h(...)` calls), you can enable Volar's Take Over mode by following these steps:
1. Run `Extensions: Show Built-in Extensions` from VS Code's command palette, look for `TypeScript and JavaScript Language Features`, then right click and select `Disable (Workspace)`. By default, Take Over mode will enable itself if the default TypeScript extension is disabled.
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).

View File

@@ -0,0 +1,18 @@
overwrite: true
schema: "../../gql-gen/*.gql"
generates:
src/api/generated/graphql.ts:
documents: "src/**/*.graphql"
plugins:
- add:
content: >
/* eslint-disable */
// Auto-generated file (DO NOT EDIT!!!), refer gql-codegen.yml
- typescript
- typescript-operations
- typed-document-node
- typescript-urql-graphcache
src/api/generated/backend-schema.json:
plugins:
- urql-introspection

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hoppscotch - Open source API development ecosystem</title>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="apple-touch-icon" href="/icon.png" />
</head>
<body>
<div id="app"></div>
<script>
// Shims to make swagger-parser package work
window.global = window
</script>
<script type="module">
import { Buffer } from "buffer"
import process from "process"
// // Shims to make postman-collection work
window.Buffer = Buffer
window.process = process
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,118 @@
import { IHTMLTag } from "vite-plugin-html-config"
export const APP_INFO = {
name: "Hoppscotch",
shortDescription: "Open source API development ecosystem",
description:
"Helps you create requests faster, saving precious time on development.",
keywords:
"hoppscotch, hopp scotch, hoppscotch online, hoppscotch app, postwoman, postwoman chrome, postwoman online, postwoman for mac, postwoman app, postwoman for windows, postwoman google chrome, postwoman chrome app, get postwoman, postwoman web, postwoman android, postwoman app for chrome, postwoman mobile app, postwoman web app, api, request, testing, tool, rest, websocket, sse, graphql, socketio",
app: {
background: "#202124",
},
social: {
twitter: "@hoppscotch_io",
},
} as const
export const META_TAGS = (env: Record<string, string>): IHTMLTag[] => [
{
name: "keywords",
content: APP_INFO.keywords,
},
{
name: "X-UA-Compatible",
content: "IE=edge, chrome=1",
},
{
name: "name",
content: `${APP_INFO.name}${APP_INFO.shortDescription}`,
},
{
name: "description",
content: APP_INFO.description,
},
{
name: "image",
content: `${env.VITE_BASE_URL}/banner.png`,
},
// Open Graph tags
{
name: "og:title",
content: `${APP_INFO.name}${APP_INFO.shortDescription}`,
},
{
name: "og:description",
content: APP_INFO.description,
},
{
name: "og:image",
content: `${env.VITE_BASE_URL}/banner.png`,
},
// Twitter tags
{
name: "twitter:card",
content: "summary_large_image",
},
{
name: "twitter:site",
content: APP_INFO.social.twitter,
},
{
name: "twitter:creator",
content: APP_INFO.social.twitter,
},
{
name: "twitter:title",
content: `${APP_INFO.name}${APP_INFO.shortDescription}`,
},
{
name: "twitter:description",
content: APP_INFO.description,
},
{
name: "twitter:image",
content: `${env.VITE_BASE_URL}/banner.png`,
},
// Add to homescreen for Chrome on Android. Fallback for PWA (handled by nuxt)
{
name: "application-name",
content: APP_INFO.name,
},
// Windows phone tile icon
{
name: "msapplication-TileImage",
content: `${env.VITE_BASE_URL}/icon.png`,
},
{
name: "msapplication-TileColor",
content: APP_INFO.app.background,
},
{
name: "msapplication-tap-highlight",
content: "no",
},
// iOS Safari
{
name: "apple-mobile-web-app-title",
content: APP_INFO.name,
},
{
name: "apple-mobile-web-app-capable",
content: "yes",
},
{
name: "apple-mobile-web-app-status-bar-style",
content: "black-translucent",
},
// PWA
{
name: "theme-color",
content: APP_INFO.app.background,
},
{
name: "mask-icon",
content: "/icon.png",
color: APP_INFO.app.background,
},
]

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