Compare commits

...

134 Commits

Author SHA1 Message Date
nivedin
01200c9e5f fix: duplicate tab reference bug 2023-09-18 11:11:16 +05:30
Yuri Grand
75193a7aa8 i18n: translate locales to russian (#3312)
chore: translate locales to russian
2023-09-13 12:53:10 +05:30
tyo
b269c239d9 chore(i18n): update translation for Indonesian (#3284) 2023-09-13 12:48:46 +05:30
Liyas Thomas
72b4a1fc4e fix: typo in "twitter link" and "invite to hoppscotch" action (#3346) 2023-09-13 11:55:58 +05:30
DNT
d2d1674d31 i18n: update vi.json (#3241) 2023-09-13 11:52:37 +05:30
Joel Jacob Stephen
a6b57777e3 refactor: remove font sizes from the app (#3341)
* refactor: remove font size from settings

* refactor: remove font size from themes

* refactor: remove font size from spotlight

* refactor: remove default font size

* chore: clean up

---------

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-09-13 11:45:38 +05:30
Joel Jacob Stephen
65ef4db86f refactor: remove zen mode from the app (#3337)
* refactor: remove zen mode from settings

* refactor: remove zen mode from footer and options
2023-09-12 14:10:38 +05:30
Nivedin
7201147b55 fix: context-menu position fixed while scrolling (#3340) 2023-09-12 12:43:10 +05:30
Anwarul Islam
dd143c95a9 fix: unusual behavior while scrolling through spotlight entries (#3324)
* fix: spotlight scroll issue

* fix: entry hidden issue

* chore: back to loop mode
2023-09-12 12:42:44 +05:30
James George
005581ee7d fix: broken link to REST API Testing docs (#3333)
fix: broken link to REST API Testing docs
2023-09-12 12:32:10 +05:30
Joel Jacob Stephen
1431ecc6d7 refactor: keyboard shortcuts now supports different keyboard layouts including Dvorak (#3332)
* refactor: support mulitple keyboard layouts such as dvorak

* chore: replace redundant variable usage
2023-09-08 22:02:39 +05:30
Liyas Thomas
f34d896095 docs: updated screenshots and features list (#3310) 2023-09-05 12:06:47 +05:30
Andrew Bastin
e95ebb9226 chore: add release tag ci pipeline to push to docker hub 2023-08-31 15:49:32 +05:30
Andrew Bastin
57365eeae0 chore: bump version to 2023.8.0 2023-08-31 13:55:36 +05:30
Joel Jacob Stephen
b22bd97818 style: updated font size and truncation on fields in the invited users table in admin dashboard (#3300)
style: updated font size and fixed truncation issue on invited table
2023-08-28 23:27:55 +05:30
Anwarul Islam
b953b32ff4 fix: spotlight actions on graphql (#3299)
* fix: spotlight actions for graphql

* fix: environment actions

* fix: gql rename request

* fix: graphql spotlight actions

* fix: tab shortcuts not working properly

* fix: only show download and copy response when there is a response

---------

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-08-28 20:40:01 +05:30
Liyas Thomas
0eacd6763b chore: improved command labels and icons (#3295)
* chore: improved command labels and icons

* chore: fix tests

---------

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-08-28 18:15:00 +05:30
Anwarul Islam
8499ac7fec fix: graphql operation highlight on focus changed (#3297) 2023-08-28 17:55:42 +05:30
Nivedin
4adac4af38 fix: inspections bugs (#3277)
* fix: environment add bug in inspection

* chore: add 127.0.0.1 in url inspection

* chore: update browserextension inspection help url

* fix: team env not showing bug in selector

* chore: rework inspector systems to be reactive

* chore: handling tab changes gracefully

* refactor: move out url interceptor from the platform

* chore: add view function in inspector service to get views into the list

* fix: interceptors not kicking in on initial load

* fix: don't show no internet connection error unless browser deems so

* chore: fix tests

---------

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-08-28 17:43:46 +05:30
Akash K
fd162e242c fix: issues with codegen (#3293)
* fix: fix issues with httpsnippet upgrade

* chore: fix HttpSnippet import
2023-08-28 15:57:44 +05:30
Andrew Bastin
3e83828722 chore: correct spelling for footer custom entries 2023-08-26 04:43:34 +05:30
Andrew Bastin
f7dc36e3f1 fix: correct typo 'additionalFooterMenuItems' 2023-08-26 03:09:11 +05:30
Andrew Bastin
a7566dfd86 feat: move crisp out of common (#3287)
* feat: move crisp out of common

* fix: update static spotlight searcher

* chore: fix typo
2023-08-26 03:00:58 +05:30
Mir Arif Hasan
d4d7a20fbd HBE-258 hotfix: skip parameter in findMany in shortcode module (#3294)
fix: skip parameter in findMany
2023-08-26 01:35:51 +05:30
Andrew Bastin
dfb281bcf7 chore: update prod.Dockerfile to add step for the backend container to not copy .env in 2023-08-25 21:03:00 +05:30
Andrew Bastin
c62482e81f fix: login component in app not respecting allowed auth provider ids 2023-08-25 19:13:03 +05:30
Anwarul Islam
886847ab7b fix: corrections for spotlight searchers (#3275) 2023-08-25 01:44:29 +05:30
Nivedin
a268cab11e fix: context menu bugs (#3279) 2023-08-25 00:27:03 +05:30
Nivedin
e9509b9fa1 fix: tab right click rename bug (#3286) 2023-08-25 00:20:08 +05:30
Liyas Thomas
8db452089c fix: icons inside tooltip (#3283) 2023-08-24 23:55:22 +05:30
Andrew Bastin
a1764023f3 fix: sh-admin not properly loading in env variables 2023-08-24 20:41:46 +05:30
Andrew Bastin
b08b63dc73 feat: cleaner save context handling for graphql (#3282)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-24 19:07:17 +05:30
Nivedin
a9a4ebf595 fix: autocomplete bug (#3285)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-24 18:46:58 +05:30
Andrew Bastin
a8e279db28 fix: codegen breaking 2023-08-24 15:03:50 +05:30
Andrew Bastin
d09a3e9237 fix: import-meta-env crashes while using dev mode 2023-08-24 09:43:52 +05:30
Andrew Bastin
efa40cf6ea feat: container registry friendly docker images and all-in-one container (#3193)
Co-authored-by: Balu Babu <balub997@gmail.com>
2023-08-24 00:01:28 +05:30
Akash K
1a3d9f18ab fix: issues in displaying the suggestions menu on EnvInput (#3280) 2023-08-23 21:18:48 +05:30
Akash K
653ccd3240 fix: vertical scroll not working on codemirror instance when lines are not wrapped (#3276)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-23 18:14:12 +05:30
Anwarul Islam
c0806cfd07 chore: removed unnecessary dependencies from hoppscotch-ui (#3077) 2023-08-23 18:13:19 +05:30
Liyas Thomas
008eb6b77b chore: minor ui improvements (#3274) 2023-08-22 22:22:43 +05:30
Joel Jacob Stephen
ac60843183 refactor: autofocus can be disabled in smart input hopp ui component (#3273)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-22 20:10:41 +05:30
Anwarul Islam
3c3fb1e4a9 fix: minor spotlight related issues (#3271)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-08-22 17:58:32 +05:30
Anwarul Islam
88212e8cfe feat: gql revamp (#2644)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-08-22 17:43:43 +05:30
Andrew Bastin
191fa376d2 chore: remove go to docs search entry and update i18n 2023-08-22 01:09:48 +05:30
Andrew Bastin
6efae3a395 fix: crash when closing tab (fixes HFE-146) 2023-08-22 01:07:16 +05:30
Andrew Bastin
cb8678f07f fix: codegen modal breaking, downgrade back to 2.0 2023-08-22 00:49:13 +05:30
Andrew Bastin
b32b0f9bcb fix: modals inputs not working properly 2023-08-22 00:17:03 +05:30
Anwarul Islam
5a91fb53b2 feat: expanded search capabilities of spotlight (#3255)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-08-21 20:03:51 +05:30
Liyas Thomas
b0b6edc58e fix: search input autofocus (#3265) 2023-08-21 14:58:44 +05:30
Akash K
8c57d81718 chore: bump dependencies (#3258)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-08-21 09:06:30 +05:30
Andrew Bastin
10bb68a538 refactor: move from network strategies to generic interceptor service (#3242) 2023-08-21 07:50:35 +05:30
Anwarul Islam
d4d1e27ba9 feat: smart-tree component added to hoppscotch-ui (#3210) 2023-08-20 20:48:32 +05:30
Liyas Thomas
d5c887f311 fix: placeholder size and text overflow on tab head (#3261) 2023-08-18 21:32:10 +05:30
Akash K
ce7adf6da3 feat: load allowed login methods from .env (#3264)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-08-18 21:23:56 +05:30
Andrew Bastin
c626fb9241 feat: spotlight collection searcher (#3262)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-18 20:56:45 +05:30
Nivedin
f21ed30e10 feat: inspections (#3213)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-18 01:37:21 +05:30
Anwarul Islam
b55970cc7a spotlight: settings based actions added (#3244)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-17 22:04:58 +05:30
Joel Jacob Stephen
74ad2e43a4 feat: ability to conditionally enable auth providers in dashboard (#3225) 2023-08-17 21:53:34 +05:30
Liyas Thomas
2d6282cf8b refactor: polish environment selector (#3260) 2023-08-17 16:46:45 +05:30
Liyas Thomas
e255c46455 feat: environment quick peek (#3119) 2023-08-15 19:30:37 +05:30
Andrew Bastin
15c2c7bb5b feat: divider for the additional platform login items 2023-08-15 16:15:03 +05:30
Andrew Bastin
71bcd22444 feat: allow platforms to define additional entries in the login dialog 2023-08-14 22:18:37 +05:30
Joel Jacob Stephen
2d104160f2 refactor: revoke team invitation in admin dashboard (#3232) 2023-08-14 17:37:05 +05:30
Anwarul Islam
f7c1825de5 spotlight: navigation searcher added (#3245)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-08-12 22:21:45 +05:30
Mir Arif Hasan
2c1fd5d711 feat: prefix VITE_ added in conditional auth provider env variable (#3246) (HBE-248) 2023-08-08 14:16:13 +05:30
Nivedin
085fbb2a9b feat: tippy menu for history and tab (#3220)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-08 13:23:11 +05:30
Andrew Bastin
05f2d8817b chore: merge hoppscotch/main into release/2023.8.0 2023-08-08 12:18:01 +05:30
Joel Jacob Stephen
81fbb22c51 feat: introducing a new smart input hoppscotch ui component (#3089)
Co-authored-by: Anwarul Islam <anwaarulislaam@gmail.com>
2023-08-05 23:45:02 +05:30
Liyas Thomas
01cf59c663 fix: disable line wrapping in EnvInput component (#3230) 2023-08-05 23:43:02 +05:30
Liyas Thomas
5c8ebaff3e refactor: fonts are now bundled with packages (#3227) 2023-08-05 23:42:31 +05:30
Andrew Bastin
0e70c28324 feat: dynamically select which auth providers for your instance of hoppscotch (be implementation) 2023-08-03 20:12:54 +05:30
Ankit Sridhar
88f6a4ae26 [feat] : Allow admins to revoke a team invite (HBE-230) (#3162)
feat: added functionality for admin to revoke team invite
2023-08-03 14:08:32 +05:30
Anwarul Islam
610538ca02 chore: type and ux improvement for SmartTree (#3126) 2023-08-02 20:54:02 +05:30
Nivedin
8970ff5c68 feat: context menu (#3180)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-02 20:52:16 +05:30
Liyas Thomas
d1a564d5b8 fix: elastic overscroll on safari (#3221) 2023-08-02 20:47:54 +05:30
Anwarul Islam
8bb1d19c07 fix: firefox browser scrollbar issue (#3201) 2023-08-01 13:20:17 +05:30
Liyas Thomas
c1efa381f0 feat: svg badge asset (#3196) 2023-07-25 20:45:22 +05:30
Andrew Bastin
29171d1b6f fix: generate-ui failing to build 2023-07-18 22:27:37 +05:30
Andrew Bastin
e869d49e16 chore: run tests on and against release branches 2023-07-18 21:46:38 +05:30
Andrew Bastin
6496bea846 chore: bump version to 2023.4.8 2023-07-18 21:46:36 +05:30
NicklasWallgren
39842559b5 fix: reduce the memory consumption during build to prevent OOM (#3148)
Co-authored-by: Nicklas Wallgren <nicklas.wallgren@folksam.se>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-07-18 00:08:06 +05:30
Anwarul Islam
51efb35aa6 fix: keybinding modifier issue (#3163) 2023-07-17 23:56:08 +05:30
NicklasWallgren
9402bb9285 fix: add healthcheck for db and remove unwanted volumes (#3150) 2023-07-17 21:22:56 +05:30
Liyas Thomas
5a516f7242 docs: fixed shortcut keys for spotlight and shortcuts menu (#3192) 2023-07-17 19:27:49 +05:30
Liyas Thomas
3b217d78e7 fix: deps mismatch for vite-plugin-pages-sitemap (#3191) 2023-07-17 19:26:43 +05:30
Liyas Thomas
8e153b38dc redesigned search button (#3187) 2023-07-17 14:40:14 +05:30
Akash K
6f38bfb148 chore: update generateSitemap usage (#3182) 2023-07-17 14:39:32 +05:30
Balu Babu
82b6e08d68 fix: fixed issue in team-environment test cases (#3189) 2023-07-17 12:33:11 +05:30
Liyas Thomas
31fd6567b7 fix: text overflow on spotlight search results (#3181) 2023-07-17 12:32:45 +05:30
Anwarul Islam
25177bd635 fix: update vite-plugin-dts version which fixes build issue on docker/alpine (#3179) 2023-07-17 12:32:25 +05:30
5idereal
6928eb7992 feat(lang): update tw translation (#3170) 2023-07-14 11:36:08 +05:30
Andrew Bastin
8300f9a0a2 chore: merge release/2023.4.8 into release/2023.8.0 2023-07-13 12:10:14 +05:30
Mir Arif Hasan
525ba77739 refactor: team invitation module in pseudo fp-ts (#3175) 2023-07-13 11:58:03 +05:30
Balu Babu
6bc748a267 refactor: introduce team-environments into self-host refactored to pseudo-fp format (#3177) 2023-07-13 11:52:19 +05:30
Andrew Bastin
5230d2d3b8 feat: revamped spotlight (#3171)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-07-11 23:02:33 +05:30
Nivedin
c3531c9d8b feat: auto-complete recent history entries in URL bar (#3141)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-07-11 20:03:42 +05:30
Andrew Bastin
b29c04c28d fix: email not being checked case insensitive on team invitation acceptance (#3174) 2023-07-11 20:03:08 +05:30
Liyas Thomas
b2af353941 chore: new filled star icon to toggle favorite history entry (#3164) 2023-07-06 13:30:38 +05:30
Andrew Bastin
9dbce74f5e chore: bump version to 2023.4.7 2023-06-27 15:43:03 +05:30
Liyas Thomas
db1cf5cc08 fix: explicitly added background color 2023-06-27 15:43:03 +05:30
Liyas Thomas
09360abf81 fix: text overflow on details summary label (#3160)
Co-authored-by: Nivedin <nivedinp@gmail.com>
2023-06-27 15:42:58 +05:30
Andrew Bastin
355bd62b8d feat: introduce more events into the analytics pipeline (#3156) 2023-06-27 15:37:25 +05:30
James Butler
5650de1183 fix: self-host unable to use Azure oauth (#3138) 2023-06-27 15:37:25 +05:30
Akash K
2ee8614b93 fix: use --location param for url when parsing curl (#3152) 2023-06-27 15:37:25 +05:30
Ankit Sridhar
5632334c9a fix: remove existing team invitation for an invitee when adding invitee to team by admin (HBE-229) (#3157) 2023-06-27 15:37:25 +05:30
Anwarul Islam
780dd8a713 fix: graphql authorization headers (#3136) 2023-06-27 15:37:25 +05:30
Nivedin
7db3c6d290 fix: unified bg color in collection tree (#3155) 2023-06-27 15:37:25 +05:30
Omer Baflah
c765270dfe fix: correct typos (#3153) 2023-06-27 15:37:25 +05:30
Webysther Sperandio
03f667c21d feat: custom location on admin redirect to base (#3103) 2023-06-27 15:37:25 +05:30
Balázs Úr
f79f3078dc chore(i18n): updated hungarian translation (#3151) 2023-06-27 15:37:25 +05:30
Nivedin
6e29a2f6d4 fix: shortcode resolution screen is stuck on invalid shortcodes (#3142)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-06-27 15:37:25 +05:30
Balu Babu
6304fd50c3 fix: fixed issue with team-invitations and new user accounts (#3137) 2023-06-27 15:37:25 +05:30
Andrew Bastin
2ec29c47ad chore: merge release/2023.4.7 into main 2023-06-27 14:17:26 +05:30
Andrew Bastin
399a238bf4 chore: bump version to 2023.4.7 2023-06-27 14:15:12 +05:30
Liyas Thomas
b20ab72298 fix: explicitly added background color 2023-06-26 19:57:43 +05:30
Liyas Thomas
f723e6496a fix: text overflow on details summary label (#3160)
Co-authored-by: Nivedin <nivedinp@gmail.com>
2023-06-26 18:30:25 +05:30
Andrew Bastin
8c0aff8863 feat: introduce more events into the analytics pipeline (#3156) 2023-06-24 10:18:35 +05:30
James Butler
64c5077506 fix: self-host unable to use Azure oauth (#3138) 2023-06-22 23:43:05 +05:30
Akash K
2afc87847d fix: use --location param for url when parsing curl (#3152) 2023-06-22 23:40:09 +05:30
Ankit Sridhar
878ec833ce fix: remove existing team invitation for an invitee when adding invitee to team by admin (HBE-229) (#3157) 2023-06-22 23:38:02 +05:30
Anwarul Islam
039de8015f fix: graphql authorization headers (#3136) 2023-06-22 23:32:23 +05:30
Nivedin
f67b366b90 fix: unified bg color in collection tree (#3155) 2023-06-22 00:38:28 +05:30
Andrew Bastin
6f35574d68 refactor: move hoppscotch-common tests to vitest (#3154) 2023-06-22 00:36:25 +05:30
Omer Baflah
77e8a36ab0 fix: correct typos (#3153) 2023-06-22 00:35:57 +05:30
Webysther Sperandio
d7cc9f5dbc feat: custom location on admin redirect to base (#3103) 2023-06-21 00:13:40 +05:30
Anwarul Islam
fc3e3aeaec feat: placeholder component in hoppscotch-ui (#3123)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-06-21 00:09:16 +05:30
Balázs Úr
4ba135f3b9 chore(i18n): updated hungarian translation (#3151) 2023-06-20 14:28:53 +05:30
Nivedin
24894e05dc fix: shortcode resolution screen is stuck on invalid shortcodes (#3142)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-06-19 14:04:07 +05:30
Andrew Bastin
e2b668bee2 chore(ci): add manual workflow dispatch for hoppscotch-ui deploy script 2023-06-19 12:33:52 +05:30
Andrew Bastin
f112c46bb4 chore(ci): re-introduce hoppscotch-ui deploy script 2023-06-19 11:51:14 +05:30
Joel Jacob Stephen
331d482b22 feat: introducing i18n support to admin dashboard (#3051) 2023-06-16 09:47:00 +05:30
Andrew Bastin
b07243f131 chore: merge main@2023.4.6 into release/2023.8.0 2023-06-16 09:45:05 +05:30
Balu Babu
84b0c30d64 fix: fixed issue with team-invitations and new user accounts (#3137) 2023-06-15 17:15:06 +05:30
Andrew Bastin
81a7e23a12 feat: introduce dioc into hoppscotch-common 2023-06-07 15:20:49 +05:30
419 changed files with 28412 additions and 13394 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
**/*/node_modules

View File

@@ -13,6 +13,7 @@ SESSION_SECRET='add some secret here'
# Hoppscotch App Domain Config # Hoppscotch App Domain Config
REDIRECT_URL="http://localhost:3000" REDIRECT_URL="http://localhost:3000"
WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100" WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
VITE_ALLOWED_AUTH_PROVIDERS = GOOGLE,GITHUB,MICROSOFT,EMAIL
# Google Auth Config # Google Auth Config
GOOGLE_CLIENT_ID="************************************************" GOOGLE_CLIENT_ID="************************************************"
@@ -31,6 +32,7 @@ MICROSOFT_CLIENT_ID="************************************************"
MICROSOFT_CLIENT_SECRET="************************************************" MICROSOFT_CLIENT_SECRET="************************************************"
MICROSOFT_CALLBACK_URL="http://localhost:3170/v1/auth/microsoft/callback" MICROSOFT_CALLBACK_URL="http://localhost:3170/v1/auth/microsoft/callback"
MICROSOFT_SCOPE="user.read" MICROSOFT_SCOPE="user.read"
MICROSOFT_TENANT="common"
# Mailer config # Mailer config
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com" MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com"

View File

@@ -0,0 +1,66 @@
name: "Push containers to Docker Hub on release"
on:
push:
tags:
- '*.*.*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup environment
run: cp .env.example .env
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push `${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}`
uses: docker/build-push-action@v4
with:
context: .
file: ./prod.Dockerfile
target: backend
push: true
tags: |
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:latest
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:${{ github.ref_name }}
- name: Build and push `${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}`
uses: docker/build-push-action@v4
with:
context: .
file: ./prod.Dockerfile
target: app
push: true
tags: |
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:latest
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:${{ github.ref_name }}
- name: Build and push `${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}`
uses: docker/build-push-action@v4
with:
context: .
file: ./prod.Dockerfile
target: sh_admin
push: true
tags: |
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:latest
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:${{ github.ref_name }}
- name: Build and push `${{ secrets.DOCKER_AIO_CONTAINER_NAME }}`
uses: docker/build-push-action@v4
with:
context: .
file: ./prod.Dockerfile
target: aio
push: true
tags: |
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:latest
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:${{ github.ref_name }}

View File

@@ -2,9 +2,9 @@ name: Node.js CI
on: on:
push: push:
branches: [main, staging] branches: [main, staging, "release/**"]
pull_request: pull_request:
branches: [main, staging] branches: [main, staging, "release/**"]
jobs: jobs:
test: test:

42
.github/workflows/ui.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Deploy to Netlify (ui)
on:
push:
branches: [main]
# run this workflow only if an update is made to the ui package
paths:
- "packages/hoppscotch-ui/**"
workflow_dispatch:
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup environment
run: mv .env.example .env
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 8
run_install: true
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Build site
run: pnpm run generate-ui
# Deploy the ui site with netlify-cli
- name: Deploy to Netlify (ui)
run: npx netlify-cli deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

View File

@@ -1,3 +1,8 @@
module.exports = { module.exports = {
semi: false semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
useTabs: false,
tabWidth: 2
} }

View File

@@ -6,8 +6,8 @@ We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status, identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity nationality, personal appearance, race, caste, color, religion, or sexual
and orientation. identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community. diverse, inclusive, and healthy community.
@@ -22,17 +22,17 @@ community include:
* Giving and gracefully accepting constructive feedback * Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, * Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience and learning from the experience
* Focusing on what is best not just for us as individuals, but for the * Focusing on what is best not just for us as individuals, but for the overall
overall community community
Examples of unacceptable behavior include: Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or * The use of sexualized language or imagery, and sexual attention or advances of
advances of any kind any kind
* Trolling, insulting or derogatory comments, and personal or political attacks * Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment * Public or private harassment
* Publishing others' private information, such as a physical or email * Publishing others' private information, such as a physical or email address,
address, without their explicit permission without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a * Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
@@ -82,15 +82,15 @@ behavior was inappropriate. A public apology may be requested.
### 2. Warning ### 2. Warning
**Community Impact**: A violation through a single incident or series **Community Impact**: A violation through a single incident or series of
of actions. actions.
**Consequence**: A warning with consequences for continued behavior. No **Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or like social media. Violating these terms may lead to a temporary or permanent
permanent ban. ban.
### 3. Temporary Ban ### 3. Temporary Ban
@@ -109,20 +109,24 @@ Violating these terms may lead to a permanent ban.
standards, including sustained inappropriate behavior, harassment of an standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals. individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within **Consequence**: A permanent ban from any sort of public interaction within the
the community. community.
## Attribution ## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at version 2.1, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by [Mozilla's code of conduct Community Impact Guidelines were inspired by
enforcement ladder](https://github.com/mozilla/diversity). [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
https://www.contributor-covenant.org/translations. [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

180
README.md
View File

@@ -2,23 +2,18 @@
<a href="https://hoppscotch.io"> <a href="https://hoppscotch.io">
<img <img
src="https://avatars.githubusercontent.com/u/56705483" src="https://avatars.githubusercontent.com/u/56705483"
alt="Hoppscotch Logo" alt="Hoppscotch"
height="64" height="64"
/> />
</a> </a>
<br />
<p>
<h3> <h3>
<b> <b>
Hoppscotch Hoppscotch
</b> </b>
</h3> </h3>
</p>
<p>
<b> <b>
Open source API development ecosystem Open Source API Development Ecosystem
</b> </b>
</p>
<p> <p>
[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen?logo=github)](CODE_OF_CONDUCT.md) [![Website](https://img.shields.io/website?url=https%3A%2F%2Fhoppscotch.io&logo=hoppscotch)](https://hoppscotch.io) [![Tests](https://github.com/hoppscotch/hoppscotch/actions/workflows/tests.yml/badge.svg)](https://github.com/hoppscotch/hoppscotch/actions) [![Tweet](https://img.shields.io/twitter/url?url=https%3A%2F%2Fhoppscotch.io%2F)](https://twitter.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io) [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen?logo=github)](CODE_OF_CONDUCT.md) [![Website](https://img.shields.io/website?url=https%3A%2F%2Fhoppscotch.io&logo=hoppscotch)](https://hoppscotch.io) [![Tests](https://github.com/hoppscotch/hoppscotch/actions/workflows/tests.yml/badge.svg)](https://github.com/hoppscotch/hoppscotch/actions) [![Tweet](https://img.shields.io/twitter/url?url=https%3A%2F%2Fhoppscotch.io%2F)](https://twitter.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io)
@@ -34,23 +29,18 @@
</p> </p>
<br /> <br />
<p> <p>
<a href="https://hoppscotch.io/#gh-light-mode-only" target="_blank"> <a href="https://hoppscotch.io">
<img <picture>
src="./packages/hoppscotch-common/public/images/banner-light.png" <source media="(prefers-color-scheme: dark)" srcset="./packages/hoppscotch-common/public/images/banner-dark.png">
alt="Hoppscotch" <source media="(prefers-color-scheme: light)" srcset="./packages/hoppscotch-common/public/images/banner-light.png">
width="100%" <img alt="Hoppscotch" src="./packages/hoppscotch-common/public/images/banner-dark.png">
/> </picture>
</a>
<a href="https://hoppscotch.io/#gh-dark-mode-only" target="_blank">
<img
src="./packages/hoppscotch-common/public/images/banner-dark.png"
alt="Hoppscotch"
width="100%"
/>
</a> </a>
</p> </p>
</div> </div>
_We highly recommend you take a look at the [**Hoppscotch Documentation**](https://docs.hoppscotch.io) to learn more about the app._
#### **Support** #### **Support**
[![Chat on Discord](https://img.shields.io/badge/chat-Discord-7289DA?logo=discord)](https://hoppscotch.io/discord) [![Chat on Telegram](https://img.shields.io/badge/chat-Telegram-2CA5E0?logo=telegram)](https://hoppscotch.io/telegram) [![Discuss on GitHub](https://img.shields.io/badge/discussions-GitHub-333333?logo=github)](https://github.com/hoppscotch/hoppscotch/discussions) [![Chat on Discord](https://img.shields.io/badge/chat-Discord-7289DA?logo=discord)](https://hoppscotch.io/discord) [![Chat on Telegram](https://img.shields.io/badge/chat-Telegram-2CA5E0?logo=telegram)](https://hoppscotch.io/telegram) [![Discuss on GitHub](https://img.shields.io/badge/discussions-GitHub-333333?logo=github)](https://github.com/hoppscotch/hoppscotch/discussions)
@@ -59,9 +49,9 @@
❤️ **Lightweight:** Crafted with minimalistic UI design. ❤️ **Lightweight:** Crafted with minimalistic UI design.
⚡️ **Fast:** Send requests and get/copy responses in real-time. ⚡️ **Fast:** Send requests and get responses in real time.
**HTTP Methods** 🗄️ **HTTP Methods:** Request methods define the type of action you are requesting to be performed.
- `GET` - Requests retrieve resource information - `GET` - Requests retrieve resource information
- `POST` - The server creates a new entry in a database - `POST` - The server creates a new entry in a database
@@ -74,17 +64,15 @@
- `TRACE` - Performs a message loop-back test along the path to the target resource - `TRACE` - Performs a message loop-back test along the path to the target resource
- `<custom>` - Some APIs use custom request methods such as `LIST`. Type in your custom methods. - `<custom>` - Some APIs use custom request methods such as `LIST`. Type in your custom methods.
🌈 **Make it yours:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings). 🌈 **Theming:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings).
**Theming** - Choose a theme: System preference, Light, Dark, and Black
- Choose accent colors: Green, Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
- Choose a theme: System (default), Light, Dark, and Black
- Choose accent color: Green (default), Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
- Distraction-free Zen mode - Distraction-free Zen mode
_Customized themes are synced with cloud / local session_ _Customized themes are synced with your cloud/local session._
🔥 **PWA:** Install as a [PWA](https://web.dev/what-are-pwas/) on your device. 🔥 **PWA:** Install as a [Progressive Web App](https://web.dev/progressive-web-apps) on your device.
- Instant loading with Service Workers - Instant loading with Service Workers
- Offline support - Offline support
@@ -107,7 +95,7 @@ _Customized themes are synced with cloud / local session_
📡 **Server-Sent Events:** Receive a stream of updates from a server over an HTTP connection without resorting to polling. 📡 **Server-Sent Events:** Receive a stream of updates from a server over an HTTP connection without resorting to polling.
🌩 **Socket.IO:** Send and Receive data with SocketIO server. 🌩 **Socket.IO:** Send and Receive data with the SocketIO server.
🦟 **MQTT:** Subscribe and Publish to topics of an MQTT Broker. 🦟 **MQTT:** Subscribe and Publish to topics of an MQTT Broker.
@@ -127,7 +115,7 @@ _Customized themes are synced with cloud / local session_
- OAuth 2.0 - OAuth 2.0
- OIDC Access Token/PKCE - OIDC Access Token/PKCE
📢 **Headers:** Describes the format the body of your request is being sent as. 📢 **Headers:** Describes the format the body of your request is being sent in.
📫 **Parameters:** Use request parameters to set varying parts in simulated requests. 📫 **Parameters:** Use request parameters to set varying parts in simulated requests.
@@ -137,14 +125,14 @@ _Customized themes are synced with cloud / local session_
- FormData, JSON, and many more - FormData, JSON, and many more
- Toggle between key-value and RAW input parameter list - Toggle between key-value and RAW input parameter list
👋 **Response:** Contains the status line, headers, and the message/response body. 📮 **Response:** Contains the status line, headers, and the message/response body.
- Copy response to clipboard - Copy the response to the clipboard
- Download response as a file - Download the response as a file
- View response headers - View response headers
- View raw and preview of HTML, image, JSON, XML responses - View raw and preview HTML, image, JSON, and XML responses
**History:** Request entries are synced with cloud / local session storage to restore with a single click. **History:** Request entries are synced with your cloud/local session storage.
📁 **Collections:** Keep your API requests organized with collections and folders. Reuse them with a single click. 📁 **Collections:** Keep your API requests organized with collections and folders. Reuse them with a single click.
@@ -152,7 +140,32 @@ _Customized themes are synced with cloud / local session_
- Nested folders - Nested folders
- Export and import as a file or GitHub gist - Export and import as a file or GitHub gist
_Collections are synced with cloud / local session storage_ _Collections are synced with your cloud/local session storage._
📜 **Pre-Request Scripts:** Snippets of code associated with a request that is executed before the request is sent.
- Set environment variables
- Include timestamp in the request headers
- Send a random alphanumeric string in the URL parameters
- Any JavaScript functions
👨‍👩‍👧‍👦 **Teams:** Helps you collaborate across your teams to design, develop, and test APIs faster.
- Create unlimited teams
- Create unlimited shared collections
- Create unlimited team members
- Role-based access control
- Cloud sync
- Multiple devices
👥 **Workspaces:** Organize your personal and team collections environments into workspaces. Easily switch between workspaces to manage multiple projects.
- Create unlimited workspaces
- Switch between personal and team workspaces
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
🌐 **Proxy:** Enable Proxy Mode from Settings to access blocked APIs. 🌐 **Proxy:** Enable Proxy Mode from Settings to access blocked APIs.
@@ -161,60 +174,31 @@ _Collections are synced with cloud / local session storage_
- Access APIs served in non-HTTPS (`http://`) endpoints - Access APIs served in non-HTTPS (`http://`) endpoints
- Use your Proxy URL - Use your Proxy URL
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/support/privacy)**_ _Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/support/privacy)**._
📜 **Pre-Request Scripts β:** Snippets of code associated with a request that is executed before the request is sent.
- Set environment variables
- Include timestamp in the request headers
- Send a random alphanumeric string in the URL parameters
- Any JavaScript functions
📄 **API Documentation:** Create and share dynamic API documentation easily, quickly.
1. Add your requests to Collections and Folders
2. Export Collections and easily share your APIs with the rest of your team
3. Import Collections and Generate Documentation on-the-go
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
🌎 **i18n:** Experience the app in your language. 🌎 **i18n:** Experience the app in your language.
Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md), and the process for submitting pull requests to us. Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md) and the process for submitting pull requests to us.
📦 **Add-ons:** Official add-ons for hoppscotch. ☁️ **Auth + Sync:** Sign in and sync your data in real-time across all your devices.
- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch **Sign in with:**
- **[CLI β](https://github.com/hoppscotch/hopp-cli)** - A CLI solution for Hoppscotch
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that simplifies access to Hoppscotch
[![Firefox](https://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_16x16.png) **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) &nbsp;|&nbsp; [![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_16x16.png) **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
> **Extensions fixes `CORS` issues.**
- **[Hopp-Doc-Gen](https://github.com/hoppscotch/hopp-doc-gen)** - An API doc generator CLI for Hoppscotch
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
☁️ **Auth + Sync:** Sign in and sync your data in real-time.
**Sign in with**
- GitHub - GitHub
- Google - Google
- Microsoft - Microsoft
- Email - Email
- SSO (Single Sign-On)[^EE]
**Synchronize your data** **🔄 Synchronize your data:** Handoff to continue tasks on your other devices.
- Workspaces
- History - History
- Collections - Collections
- Environments - Environments
- Settings - Settings
**Post-Request Tests β:** Write tests associated with a request that is executed after the request's response. **Post-Request Tests:** Write tests associated with a request that is executed after the request's response.
- Check the status code as an integer - Check the status code as an integer
- Filter response headers - Filter response headers
@@ -222,7 +206,7 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
- Set environment variables - Set environment variables
- Write JavaScript code - Write JavaScript code
🌱 **Environments** : Environment variables allow you to store and reuse values in your requests and scripts. 🌱 **Environments:** Environment variables allow you to store and reuse values in your requests and scripts.
- Unlimited environments and variables - Unlimited environments and variables
- Initialize through the pre-request script - Initialize through the pre-request script
@@ -241,22 +225,31 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
</details> </details>
👨‍👩‍👧‍👦 **Teams β:** Helps you collaborate across your team to design, develop, and test APIs faster.
- Unlimited teams
- Unlimited shared collections
- Unlimited team members
- Role-based access control
- Cloud sync
- Multiple devices
🚚 **Bulk Edit:** Edit key-value pairs in bulk. 🚚 **Bulk Edit:** Edit key-value pairs in bulk.
- Entries are separated by newline - Entries are separated by newline
- Keys and values are separated by `:` - Keys and values are separated by `:`
- Prepend `#` to any row you want to add but keep disabled - Prepend `#` to any row you want to add but keep disabled
**For more features, please read our [documentation](https://docs.hoppscotch.io).** 🎛️ **Admin dashboard:** Manage your team and invite members.
- Insights
- Manage users
- Manage teams
📦 **Add-ons:** Official add-ons for hoppscotch.
- **[Hoppscotch CLI](https://github.com/hoppscotch/hopp-cli)** - Command-line interface for Hoppscotch.
- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch.
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that enhance your Hoppscotch experience.
[![Firefox](https://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_16x16.png) **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) &nbsp;|&nbsp; [![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_16x16.png) **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
> **Extensions fix `CORS` issues.**
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
**For a complete list of features, please read our [documentation](https://docs.hoppscotch.io).**
## **Demo** ## **Demo**
@@ -268,18 +261,9 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
2. Click "Send" to simulate the request 2. Click "Send" to simulate the request
3. View the response 3. View the response
## **Built with**
- [HTML](https://developer.mozilla.org/en-US/docs/Web/HTML)
- [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), [SCSS](https://sass-lang.com), [Windi CSS](https://windicss.org)
- [JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript)
- [TypeScript](https://www.typescriptlang.org)
- [Vue](https://vuejs.org)
- [Vite](https://vitejs.dev)
## **Developing** ## **Developing**
Follow our [self-hosting guide](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment. Follow our [self-hosting documentation](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment.
## **Contributing** ## **Contributing**
@@ -297,7 +281,7 @@ See the [`CHANGELOG`](CHANGELOG.md) file for details.
## **Authors** ## **Authors**
This project exists thanks to all the people who contribute — [contribute](CONTRIBUTING.md). This project owes its existence to the collective efforts of all those who contribute — [contribute now](CONTRIBUTING.md).
<div align="center"> <div align="center">
<a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors"> <a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors">
@@ -309,4 +293,6 @@ This project exists thanks to all the people who contribute — [contribute](CON
## **License** ## **License**
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [`LICENSE`](LICENSE) file for details. This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) see the [`LICENSE`](LICENSE) file for details.
[^EE]: Enterprise edition feature. [Learn more](https://docs.hoppscotch.io/documentation/self-host/getting-started).

View File

@@ -2,8 +2,9 @@
This document outlines security procedures and general policies for the Hoppscotch project. This document outlines security procedures and general policies for the Hoppscotch project.
1. [Reporting a security vulnerability](#reporting-a-security-vulnerability) - [Security Policy](#security-policy)
3. [Incident response process](#incident-response-process) - [Reporting a security vulnerability](#reporting-a-security-vulnerability)
- [Incident response process](#incident-response-process)
## Reporting a security vulnerability ## Reporting a security vulnerability

View File

@@ -9,26 +9,24 @@ Before you start working on a new language, please look through the [open pull r
if there is no existing translation, you can create a new one by following these steps: if there is no existing translation, you can create a new one by following these steps:
1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).** 1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).**
2. **Checkout the `i18n` branch for latest translations.** 2. **Checkout the `main` branch for latest translations.**
3. **Create a new branch for your translation with base branch `i18n`.** 3. **Create a new branch for your translation with base branch `main`.**
4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.** 4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.**
5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.** 5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.**
6. **Translate the strings in the target language file.** 6. **Translate the strings in the target language file.**
7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).** 7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).**
8. **Save & commit changes.** 8. **Save and commit changes.**
9. **Send a pull request.** 9. **Send a pull request.**
_You may send a pull request before all steps above are complete: e.g., you may want to ask for help with translations, or getting tests to pass. However, your pull request will not be merged until all steps above are complete._ _You may send a pull request before all steps above are complete: e.g., you may want to ask for help with translations, or getting tests to pass. However, your pull request will not be merged until all steps above are complete._
`i18n` branch will be merged into `main` branch once every week.
Completing an initial translation of the whole site is a fairly large task. One way to break that task up is to work with other translators through pull requests on your fork. You can also [add collaborators to your fork](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) if you'd like to invite other translators to commit directly to your fork and share responsibility for merging pull requests. Completing an initial translation of the whole site is a fairly large task. One way to break that task up is to work with other translators through pull requests on your fork. You can also [add collaborators to your fork](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) if you'd like to invite other translators to commit directly to your fork and share responsibility for merging pull requests.
## Updating a translation ## Updating a translation
### Corrections ### Corrections
If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, open an issue. If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, [open an issue](https://github.com/hoppscotch/hoppscotch/issues/new/choose).
### Broken links ### Broken links

11
aio.Caddyfile Normal file
View File

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

72
aio_run.mjs Normal file
View File

@@ -0,0 +1,72 @@
#!/usr/local/bin/node
// @ts-check
import { execSync, spawn } from "child_process"
import fs from "fs"
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.log("error")
console.log(stuff)
})
return childProcess
}
const envFileContent = Object.entries(process.env)
.filter(([env]) => env.startsWith("VITE_"))
.map(([env, val]) => `${env}=${
(val.startsWith("\"") && val.endsWith("\""))
? val
: `"${val}"`
}`)
.join("\n")
fs.writeFileSync("build.env", envFileContent)
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 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

@@ -8,23 +8,25 @@ services:
hoppscotch-backend: hoppscotch-backend:
container_name: hoppscotch-backend container_name: hoppscotch-backend
build: build:
dockerfile: packages/hoppscotch-backend/Dockerfile dockerfile: prod.Dockerfile
context: . context: .
target: prod target: backend
env_file: env_file:
- ./.env - ./.env
restart: always restart: always
environment: environment:
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well) # Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300 - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=3000 - PORT=3170
volumes: volumes:
- ./packages/hoppscotch-backend/:/usr/src/app # Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/ - /usr/src/app/node_modules/
depends_on: depends_on:
- hoppscotch-db hoppscotch-db:
condition: service_healthy
ports: ports:
- "3170:3000" - "3170:3170"
# The main hoppscotch app. This will be hosted at port 3000 # The main hoppscotch app. This will be hosted at port 3000
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for # NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
@@ -32,8 +34,9 @@ services:
hoppscotch-app: hoppscotch-app:
container_name: hoppscotch-app container_name: hoppscotch-app
build: build:
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile dockerfile: prod.Dockerfile
context: . context: .
target: app
env_file: env_file:
- ./.env - ./.env
depends_on: depends_on:
@@ -47,8 +50,9 @@ services:
hoppscotch-sh-admin: hoppscotch-sh-admin:
container_name: hoppscotch-sh-admin container_name: hoppscotch-sh-admin
build: build:
dockerfile: packages/hoppscotch-sh-admin/Dockerfile dockerfile: prod.Dockerfile
context: . context: .
target: sh_admin
env_file: env_file:
- ./.env - ./.env
depends_on: depends_on:
@@ -56,16 +60,91 @@ services:
ports: ports:
- "3100:8080" - "3100:8080"
# The service that spins up all 3 services at once in one container
hoppscotch-aio:
container_name: hoppscotch-aio
build:
dockerfile: prod.Dockerfile
context: .
target: aio
env_file:
- ./.env
depends_on:
hoppscotch-db:
condition: service_healthy
ports:
- "3000:3000"
- "3100:3100"
- "3170:3170"
# The preset DB service, you can delete/comment the below lines if # The preset DB service, you can delete/comment the below lines if
# you are using an external postgres instance # you are using an external postgres instance
# This will be exposed at port 5432 # This will be exposed at port 5432
hoppscotch-db: hoppscotch-db:
image: postgres image: postgres:15
ports: ports:
- "5432:5432" - "5432:5432"
user: postgres
environment: environment:
# The default user defined by the docker image
POSTGRES_USER: postgres
# NOTE: Please UPDATE THIS PASSWORD! # NOTE: Please UPDATE THIS PASSWORD!
POSTGRES_PASSWORD: testpass POSTGRES_PASSWORD: testpass
POSTGRES_DB: hoppscotch POSTGRES_DB: hoppscotch
healthcheck:
test:
[
"CMD-SHELL",
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"
]
interval: 5s
timeout: 5s
retries: 10
# All the services listed below are deprececated
hoppscotch-old-backend:
container_name: hoppscotch-old-backend
build:
dockerfile: packages/hoppscotch-backend/Dockerfile
context: .
target: prod
env_file:
- ./.env
restart: always
environment:
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=3000
volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/
depends_on:
hoppscotch-db:
condition: service_healthy
ports:
- "3170:3000"
hoppscotch-old-app:
container_name: hoppscotch-old-app
build:
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
context: .
env_file:
- ./.env
depends_on:
- hoppscotch-old-backend
ports:
- "3000:8080"
hoppscotch-old-sh-admin:
container_name: hoppscotch-old-sh-admin
build:
dockerfile: packages/hoppscotch-sh-admin/Dockerfile
context: .
env_file:
- ./.env
depends_on:
- hoppscotch-old-backend
ports:
- "3100:8080"

14
healthcheck.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
curlCheck() {
if ! curl -s --head "$1" | head -n 1 | grep -q "HTTP/1.[01] [23].."; then
echo "URL request failed!"
exit 1
else
echo "URL request succeeded!"
fi
}
curlCheck "http://localhost:3000"
curlCheck "http://localhost:3100"
curlCheck "http://localhost:3170/ping"

View File

@@ -32,5 +32,14 @@
"@types/node": "^17.0.24", "@types/node": "^17.0.24",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"http-server": "^14.1.1" "http-server": "^14.1.1"
},
"pnpm": {
"packageExtensions": {
"httpsnippet@^3.0.1": {
"peerDependencies": {
"ajv": "6.12.3"
}
}
}
} }
} }

View File

@@ -17,12 +17,12 @@
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@codemirror/language": "^6.2.0", "@codemirror/language": "^6.9.0",
"@lezer/highlight": "^1.0.0", "@lezer/highlight": "^1.1.6",
"@lezer/lr": "^1.2.0" "@lezer/lr": "^1.3.10"
}, },
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.1.0", "@lezer/generator": "^1.5.0",
"mocha": "^9.2.2", "mocha": "^9.2.2",
"rollup": "^2.70.2", "rollup": "^2.70.2",
"rollup-plugin-dts": "^4.2.1", "rollup-plugin-dts": "^4.2.1",

24
packages/dioc/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# 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?

141
packages/dioc/README.md Normal file
View File

@@ -0,0 +1,141 @@
# dioc
A small and lightweight dependency injection / inversion of control system.
### About
`dioc` is a really simple **DI/IOC** system where you write services (which are singletons per container) that can depend on each other and emit events that can be listened upon.
### Demo
```ts
import { Service, Container } from "dioc"
// Here is a simple service, which you can define by extending the Service class
// and providing an ID static field (of type string)
export class PersistenceService extends Service {
// This should be unique for each container
public static ID = "PERSISTENCE_SERVICE"
public read(key: string): string | undefined {
// ...
}
public write(key: string, value: string) {
// ...
}
}
type TodoServiceEvent =
| { type: "TODO_CREATED"; index: number }
| { type: "TODO_DELETED"; index: number }
// Services have a built in event system
// Define the generic argument to say what are the possible emitted values
export class TodoService extends Service<TodoServiceEvent> {
public static ID = "TODO_SERVICE"
// Inject persistence service into this service
private readonly persistence = this.bind(PersistenceService)
public todos = []
// Service constructors cannot have arguments
constructor() {
super()
this.todos = JSON.parse(this.persistence.read("todos") ?? "[]")
}
public addTodo(text: string) {
// ...
// You can access services via the bound fields
this.persistence.write("todos", JSON.stringify(this.todos))
// This is how you emit an event
this.emit({
type: "TODO_CREATED",
index,
})
}
public removeTodo(index: number) {
// ...
this.emit({
type: "TODO_DELETED",
index,
})
}
}
// Services need a container to run in
const container = new Container()
// You can initialize and get services using Container#bind
// It will automatically initialize the service (and its dependencies)
const todoService = container.bind(TodoService) // Returns an instance of TodoService
```
### Demo (Unit Test)
`dioc/testing` contains `TestContainer` which lets you bind mocked services to the container.
```ts
import { TestContainer } from "dioc/testing"
import { TodoService, PersistenceService } from "./demo.ts" // The above demo code snippet
import { describe, it, expect, vi } from "vitest"
describe("TodoService", () => {
it("addTodo writes to persistence", () => {
const container = new TestContainer()
const writeFn = vi.fn()
// The first parameter is the service to mock and the second parameter
// is the mocked service fields and functions
container.bindMock(PersistenceService, {
read: () => undefined, // Not really important for this test
write: writeFn,
})
// the peristence service bind in TodoService will now use the
// above defined mocked implementation
const todoService = container.bind(TodoService)
todoService.addTodo("sup")
expect(writeFn).toHaveBeenCalledOnce()
expect(writeFn).toHaveBeenCalledWith("todos", JSON.stringify(["sup"]))
})
})
```
### Demo (Vue)
`dioc/vue` contains a Vue Plugin and a `useService` composable that allows Vue components to use the defined services.
In the app entry point:
```ts
import { createApp } from "vue"
import { diocPlugin } from "dioc/vue"
const app = createApp()
app.use(diocPlugin, {
container: new Container(), // You can pass in the container you want to provide to the components here
})
```
In your Vue components:
```vue
<script setup>
import { TodoService } from "./demo.ts" // The above demo
import { useService } from "dioc/vue"
const todoService = useService(TodoService) // Returns an instance of the TodoService class
</script>
```

2
packages/dioc/index.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export { default } from "./dist/main.d.ts"
export * from "./dist/main.d.ts"

View File

@@ -0,0 +1,147 @@
import { Service } from "./service"
import { Observable, Subject } from 'rxjs'
/**
* Stores the current container instance in the current operating context.
*
* NOTE: This should not be used outside of dioc library code
*/
export let currentContainer: Container | null = null
/**
* The events emitted by the container
*
* `SERVICE_BIND` - emitted when a service is bound to the container directly or as a dependency to another service
* `SERVICE_INIT` - emitted when a service is initialized
*/
export type ContainerEvent =
| {
type: 'SERVICE_BIND';
/** The Service ID of the service being bounded (the dependency) */
boundeeID: string;
/**
* The Service ID of the bounder that is binding the boundee (the dependent)
*
* NOTE: This will be undefined if the service is bound directly to the container
*/
bounderID: string | undefined
}
| {
type: 'SERVICE_INIT';
/** The Service ID of the service being initialized */
serviceID: string
}
/**
* The dependency injection container, allows for services to be initialized and maintains the dependency trees.
*/
export class Container {
/** Used during the `bind` operation to detect circular dependencies */
private bindStack: string[] = []
/** The map of bound services to their IDs */
protected boundMap = new Map<string, Service<unknown>>()
/** The RxJS observable representing the event stream */
protected event$ = new Subject<ContainerEvent>()
/**
* Returns whether a container has the given service bound
* @param service The service to check for
*/
public hasBound<
T extends typeof Service<any> & { ID: string }
>(service: T): boolean {
return this.boundMap.has(service.ID)
}
/**
* Returns the service bound to the container with the given ID or if not found, undefined.
*
* NOTE: This is an advanced method and should not be used as much as possible.
*
* @param serviceID The ID of the service to get
*/
public getBoundServiceWithID(serviceID: string): Service<unknown> | undefined {
return this.boundMap.get(serviceID)
}
/**
* Binds a service to the container. This is equivalent to marking a service as a dependency.
* @param service The class reference of a service to bind
* @param bounder The class reference of the service that is binding the service (if bound directly to the container, this should be undefined)
*/
public bind<T extends typeof Service<any> & { ID: string }>(
service: T,
bounder: ((typeof Service<T>) & { ID: string }) | undefined = undefined
): InstanceType<T> {
// We need to store the current container in a variable so that we can restore it after the bind operation
const oldCurrentContainer = currentContainer;
currentContainer = this;
// If the service is already bound, return the existing instance
if (this.hasBound(service)) {
this.event$.next({
type: 'SERVICE_BIND',
boundeeID: service.ID,
bounderID: bounder?.ID // Return the bounder ID if it is defined, else assume its the container
})
return this.boundMap.get(service.ID) as InstanceType<T> // Casted as InstanceType<T> because service IDs and types are expected to match
}
// Detect circular dependency and throw error
if (this.bindStack.findIndex((serviceID) => serviceID === service.ID) !== -1) {
const circularServices = `${this.bindStack.join(' -> ')} -> ${service.ID}`
throw new Error(`Circular dependency detected.\nChain: ${circularServices}`)
}
// Push the service ID onto the bind stack to detect circular dependencies
this.bindStack.push(service.ID)
// Initialize the service and emit events
// NOTE: We need to cast the service to any as TypeScript thinks that the service is abstract
const instance: Service<any> = new (service as any)()
this.boundMap.set(service.ID, instance)
this.bindStack.pop()
this.event$.next({
type: 'SERVICE_INIT',
serviceID: service.ID,
})
this.event$.next({
type: 'SERVICE_BIND',
boundeeID: service.ID,
bounderID: bounder?.ID
})
// Restore the current container
currentContainer = oldCurrentContainer;
// We expect the return type to match the service definition
return instance as InstanceType<T>
}
/**
* Returns an iterator of the currently bound service IDs and their instances
*/
public getBoundServices(): IterableIterator<[string, Service<any>]> {
return this.boundMap.entries()
}
/**
* Returns the public container event stream
*/
public getEventStream(): Observable<ContainerEvent> {
return this.event$.asObservable()
}
}

View File

@@ -0,0 +1,2 @@
export * from "./container"
export * from "./service"

View File

@@ -0,0 +1,65 @@
import { Observable, Subject } from 'rxjs'
import { Container, currentContainer } from './container'
/**
* A Dioc service that can bound to a container and can bind dependency services.
*
* NOTE: Services cannot have a constructor that takes arguments.
*
* @template EventDef The type of events that can be emitted by the service. These will be accessible by event streams
*/
export abstract class Service<EventDef = {}> {
/**
* The internal event stream of the service
*/
private event$ = new Subject<EventDef>()
/** The container the service is bound to */
#container: Container
constructor() {
if (!currentContainer) {
throw new Error(
`Tried to initialize service with no container (ID: ${ (this.constructor as any).ID })`
)
}
this.#container = currentContainer
}
/**
* Binds a dependency service into this service.
* @param service The class reference of the service to bind
*/
protected bind<T extends typeof Service<any> & { ID: string }>(service: T): InstanceType<T> {
if (!currentContainer) {
throw new Error('No currentContainer defined.')
}
return currentContainer.bind(service, this.constructor as typeof Service<any> & { ID: string })
}
/**
* Returns the container the service is bound to
*/
protected getContainer(): Container {
return this.#container
}
/**
* Emits an event on the service's event stream
* @param event The event to emit
*/
protected emit(event: EventDef) {
this.event$.next(event)
}
/**
* Returns the event stream of the service
*/
public getEventStream(): Observable<EventDef> {
return this.event$.asObservable()
}
}

View File

@@ -0,0 +1,33 @@
import { Container, Service } from "./main";
/**
* A container that can be used for writing tests, contains additional methods
* for binding suitable for writing tests. (see `bindMock`).
*/
export class TestContainer extends Container {
/**
* Binds a mock service to the container.
*
* @param service
* @param mock
*/
public bindMock<
T extends typeof Service<any> & { ID: string },
U extends Partial<InstanceType<T>>
>(service: T, mock: U): U {
if (this.boundMap.has(service.ID)) {
throw new Error(`Service '${service.ID}' already bound to container. Did you already call bindMock on this ?`)
}
this.boundMap.set(service.ID, mock as any)
this.event$.next({
type: "SERVICE_BIND",
boundeeID: service.ID,
bounderID: undefined,
})
return mock
}
}

34
packages/dioc/lib/vue.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Plugin, inject } from "vue"
import { Container } from "./container"
import { Service } from "./service"
const VUE_CONTAINER_KEY = Symbol()
// TODO: Some Vue version issue with plugin generics is breaking type checking
/**
* The Vue Dioc Plugin, this allows the composables to work and access the container
*
* NOTE: Make sure you add `vue` as dependency to be able to use this plugin (duh)
*/
export const diocPlugin: Plugin = {
install(app, { container }) {
app.provide(VUE_CONTAINER_KEY, container)
}
}
/**
* A composable that binds a service to a Vue Component
*
* @param service The class reference of the service to bind
*/
export function useService<
T extends typeof Service<any> & { ID: string }
>(service: T): InstanceType<T> {
const container = inject(VUE_CONTAINER_KEY) as Container | undefined | null
if (!container) {
throw new Error("Container not found, did you forget to install the dioc plugin?")
}
return container.bind(service)
}

View File

@@ -0,0 +1,54 @@
{
"name": "dioc",
"private": true,
"version": "0.1.0",
"type": "module",
"files": [
"dist",
"index.d.ts"
],
"main": "./dist/counter.umd.cjs",
"module": "./dist/counter.js",
"types": "./index.d.ts",
"exports": {
".": {
"types": "./dist/main.d.ts",
"require": "./dist/index.cjs",
"import": "./dist/index.js"
},
"./vue": {
"types": "./dist/vue.d.ts",
"require": "./dist/vue.cjs",
"import": "./dist/vue.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"require": "./dist/testing.cjs",
"import": "./dist/testing.js"
}
},
"scripts": {
"dev": "vite",
"build": "vite build && tsc --emitDeclarationOnly",
"prepare": "pnpm run build",
"test": "vitest run",
"do-test": "pnpm run test",
"test:watch": "vitest"
},
"devDependencies": {
"typescript": "^4.9.4",
"vite": "^4.0.4",
"vitest": "^0.29.3"
},
"dependencies": {
"rxjs": "^7.8.1"
},
"peerDependencies": {
"vue": "^3.2.25"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
}
}

View File

@@ -0,0 +1,262 @@
import { it, expect, describe, vi } from "vitest"
import { Service } from "../lib/service"
import { Container, currentContainer, ContainerEvent } from "../lib/container"
class TestServiceA extends Service {
public static ID = "TestServiceA"
}
class TestServiceB extends Service {
public static ID = "TestServiceB"
// Marked public to allow for testing
public readonly serviceA = this.bind(TestServiceA)
}
describe("Container", () => {
describe("getBoundServiceWithID", () => {
it("returns the service instance if it is bound to the container", () => {
const container = new Container()
const service = container.bind(TestServiceA)
expect(container.getBoundServiceWithID(TestServiceA.ID)).toBe(service)
})
it("returns undefined if the service is not bound to the container", () => {
const container = new Container()
expect(container.getBoundServiceWithID(TestServiceA.ID)).toBeUndefined()
})
})
describe("bind", () => {
it("correctly binds the service to it", () => {
const container = new Container()
const service = container.bind(TestServiceA)
// @ts-expect-error getContainer is defined as a protected property, but we are leveraging it here to check
expect(service.getContainer()).toBe(container)
})
it("after bind, the current container is set back to its previous value", () => {
const originalValue = currentContainer
const container = new Container()
container.bind(TestServiceA)
expect(currentContainer).toBe(originalValue)
})
it("dependent services are registered in the same container", () => {
const container = new Container()
const serviceB = container.bind(TestServiceB)
// @ts-expect-error getContainer is defined as a protected property, but we are leveraging it here to check
expect(serviceB.serviceA.getContainer()).toBe(container)
})
it("binding an already initialized service returns the initialized instance (services are singletons)", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
const serviceA2 = container.bind(TestServiceA)
expect(serviceA).toBe(serviceA2)
})
it("binding a service which is a dependency of another service returns the same instance created from the dependency resolution (services are singletons)", () => {
const container = new Container()
const serviceB = container.bind(TestServiceB)
const serviceA = container.bind(TestServiceA)
expect(serviceB.serviceA).toBe(serviceA)
})
it("binding an initialized service as a dependency returns the same instance", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
const serviceB = container.bind(TestServiceB)
expect(serviceB.serviceA).toBe(serviceA)
})
it("container emits an init event when an uninitialized service is initialized via bind and event only called once", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_INIT" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_INIT") {
serviceFunc(ev)
}
})
const instance = container.bind(TestServiceA)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceA.ID,
})
})
it("the bind event emitted has an undefined bounderID when the service is bound directly to the container", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_BIND" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_BIND") {
serviceFunc(ev)
}
})
container.bind(TestServiceA)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: undefined,
})
})
it("the bind event emitted has the correct bounderID when the service is bound to another service", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_BIND" }],
void
>()
container.getEventStream().subscribe((ev) => {
// We only care about the bind event of TestServiceA
if (ev.type === "SERVICE_BIND" && ev.boundeeID === TestServiceA.ID) {
serviceFunc(ev)
}
})
container.bind(TestServiceB)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: TestServiceB.ID,
})
})
})
describe("hasBound", () => {
it("returns true if the given service is bound to the container", () => {
const container = new Container()
container.bind(TestServiceA)
expect(container.hasBound(TestServiceA)).toEqual(true)
})
it("returns false if the given service is not bound to the container", () => {
const container = new Container()
expect(container.hasBound(TestServiceA)).toEqual(false)
})
it("returns true when the service is bound because it is a dependency of another service", () => {
const container = new Container()
container.bind(TestServiceB)
expect(container.hasBound(TestServiceA)).toEqual(true)
})
})
describe("getEventStream", () => {
it("returns an observable which emits events correctly when services are initialized", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_INIT" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_INIT") {
serviceFunc(ev)
}
})
container.bind(TestServiceB)
expect(serviceFunc).toHaveBeenCalledTimes(2)
expect(serviceFunc).toHaveBeenNthCalledWith(1, <ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceA.ID,
})
expect(serviceFunc).toHaveBeenNthCalledWith(2, <ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceB.ID,
})
})
it("returns an observable which emits events correctly when services are bound", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_BIND" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_BIND") {
serviceFunc(ev)
}
})
container.bind(TestServiceB)
expect(serviceFunc).toHaveBeenCalledTimes(2)
expect(serviceFunc).toHaveBeenNthCalledWith(1, <ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: TestServiceB.ID,
})
expect(serviceFunc).toHaveBeenNthCalledWith(2, <ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceB.ID,
bounderID: undefined,
})
})
})
describe("getBoundServices", () => {
it("returns an iterator over all services bound to the container in the format [service id, service instance]", () => {
const container = new Container()
const instanceB = container.bind(TestServiceB)
const instanceA = instanceB.serviceA
expect(Array.from(container.getBoundServices())).toEqual([
[TestServiceA.ID, instanceA],
[TestServiceB.ID, instanceB],
])
})
it("returns an empty iterator if no services are bound", () => {
const container = new Container()
expect(Array.from(container.getBoundServices())).toEqual([])
})
})
})

View File

@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest"
import { Service, Container } from "../lib/main"
class TestServiceA extends Service {
public static ID = "TestServiceA"
}
class TestServiceB extends Service<"test"> {
public static ID = "TestServiceB"
// Marked public to allow for testing
public readonly serviceA = this.bind(TestServiceA)
public emitTestEvent() {
this.emit("test")
}
}
describe("Service", () => {
describe("constructor", () => {
it("throws an error if the service is initialized without a container", () => {
expect(() => new TestServiceA()).toThrowError(
"Tried to initialize service with no container (ID: TestServiceA)"
)
})
})
describe("bind", () => {
it("correctly binds the dependency service using the container", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
const serviceB = container.bind(TestServiceB)
expect(serviceB.serviceA).toBe(serviceA)
})
})
describe("getContainer", () => {
it("returns the container the service is bound to", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
// @ts-expect-error getContainer is a protected member, we are just using it to help with testing
expect(serviceA.getContainer()).toBe(container)
})
})
describe("getEventStream", () => {
it("returns the valid event stream of the service", () => {
const container = new Container()
const serviceB = container.bind(TestServiceB)
const serviceFunc = vi.fn()
serviceB.getEventStream().subscribe(serviceFunc)
serviceB.emitTestEvent()
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith("test")
})
})
})

View File

@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest"
import { TestContainer } from "../lib/testing"
import { Service } from "../lib/service"
import { ContainerEvent } from "../lib/container"
class TestServiceA extends Service {
public static ID = "TestServiceA"
public test() {
return "real"
}
}
class TestServiceB extends Service {
public static ID = "TestServiceB"
// declared public to help with testing
public readonly serviceA = this.bind(TestServiceA)
public test() {
return this.serviceA.test()
}
}
describe("TestContainer", () => {
describe("bindMock", () => {
it("returns the fake service defined", () => {
const container = new TestContainer()
const fakeService = {
test: () => "fake",
}
const result = container.bindMock(TestServiceA, fakeService)
expect(result).toBe(fakeService)
})
it("new services bound to the container get the mock service", () => {
const container = new TestContainer()
const fakeServiceA = {
test: () => "fake",
}
container.bindMock(TestServiceA, fakeServiceA)
const serviceB = container.bind(TestServiceB)
expect(serviceB.serviceA).toBe(fakeServiceA)
})
it("container emits SERVICE_BIND event", () => {
const container = new TestContainer()
const fakeServiceA = {
test: () => "fake",
}
const serviceFunc = vi.fn<[ContainerEvent, void]>()
container.getEventStream().subscribe((ev) => {
serviceFunc(ev)
})
container.bindMock(TestServiceA, fakeServiceA)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: undefined,
})
})
it("throws if service already bound", () => {
const container = new TestContainer()
const fakeServiceA = {
test: () => "fake",
}
container.bindMock(TestServiceA, fakeServiceA)
expect(() => {
container.bindMock(TestServiceA, fakeServiceA)
}).toThrowError(
"Service 'TestServiceA' already bound to container. Did you already call bindMock on this ?"
)
})
})
})

2
packages/dioc/testing.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export { default } from "./dist/testing.d.ts"
export * from "./dist/testing.d.ts"

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"declaration": true,
"sourceMap": true,
"outDir": "dist",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true
},
"include": ["lib"]
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: {
index: './lib/main.ts',
vue: './lib/vue.ts',
testing: './lib/testing.ts',
},
},
rollupOptions: {
external: ['vue'],
}
},
})

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
}
})

2
packages/dioc/vue.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export { default } from "./dist/vue.d.ts"
export * from "./dist/vue.d.ts"

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoppscotch-backend", "name": "hoppscotch-backend",
"version": "2023.4.6", "version": "2023.8.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -33,7 +33,7 @@
"@nestjs/passport": "^9.0.0", "@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.2.1", "@nestjs/platform-express": "^9.2.1",
"@nestjs/throttler": "^4.0.0", "@nestjs/throttler": "^4.0.0",
"@prisma/client": "^4.7.1", "@prisma/client": "^4.16.2",
"apollo-server-express": "^3.11.1", "apollo-server-express": "^3.11.1",
"apollo-server-plugin-base": "^3.7.1", "apollo-server-plugin-base": "^3.7.1",
"argon2": "^0.30.3", "argon2": "^0.30.3",
@@ -57,7 +57,7 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"passport-microsoft": "^1.0.0", "passport-microsoft": "^1.0.0",
"prisma": "^4.7.1", "prisma": "^4.16.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^7.6.0" "rxjs": "^7.6.0"

View File

@@ -5,7 +5,7 @@ datasource db {
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x"] binaryTargets = ["native", "debian-openssl-1.1.x", "debian-openssl-3.0.x"]
} }
model Team { model Team {

View File

@@ -411,6 +411,23 @@ export class AdminResolver {
return deletedTeam.right; return deletedTeam.right;
} }
@Mutation(() => Boolean, {
description: 'Revoke a team Invite by Invite ID',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeTeamInviteByAdmin(
@Args({
name: 'inviteID',
description: 'Team Invite ID',
type: () => ID,
})
inviteID: string,
): Promise<boolean> {
const invite = await this.adminService.revokeTeamInviteByID(inviteID);
if (E.isLeft(invite)) throwErr(invite.left);
return true;
}
/* Subscriptions */ /* Subscriptions */
@Subscription(() => InvitedUser, { @Subscription(() => InvitedUser, {

View File

@@ -11,6 +11,7 @@ import {
INVALID_EMAIL, INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT, ONLY_ONE_ADMIN_ACCOUNT,
TEAM_INVITE_ALREADY_MEMBER, TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_NO_INVITE_FOUND,
USER_ALREADY_INVITED, USER_ALREADY_INVITED,
USER_IS_ADMIN, USER_IS_ADMIN,
USER_NOT_FOUND, USER_NOT_FOUND,
@@ -181,7 +182,7 @@ export class AdminService {
* @returns an array team invitations * @returns an array team invitations
*/ */
async pendingInvitationCountInTeam(teamID: string) { async pendingInvitationCountInTeam(teamID: string) {
const invitations = await this.teamInvitationService.getAllTeamInvitations( const invitations = await this.teamInvitationService.getTeamInvitations(
teamID, teamID,
); );
@@ -236,11 +237,11 @@ export class AdminService {
const user = await this.userService.findUserByEmail(userEmail); const user = await this.userService.findUserByEmail(userEmail);
if (O.isNone(user)) return E.left(USER_NOT_FOUND); if (O.isNone(user)) return E.left(USER_NOT_FOUND);
const isUserAlreadyMember = await this.teamService.getTeamMemberTE( const teamMember = await this.teamService.getTeamMemberTE(
teamID, teamID,
user.value.uid, user.value.uid,
)(); )();
if (E.left(isUserAlreadyMember)) { if (E.isLeft(teamMember)) {
const addedUser = await this.teamService.addMemberToTeamWithEmail( const addedUser = await this.teamService.addMemberToTeamWithEmail(
teamID, teamID,
userEmail, userEmail,
@@ -248,6 +249,18 @@ export class AdminService {
); );
if (E.isLeft(addedUser)) return E.left(addedUser.left); if (E.isLeft(addedUser)) return E.left(addedUser.left);
const userInvitation =
await this.teamInvitationService.getTeamInviteByEmailAndTeamID(
userEmail,
teamID,
);
if (E.isRight(userInvitation)) {
await this.teamInvitationService.revokeInvitation(
userInvitation.right.id,
);
}
return E.right(addedUser.right); return E.right(addedUser.right);
} }
@@ -404,4 +417,19 @@ export class AdminService {
if (E.isLeft(team)) return E.left(team.left); if (E.isLeft(team)) return E.left(team.left);
return E.right(team.right); return E.right(team.right);
} }
/**
* Revoke a team invite by ID
* @param inviteID Team Invite ID
* @returns an Either of boolean or error
*/
async revokeTeamInviteByID(inviteID: string) {
const teamInvite = await this.teamInvitationService.revokeInvitation(
inviteID,
);
if (E.isLeft(teamInvite)) return E.left(teamInvite.left);
return E.right(teamInvite.right);
}
} }

View File

@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('ping')
export class AppController {
@Get()
ping(): string {
return 'Success';
}
}

View File

@@ -19,6 +19,7 @@ import { UserCollectionModule } from './user-collection/user-collection.module';
import { ShortcodeModule } from './shortcode/shortcode.module'; import { ShortcodeModule } from './shortcode/shortcode.module';
import { COOKIES_NOT_FOUND } from './errors'; import { COOKIES_NOT_FOUND } from './errors';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
@Module({ @Module({
imports: [ imports: [
@@ -81,5 +82,6 @@ import { ThrottlerModule } from '@nestjs/throttler';
ShortcodeModule, ShortcodeModule,
], ],
providers: [GQLComplexityPlugin], providers: [GQLComplexityPlugin],
controllers: [AppController],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -2,9 +2,9 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
InternalServerErrorException,
Post, Post,
Query, Query,
Req,
Request, Request,
Res, Res,
UseGuards, UseGuards,
@@ -19,12 +19,18 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { GqlUser } from 'src/decorators/gql-user.decorator'; import { GqlUser } from 'src/decorators/gql-user.decorator';
import { AuthUser } from 'src/types/AuthUser'; import { AuthUser } from 'src/types/AuthUser';
import { RTCookie } from 'src/decorators/rt-cookie.decorator'; import { RTCookie } from 'src/decorators/rt-cookie.decorator';
import { authCookieHandler, throwHTTPErr } from './helper'; import {
AuthProvider,
authCookieHandler,
authProviderCheck,
throwHTTPErr,
} from './helper';
import { GoogleSSOGuard } from './guards/google-sso.guard'; import { GoogleSSOGuard } from './guards/google-sso.guard';
import { GithubSSOGuard } from './guards/github-sso.guard'; import { GithubSSOGuard } from './guards/github-sso.guard';
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard'; import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { SkipThrottle } from '@nestjs/throttler'; import { SkipThrottle } from '@nestjs/throttler';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@UseGuards(ThrottlerBehindProxyGuard) @UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' }) @Controller({ path: 'auth', version: '1' })
@@ -39,6 +45,9 @@ export class AuthController {
@Body() authData: SignInMagicDto, @Body() authData: SignInMagicDto,
@Query('origin') origin: string, @Query('origin') origin: string,
) { ) {
if (!authProviderCheck(AuthProvider.EMAIL))
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
const deviceIdToken = await this.authService.signInMagicLink( const deviceIdToken = await this.authService.signInMagicLink(
authData.email, authData.email,
origin, origin,

View File

@@ -11,6 +11,7 @@ import { RTJwtStrategy } from './strategies/rt-jwt.strategy';
import { GoogleStrategy } from './strategies/google.strategy'; import { GoogleStrategy } from './strategies/google.strategy';
import { GithubStrategy } from './strategies/github.strategy'; import { GithubStrategy } from './strategies/github.strategy';
import { MicrosoftStrategy } from './strategies/microsoft.strategy'; import { MicrosoftStrategy } from './strategies/microsoft.strategy';
import { AuthProvider, authProviderCheck } from './helper';
@Module({ @Module({
imports: [ imports: [
@@ -26,9 +27,9 @@ import { MicrosoftStrategy } from './strategies/microsoft.strategy';
AuthService, AuthService,
JwtStrategy, JwtStrategy,
RTJwtStrategy, RTJwtStrategy,
GoogleStrategy, ...(authProviderCheck(AuthProvider.GOOGLE) ? [GoogleStrategy] : []),
GithubStrategy, ...(authProviderCheck(AuthProvider.GITHUB) ? [GithubStrategy] : []),
MicrosoftStrategy, ...(authProviderCheck(AuthProvider.MICROSOFT) ? [MicrosoftStrategy] : []),
], ],
controllers: [AuthController], controllers: [AuthController],
}) })

View File

@@ -228,7 +228,7 @@ export class AuthService {
url = process.env.VITE_BASE_URL; url = process.env.VITE_BASE_URL;
} }
await this.mailerService.sendAuthEmail(email, { await this.mailerService.sendEmail(email, {
template: 'code-your-own', template: 'code-your-own',
variables: { variables: {
inviteeEmail: email, inviteeEmail: email,

View File

@@ -1,8 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@Injectable() @Injectable()
export class GithubSSOGuard extends AuthGuard('github') { export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.GITHUB))
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
return super.canActivate(context);
}
getAuthenticateOptions(context: ExecutionContext) { getAuthenticateOptions(context: ExecutionContext) {
const req = context.switchToHttp().getRequest(); const req = context.switchToHttp().getRequest();

View File

@@ -1,8 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@Injectable() @Injectable()
export class GoogleSSOGuard extends AuthGuard('google') { export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.GOOGLE))
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
return super.canActivate(context);
}
getAuthenticateOptions(context: ExecutionContext) { getAuthenticateOptions(context: ExecutionContext) {
const req = context.switchToHttp().getRequest(); const req = context.switchToHttp().getRequest();

View File

@@ -1,8 +1,26 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@Injectable() @Injectable()
export class MicrosoftSSOGuard extends AuthGuard('microsoft') { export class MicrosoftSSOGuard
extends AuthGuard('microsoft')
implements CanActivate
{
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.MICROSOFT))
throwHTTPErr({
message: AUTH_PROVIDER_NOT_SPECIFIED,
statusCode: 404,
});
return super.canActivate(context);
}
getAuthenticateOptions(context: ExecutionContext) { getAuthenticateOptions(context: ExecutionContext) {
const req = context.switchToHttp().getRequest(); const req = context.switchToHttp().getRequest();

View File

@@ -1,10 +1,11 @@
import { ForbiddenException, HttpException, HttpStatus } from '@nestjs/common'; import { HttpException, HttpStatus } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { AuthError } from 'src/types/AuthError'; import { AuthError } from 'src/types/AuthError';
import { AuthTokens } from 'src/types/AuthTokens'; import { AuthTokens } from 'src/types/AuthTokens';
import { Response } from 'express'; import { Response } from 'express';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import { COOKIES_NOT_FOUND } from 'src/errors'; import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
import { throwErr } from 'src/utils';
enum AuthTokenType { enum AuthTokenType {
ACCESS_TOKEN = 'access_token', ACCESS_TOKEN = 'access_token',
@@ -16,6 +17,13 @@ export enum Origin {
APP = 'app', APP = 'app',
} }
export enum AuthProvider {
GOOGLE = 'GOOGLE',
GITHUB = 'GITHUB',
MICROSOFT = 'MICROSOFT',
EMAIL = 'EMAIL',
}
/** /**
* This function allows throw to be used as an expression * This function allows throw to be used as an expression
* @param errMessage Message present in the error message * @param errMessage Message present in the error message
@@ -97,3 +105,25 @@ export const subscriptionContextCookieParser = (rawCookies: string) => {
refresh_token: cookies[AuthTokenType.REFRESH_TOKEN], refresh_token: cookies[AuthTokenType.REFRESH_TOKEN],
}; };
}; };
/**
* Check to see if given auth provider is present in the VITE_ALLOWED_AUTH_PROVIDERS env variable
*
* @param provider Provider we want to check the presence of
* @returns Boolean if provider specified is present or not
*/
export function authProviderCheck(provider: string) {
if (!provider) {
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
}
const envVariables = process.env.VITE_ALLOWED_AUTH_PROVIDERS
? process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
provider.trim().toUpperCase(),
)
: [];
if (!envVariables.includes(provider.toUpperCase())) return false;
return true;
}

View File

@@ -17,7 +17,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy) {
clientSecret: process.env.MICROSOFT_CLIENT_SECRET, clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
callbackURL: process.env.MICROSOFT_CALLBACK_URL, callbackURL: process.env.MICROSOFT_CALLBACK_URL,
scope: [process.env.MICROSOFT_SCOPE], scope: [process.env.MICROSOFT_SCOPE],
passReqToCallback: true, tenant: process.env.MICROSOFT_TENANT,
store: true, store: true,
}); });
} }

View File

@@ -23,7 +23,31 @@ export const AUTH_FAIL = 'auth/fail';
export const JSON_INVALID = 'json_invalid'; export const JSON_INVALID = 'json_invalid';
/** /**
* Tried to delete an user data document from fb firestore but failed. * Auth Provider not specified
* (Auth)
*/
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file
*/
export const ENV_NOT_FOUND_KEY_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file
*/
export const ENV_EMPTY_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" contains unsupported provider in .env file
*/
export const ENV_NOT_SUPPORT_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" contains an unsupported auth provider in .env file';
/**
* Tried to delete a user data document from fb firestore but failed.
* (FirebaseService) * (FirebaseService)
*/ */
export const USER_FB_DOCUMENT_DELETION_FAILED = export const USER_FB_DOCUMENT_DELETION_FAILED =
@@ -231,7 +255,7 @@ export const TEAM_COLL_INVALID_JSON = 'team_coll/invalid_json';
export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const; export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
/** /**
* Tried to perform action on a request that doesn't accept their member role level * Tried to perform an action on a request that doesn't accept their member role level
* (GqlRequestTeamMemberGuard) * (GqlRequestTeamMemberGuard)
*/ */
export const TEAM_REQ_NOT_REQUIRED_ROLE = 'team_req/not_required_role'; export const TEAM_REQ_NOT_REQUIRED_ROLE = 'team_req/not_required_role';
@@ -262,7 +286,7 @@ export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
export const SENDER_EMAIL_INVALID = 'mailer/sender_email_invalid' as const; export const SENDER_EMAIL_INVALID = 'mailer/sender_email_invalid' as const;
/** /**
* Tried to perform action on a request when the user is not even member of the team * Tried to perform an action on a request when the user is not even a member of the team
* (GqlRequestTeamMemberGuard, GqlCollectionTeamMemberGuard) * (GqlRequestTeamMemberGuard, GqlCollectionTeamMemberGuard)
*/ */
export const TEAM_REQ_NOT_MEMBER = 'team_req/not_member'; export const TEAM_REQ_NOT_MEMBER = 'team_req/not_member';
@@ -307,11 +331,18 @@ export const SHORTCODE_INVALID_JSON = 'shortcode/invalid_json' as const;
export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const; export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const;
/** /**
* Invalid or non-existent TEAM ENVIRONMMENT ID * Invalid or non-existent TEAM ENVIRONMENT ID
* (TeamEnvironmentsService) * (TeamEnvironmentsService)
*/ */
export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const; export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const;
/**
* Invalid TEAM ENVIRONMENT name
* (TeamEnvironmentsService)
*/
export const TEAM_ENVIRONMENT_SHORT_NAME =
'team_environment/short_name' as const;
/** /**
* The user is not a member of the team of the given environment * The user is not a member of the team of the given environment
* (GqlTeamEnvTeamGuard) * (GqlTeamEnvTeamGuard)
@@ -340,7 +371,7 @@ export const USER_SETTINGS_NULL_SETTINGS =
'user_settings/null_settings' as const; 'user_settings/null_settings' as const;
/* /*
* Global environment doesnt exists for the user * Global environment doesn't exist for the user
* (UserEnvironmentsService) * (UserEnvironmentsService)
*/ */
export const USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS = export const USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS =

View File

@@ -5,7 +5,6 @@ import {
UserMagicLinkMailDescription, UserMagicLinkMailDescription,
} from './MailDescriptions'; } from './MailDescriptions';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import * as TE from 'fp-ts/TaskEither';
import { EMAIL_FAILED } from 'src/errors'; import { EMAIL_FAILED } from 'src/errors';
import { MailerService as NestMailerService } from '@nestjs-modules/mailer'; import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
@@ -35,33 +34,14 @@ export class MailerService {
/** /**
* Sends an email to the given email address given a mail description * Sends an email to the given email address given a mail description
* @param to The email address to be sent to (NOTE: this is not validated) * @param to Receiver's email id
* @param mailDesc Definition of what email to be sent * @param mailDesc Definition of what email to be sent
* @returns Response if email was send successfully or not
*/ */
sendMail( async sendEmail(
to: string, to: string,
mailDesc: MailDescription | UserMagicLinkMailDescription, mailDesc: MailDescription | UserMagicLinkMailDescription,
) { ) {
return TE.tryCatch(
async () => {
await this.nestMailerService.sendMail({
to,
template: mailDesc.template,
subject: this.resolveSubjectForMailDesc(mailDesc),
context: mailDesc.variables,
});
},
() => EMAIL_FAILED,
);
}
/**
*
* @param to Receiver's email id
* @param mailDesc Details of email to be sent for Magic-Link auth
* @returns Response if email was send successfully or not
*/
async sendAuthEmail(to: string, mailDesc: UserMagicLinkMailDescription) {
try { try {
await this.nestMailerService.sendMail({ await this.nestMailerService.sendMail({
to, to,

View File

@@ -5,11 +5,14 @@ import * as cookieParser from 'cookie-parser';
import { VersioningType } from '@nestjs/common'; import { VersioningType } from '@nestjs/common';
import * as session from 'express-session'; import * as session from 'express-session';
import { emitGQLSchemaFile } from './gql-schema'; import { emitGQLSchemaFile } from './gql-schema';
import { checkEnvironmentAuthProvider } from './utils';
async function bootstrap() { async function bootstrap() {
console.log(`Running in production: ${process.env.PRODUCTION}`); console.log(`Running in production: ${process.env.PRODUCTION}`);
console.log(`Port: ${process.env.PORT}`); console.log(`Port: ${process.env.PORT}`);
checkEnvironmentAuthProvider();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.use( app.use(

View File

@@ -150,7 +150,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
orderBy: { orderBy: {
createdOn: 'desc', createdOn: 'desc',
}, },
skip: 1, skip: args.cursor ? 1 : 0,
take: args.take, take: args.take,
cursor: args.cursor ? { id: args.cursor } : undefined, cursor: args.cursor ? { id: args.cursor } : undefined,
}); });

View File

@@ -1,15 +1,5 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import * as TE from 'fp-ts/TaskEither';
import * as O from 'fp-ts/Option';
import * as S from 'fp-ts/string';
import { pipe } from 'fp-ts/function';
import {
getAnnotatedRequiredRoles,
getGqlArg,
getUserFromGQLContext,
throwErr,
} from 'src/utils';
import { TeamEnvironmentsService } from './team-environments.service'; import { TeamEnvironmentsService } from './team-environments.service';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
@@ -19,6 +9,10 @@ import {
TEAM_ENVIRONMENT_NOT_FOUND, TEAM_ENVIRONMENT_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
import { GqlExecutionContext } from '@nestjs/graphql';
import * as E from 'fp-ts/Either';
import { TeamMemberRole } from '@prisma/client';
import { throwErr } from 'src/utils';
/** /**
* A guard which checks whether the caller of a GQL Operation * A guard which checks whether the caller of a GQL Operation
@@ -33,50 +27,31 @@ export class GqlTeamEnvTeamGuard implements CanActivate {
private readonly teamService: TeamService, private readonly teamService: TeamService,
) {} ) {}
canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe( const requireRoles = this.reflector.get<TeamMemberRole[]>(
TE.Do, 'requiresTeamRole',
context.getHandler(),
);
if (!requireRoles) throw new Error(BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES);
TE.bindW('requiredRoles', () => const gqlExecCtx = GqlExecutionContext.create(context);
pipe(
getAnnotatedRequiredRoles(this.reflector, context),
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES),
),
),
TE.bindW('user', () => const { user } = gqlExecCtx.getContext().req;
pipe( if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX);
getUserFromGQLContext(context),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
TE.bindW('envID', () => const { id } = gqlExecCtx.getArgs<{ id: string }>();
pipe( if (!id) throwErr(BUG_TEAM_ENV_GUARD_NO_ENV_ID);
getGqlArg('id', context),
O.fromPredicate(S.isString),
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_ENV_ID),
),
),
TE.bindW('membership', ({ envID, user }) => const teamEnvironment =
pipe( await this.teamEnvironmentService.getTeamEnvironment(id);
this.teamEnvironmentService.getTeamEnvironment(envID), if (E.isLeft(teamEnvironment)) throwErr(TEAM_ENVIRONMENT_NOT_FOUND);
TE.fromTaskOption(() => TEAM_ENVIRONMENT_NOT_FOUND),
TE.chainW((env) =>
pipe(
this.teamService.getTeamMemberTE(env.teamID, user.uid),
TE.mapLeft(() => TEAM_ENVIRONMENT_NOT_TEAM_MEMBER),
),
),
),
),
TE.map(({ membership, requiredRoles }) => const member = await this.teamService.getTeamMember(
requiredRoles.includes(membership.role), teamEnvironment.right.teamID,
), user.uid,
);
if (!member) throwErr(TEAM_ENVIRONMENT_NOT_TEAM_MEMBER);
TE.getOrElse(throwErr), return requireRoles.includes(member.role);
)();
} }
} }

View File

@@ -0,0 +1,41 @@
import { ArgsType, Field, ID } from '@nestjs/graphql';
@ArgsType()
export class CreateTeamEnvironmentArgs {
@Field({
name: 'name',
description: 'Name of the Team Environment',
})
name: string;
@Field(() => ID, {
name: 'teamID',
description: 'ID of the Team',
})
teamID: string;
@Field({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string;
}
@ArgsType()
export class UpdateTeamEnvironmentArgs {
@Field(() => ID, {
name: 'id',
description: 'ID of the Team Environment',
})
id: string;
@Field({
name: 'name',
description: 'Name of the Team Environment',
})
name: string;
@Field({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string;
}

View File

@@ -13,6 +13,11 @@ import { throwErr } from 'src/utils';
import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard'; import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard';
import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironment } from './team-environments.model';
import { TeamEnvironmentsService } from './team-environments.service'; import { TeamEnvironmentsService } from './team-environments.service';
import * as E from 'fp-ts/Either';
import {
CreateTeamEnvironmentArgs,
UpdateTeamEnvironmentArgs,
} from './input-type.args';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@Resolver(() => 'TeamEnvironment') @Resolver(() => 'TeamEnvironment')
@@ -29,29 +34,18 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard) @UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
createTeamEnvironment( async createTeamEnvironment(
@Args({ @Args() args: CreateTeamEnvironmentArgs,
name: 'name',
description: 'Name of the Team Environment',
})
name: string,
@Args({
name: 'teamID',
description: 'ID of the Team',
type: () => ID,
})
teamID: string,
@Args({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
return this.teamEnvironmentsService.createTeamEnvironment( const teamEnvironment =
name, await this.teamEnvironmentsService.createTeamEnvironment(
teamID, args.name,
variables, args.teamID,
)(); args.variables,
);
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
return teamEnvironment.right;
} }
@Mutation(() => Boolean, { @Mutation(() => Boolean, {
@@ -59,7 +53,7 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
deleteTeamEnvironment( async deleteTeamEnvironment(
@Args({ @Args({
name: 'id', name: 'id',
description: 'ID of the Team Environment', description: 'ID of the Team Environment',
@@ -67,10 +61,12 @@ export class TeamEnvironmentsResolver {
}) })
id: string, id: string,
): Promise<boolean> { ): Promise<boolean> {
return pipe( const isDeleted = await this.teamEnvironmentsService.deleteTeamEnvironment(
this.teamEnvironmentsService.deleteTeamEnvironment(id), id,
TE.getOrElse(throwErr), );
)();
if (E.isLeft(isDeleted)) throwErr(isDeleted.left);
return isDeleted.right;
} }
@Mutation(() => TeamEnvironment, { @Mutation(() => TeamEnvironment, {
@@ -79,28 +75,19 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
updateTeamEnvironment( async updateTeamEnvironment(
@Args({ @Args()
name: 'id', args: UpdateTeamEnvironmentArgs,
description: 'ID of the Team Environment',
type: () => ID,
})
id: string,
@Args({
name: 'name',
description: 'Name of the Team Environment',
})
name: string,
@Args({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
return pipe( const updatedTeamEnvironment =
this.teamEnvironmentsService.updateTeamEnvironment(id, name, variables), await this.teamEnvironmentsService.updateTeamEnvironment(
TE.getOrElse(throwErr), args.id,
)(); args.name,
args.variables,
);
if (E.isLeft(updatedTeamEnvironment)) throwErr(updatedTeamEnvironment.left);
return updatedTeamEnvironment.right;
} }
@Mutation(() => TeamEnvironment, { @Mutation(() => TeamEnvironment, {
@@ -108,7 +95,7 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
deleteAllVariablesFromTeamEnvironment( async deleteAllVariablesFromTeamEnvironment(
@Args({ @Args({
name: 'id', name: 'id',
description: 'ID of the Team Environment', description: 'ID of the Team Environment',
@@ -116,10 +103,13 @@ export class TeamEnvironmentsResolver {
}) })
id: string, id: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
return pipe( const teamEnvironment =
this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(id), await this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
TE.getOrElse(throwErr), id,
)(); );
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
return teamEnvironment.right;
} }
@Mutation(() => TeamEnvironment, { @Mutation(() => TeamEnvironment, {
@@ -127,7 +117,7 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
createDuplicateEnvironment( async createDuplicateEnvironment(
@Args({ @Args({
name: 'id', name: 'id',
description: 'ID of the Team Environment', description: 'ID of the Team Environment',
@@ -135,10 +125,12 @@ export class TeamEnvironmentsResolver {
}) })
id: string, id: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
return pipe( const res = await this.teamEnvironmentsService.createDuplicateEnvironment(
this.teamEnvironmentsService.createDuplicateEnvironment(id), id,
TE.getOrElse(throwErr), );
)();
if (E.isLeft(res)) throwErr(res.left);
return res.right;
} }
/* Subscriptions */ /* Subscriptions */

View File

@@ -2,7 +2,11 @@ import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironment } from './team-environments.model';
import { TeamEnvironmentsService } from './team-environments.service'; import { TeamEnvironmentsService } from './team-environments.service';
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors'; import {
JSON_INVALID,
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
} from 'src/errors';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
@@ -31,125 +35,81 @@ beforeEach(() => {
describe('TeamEnvironmentsService', () => { describe('TeamEnvironmentsService', () => {
describe('getTeamEnvironment', () => { describe('getTeamEnvironment', () => {
test('queries the db with the id', async () => { test('should successfully return a TeamEnvironment with valid ID', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
await teamEnvironmentsService.getTeamEnvironment('123')();
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: '123',
},
}),
); );
});
test('requests prisma to reject the query promise if not found', async () => { const result = await teamEnvironmentsService.getTeamEnvironment(
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); teamEnvironment.id,
await teamEnvironmentsService.getTeamEnvironment('123')();
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
rejectOnNotFound: true,
}),
); );
expect(result).toEqualRight(teamEnvironment);
}); });
test('should return a Some of the correct environment if exists', async () => { test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
'RejectOnNotFound',
);
const result = await teamEnvironmentsService.getTeamEnvironment('123')(); const result = await teamEnvironmentsService.getTeamEnvironment(
teamEnvironment.id,
expect(result).toEqualSome(teamEnvironment); );
}); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
test('should return a None if the environment does not exist', async () => {
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
const result = await teamEnvironmentsService.getTeamEnvironment('123')();
expect(result).toBeNone();
}); });
}); });
describe('createTeamEnvironment', () => { describe('createTeamEnvironment', () => {
test('should create and return a new team environment given a valid name,variable and team ID', async () => { test('should successfully create and return a new team environment given valid inputs', async () => {
mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment); mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
const result = await teamEnvironmentsService.createTeamEnvironment( const result = await teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name, teamEnvironment.name,
teamEnvironment.teamID, teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
)(); );
expect(result).toEqual(<TeamEnvironment>{ expect(result).toEqualRight({
id: teamEnvironment.id, ...teamEnvironment,
name: teamEnvironment.name,
teamID: teamEnvironment.teamID,
variables: JSON.stringify(teamEnvironment.variables), variables: JSON.stringify(teamEnvironment.variables),
}); });
}); });
test('should reject if given team ID is invalid', async () => { test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any); const result = await teamEnvironmentsService.createTeamEnvironment(
'12',
await expect(
teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name,
'invalidteamid',
JSON.stringify(teamEnvironment.variables),
),
).rejects.toBeDefined();
});
test('should reject if provided team environment name is not a string', async () => {
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
await expect(
teamEnvironmentsService.createTeamEnvironment(
null as any,
teamEnvironment.teamID, teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
), );
).rejects.toBeDefined();
});
test('should reject if provided variable is not a string', async () => { expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
await expect(
teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name,
teamEnvironment.teamID,
null as any,
),
).rejects.toBeDefined();
}); });
test('should send pubsub message to "team_environment/<teamID>/created" if team environment is created successfully', async () => { test('should send pubsub message to "team_environment/<teamID>/created" if team environment is created successfully', async () => {
mockPrisma.teamEnvironment.create.mockResolvedValueOnce(teamEnvironment); mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
const result = await teamEnvironmentsService.createTeamEnvironment( const result = await teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name, teamEnvironment.name,
teamEnvironment.teamID, teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/created`, `team_environment/${teamEnvironment.teamID}/created`,
result, {
...teamEnvironment,
variables: JSON.stringify(teamEnvironment.variables),
},
); );
}); });
}); });
describe('deleteTeamEnvironment', () => { describe('deleteTeamEnvironment', () => {
test('should resolve to true given a valid team environment ID', async () => { test('should successfully delete a TeamEnvironment with a valid ID', async () => {
mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment); mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment);
const result = await teamEnvironmentsService.deleteTeamEnvironment( const result = await teamEnvironmentsService.deleteTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(result).toEqualRight(true); expect(result).toEqualRight(true);
}); });
@@ -159,7 +119,7 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.deleteTeamEnvironment( const result = await teamEnvironmentsService.deleteTeamEnvironment(
'invalidid', 'invalidid',
)(); );
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -169,7 +129,7 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.deleteTeamEnvironment( const result = await teamEnvironmentsService.deleteTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/deleted`, `team_environment/${teamEnvironment.teamID}/deleted`,
@@ -182,7 +142,7 @@ describe('TeamEnvironmentsService', () => {
}); });
describe('updateVariablesInTeamEnvironment', () => { describe('updateVariablesInTeamEnvironment', () => {
test('should add new variable to a team environment', async () => { test('should successfully add new variable to a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: 'value' }], variables: [{ key: 'value' }],
@@ -192,7 +152,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: 'value' }]), JSON.stringify([{ key: 'value' }]),
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -200,7 +160,7 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should add new variable to already existing list of variables in a team environment', async () => { test('should successfully add new variable to already existing list of variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: 'value' }, { key_2: 'value_2' }], variables: [{ key: 'value' }, { key_2: 'value_2' }],
@@ -210,7 +170,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]), JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]),
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -218,7 +178,7 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should edit existing variables in a team environment', async () => { test('should successfully edit existing variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: '1234' }], variables: [{ key: '1234' }],
@@ -228,7 +188,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: '1234' }]), JSON.stringify([{ key: '1234' }]),
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -236,22 +196,7 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should delete existing variable in a team environment', async () => { test('should successfully edit name of an existing team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
const result = await teamEnvironmentsService.updateTeamEnvironment(
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{}]),
)();
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
variables: JSON.stringify([{}]),
});
});
test('should edit name of an existing team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: '123' }], variables: [{ key: '123' }],
@@ -261,7 +206,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: '123' }]), JSON.stringify([{ key: '123' }]),
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -269,14 +214,24 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
const result = await teamEnvironmentsService.updateTeamEnvironment(
teamEnvironment.id,
'12',
JSON.stringify([{ key: 'value' }]),
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
});
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
const result = await teamEnvironmentsService.updateTeamEnvironment( const result = await teamEnvironmentsService.updateTeamEnvironment(
'invalidid', 'invalidid',
teamEnvironment.name, teamEnvironment.name,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
)(); );
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -288,7 +243,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: 'value' }]), JSON.stringify([{ key: 'value' }]),
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/updated`, `team_environment/${teamEnvironment.teamID}/updated`,
@@ -301,13 +256,13 @@ describe('TeamEnvironmentsService', () => {
}); });
describe('deleteAllVariablesFromTeamEnvironment', () => { describe('deleteAllVariablesFromTeamEnvironment', () => {
test('should delete all variables in a team environment', async () => { test('should successfully delete all variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment); mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
const result = const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -315,13 +270,13 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
const result = const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
'invalidid', 'invalidid',
)(); );
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -332,7 +287,7 @@ describe('TeamEnvironmentsService', () => {
const result = const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/updated`, `team_environment/${teamEnvironment.teamID}/updated`,
@@ -345,33 +300,33 @@ describe('TeamEnvironmentsService', () => {
}); });
describe('createDuplicateEnvironment', () => { describe('createDuplicateEnvironment', () => {
test('should duplicate an existing team environment', async () => { test('should successfully duplicate an existing team environment', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce( mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
teamEnvironment, teamEnvironment,
); );
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({ mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
...teamEnvironment,
id: 'newid', id: 'newid',
...teamEnvironment,
}); });
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
id: 'newid', id: 'newid',
...teamEnvironment,
variables: JSON.stringify(teamEnvironment.variables), variables: JSON.stringify(teamEnvironment.variables),
}); });
}); });
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError'); mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -382,19 +337,19 @@ describe('TeamEnvironmentsService', () => {
); );
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({ mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
...teamEnvironment,
id: 'newid', id: 'newid',
...teamEnvironment,
}); });
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/created`, `team_environment/${teamEnvironment.teamID}/created`,
{ {
...teamEnvironment,
id: 'newid', id: 'newid',
...teamEnvironment,
variables: JSON.stringify([{}]), variables: JSON.stringify([{}]),
}, },
); );

View File

@@ -1,15 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { pipe } from 'fp-ts/function'; import { TeamEnvironment as DBTeamEnvironment, Prisma } from '@prisma/client';
import * as T from 'fp-ts/Task';
import * as TO from 'fp-ts/TaskOption';
import * as TE from 'fp-ts/TaskEither';
import * as A from 'fp-ts/Array';
import { Prisma } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironment } from './team-environments.model';
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors'; import {
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
} from 'src/errors';
import * as E from 'fp-ts/Either';
import { isValidLength } from 'src/utils';
@Injectable() @Injectable()
export class TeamEnvironmentsService { export class TeamEnvironmentsService {
constructor( constructor(
@@ -17,219 +16,218 @@ export class TeamEnvironmentsService {
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
) {} ) {}
getTeamEnvironment(id: string) { TITLE_LENGTH = 3;
return TO.tryCatch(() =>
this.prisma.teamEnvironment.findFirst({ /**
where: { id }, * TeamEnvironments are saved in the DB in the following way
rejectOnNotFound: true, * [{ key: value }, { key: value },....]
}), *
); */
/**
* Typecast a database TeamEnvironment to a TeamEnvironment model
* @param teamEnvironment database TeamEnvironment
* @returns TeamEnvironment model
*/
private cast(teamEnvironment: DBTeamEnvironment): TeamEnvironment {
return {
id: teamEnvironment.id,
name: teamEnvironment.name,
teamID: teamEnvironment.teamID,
variables: JSON.stringify(teamEnvironment.variables),
};
} }
createTeamEnvironment(name: string, teamID: string, variables: string) { /**
return pipe( * Get details of a TeamEnvironment.
() => *
this.prisma.teamEnvironment.create({ * @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async getTeamEnvironment(id: string) {
try {
const teamEnvironment =
await this.prisma.teamEnvironment.findFirstOrThrow({
where: { id },
});
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Create a new TeamEnvironment.
*
* @param name name of new TeamEnvironment
* @param teamID teamID of new TeamEnvironment
* @param variables JSONified string of contents of new TeamEnvironment
* @returns Either of a TeamEnvironment or error message
*/
async createTeamEnvironment(name: string, teamID: string, variables: string) {
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
const result = await this.prisma.teamEnvironment.create({
data: { data: {
name: name, name: name,
teamID: teamID, teamID: teamID,
variables: JSON.parse(variables), variables: JSON.parse(variables),
}, },
}), });
T.chainFirst(
(environment) => () => const createdTeamEnvironment = this.cast(result);
this.pubsub.publish( this.pubsub.publish(
`team_environment/${environment.teamID}/created`, `team_environment/${createdTeamEnvironment.teamID}/created`,
<TeamEnvironment>{ createdTeamEnvironment,
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
T.map((data) => {
return <TeamEnvironment>{
id: data.id,
name: data.name,
teamID: data.teamID,
variables: JSON.stringify(data.variables),
};
}),
); );
return E.right(createdTeamEnvironment);
} }
deleteTeamEnvironment(id: string) { /**
return pipe( * Delete a TeamEnvironment.
TE.tryCatch( *
() => * @param id TeamEnvironment ID
this.prisma.teamEnvironment.delete({ * @returns Either of boolean or error message
*/
async deleteTeamEnvironment(id: string) {
try {
const result = await this.prisma.teamEnvironment.delete({
where: { where: {
id: id, id: id,
}, },
}), });
() => TEAM_ENVIRONMENT_NOT_FOUND,
), const deletedTeamEnvironment = this.cast(result);
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish( this.pubsub.publish(
`team_environment/${environment.teamID}/deleted`, `team_environment/${deletedTeamEnvironment.teamID}/deleted`,
<TeamEnvironment>{ deletedTeamEnvironment,
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map((data) => true),
); );
return E.right(true);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
} }
updateTeamEnvironment(id: string, name: string, variables: string) { /**
return pipe( * Update a TeamEnvironment.
TE.tryCatch( *
() => * @param id TeamEnvironment ID
this.prisma.teamEnvironment.update({ * @param name TeamEnvironment name
* @param variables JSONified string of contents of new TeamEnvironment
* @returns Either of a TeamEnvironment or error message
*/
async updateTeamEnvironment(id: string, name: string, variables: string) {
try {
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
const result = await this.prisma.teamEnvironment.update({
where: { id: id }, where: { id: id },
data: { data: {
name, name,
variables: JSON.parse(variables), variables: JSON.parse(variables),
}, },
}), });
() => TEAM_ENVIRONMENT_NOT_FOUND,
), const updatedTeamEnvironment = this.cast(result);
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish( this.pubsub.publish(
`team_environment/${environment.teamID}/updated`, `team_environment/${updatedTeamEnvironment.teamID}/updated`,
<TeamEnvironment>{ updatedTeamEnvironment,
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
); );
return E.right(updatedTeamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
} }
deleteAllVariablesFromTeamEnvironment(id: string) { /**
return pipe( * Clear contents of a TeamEnvironment.
TE.tryCatch( *
() => * @param id TeamEnvironment ID
this.prisma.teamEnvironment.update({ * @returns Either of a TeamEnvironment or error message
*/
async deleteAllVariablesFromTeamEnvironment(id: string) {
try {
const result = await this.prisma.teamEnvironment.update({
where: { id: id }, where: { id: id },
data: { data: {
variables: [], variables: [],
}, },
}), });
() => TEAM_ENVIRONMENT_NOT_FOUND,
), const teamEnvironment = this.cast(result);
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish( this.pubsub.publish(
`team_environment/${environment.teamID}/updated`, `team_environment/${teamEnvironment.teamID}/updated`,
<TeamEnvironment>{ teamEnvironment,
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
); );
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
} }
createDuplicateEnvironment(id: string) { /**
return pipe( * Create a duplicate of a existing TeamEnvironment.
TE.tryCatch( *
() => * @param id TeamEnvironment ID
this.prisma.teamEnvironment.findFirst({ * @returns Either of a TeamEnvironment or error message
*/
async createDuplicateEnvironment(id: string) {
try {
const environment = await this.prisma.teamEnvironment.findFirst({
where: { where: {
id: id, id: id,
}, },
rejectOnNotFound: true, rejectOnNotFound: true,
}), });
() => TEAM_ENVIRONMENT_NOT_FOUND,
), const result = await this.prisma.teamEnvironment.create({
TE.chain((environment) =>
TE.fromTask(() =>
this.prisma.teamEnvironment.create({
data: { data: {
name: environment.name, name: environment.name,
teamID: environment.teamID, teamID: environment.teamID,
variables: environment.variables as Prisma.JsonArray, variables: environment.variables as Prisma.JsonArray,
}, },
}), });
),
), const duplicatedTeamEnvironment = this.cast(result);
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish( this.pubsub.publish(
`team_environment/${environment.teamID}/created`, `team_environment/${duplicatedTeamEnvironment.teamID}/created`,
<TeamEnvironment>{ duplicatedTeamEnvironment,
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
); );
return E.right(duplicatedTeamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
} }
fetchAllTeamEnvironments(teamID: string) { /**
return pipe( * Fetch all TeamEnvironments of a team.
() => *
this.prisma.teamEnvironment.findMany({ * @param teamID teamID of new TeamEnvironment
* @returns List of TeamEnvironments
*/
async fetchAllTeamEnvironments(teamID: string) {
const result = await this.prisma.teamEnvironment.findMany({
where: { where: {
teamID: teamID, teamID: teamID,
}, },
}), });
T.map( const teamEnvironments = result.map((item) => {
A.map( return this.cast(item);
(environment) => });
<TeamEnvironment>{
id: environment.id, return teamEnvironments;
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
);
} }
/** /**

View File

@@ -11,6 +11,6 @@ export class TeamEnvsTeamResolver {
description: 'Returns all Team Environments for the given Team', description: 'Returns all Team Environments for the given Team',
}) })
teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> { teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> {
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id)(); return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id);
} }
} }

View File

@@ -0,0 +1,20 @@
import { ArgsType, Field, ID } from '@nestjs/graphql';
import { TeamMemberRole } from 'src/team/team.model';
@ArgsType()
export class CreateTeamInvitationArgs {
@Field(() => ID, {
name: 'teamID',
description: 'ID of the Team ID to invite from',
})
teamID: string;
@Field({ name: 'inviteeEmail', description: 'Email of the user to invite' })
inviteeEmail: string;
@Field(() => TeamMemberRole, {
name: 'inviteeRole',
description: 'Role to be given to the user',
})
inviteeRole: TeamMemberRole;
}

View File

@@ -12,15 +12,10 @@ import { TeamInvitation } from './team-invitation.model';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import { pipe } from 'fp-ts/function'; import { pipe } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither'; import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model'; import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model';
import { EmailCodec } from 'src/types/Email'; import { TEAM_INVITE_NO_INVITE_FOUND, USER_NOT_FOUND } from 'src/errors';
import {
INVALID_EMAIL,
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
TEAM_INVITE_NO_INVITE_FOUND,
USER_NOT_FOUND,
} from 'src/errors';
import { GqlUser } from 'src/decorators/gql-user.decorator'; import { GqlUser } from 'src/decorators/gql-user.decorator';
import { User } from 'src/user/user.model'; import { User } from 'src/user/user.model';
import { UseGuards } from '@nestjs/common'; import { UseGuards } from '@nestjs/common';
@@ -36,6 +31,8 @@ import { UserService } from 'src/user/user.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler'; import { SkipThrottle } from '@nestjs/throttler';
import { AuthUser } from 'src/types/AuthUser';
import { CreateTeamInvitationArgs } from './input-type.args';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@Resolver(() => TeamInvitation) @Resolver(() => TeamInvitation)
@@ -79,8 +76,8 @@ export class TeamInvitationResolver {
'Gets the Team Invitation with the given ID, or null if not exists', 'Gets the Team Invitation with the given ID, or null if not exists',
}) })
@UseGuards(GqlAuthGuard, TeamInviteViewerGuard) @UseGuards(GqlAuthGuard, TeamInviteViewerGuard)
teamInvitation( async teamInvitation(
@GqlUser() user: User, @GqlUser() user: AuthUser,
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
description: 'ID of the Team Invitation to lookup', description: 'ID of the Team Invitation to lookup',
@@ -88,17 +85,11 @@ export class TeamInvitationResolver {
}) })
inviteID: string, inviteID: string,
): Promise<TeamInvitation> { ): Promise<TeamInvitation> {
return pipe( const teamInvitation = await this.teamInvitationService.getInvitation(
this.teamInvitationService.getInvitation(inviteID), inviteID,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), );
TE.chainW( if (O.isNone(teamInvitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
TE.fromPredicate( return teamInvitation.value;
(a) => a.inviteeEmail.toLowerCase() === user.email?.toLowerCase(),
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
TE.getOrElse(throwErr),
)();
} }
@Mutation(() => TeamInvitation, { @Mutation(() => TeamInvitation, {
@@ -106,56 +97,19 @@ export class TeamInvitationResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard) @UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER) @RequiresTeamRole(TeamMemberRole.OWNER)
createTeamInvitation( async createTeamInvitation(
@GqlUser() @GqlUser() user: AuthUser,
user: User, @Args() args: CreateTeamInvitationArgs,
@Args({
name: 'teamID',
description: 'ID of the Team ID to invite from',
type: () => ID,
})
teamID: string,
@Args({
name: 'inviteeEmail',
description: 'Email of the user to invite',
})
inviteeEmail: string,
@Args({
name: 'inviteeRole',
type: () => TeamMemberRole,
description: 'Role to be given to the user',
})
inviteeRole: TeamMemberRole,
): Promise<TeamInvitation> { ): Promise<TeamInvitation> {
return pipe( const teamInvitation = await this.teamInvitationService.createInvitation(
TE.Do,
// Validate email
TE.bindW('email', () =>
pipe(
EmailCodec.decode(inviteeEmail),
TE.fromEither,
TE.mapLeft(() => INVALID_EMAIL),
),
),
// Validate and get Team
TE.bindW('team', () => this.teamService.getTeamWithIDTE(teamID)),
// Create team
TE.chainW(({ email, team }) =>
this.teamInvitationService.createInvitation(
user, user,
team, args.teamID,
email, args.inviteeEmail,
inviteeRole, args.inviteeRole,
), );
),
// If failed, throw err (so the message is passed) else return value if (E.isLeft(teamInvitation)) throwErr(teamInvitation.left);
TE.getOrElse(throwErr), return teamInvitation.right;
)();
} }
@Mutation(() => Boolean, { @Mutation(() => Boolean, {
@@ -163,7 +117,7 @@ export class TeamInvitationResolver {
}) })
@UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard) @UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard)
@RequiresTeamRole(TeamMemberRole.OWNER) @RequiresTeamRole(TeamMemberRole.OWNER)
revokeTeamInvitation( async revokeTeamInvitation(
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
type: () => ID, type: () => ID,
@@ -171,19 +125,19 @@ export class TeamInvitationResolver {
}) })
inviteID: string, inviteID: string,
): Promise<true> { ): Promise<true> {
return pipe( const isRevoked = await this.teamInvitationService.revokeInvitation(
this.teamInvitationService.revokeInvitation(inviteID), inviteID,
TE.map(() => true as const), );
TE.getOrElse(throwErr), if (E.isLeft(isRevoked)) throwErr(isRevoked.left);
)(); return true;
} }
@Mutation(() => TeamMember, { @Mutation(() => TeamMember, {
description: 'Accept an Invitation', description: 'Accept an Invitation',
}) })
@UseGuards(GqlAuthGuard, TeamInviteeGuard) @UseGuards(GqlAuthGuard, TeamInviteeGuard)
acceptTeamInvitation( async acceptTeamInvitation(
@GqlUser() user: User, @GqlUser() user: AuthUser,
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
type: () => ID, type: () => ID,
@@ -191,10 +145,12 @@ export class TeamInvitationResolver {
}) })
inviteID: string, inviteID: string,
): Promise<TeamMember> { ): Promise<TeamMember> {
return pipe( const teamMember = await this.teamInvitationService.acceptInvitation(
this.teamInvitationService.acceptInvitation(inviteID, user), inviteID,
TE.getOrElse(throwErr), user,
)(); );
if (E.isLeft(teamMember)) throwErr(teamMember.left);
return teamMember.right;
} }
// Subscriptions // Subscriptions

View File

@@ -1,24 +1,25 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as TO from 'fp-ts/TaskOption'; import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither';
import { pipe, flow, constVoid } from 'fp-ts/function';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { Team, TeamMemberRole } from 'src/team/team.model'; import { TeamInvitation as DBTeamInvitation } from '@prisma/client';
import { Email } from 'src/types/Email'; import { TeamMember, TeamMemberRole } from 'src/team/team.model';
import { User } from 'src/user/user.model';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
import { import {
INVALID_EMAIL,
TEAM_INVALID_ID,
TEAM_INVITE_ALREADY_MEMBER, TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_EMAIL_DO_NOT_MATCH, TEAM_INVITE_EMAIL_DO_NOT_MATCH,
TEAM_INVITE_MEMBER_HAS_INVITE, TEAM_INVITE_MEMBER_HAS_INVITE,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { TeamInvitation } from './team-invitation.model'; import { TeamInvitation } from './team-invitation.model';
import { MailerService } from 'src/mailer/mailer.service'; import { MailerService } from 'src/mailer/mailer.service';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { validateEmail } from '../utils';
import { AuthUser } from 'src/types/AuthUser';
@Injectable() @Injectable()
export class TeamInvitationService { export class TeamInvitationService {
@@ -29,245 +30,221 @@ export class TeamInvitationService {
private readonly mailerService: MailerService, private readonly mailerService: MailerService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
) { ) {}
this.getInvitation = this.getInvitation.bind(this);
/**
* Cast a DBTeamInvitation to a TeamInvitation
* @param dbTeamInvitation database TeamInvitation
* @returns TeamInvitation model
*/
cast(dbTeamInvitation: DBTeamInvitation): TeamInvitation {
return {
...dbTeamInvitation,
inviteeRole: TeamMemberRole[dbTeamInvitation.inviteeRole],
};
} }
getInvitation(inviteID: string): TO.TaskOption<TeamInvitation> { /**
return pipe( * Get the team invite
() => * @param inviteID invite id
this.prisma.teamInvitation.findUnique({ * @returns an Option of team invitation or none
*/
async getInvitation(inviteID: string) {
try {
const dbInvitation = await this.prisma.teamInvitation.findUniqueOrThrow({
where: { where: {
id: inviteID, id: inviteID,
}, },
}), });
TO.fromTask,
TO.chain(flow(O.fromNullable, TO.fromOption)), return O.some(this.cast(dbInvitation));
TO.map((x) => x as TeamInvitation), } catch (e) {
); return O.none;
}
} }
getInvitationWithEmail(email: Email, team: Team) { /**
return pipe( * Get the team invite for an invitee with email and teamID.
() => * @param inviteeEmail invitee email
this.prisma.teamInvitation.findUnique({ * @param teamID team id
* @returns an Either of team invitation for the invitee or error
*/
async getTeamInviteByEmailAndTeamID(inviteeEmail: string, teamID: string) {
const isEmailValid = validateEmail(inviteeEmail);
if (!isEmailValid) return E.left(INVALID_EMAIL);
try {
const teamInvite = await this.prisma.teamInvitation.findUniqueOrThrow({
where: { where: {
teamID_inviteeEmail: { teamID_inviteeEmail: {
inviteeEmail: email, inviteeEmail: inviteeEmail,
teamID: team.id, teamID: teamID,
}, },
}, },
}), });
TO.fromTask,
TO.chain(flow(O.fromNullable, TO.fromOption)), return E.right(teamInvite);
); } catch (e) {
return E.left(TEAM_INVITE_NO_INVITE_FOUND);
}
} }
createInvitation( /**
creator: User, * Create a team invitation
team: Team, * @param creator creator of the invitation
inviteeEmail: Email, * @param teamID team id
* @param inviteeEmail invitee email
* @param inviteeRole invitee role
* @returns an Either of team invitation or error message
*/
async createInvitation(
creator: AuthUser,
teamID: string,
inviteeEmail: string,
inviteeRole: TeamMemberRole, inviteeRole: TeamMemberRole,
) { ) {
return pipe( // validate email
// Perform all validation checks const isEmailValid = validateEmail(inviteeEmail);
TE.sequenceArray([ if (!isEmailValid) return E.left(INVALID_EMAIL);
// creator should be a TeamMember
pipe(
this.teamService.getTeamMemberTE(team.id, creator.uid),
TE.map(constVoid),
),
// Invitee should not be a team member // team ID should valid
pipe( const team = await this.teamService.getTeamWithID(teamID);
async () => await this.userService.findUserByEmail(inviteeEmail), if (!team) return E.left(TEAM_INVALID_ID);
TO.foldW(
() => TE.right(undefined), // If no user, short circuit to completion
(user) =>
pipe(
// If user is found, check if team member
this.teamService.getTeamMemberTE(team.id, user.uid),
TE.foldW(
() => TE.right(undefined), // Not team-member, this is good
() => TE.left(TEAM_INVITE_ALREADY_MEMBER), // Is team member, not good
),
),
),
TE.map(constVoid),
),
// Should not have an existing invite // invitation creator should be a TeamMember
pipe( const isTeamMember = await this.teamService.getTeamMember(
this.getInvitationWithEmail(inviteeEmail, team), team.id,
TE.fromTaskOption(() => null), creator.uid,
TE.swap, );
TE.map(constVoid), if (!isTeamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
TE.mapLeft(() => TEAM_INVITE_MEMBER_HAS_INVITE),
),
]),
// Create the invitation // Checking to see if the invitee is already part of the team or not
TE.chainTaskK( const inviteeUser = await this.userService.findUserByEmail(inviteeEmail);
() => () => if (O.isSome(inviteeUser)) {
this.prisma.teamInvitation.create({ // invitee should not already a member
const isTeamMember = await this.teamService.getTeamMember(
team.id,
inviteeUser.value.uid,
);
if (isTeamMember) return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
// check invitee already invited earlier or not
const teamInvitation = await this.getTeamInviteByEmailAndTeamID(
inviteeEmail,
team.id,
);
if (E.isRight(teamInvitation)) return E.left(TEAM_INVITE_MEMBER_HAS_INVITE);
// create the invitation
const dbInvitation = await this.prisma.teamInvitation.create({
data: { data: {
teamID: team.id, teamID: team.id,
inviteeEmail, inviteeEmail,
inviteeRole, inviteeRole,
creatorUid: creator.uid, creatorUid: creator.uid,
}, },
}), });
),
// Send email, this is a side effect await this.mailerService.sendEmail(inviteeEmail, {
TE.chainFirstTaskK((invitation) =>
pipe(
this.mailerService.sendMail(inviteeEmail, {
template: 'team-invitation', template: 'team-invitation',
variables: { variables: {
invitee: creator.displayName ?? 'A Hoppscotch User', invitee: creator.displayName ?? 'A Hoppscotch User',
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${invitation.id}`, action_url: `${process.env.VITE_BASE_URL}/join-team?id=${dbInvitation.id}`,
invite_team_name: team.name, invite_team_name: team.name,
}, },
}), });
TE.getOrElseW(() => T.of(undefined)), // This value doesn't matter as we don't mind the return value (chainFirst) as long as the task completes const invitation = this.cast(dbInvitation);
), this.pubsub.publish(`team/${invitation.teamID}/invite_added`, invitation);
),
// Send PubSub topic return E.right(invitation);
TE.chainFirstTaskK((invitation) =>
TE.fromTask(async () => {
const inv: TeamInvitation = {
id: invitation.id,
teamID: invitation.teamID,
creatorUid: invitation.creatorUid,
inviteeEmail: invitation.inviteeEmail,
inviteeRole: TeamMemberRole[invitation.inviteeRole],
};
this.pubsub.publish(`team/${inv.teamID}/invite_added`, inv);
}),
),
// Map to model type
TE.map((x) => x as TeamInvitation),
);
}
revokeInvitation(inviteID: string) {
return pipe(
// Make sure invite exists
this.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
// Delete team invitation
TE.chainTaskK(
() => () =>
this.prisma.teamInvitation.delete({
where: {
id: inviteID,
},
}),
),
// Emit Pubsub Event
TE.chainFirst((invitation) =>
TE.fromTask(() =>
this.pubsub.publish(
`team/${invitation.teamID}/invite_removed`,
invitation.id,
),
),
),
// We are not returning anything
TE.map(constVoid),
);
}
getAllInvitationsInTeam(team: Team) {
return pipe(
() =>
this.prisma.teamInvitation.findMany({
where: {
teamID: team.id,
},
}),
T.map((x) => x as TeamInvitation[]),
);
}
acceptInvitation(inviteID: string, acceptedBy: User) {
return pipe(
TE.Do,
// First get the invitation
TE.bindW('invitation', () =>
pipe(
this.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
// Validation checks
TE.chainFirstW(({ invitation }) =>
TE.sequenceArray([
// Make sure the invited user is not part of the team
pipe(
this.teamService.getTeamMemberTE(invitation.teamID, acceptedBy.uid),
TE.swap,
TE.bimap(
() => TEAM_INVITE_ALREADY_MEMBER,
constVoid, // The return type is ignored
),
),
// Make sure the invited user and accepting user has the same email
pipe(
undefined,
TE.fromPredicate(
(a) => acceptedBy.email === invitation.inviteeEmail,
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
]),
),
// Add the team member
// TODO: Somehow bring subscriptions to this ?
TE.bindW('teamMember', ({ invitation }) =>
pipe(
TE.tryCatch(
() =>
this.teamService.addMemberToTeam(
invitation.teamID,
acceptedBy.uid,
invitation.inviteeRole,
),
() => TEAM_INVITE_ALREADY_MEMBER, // Can only fail if Team Member already exists, which we checked, but due to async lets assert that here too
),
),
),
TE.chainFirstW(({ invitation }) => this.revokeInvitation(invitation.id)),
TE.map(({ teamMember }) => teamMember),
);
} }
/** /**
* Fetch the count invitations for a given team. * Revoke a team invitation
* @param teamID team id * @param inviteID invite id
* @returns a count team invitations for a team * @returns an Either of true or error message
*/ */
async getAllTeamInvitations(teamID: string) { async revokeInvitation(inviteID: string) {
const invitations = await this.prisma.teamInvitation.findMany({ // check if the invite exists
const invitation = await this.getInvitation(inviteID);
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
// delete the invite
await this.prisma.teamInvitation.delete({
where: {
id: inviteID,
},
});
this.pubsub.publish(
`team/${invitation.value.teamID}/invite_removed`,
invitation.value.id,
);
return E.right(true);
}
/**
* Accept a team invitation
* @param inviteID invite id
* @param acceptedBy user who accepted the invitation
* @returns an Either of team member or error message
*/
async acceptInvitation(inviteID: string, acceptedBy: AuthUser) {
// check if the invite exists
const invitation = await this.getInvitation(inviteID);
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
// make sure the user is not already a member of the team
const teamMemberInvitee = await this.teamService.getTeamMember(
invitation.value.teamID,
acceptedBy.uid,
);
if (teamMemberInvitee) return E.left(TEAM_INVITE_ALREADY_MEMBER);
// make sure the user is the same as the invitee
if (
acceptedBy.email.toLowerCase() !==
invitation.value.inviteeEmail.toLowerCase()
)
return E.left(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
// add the user to the team
let teamMember: TeamMember;
try {
teamMember = await this.teamService.addMemberToTeam(
invitation.value.teamID,
acceptedBy.uid,
invitation.value.inviteeRole,
);
} catch (e) {
return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
// delete the invite
await this.revokeInvitation(inviteID);
return E.right(teamMember);
}
/**
* Fetch all team invitations for a given team.
* @param teamID team id
* @returns array of team invitations for a team
*/
async getTeamInvitations(teamID: string) {
const dbInvitations = await this.prisma.teamInvitation.findMany({
where: { where: {
teamID: teamID, teamID: teamID,
}, },
}); });
const invitations: TeamInvitation[] = dbInvitations.map((dbInvitation) =>
this.cast(dbInvitation),
);
return invitations; return invitations;
} }
} }

View File

@@ -1,21 +1,21 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { pipe } from 'fp-ts/function';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as T from 'fp-ts/Task';
import * as TE from 'fp-ts/TaskEither';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID, BUG_TEAM_INVITE_NO_INVITE_ID,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
TEAM_NOT_REQUIRED_ROLE, TEAM_NOT_REQUIRED_ROLE,
} from 'src/errors'; } from 'src/errors';
import { User } from 'src/user/user.model';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { TeamMemberRole } from 'src/team/team.model'; import { TeamMemberRole } from 'src/team/team.model';
/**
* This guard only allows team owner to execute the resolver
*/
@Injectable() @Injectable()
export class TeamInviteTeamOwnerGuard implements CanActivate { export class TeamInviteTeamOwnerGuard implements CanActivate {
constructor( constructor(
@@ -24,48 +24,30 @@ export class TeamInviteTeamOwnerGuard implements CanActivate {
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe( // Get GQL context
TE.Do, const gqlExecCtx = GqlExecutionContext.create(context);
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), // Get user
const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get the invite // Get the invite
TE.bindW('invite', ({ gqlCtx }) => const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
pipe( if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW((inviteID) =>
pipe(
this.teamInviteService.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
TE.bindW('user', ({ gqlCtx }) => const invitation = await this.teamInviteService.getInvitation(inviteID);
pipe( if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
gqlCtx.getContext().req.user,
O.fromNullable,
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
TE.bindW('userMember', ({ invite, user }) => // Fetch team member details of this user
this.teamService.getTeamMemberTE(invite.teamID, user.uid), const teamMember = await this.teamService.getTeamMember(
), invitation.value.teamID,
user.uid,
);
TE.chainW( if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
TE.fromPredicate( if (teamMember.role !== TeamMemberRole.OWNER)
({ userMember }) => userMember.role === TeamMemberRole.OWNER, throwErr(TEAM_NOT_REQUIRED_ROLE);
() => TEAM_NOT_REQUIRED_ROLE,
),
),
TE.fold( return true;
(err) => throwErr(err),
() => T.of(true),
),
)();
} }
} }

View File

@@ -1,20 +1,23 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import { pipe, flow } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID, BUG_TEAM_INVITE_NO_INVITE_ID,
TEAM_INVITE_NOT_VALID_VIEWER,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { User } from 'src/user/user.model';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
/**
* This guard only allows user to execute the resolver
* 1. If user is invitee, allow
* 2. Or else, if user is team member, allow
*
* TLDR: Allow if user is invitee or team member
*/
@Injectable() @Injectable()
export class TeamInviteViewerGuard implements CanActivate { export class TeamInviteViewerGuard implements CanActivate {
constructor( constructor(
@@ -23,50 +26,32 @@ export class TeamInviteViewerGuard implements CanActivate {
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe( // Get GQL context
TE.Do, const gqlExecCtx = GqlExecutionContext.create(context);
// Get GQL Context
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
// Get user // Get user
TE.bindW('user', ({ gqlCtx }) => const { user } = gqlExecCtx.getContext().req;
pipe( if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
O.fromNullable(gqlCtx.getContext<{ user?: User }>().user),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
// Get the invite // Get the invite
TE.bindW('invite', ({ gqlCtx }) => const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
pipe( if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW(
flow(
this.teamInviteService.getInvitation,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
// Check if the user and the invite email match, else if we can resolver the user as a team member const invitation = await this.teamInviteService.getInvitation(inviteID);
// any better solution ? if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
TE.chainW(({ user, invite }) =>
user.email?.toLowerCase() === invite.inviteeEmail.toLowerCase()
? TE.of(true)
: pipe(
this.teamService.getTeamMemberTE(invite.teamID, user.uid),
TE.map(() => true),
),
),
TE.mapLeft((e) => // Check if the user and the invite email match, else if user is a team member
e === 'team/member_not_found' ? TEAM_INVITE_NOT_VALID_VIEWER : e, if (
), user.email?.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
) {
const teamMember = await this.teamService.getTeamMember(
invitation.value.teamID,
user.uid,
);
TE.fold(throwErr, () => T.of(true)), if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
)(); }
return true;
} }
} }

View File

@@ -1,11 +1,7 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import { pipe, flow } from 'fp-ts/function';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as T from 'fp-ts/Task';
import * as TE from 'fp-ts/TaskEither';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { User } from 'src/user/user.model';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID, BUG_TEAM_INVITE_NO_INVITE_ID,
@@ -24,44 +20,26 @@ export class TeamInviteeGuard implements CanActivate {
constructor(private readonly teamInviteService: TeamInvitationService) {} constructor(private readonly teamInviteService: TeamInvitationService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe( // Get GQL Context
TE.Do, const gqlExecCtx = GqlExecutionContext.create(context);
// Get execution context
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
// Get user // Get user
TE.bindW('user', ({ gqlCtx }) => const { user } = gqlExecCtx.getContext().req;
pipe( if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
O.fromNullable(gqlCtx.getContext<{ user?: User }>().user),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
// Get invite // Get the invite
TE.bindW('invite', ({ gqlCtx }) => const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
pipe( if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW(
flow(
this.teamInviteService.getInvitation,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
// Check if the emails match const invitation = await this.teamInviteService.getInvitation(inviteID);
TE.chainW( if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
TE.fromPredicate(
({ user, invite }) => user.email === invite.inviteeEmail,
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
// Fold it to a promise if (
TE.fold(throwErr, () => T.of(true)), user.email.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
)(); ) {
throwErr(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
}
return true;
} }
} }

View File

@@ -12,6 +12,6 @@ export class TeamTeamInviteExtResolver {
complexity: 10, complexity: 10,
}) })
teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> { teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> {
return this.teamInviteService.getAllInvitationsInTeam(team)(); return this.teamInviteService.getTeamInvitations(team.id);
} }
} }

View File

@@ -2,7 +2,7 @@ import { HttpStatus } from '@nestjs/common';
/** /**
** Custom interface to handle errors specific to Auth module ** Custom interface to handle errors specific to Auth module
** Since its REST we need to return HTTP status code along with error message ** Since its REST we need to return the HTTP status code along with the error message
*/ */
export type AuthError = { export type AuthError = {
message: string; message: string;

View File

@@ -24,6 +24,8 @@ beforeEach(() => {
mockPubSub.publish.mockClear(); mockPubSub.publish.mockClear();
}); });
const date = new Date();
describe('UserHistoryService', () => { describe('UserHistoryService', () => {
describe('fetchUserHistory', () => { describe('fetchUserHistory', () => {
test('Should return a list of users REST history if exists', async () => { test('Should return a list of users REST history if exists', async () => {
@@ -400,7 +402,7 @@ describe('UserHistoryService', () => {
request: [{}], request: [{}],
responseMetadata: [{}], responseMetadata: [{}],
reqType: ReqType.REST, reqType: ReqType.REST,
executedOn: new Date(), executedOn: date,
isStarred: false, isStarred: false,
}); });
@@ -410,7 +412,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]), request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]), responseMetadata: JSON.stringify([{}]),
reqType: ReqType.REST, reqType: ReqType.REST,
executedOn: new Date(), executedOn: date,
isStarred: false, isStarred: false,
}; };

View File

@@ -9,7 +9,13 @@ import * as E from 'fp-ts/Either';
import * as A from 'fp-ts/Array'; import * as A from 'fp-ts/Array';
import { TeamMemberRole } from './team/team.model'; import { TeamMemberRole } from './team/team.model';
import { User } from './user/user.model'; import { User } from './user/user.model';
import { JSON_INVALID } from './errors'; import {
ENV_EMPTY_AUTH_PROVIDERS,
ENV_NOT_FOUND_KEY_AUTH_PROVIDERS,
ENV_NOT_SUPPORT_AUTH_PROVIDERS,
JSON_INVALID,
} from './errors';
import { AuthProvider } from './auth/helper';
/** /**
* A workaround to throw an exception in an expression. * A workaround to throw an exception in an expression.
@@ -152,3 +158,31 @@ export function isValidLength(title: string, length: number) {
return true; return true;
} }
/**
* This function is called by bootstrap() in main.ts
* It checks if the "VITE_ALLOWED_AUTH_PROVIDERS" environment variable is properly set or not.
* If not, it throws an error.
*/
export function checkEnvironmentAuthProvider() {
if (!process.env.hasOwnProperty('VITE_ALLOWED_AUTH_PROVIDERS')) {
throw new Error(ENV_NOT_FOUND_KEY_AUTH_PROVIDERS);
}
if (process.env.VITE_ALLOWED_AUTH_PROVIDERS === '') {
throw new Error(ENV_EMPTY_AUTH_PROVIDERS);
}
const givenAuthProviders = process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(
',',
).map((provider) => provider.toLocaleUpperCase());
const supportedAuthProviders = Object.values(AuthProvider).map(
(provider: string) => provider.toLocaleUpperCase(),
);
for (const givenAuthProvider of givenAuthProviders) {
if (!supportedAuthProviders.includes(givenAuthProvider)) {
throw new Error(ENV_NOT_SUPPORT_AUTH_PROVIDERS);
}
}
}

View File

@@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
support@hoppscotch.io.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,29 +1,19 @@
<div align="center">
<a href="https://hoppscotch.io">
<img
src="https://avatars.githubusercontent.com/u/56705483"
alt="Hoppscotch Logo"
height="64"
/>
</a>
</div>
<div align="center">
# Hoppscotch CLI <font size=2><sup>ALPHA</sup></font> # Hoppscotch CLI <font size=2><sup>ALPHA</sup></font>
</div> A CLI to run Hoppscotch Test Scripts in CI environments.
A CLI to run Hoppscotch test scripts in CI environments.
### **Commands:** ### **Commands:**
- `hopp test [options] [file]`: testing hoppscotch collection.json file - `hopp test [options] [file]`: testing hoppscotch collection.json file
### **Usage:** ### **Usage:**
```
```bash
hopp [options or commands] arguments hopp [options or commands] arguments
``` ```
### **Options:** ### **Options:**
- `-v`, `--ver`: see the current version of the CLI - `-v`, `--ver`: see the current version of the CLI
- `-h`, `--help`: display help for command - `-h`, `--help`: display help for command
@@ -45,14 +35,18 @@ hopp [options or commands] arguments
- Executes and outputs test-script response. - Executes and outputs test-script response.
#### Options: #### Options:
##### `-e <file_path>` / `--env <file_path>` ##### `-e <file_path>` / `--env <file_path>`
- Accepts path to env.json with contents in below format: - Accepts path to env.json with contents in below format:
```json ```json
{ {
"ENV1":"value1", "ENV1":"value1",
"ENV2":"value2" "ENV2":"value2"
} }
``` ```
- You can now access those variables using `pw.env.get('<var_name>')` - You can now access those variables using `pw.env.get('<var_name>')`
Taking the above example, `pw.env.get("ENV1")` will return `"value1"` Taking the above example, `pw.env.get("ENV1")` will return `"value1"`
@@ -75,4 +69,59 @@ npm i -g @hoppscotch/cli
## **Contributing:** ## **Contributing:**
To get started contributing to the repository, please read **[CONTRIBUTING.md](./CONTRIBUTING.md)** When contributing to this repository, please first discuss the change you wish to make via issue,
email, or any other method with the owners of this repository before making a change.
Please note we have a code of conduct, please follow it in all your interactions with the project.
## Pull Request Process
1. Ensure any install or build dependencies are removed before the end of the layer when doing a
build.
2. Update the README.md with details of changes to the interface, this includes new environment
variables, exposed ports, useful file locations and container parameters.
3. Increase the version numbers in any examples files and the README.md to the new version that this
Pull Request would represent. The versioning scheme we use is [SemVer](https://semver.org).
4. You may merge the Pull Request once you have the sign-off of two other developers, or if you
do not have permission to do that, you may request the second reviewer merge it for you.
## Set Up The Development Environment
1. After cloning the repository, execute the following commands:
```bash
pnpm install
pnpm run build
```
2. In order to test locally, you can use two types of package linking:
1. The 'pnpm exec' way (preferred since it does not hamper your original installation of the CLI):
```bash
pnpm link @hoppscotch/cli
// Then to use or test the CLI:
pnpm exec hopp
// After testing, to remove the package linking:
pnpm rm @hoppscotch/cli
```
2. The 'global' way (warning: this might override the globally installed CLI, if exists):
```bash
sudo pnpm link --global
// Then to use or test the CLI:
hopp
// After testing, to remove the package linking:
sudo pnpm rm --global @hoppscotch/cli
```
3. To use the Typescript watch scripts:
```bash
pnpm run dev
```

View File

@@ -6,7 +6,6 @@ module.exports = {
env: { env: {
browser: true, browser: true,
node: true, node: true,
jest: true,
}, },
parserOptions: { parserOptions: {
sourceType: "module", sourceType: "module",
@@ -30,8 +29,18 @@ module.exports = {
"import/named": "off", // because, named import issue with typescript see: https://github.com/typescript-eslint/typescript-eslint/issues/154 "import/named": "off", // because, named import issue with typescript see: https://github.com/typescript-eslint/typescript-eslint/issues/154
"no-console": "off", "no-console": "off",
"no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", "no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"prettier/prettier": "prettier/prettier": [
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
{},
{
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
useTabs: false,
tabWidth: 2,
},
],
"vue/multi-word-component-names": "off", "vue/multi-word-component-names": "off",
"vue/no-side-effects-in-computed-properties": "off", "vue/no-side-effects-in-computed-properties": "off",
"import/no-named-as-default": "off", "import/no-named-as-default": "off",

View File

@@ -1,3 +1,8 @@
module.exports = { module.exports = {
semi: false semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
useTabs: false,
tabWidth: 2
} }

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@@ -4,6 +4,7 @@
@apply after:backface-hidden; @apply after:backface-hidden;
@apply selection:bg-accentDark; @apply selection:bg-accentDark;
@apply selection:text-accentContrast; @apply selection:text-accentContrast;
@apply overscroll-none;
} }
:root { :root {
@@ -165,12 +166,6 @@ a {
@apply truncate; @apply truncate;
@apply sm:inline-flex; @apply sm:inline-flex;
} }
.env-icon {
@apply transition;
@apply inline-flex;
@apply items-center;
}
} }
.tippy-svg-arrow { .tippy-svg-arrow {
@@ -189,10 +184,11 @@ a {
@apply border-solid border-dividerDark; @apply border-solid border-dividerDark;
@apply rounded; @apply rounded;
@apply shadow-lg; @apply shadow-lg;
@apply max-w-[45vw] #{!important};
.tippy-content { .tippy-content {
@apply flex flex-col; @apply flex flex-col;
@apply max-h-56; @apply max-h-[45vh];
@apply items-stretch; @apply items-stretch;
@apply overflow-y-auto; @apply overflow-y-auto;
@apply text-secondary text-body; @apply text-secondary text-body;
@@ -200,6 +196,10 @@ a {
@apply leading-normal; @apply leading-normal;
@apply focus:outline-none; @apply focus:outline-none;
scroll-behavior: smooth; scroll-behavior: smooth;
& > span {
@apply block #{!important};
}
} }
.tippy-svg-arrow { .tippy-svg-arrow {
@@ -215,6 +215,7 @@ a {
[data-v-tippy] { [data-v-tippy] {
@apply flex flex-1; @apply flex flex-1;
@apply truncate;
} }
[interactive] > div { [interactive] > div {
@@ -325,7 +326,7 @@ pre.ace_editor {
@apply after:font-icon; @apply after:font-icon;
@apply after:text-current; @apply after:text-current;
@apply after:right-3; @apply after:right-3;
@apply after:content-["\e313"]; @apply after:content-["\e5cf"];
@apply after:text-lg; @apply after:text-lg;
} }
@@ -480,6 +481,10 @@ pre.ace_editor {
} }
} }
.cm-scroller {
@apply overscroll-y-auto;
}
.cm-editor { .cm-editor {
.cm-line::selection { .cm-line::selection {
@apply bg-accentDark #{!important}; @apply bg-accentDark #{!important};
@@ -567,3 +572,11 @@ details[open] summary .indicator {
@apply rounded; @apply rounded;
@apply border-0; @apply border-0;
} }
.gql-operation-not-highlight {
@apply opacity-50;
}
.gql-operation-highlight {
@apply opacity-100;
}

View File

@@ -1,8 +1,21 @@
@mixin base-theme { @mixin base-theme {
--font-sans: "Inter", sans-serif; --font-sans: "Inter Variable", sans-serif;
--font-mono: "Roboto Mono", monospace; --font-icon: "Material Symbols Rounded Variable";
--font-icon: "Material Icons"; --font-mono: "Roboto Mono Variable", monospace;
--font-size-tiny: calc(var(--font-size-body) - 0.062rem); --font-size-body: 0.75rem;
--font-size-tiny: 0.688rem;
--line-height-body: 1rem;
--upper-primary-sticky-fold: 4.125rem;
--upper-secondary-sticky-fold: 6.188rem;
--upper-tertiary-sticky-fold: 8.25rem;
--upper-mobile-primary-sticky-fold: 6.625rem;
--upper-mobile-secondary-sticky-fold: 8.688rem;
--upper-mobile-sticky-fold: 10.75rem;
--upper-mobile-tertiary-sticky-fold: 8.25rem;
--lower-primary-sticky-fold: 3rem;
--lower-secondary-sticky-fold: 5.063rem;
--lower-tertiary-sticky-fold: 7.125rem;
--sidebar-primary-sticky-fold: 2rem;
} }
@mixin dark-theme { @mixin dark-theme {
@@ -200,8 +213,8 @@
:root { :root {
@include base-theme; @include base-theme;
@include dark-theme; @include dark-theme;
@include green-theme;
@include dark-editor-theme; @include dark-editor-theme;
@include green-theme;
} }
:root.light { :root.light {
@@ -257,63 +270,3 @@
:root[data-accent="yellow"] { :root[data-accent="yellow"] {
@include yellow-theme; @include yellow-theme;
} }
@mixin font-small {
--font-size-body: 0.75rem;
--line-height-body: 1rem;
--upper-primary-sticky-fold: 4.125rem;
--upper-secondary-sticky-fold: 6.188rem;
--upper-tertiary-sticky-fold: 8.25rem;
--upper-mobile-primary-sticky-fold: 6.625rem;
--upper-mobile-secondary-sticky-fold: 8.688rem;
--upper-mobile-sticky-fold: 10.75rem;
--upper-mobile-tertiary-sticky-fold: 8.25rem;
--lower-primary-sticky-fold: 3rem;
--lower-secondary-sticky-fold: 5.063rem;
--lower-tertiary-sticky-fold: 7.125rem;
--sidebar-primary-sticky-fold: 2rem;
}
@mixin font-medium {
--font-size-body: 0.875rem;
--line-height-body: 1.25rem;
--upper-primary-sticky-fold: 4.375rem;
--upper-secondary-sticky-fold: 6.688rem;
--upper-tertiary-sticky-fold: 9rem;
--upper-mobile-primary-sticky-fold: 7.125rem;
--upper-mobile-secondary-sticky-fold: 9.438rem;
--upper-mobile-sticky-fold: 11.75rem;
--upper-mobile-tertiary-sticky-fold: 9rem;
--lower-primary-sticky-fold: 3.25rem;
--lower-secondary-sticky-fold: 5.563rem;
--lower-tertiary-sticky-fold: 7.875rem;
--sidebar-primary-sticky-fold: 2.25rem;
}
@mixin font-large {
--font-size-body: 1rem;
--line-height-body: 1.5rem;
--upper-primary-sticky-fold: 4.625rem;
--upper-secondary-sticky-fold: 7.188rem;
--upper-tertiary-sticky-fold: 9.75rem;
--upper-mobile-primary-sticky-fold: 7.625rem;
--upper-mobile-secondary-sticky-fold: 10.188rem;
--upper-mobile-sticky-fold: 12.75rem;
--upper-mobile-tertiary-sticky-fold: 9.75rem;
--lower-primary-sticky-fold: 3.5rem;
--lower-secondary-sticky-fold: 6.063rem;
--lower-tertiary-sticky-fold: 8.625rem;
--sidebar-primary-sticky-fold: 2.5rem;
}
:root[data-font-size="small"] {
@include font-small;
}
:root[data-font-size="medium"] {
@include font-medium;
}
:root[data-font-size="large"] {
@include font-large;
}

View File

@@ -4,6 +4,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"choose_file": "Choose a file", "choose_file": "Choose a file",
"clear": "Clear", "clear": "Clear",
"clear_history": "Clear All History",
"clear_all": "Clear all", "clear_all": "Clear all",
"close": "Close", "close": "Close",
"connect": "Connect", "connect": "Connect",
@@ -30,6 +31,7 @@
"open_workspace": "Open workspace", "open_workspace": "Open workspace",
"paste": "Paste", "paste": "Paste",
"prettify": "Prettify", "prettify": "Prettify",
"rename": "Rename",
"remove": "Remove", "remove": "Remove",
"restore": "Restore", "restore": "Restore",
"save": "Save", "save": "Save",
@@ -67,6 +69,8 @@
"invite": "Invite", "invite": "Invite",
"invite_description": "Hoppscotch is an open source API development ecosystem. We designed a simple and intuitive interface for creating and managing your APIs. Hoppscotch is a tool that helps you build, test, document and share your APIs.", "invite_description": "Hoppscotch is an open source API development ecosystem. We designed a simple and intuitive interface for creating and managing your APIs. Hoppscotch is a tool that helps you build, test, document and share your APIs.",
"invite_your_friends": "Invite your friends", "invite_your_friends": "Invite your friends",
"social_links": "Social links",
"social_description": "Follow us on social media to stay updated with the latest news, updates and releases.",
"join_discord_community": "Join our Discord community", "join_discord_community": "Join our Discord community",
"keyboard_shortcuts": "Keyboard shortcuts", "keyboard_shortcuts": "Keyboard shortcuts",
"name": "Hoppscotch", "name": "Hoppscotch",
@@ -131,6 +135,7 @@
"renamed": "Collection renamed", "renamed": "Collection renamed",
"request_in_use": "Request in use", "request_in_use": "Request in use",
"save_as": "Save as", "save_as": "Save as",
"save_to_collection": "Save to Collection",
"select": "Select a Collection", "select": "Select a Collection",
"select_location": "Select location", "select_location": "Select location",
"select_team": "Select a team", "select_team": "Select a team",
@@ -148,8 +153,15 @@
"remove_telemetry": "Are you sure you want to opt-out of Telemetry?", "remove_telemetry": "Are you sure you want to opt-out of Telemetry?",
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
"save_unsaved_tab": "Do you want to save changes made in this tab?", "save_unsaved_tab": "Do you want to save changes made in this tab?",
"close_unsaved_tab": "Are you sure you want to close this tab?",
"close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.",
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress." "sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
}, },
"context_menu": {
"set_environment_variable": "Set as variable",
"add_parameters": "Add to parameters",
"open_request_in_new_tab": "Open request in new tab"
},
"count": { "count": {
"header": "Header {count}", "header": "Header {count}",
"message": "Message {count}", "message": "Message {count}",
@@ -192,17 +204,31 @@
"create_new": "Create new environment", "create_new": "Create new environment",
"created": "Environment created", "created": "Environment created",
"deleted": "Environment deletion", "deleted": "Environment deletion",
"duplicated": "Environment duplicated",
"edit": "Edit Environment", "edit": "Edit Environment",
"global": "Global",
"empty_variables": "No variables",
"global_variables": "Global variables",
"invalid_name": "Please provide a name for the environment", "invalid_name": "Please provide a name for the environment",
"list": "Environment variables",
"my_environments": "My Environments", "my_environments": "My Environments",
"name": "Name",
"nested_overflow": "nested environment variables are limited to 10 levels", "nested_overflow": "nested environment variables are limited to 10 levels",
"new": "New Environment", "new": "New Environment",
"no_active_environment": "No active environment",
"no_environment": "No environment", "no_environment": "No environment",
"no_environment_description": "No environments were selected. Choose what to do with the following variables.", "no_environment_description": "No environments were selected. Choose what to do with the following variables.",
"quick_peek": "Environment Quick Peek",
"replace_with_variable": "Replace with variable",
"scope": "Scope",
"select": "Select environment", "select": "Select environment",
"set": "Set environment",
"set_as_environment": "Set as environment",
"team_environments": "Team Environments", "team_environments": "Team Environments",
"title": "Environments", "title": "Environments",
"updated": "Environment updated", "updated": "Environment updated",
"value": "Value",
"variable": "Variable",
"variable_list": "Variable List" "variable_list": "Variable List"
}, },
"error": { "error": {
@@ -226,6 +252,7 @@
"no_duration": "No duration", "no_duration": "No duration",
"no_results_found": "No matches found", "no_results_found": "No matches found",
"page_not_found": "This page could not be found", "page_not_found": "This page could not be found",
"proxy_error": "Proxy error",
"script_fail": "Could not execute pre-request script", "script_fail": "Could not execute pre-request script",
"something_went_wrong": "Something went wrong", "something_went_wrong": "Something went wrong",
"test_script_fail": "Could not execute post-request script" "test_script_fail": "Could not execute post-request script"
@@ -253,6 +280,10 @@
"graphql": { "graphql": {
"mutations": "Mutations", "mutations": "Mutations",
"schema": "Schema", "schema": "Schema",
"switch_connection": "Switch connection",
"connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is",
"connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is",
"connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?",
"subscriptions": "Subscriptions" "subscriptions": "Subscriptions"
}, },
"group": { "group": {
@@ -282,6 +313,30 @@
"preview": "Hide Preview", "preview": "Hide Preview",
"sidebar": "Collapse sidebar" "sidebar": "Collapse sidebar"
}, },
"inspections": {
"title": "Inspector",
"description": "Inspect possible errors",
"environment": {
"add_environment": "Add to Environment",
"not_found": "Environment variable “{environment}” not found."
},
"header": {
"cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead."
},
"response": {
"401_error": "Please check your authentication credentials.",
"404_error": "Please check your request URL and method type.",
"network_error": "Please check your network connection.",
"cors_error": "Please check your Cross-Origin Resource Sharing configuration.",
"default_error": "Please check your request."
},
"url": {
"extension_not_installed": "Extension not installed.",
"extention_not_enabled": "Extension not enabled.",
"extention_enable_action": "Enable Browser Extension",
"extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list."
}
},
"import": { "import": {
"collections": "Import collections", "collections": "Import collections",
"curl": "Import cURL", "curl": "Import cURL",
@@ -418,8 +473,10 @@
"payload": "Payload", "payload": "Payload",
"query": "Query", "query": "Query",
"raw_body": "Raw Request Body", "raw_body": "Raw Request Body",
"rename": "Rename Request",
"renamed": "Request renamed", "renamed": "Request renamed",
"run": "Run", "run": "Run",
"stop": "Stop",
"save": "Save", "save": "Save",
"save_as": "Save as", "save_as": "Save as",
"saved": "Request saved", "saved": "Request saved",
@@ -459,9 +516,9 @@
"account_name_description": "This is your display name.", "account_name_description": "This is your display name.",
"background": "Background", "background": "Background",
"black_mode": "Black", "black_mode": "Black",
"dark_mode": "Dark",
"change_font_size": "Change font size", "change_font_size": "Change font size",
"choose_language": "Choose language", "choose_language": "Choose language",
"dark_mode": "Dark",
"delete_account": "Delete account", "delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation", "expand_navigation": "Expand navigation",
@@ -525,6 +582,10 @@
"show_all": "Keyboard shortcuts", "show_all": "Keyboard shortcuts",
"title": "General" "title": "General"
}, },
"others": {
"title": "Others",
"prettify": "Prettify Editor's Content"
},
"miscellaneous": { "miscellaneous": {
"invite": "Invite people to Hoppscotch", "invite": "Invite people to Hoppscotch",
"title": "Miscellaneous" "title": "Miscellaneous"
@@ -545,6 +606,9 @@
"delete_method": "Select DELETE method", "delete_method": "Select DELETE method",
"get_method": "Select GET method", "get_method": "Select GET method",
"head_method": "Select HEAD method", "head_method": "Select HEAD method",
"rename": "Rename Request",
"import_curl": "Import cURL",
"show_code": "Generate code snippet",
"method": "Method", "method": "Method",
"next_method": "Select Next method", "next_method": "Select Next method",
"post_method": "Select POST method", "post_method": "Select POST method",
@@ -553,6 +617,7 @@
"reset_request": "Reset Request", "reset_request": "Reset Request",
"save_to_collections": "Save to Collections", "save_to_collections": "Save to Collections",
"send_request": "Send Request", "send_request": "Send Request",
"save_request": "Save Request",
"title": "Request" "title": "Request"
}, },
"response": { "response": {
@@ -561,10 +626,10 @@
"title": "Response" "title": "Response"
}, },
"theme": { "theme": {
"black": "Switch theme to black mode", "black": "Switch theme to Black Mode",
"dark": "Switch theme to dark mode", "dark": "Switch theme to Dark Mode",
"light": "Switch theme to light mode", "light": "Switch theme to Light Mode",
"system": "Switch theme to system mode", "system": "Switch theme to System Mode",
"title": "Theme" "title": "Theme"
} }
}, },
@@ -582,6 +647,90 @@
"log": "Log", "log": "Log",
"url": "URL" "url": "URL"
}, },
"spotlight": {
"general": {
"help_menu": "Help and support",
"chat": "Chat with support",
"open_docs": "Read Documentation",
"open_keybindings": "Keyboard shortcuts",
"open_github": "Open GitHub repository",
"social": "Social",
"title": "General"
},
"miscellaneous": {
"invite": "Invite your friends to Hoppscotch",
"title": "Miscellaneous"
},
"request": {
"switch_to": "Switch to",
"select_method": "Select method",
"save_as_new": "Save as new request",
"tab_parameters": "Parameters tab",
"tab_body": "Body tab",
"tab_headers": "Headers tab",
"tab_authorization": "Authorization tab",
"tab_pre_request_script": "Pre-request script tab",
"tab_tests": "Tests tab",
"tab_query": "Query tab",
"tab_variables": "Variables tab"
},
"graphql": {
"connect": "Connect to server",
"disconnect": "Disconnect from server"
},
"response": {
"copy": "Copy response",
"download": "Download response as file",
"title": "Response"
},
"environments": {
"new": "Create new environment",
"new_variable": "Create a new environment variable",
"edit": "Edit current environment",
"delete": "Delete current environment",
"duplicate": "Duplicate current environment",
"edit_global": "Edit global environment",
"duplicate_global": "Duplicate global environment",
"title": "Environments"
},
"workspace": {
"new": "Create new team",
"edit": "Edit current team",
"delete": "Delete current team",
"invite": "Invite people to team",
"switch_to_personal": "Switch to your personal workspace",
"title": "Teams"
},
"tab": {
"duplicate": "Duplicate current tab",
"close_current": "Close current tab",
"close_others": "Close all other tabs",
"new_tab": "Open a new tab",
"title": "Tabs"
},
"section": {
"user": "User",
"theme": "Theme",
"interface": "Interface",
"interceptor": "Interceptor"
},
"change_language": "Change Language",
"settings": {
"theme": {
"black": "Black",
"dark": "Dark",
"light": "Light",
"system": "System preference"
},
"font": {
"size_sm": "Small",
"size_md": "Medium",
"size_lg": "Large"
},
"change_interceptor": "Change Interceptor",
"change_language": "Change Language"
}
},
"sse": { "sse": {
"event_type": "Event type", "event_type": "Event type",
"log": "Log", "log": "Log",
@@ -639,8 +788,11 @@
"tab": { "tab": {
"authorization": "Authorization", "authorization": "Authorization",
"body": "Body", "body": "Body",
"close": "Close Tab",
"close_others": "Close other Tabs",
"collections": "Collections", "collections": "Collections",
"documentation": "Documentation", "documentation": "Documentation",
"duplicate": "Duplicate Tab",
"environments": "Environments", "environments": "Environments",
"headers": "Headers", "headers": "Headers",
"history": "History", "history": "History",

View File

@@ -5,29 +5,29 @@
"choose_file": "Válasszon egy fájlt", "choose_file": "Válasszon egy fájlt",
"clear": "Törlés", "clear": "Törlés",
"clear_all": "Összes törlése", "clear_all": "Összes törlése",
"close": "Close", "close": "Bezárás",
"connect": "Kapcsolódás", "connect": "Kapcsolódás",
"connecting": "Connecting", "connecting": "Kapcsolódás",
"copy": "Másolás", "copy": "Másolás",
"delete": "Törlés", "delete": "Törlés",
"disconnect": "Leválasztás", "disconnect": "Leválasztás",
"dismiss": "Eltüntetés", "dismiss": "Eltüntetés",
"dont_save": "Ne mentse", "dont_save": "Ne mentse",
"download_file": "Fájl letöltése", "download_file": "Fájl letöltése",
"drag_to_reorder": "Drag to reorder", "drag_to_reorder": "Húzza az átrendezéshez",
"duplicate": "Kettőzés", "duplicate": "Kettőzés",
"edit": "Szerkesztés", "edit": "Szerkesztés",
"filter": "Filter", "filter": "Szűrő",
"go_back": "Vissza", "go_back": "Vissza",
"go_forward": "Go forward", "go_forward": "Előre",
"group_by": "Group by", "group_by": "Csoportosítás",
"label": "Címke", "label": "Címke",
"learn_more": "Tudjon meg többet", "learn_more": "Tudjon meg többet",
"less": "Kevesebb", "less": "Kevesebb",
"more": "Több", "more": "Több",
"new": "Új", "new": "Új",
"no": "Nem", "no": "Nem",
"open_workspace": "Open workspace", "open_workspace": "Munkaterület megnyitása",
"paste": "Beillesztés", "paste": "Beillesztés",
"prettify": "Csinosítás", "prettify": "Csinosítás",
"remove": "Eltávolítás", "remove": "Eltávolítás",
@@ -38,7 +38,7 @@
"search": "Keresés", "search": "Keresés",
"send": "Küldés", "send": "Küldés",
"start": "Indítás", "start": "Indítás",
"starting": "Starting", "starting": "Indítás",
"stop": "Leállítás", "stop": "Leállítás",
"to_close": "a bezáráshoz", "to_close": "a bezáráshoz",
"to_navigate": "a navigáláshoz", "to_navigate": "a navigáláshoz",
@@ -118,16 +118,16 @@
}, },
"collection": { "collection": {
"created": "Gyűjtemény létrehozva", "created": "Gyűjtemény létrehozva",
"different_parent": "Cannot reorder collection with different parent", "different_parent": "Nem lehet átrendezni a különböző szülővel rendelkező gyűjteményt",
"edit": "Gyűjtemény szerkesztése", "edit": "Gyűjtemény szerkesztése",
"invalid_name": "Adjon nevet a gyűjteménynek", "invalid_name": "Adjon nevet a gyűjteménynek",
"invalid_root_move": "Collection already in the root", "invalid_root_move": "A gyűjtemény már a gyökérben van",
"moved": "Moved Successfully", "moved": "Sikeresen áthelyezve",
"my_collections": "Saját gyűjtemények", "my_collections": "Saját gyűjtemények",
"name": "Saját új gyűjtemény", "name": "Saját új gyűjtemény",
"name_length_insufficient": "A gyűjtemény nevének legalább 3 karakter hosszúságúnak kell lennie", "name_length_insufficient": "A gyűjtemény nevének legalább 3 karakter hosszúságúnak kell lennie",
"new": "Új gyűjtemény", "new": "Új gyűjtemény",
"order_changed": "Collection Order Updated", "order_changed": "Gyűjtemény sorrendje frissítve",
"renamed": "Gyűjtemény átnevezve", "renamed": "Gyűjtemény átnevezve",
"request_in_use": "A kérés használatban", "request_in_use": "A kérés használatban",
"save_as": "Mentés másként", "save_as": "Mentés másként",
@@ -147,7 +147,7 @@
"remove_team": "Biztosan törölni szeretné ezt a csapatot?", "remove_team": "Biztosan törölni szeretné ezt a csapatot?",
"remove_telemetry": "Biztosan ki szeretné kapcsolni a telemetriát?", "remove_telemetry": "Biztosan ki szeretné kapcsolni a telemetriát?",
"request_change": "Biztosan el szeretné vetni a jelenlegi kérést? Minden mentetlen változtatás el fog veszni.", "request_change": "Biztosan el szeretné vetni a jelenlegi kérést? Minden mentetlen változtatás el fog veszni.",
"save_unsaved_tab": "Do you want to save changes made in this tab?", "save_unsaved_tab": "Szeretné menteni az ezen a lapon elvégzett változtatásokat?",
"sync": "Szeretné visszaállítani a munkaterületét a felhőből? Ez el fogja vetni a helyi folyamatát." "sync": "Szeretné visszaállítani a munkaterületét a felhőből? Ez el fogja vetni a helyi folyamatát."
}, },
"count": { "count": {
@@ -180,8 +180,8 @@
"profile": "Jelentkezzen be a profilja megtekintéséhez", "profile": "Jelentkezzen be a profilja megtekintéséhez",
"protocols": "A protokollok üresek", "protocols": "A protokollok üresek",
"schema": "Kapcsolódjon egy GraphQL-végponthoz a séma megtekintéséhez", "schema": "Kapcsolódjon egy GraphQL-végponthoz a séma megtekintéséhez",
"shortcodes": "Shortcodes are empty", "shortcodes": "A rövid kódok üresek",
"subscription": "Subscriptions are empty", "subscription": "A feliratkozások üresek",
"team_name": "A csapat neve üres", "team_name": "A csapat neve üres",
"teams": "Ön nem tartozik semmilyen csapathoz", "teams": "Ön nem tartozik semmilyen csapathoz",
"tests": "Nincsenek tesztek ehhez a kéréshez" "tests": "Nincsenek tesztek ehhez a kéréshez"
@@ -194,13 +194,13 @@
"deleted": "Környezet törlése", "deleted": "Környezet törlése",
"edit": "Környezet szerkesztése", "edit": "Környezet szerkesztése",
"invalid_name": "Adjon nevet a környezetnek", "invalid_name": "Adjon nevet a környezetnek",
"my_environments": "My Environments", "my_environments": "Saját környezetek",
"nested_overflow": "az egymásba ágyazott környezeti változók 10 szintre vannak korlátozva", "nested_overflow": "az egymásba ágyazott környezeti változók 10 szintre vannak korlátozva",
"new": "Új környezet", "new": "Új környezet",
"no_environment": "Nincs környezet", "no_environment": "Nincs környezet",
"no_environment_description": "Nem lettek környezetek kiválasztva. Válassza ki, hogy mit kell tenni a következő változókkal.", "no_environment_description": "Nem lettek környezetek kiválasztva. Válassza ki, hogy mit kell tenni a következő változókkal.",
"select": "Környezet kiválasztása", "select": "Környezet kiválasztása",
"team_environments": "Team Environments", "team_environments": "Csapatkörnyezetek",
"title": "Környezetek", "title": "Környezetek",
"updated": "Környezet frissítve", "updated": "Környezet frissítve",
"variable_list": "Változólista" "variable_list": "Változólista"
@@ -209,9 +209,9 @@
"browser_support_sse": "Úgy tűnik, hogy ez a böngésző nem támogatja a kiszolgáló által küldött eseményeket.", "browser_support_sse": "Úgy tűnik, hogy ez a böngésző nem támogatja a kiszolgáló által küldött eseményeket.",
"check_console_details": "Nézze meg a konzolnaplót a részletekért.", "check_console_details": "Nézze meg a konzolnaplót a részletekért.",
"curl_invalid_format": "A cURL nincs megfelelően formázva", "curl_invalid_format": "A cURL nincs megfelelően formázva",
"danger_zone": "Danger zone", "danger_zone": "Veszélyes zóna",
"delete_account": "Your account is currently an owner in these teams:", "delete_account": "Az Ön fiókja jelenleg tulajdonos ezekben a csapatokban:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", "delete_account_description": "El kell távolítani magát, át kell adnia a tulajdonjogot vagy törölnie kell ezeket a csapatokat, mielőtt törölhetné a fiókját.",
"empty_req_name": "Üres kérésnév", "empty_req_name": "Üres kérésnév",
"f12_details": "(F12 a részletekért)", "f12_details": "(F12 a részletekért)",
"gql_prettify_invalid_query": "Nem sikerült csinosítani egy érvénytelen lekérdezést, oldja meg a lekérdezés szintaktikai hibáit, és próbálja újra", "gql_prettify_invalid_query": "Nem sikerült csinosítani egy érvénytelen lekérdezést, oldja meg a lekérdezés szintaktikai hibáit, és próbálja újra",
@@ -219,13 +219,13 @@
"incorrect_email": "Hibás e-mail", "incorrect_email": "Hibás e-mail",
"invalid_link": "Érvénytelen hivatkozás", "invalid_link": "Érvénytelen hivatkozás",
"invalid_link_description": "A kattintott hivatkozás érvénytelen vagy lejárt.", "invalid_link_description": "A kattintott hivatkozás érvénytelen vagy lejárt.",
"json_parsing_failed": "Invalid JSON", "json_parsing_failed": "Érvénytelen JSON",
"json_prettify_invalid_body": "Nem sikerült csinosítani egy érvénytelen törzset, oldja meg a JSON szintaktikai hibáit, és próbálja újra", "json_prettify_invalid_body": "Nem sikerült csinosítani egy érvénytelen törzset, oldja meg a JSON szintaktikai hibáit, és próbálja újra",
"network_error": "Úgy tűnik, hogy hálózati hiba van. Próbálja újra.", "network_error": "Úgy tűnik, hogy hálózati hiba van. Próbálja újra.",
"network_fail": "Nem sikerült elküldeni a kérést", "network_fail": "Nem sikerült elküldeni a kérést",
"no_duration": "Nincs időtartam", "no_duration": "Nincs időtartam",
"no_results_found": "No matches found", "no_results_found": "Nincs találat",
"page_not_found": "This page could not be found", "page_not_found": "Ez az oldal nem található",
"script_fail": "Nem sikerült végrehajtani a kérés előtti parancsfájlt", "script_fail": "Nem sikerült végrehajtani a kérés előtti parancsfájlt",
"something_went_wrong": "Valami elromlott", "something_went_wrong": "Valami elromlott",
"test_script_fail": "Nem sikerült végrehajtani a kérés utáni parancsfájlt" "test_script_fail": "Nem sikerült végrehajtani a kérés utáni parancsfájlt"
@@ -238,9 +238,9 @@
"title": "Exportálás" "title": "Exportálás"
}, },
"filter": { "filter": {
"all": "All", "all": "Összes",
"none": "None", "none": "Nincs",
"starred": "Starred" "starred": "Csillagozott"
}, },
"folder": { "folder": {
"created": "Mappa létrehozva", "created": "Mappa létrehozva",
@@ -256,7 +256,7 @@
"subscriptions": "Feliratkozások" "subscriptions": "Feliratkozások"
}, },
"group": { "group": {
"time": "Time", "time": "Idő",
"url": "URL" "url": "URL"
}, },
"header": { "header": {
@@ -316,32 +316,32 @@
"zen_mode": "Zen mód" "zen_mode": "Zen mód"
}, },
"modal": { "modal": {
"close_unsaved_tab": "You have unsaved changes", "close_unsaved_tab": "Elmentetlen változtatásai vannak",
"collections": "Gyűjtemények", "collections": "Gyűjtemények",
"confirm": "Megerősítés", "confirm": "Megerősítés",
"edit_request": "Kérés szerkesztése", "edit_request": "Kérés szerkesztése",
"import_export": "Importálás és exportálás" "import_export": "Importálás és exportálás"
}, },
"mqtt": { "mqtt": {
"already_subscribed": "You are already subscribed to this topic.", "already_subscribed": "Ön már feliratkozott erre a témára.",
"clean_session": "Clean Session", "clean_session": "Munkamenet törlése",
"clear_input": "Clear input", "clear_input": "Bevitel törlése",
"clear_input_on_send": "Clear input on send", "clear_input_on_send": "Bevitel törlése küldéskor",
"client_id": "Client ID", "client_id": "Ügyfél-azonosító",
"color": "Pick a color", "color": "Válasszon színt",
"communication": "Kommunikáció", "communication": "Kommunikáció",
"connection_config": "Connection Config", "connection_config": "Kapcsolat beállításai",
"connection_not_authorized": "This MQTT connection does not use any authentication.", "connection_not_authorized": "Ez az MQTT-kapcsolat nem használ semmilyen hitelesítést.",
"invalid_topic": "Please provide a topic for the subscription", "invalid_topic": "Adjon témát a feliratkozáshoz",
"keep_alive": "Keep Alive", "keep_alive": "Életben tartás",
"log": "Napló", "log": "Napló",
"lw_message": "Last-Will Message", "lw_message": "Utolsó kívánság üzenet",
"lw_qos": "Last-Will QoS", "lw_qos": "Utolsó kívánság QoS",
"lw_retain": "Last-Will Retain", "lw_retain": "Utolsó kívánság megtartás",
"lw_topic": "Last-Will Topic", "lw_topic": "Utolsó kívánság téma",
"message": "Üzenet", "message": "Üzenet",
"new": "New Subscription", "new": "Új feliratkozás",
"not_connected": "Please start a MQTT connection first.", "not_connected": "Először indítson egy MQTT-kapcsolatot.",
"publish": "Közzététel", "publish": "Közzététel",
"qos": "QoS", "qos": "QoS",
"ssl": "SSL", "ssl": "SSL",
@@ -368,7 +368,7 @@
}, },
"profile": { "profile": {
"app_settings": "Alkalmazás beállításai", "app_settings": "Alkalmazás beállításai",
"default_hopp_displayname": "Unnamed User", "default_hopp_displayname": "Névtelen felhasználó",
"editor": "Szerkesztő", "editor": "Szerkesztő",
"editor_description": "A szerkesztők hozzáadhatnak, szerkeszthetnek és törölhetnek kéréseket.", "editor_description": "A szerkesztők hozzáadhatnak, szerkeszthetnek és törölhetnek kéréseket.",
"email_verification_mail": "Egy ellenőrző e-mail el lett küldve az e-mail-címére. Kattintson a hivatkozásra az e-mail-címe ellenőrzéséhez.", "email_verification_mail": "Egy ellenőrző e-mail el lett küldve az e-mail-címére. Kattintson a hivatkozásra az e-mail-címe ellenőrzéséhez.",
@@ -391,26 +391,26 @@
"choose_language": "Nyelv kiválasztása", "choose_language": "Nyelv kiválasztása",
"content_type": "Tartalom típusa", "content_type": "Tartalom típusa",
"content_type_titles": { "content_type_titles": {
"others": "Others", "others": "Egyebek",
"structured": "Structured", "structured": "Szerkesztett",
"text": "Text" "text": "Szöveg"
}, },
"copy_link": "Hivatkozás másolása", "copy_link": "Hivatkozás másolása",
"different_collection": "Cannot reorder requests from different collections", "different_collection": "Nem lehet átrendezni a különböző gyűjteményekből érkező kéréseket",
"duplicated": "Request duplicated", "duplicated": "Kérés megkettőzve",
"duration": "Időtartam", "duration": "Időtartam",
"enter_curl": "cURL megadása", "enter_curl": "cURL-parancs megadása",
"generate_code": "Kód előállítása", "generate_code": "Kód előállítása",
"generated_code": "Előállított kód", "generated_code": "Előállított kód",
"header_list": "Fejléclista", "header_list": "Fejléclista",
"invalid_name": "Adjon nevet a kérésnek", "invalid_name": "Adjon nevet a kérésnek",
"method": "Módszer", "method": "Módszer",
"moved": "Request moved", "moved": "Kérés áthelyezve",
"name": "Kérés neve", "name": "Kérés neve",
"new": "Új kérés", "new": "Új kérés",
"order_changed": "Request Order Updated", "order_changed": "Kérés sorrendje frissítve",
"override": "Felülbírálás", "override": "Felülbírálás",
"override_help": "A <kbd>Content-Type</kbd> beállítása a fejlécekben", "override_help": "<kbd>Content-Type</kbd> beállítása a fejlécekben",
"overriden": "Felülbírálva", "overriden": "Felülbírálva",
"parameter_list": "Lekérdezési paraméterek", "parameter_list": "Lekérdezési paraméterek",
"parameters": "Paraméterek", "parameters": "Paraméterek",
@@ -429,12 +429,12 @@
"type": "Kérés típusa", "type": "Kérés típusa",
"url": "URL", "url": "URL",
"variables": "Változók", "variables": "Változók",
"view_my_links": "View my links" "view_my_links": "Saját hivatkozások megtekintése"
}, },
"response": { "response": {
"audio": "Audio", "audio": "Hang",
"body": "Válasz törzse", "body": "Válasz törzse",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "JSON-válasz törzsének szűrése (JSONPath szintaxist használ)",
"headers": "Fejlécek", "headers": "Fejlécek",
"html": "HTML", "html": "HTML",
"image": "Kép", "image": "Kép",
@@ -446,14 +446,14 @@
"status": "Állapot", "status": "Állapot",
"time": "Idő", "time": "Idő",
"title": "Válasz", "title": "Válasz",
"video": "Video", "video": "Videó",
"waiting_for_connection": "várakozás kapcsolódásra", "waiting_for_connection": "várakozás kapcsolódásra",
"xml": "XML" "xml": "XML"
}, },
"settings": { "settings": {
"accent_color": "Kiemelőszín", "accent_color": "Kiemelőszín",
"account": "Fiók", "account": "Fiók",
"account_deleted": "Your account has been deleted", "account_deleted": "A fiókja törölve lett",
"account_description": "A fiókbeállítások személyre szabása.", "account_description": "A fiókbeállítások személyre szabása.",
"account_email_description": "Az Ön elsődleges e-mail-címe.", "account_email_description": "Az Ön elsődleges e-mail-címe.",
"account_name_description": "Ez a megjelenített neve.", "account_name_description": "Ez a megjelenített neve.",
@@ -462,8 +462,8 @@
"change_font_size": "Betűméret megváltoztatása", "change_font_size": "Betűméret megváltoztatása",
"choose_language": "Nyelv kiválasztása", "choose_language": "Nyelv kiválasztása",
"dark_mode": "Sötét", "dark_mode": "Sötét",
"delete_account": "Delete account", "delete_account": "Fiók törlése",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", "delete_account_description": "Ha törli a fiókját, akkor az összes adata véglegesen törlésre kerül. Ezt a műveletet nem lehet visszavonni.",
"expand_navigation": "Navigáció kinyitása", "expand_navigation": "Navigáció kinyitása",
"experiments": "Kísérletek", "experiments": "Kísérletek",
"experiments_notice": "Ez olyan kísérletek gyűjteménye, amelyeken dolgozunk, és amelyek hasznosak, szórakoztatóak lehetnek, mindkettő, vagy egyik sem. Ezek nem véglegesek és nem stabilak, ezért ha valami túl furcsa dolog történik, ne essen pánikba. Egyszerűen kapcsolja ki a hibás dolgot. Viccet félretéve, ", "experiments_notice": "Ez olyan kísérletek gyűjteménye, amelyeken dolgozunk, és amelyek hasznosak, szórakoztatóak lehetnek, mindkettő, vagy egyik sem. Ezek nem véglegesek és nem stabilak, ezért ha valami túl furcsa dolog történik, ne essen pánikba. Egyszerűen kapcsolja ki a hibás dolgot. Viccet félretéve, ",
@@ -490,8 +490,8 @@
"proxy_use_toggle": "A proxy középprogram használata a kérések küldéséhez", "proxy_use_toggle": "A proxy középprogram használata a kérések küldéséhez",
"read_the": "Olvassa el:", "read_the": "Olvassa el:",
"reset_default": "Visszaállítás az alapértelmezettre", "reset_default": "Visszaállítás az alapértelmezettre",
"short_codes": "Short codes", "short_codes": "Rövid kódok",
"short_codes_description": "Short codes which were created by you.", "short_codes_description": "Az Ön által létrehozott rövid kódok.",
"sidebar_on_left": "Oldalsáv a bal oldalon", "sidebar_on_left": "Oldalsáv a bal oldalon",
"sync": "Szinkronizálás", "sync": "Szinkronizálás",
"sync_collections": "Gyűjtemények", "sync_collections": "Gyűjtemények",
@@ -505,16 +505,16 @@
"theme_description": "Az alkalmazás témájának személyre szabása.", "theme_description": "Az alkalmazás témájának személyre szabása.",
"use_experimental_url_bar": "Kísérleti URL-sáv használata a környezet kiemelésével", "use_experimental_url_bar": "Kísérleti URL-sáv használata a környezet kiemelésével",
"user": "Felhasználó", "user": "Felhasználó",
"verified_email": "Verified email", "verified_email": "Ellenőrzött e-mail-cím",
"verify_email": "E-mail-cím ellenőrzése" "verify_email": "E-mail-cím ellenőrzése"
}, },
"shortcodes": { "shortcodes": {
"actions": "Actions", "actions": "Műveletek",
"created_on": "Created on", "created_on": "Létrehozva",
"deleted": "Shortcode deleted", "deleted": "Rövid kód törölve",
"method": "Method", "method": "Módszer",
"not_found": "Shortcode not found", "not_found": "A rövid kód nem található",
"short_code": "Short code", "short_code": "Rövid kód",
"url": "URL" "url": "URL"
}, },
"shortcut": { "shortcut": {
@@ -556,9 +556,9 @@
"title": "Kérés" "title": "Kérés"
}, },
"response": { "response": {
"copy": "Copy response to clipboard", "copy": "Válasz másolása a vágólapra",
"download": "Download response as file", "download": "Válasz letöltés fájlként",
"title": "Response" "title": "Válasz"
}, },
"theme": { "theme": {
"black": "Téma átváltása fekete módra", "black": "Téma átváltása fekete módra",
@@ -576,8 +576,8 @@
}, },
"socketio": { "socketio": {
"communication": "Kommunikáció", "communication": "Kommunikáció",
"connection_not_authorized": "This SocketIO connection does not use any authentication.", "connection_not_authorized": "Ez a SocketIO-kapcsolat nem használ semmilyen hitelesítést.",
"event_name": "Esemény neve", "event_name": "Esemény vagy téma neve",
"events": "Események", "events": "Események",
"log": "Napló", "log": "Napló",
"url": "URL" "url": "URL"
@@ -594,9 +594,9 @@
"connected": "Kapcsolódva", "connected": "Kapcsolódva",
"connected_to": "Kapcsolódva ehhez: {name}", "connected_to": "Kapcsolódva ehhez: {name}",
"connecting_to": "Kapcsolódás ehhez: {name}…", "connecting_to": "Kapcsolódás ehhez: {name}…",
"connection_error": "Failed to connect", "connection_error": "Nem sikerült kapcsolódni",
"connection_failed": "Connection failed", "connection_failed": "A kapcsolódás sikertelen",
"connection_lost": "Connection lost", "connection_lost": "A kapcsolat elveszett",
"copied_to_clipboard": "Vágólapra másolva", "copied_to_clipboard": "Vágólapra másolva",
"deleted": "Törölve", "deleted": "Törölve",
"deprecated": "ELAVULT", "deprecated": "ELAVULT",
@@ -611,17 +611,17 @@
"history_deleted": "Előzmények törölve", "history_deleted": "Előzmények törölve",
"linewrap": "Sorok tördelése", "linewrap": "Sorok tördelése",
"loading": "Betöltés…", "loading": "Betöltés…",
"message_received": "Message: {message} arrived on topic: {topic}", "message_received": "Üzenet: {message} érkezett ehhez a témához: {topic}",
"mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", "mqtt_subscription_failed": "Valami elromlott a következő témára való feliratkozás során: {topic}",
"none": "Nincs", "none": "Nincs",
"nothing_found": "Semmi sem található ehhez:", "nothing_found": "Semmi sem található ehhez:",
"published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", "published_error": "Valami elromlott a következő üzenet közzététele során: {topic}, ehhez a témához: {message}",
"published_message": "Published message: {message} to topic: {topic}", "published_message": "Közzétett üzenet: {message}, ehhez a témához: {topic}",
"reconnection_error": "Failed to reconnect", "reconnection_error": "Nem sikerült újrakapcsolódni",
"subscribed_failed": "Failed to subscribe to topic: {topic}", "subscribed_failed": "Nem sikerült feliratkozni erre a témára: {topic}",
"subscribed_success": "Successfully subscribed to topic: {topic}", "subscribed_success": "Sikeresen feliratkozott erre a témára: {topic}",
"unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", "unsubscribed_failed": "Nem sikerült leiratkozni erről a témáról: {topic}",
"unsubscribed_success": "Successfully unsubscribed from topic: {topic}", "unsubscribed_success": "Sikeresen leiratkozott erről a témáról: {topic}",
"waiting_send_request": "Várakozás a kérés elküldésére" "waiting_send_request": "Várakozás a kérés elküldésére"
}, },
"support": { "support": {
@@ -641,7 +641,7 @@
"body": "Törzs", "body": "Törzs",
"collections": "Gyűjtemények", "collections": "Gyűjtemények",
"documentation": "Dokumentáció", "documentation": "Dokumentáció",
"environments": "Environments", "environments": "Környezetek",
"headers": "Fejlécek", "headers": "Fejlécek",
"history": "Előzmények", "history": "Előzmények",
"mqtt": "MQTT", "mqtt": "MQTT",
@@ -666,7 +666,7 @@
"email_do_not_match": "Az e-mail-cím nem egyezik a fiókja részleteivel. Vegye fel a kapcsolatot a csapat tulajdonosával.", "email_do_not_match": "Az e-mail-cím nem egyezik a fiókja részleteivel. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"exit": "Kilépés a csapatból", "exit": "Kilépés a csapatból",
"exit_disabled": "Csak a tulajdonos nem léphet ki a csapatból", "exit_disabled": "Csak a tulajdonos nem léphet ki a csapatból",
"invalid_coll_id": "Invalid collection ID", "invalid_coll_id": "Érvénytelen gyűjteményazonosító",
"invalid_email_format": "Az e-mail formátuma érvénytelen", "invalid_email_format": "Az e-mail formátuma érvénytelen",
"invalid_id": "Érvénytelen csapatazonosító. Vegye fel a kapcsolatot a csapat tulajdonosával.", "invalid_id": "Érvénytelen csapatazonosító. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"invalid_invite_link": "Érvénytelen meghívási hivatkozás", "invalid_invite_link": "Érvénytelen meghívási hivatkozás",
@@ -690,7 +690,7 @@
"member_removed": "Felhasználó eltávolítva", "member_removed": "Felhasználó eltávolítva",
"member_role_updated": "Felhasználói szerepek frissítve", "member_role_updated": "Felhasználói szerepek frissítve",
"members": "Tagok", "members": "Tagok",
"more_members": "+{count} more", "more_members": "+{count} további",
"name_length_insufficient": "A csapat nevének legalább 6 karakter hosszúságúnak kell lennie", "name_length_insufficient": "A csapat nevének legalább 6 karakter hosszúságúnak kell lennie",
"name_updated": "Csapatnév frissítve", "name_updated": "Csapatnév frissítve",
"new": "Új csapat", "new": "Új csapat",
@@ -698,13 +698,13 @@
"new_name": "Saját új csapat", "new_name": "Saját új csapat",
"no_access": "Nincs szerkesztési jogosultsága ezekhez a gyűjteményekhez", "no_access": "Nincs szerkesztési jogosultsága ezekhez a gyűjteményekhez",
"no_invite_found": "A meghívás nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.", "no_invite_found": "A meghívás nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"no_request_found": "Request not found.", "no_request_found": "A kérés nem található.",
"not_found": "A csapat nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.", "not_found": "A csapat nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"not_valid_viewer": "Ön nem érvényes megtekintő. Vegye fel a kapcsolatot a csapat tulajdonosával.", "not_valid_viewer": "Ön nem érvényes megtekintő. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"parent_coll_move": "Cannot move collection to a child collection", "parent_coll_move": "Nem lehet áthelyezni a gyűjteményt egy gyermekgyűjteménybe",
"pending_invites": "Függőben lévő meghívások", "pending_invites": "Függőben lévő meghívások",
"permissions": "Jogosultságok", "permissions": "Jogosultságok",
"same_target_destination": "Same target and destination", "same_target_destination": "Ugyanaz a cél és célhely",
"saved": "Csapat elmentve", "saved": "Csapat elmentve",
"select_a_team": "Csapat kiválasztása", "select_a_team": "Csapat kiválasztása",
"title": "Csapatok", "title": "Csapatok",
@@ -712,9 +712,9 @@
"we_sent_invite_link_description": "Kérje meg az összes meghívottat, hogy nézzék meg a beérkező leveleiket. Kattintsanak a hivatkozásra a csapathoz való csatlakozáshoz." "we_sent_invite_link_description": "Kérje meg az összes meghívottat, hogy nézzék meg a beérkező leveleiket. Kattintsanak a hivatkozásra a csapathoz való csatlakozáshoz."
}, },
"team_environment": { "team_environment": {
"deleted": "Environment Deleted", "deleted": "Környezet törölve",
"duplicate": "Environment Duplicated", "duplicate": "Környezet megkettőzve",
"not_found": "Environment not found." "not_found": "A környezet nem található."
}, },
"test": { "test": {
"failed": "teszt sikertelen", "failed": "teszt sikertelen",
@@ -734,9 +734,9 @@
"url": "URL" "url": "URL"
}, },
"workspace": { "workspace": {
"change": "Change workspace", "change": "Munkaterület váltása",
"personal": "My Workspace", "personal": "Saját munkaterület",
"team": "Team Workspace", "team": "Csapat-munkaterület",
"title": "Workspaces" "title": "Munkaterületek"
} }
} }

View File

@@ -118,22 +118,22 @@
}, },
"collection": { "collection": {
"created": "Koleksi dibuat", "created": "Koleksi dibuat",
"different_parent": "Cannot reorder collection with different parent", "different_parent": "Tidak dapat mengubah urutan koleksi dengan induk yang berbeda",
"edit": "Mengubah Koleksi", "edit": "Mengubah Koleksi",
"invalid_name": "Berikan nama untuk Koleksi", "invalid_name": "Berikan nama untuk Koleksi",
"invalid_root_move": "Collection already in the root", "invalid_root_move": "Koleksi sudah berada di akar direktori",
"moved": "Moved Successfully", "moved": "Berhasil Dipindahkan",
"my_collections": "Koleksi Saya", "my_collections": "Koleksi Saya",
"name": "Koleksi Baru Saya", "name": "Koleksi Baru Saya",
"name_length_insufficient": "Nama koleksi harus minimal 3 karakter", "name_length_insufficient": "Nama koleksi harus minimal 3 karakter",
"new": "Koleksi baru", "new": "Koleksi baru",
"order_changed": "Collection Order Updated", "order_changed": "Pembaruan Urutan Koleksi",
"renamed": "Koleksi berganti nama", "renamed": "Koleksi berganti nama",
"request_in_use": "Permintaan sedang digunakan", "request_in_use": "Permintaan sedang digunakan",
"save_as": "Simpan Sebagai", "save_as": "Simpan Sebagai",
"select": "Pilih Koleksi", "select": "Pilih Koleksi",
"select_location": "Pilih lokasi", "select_location": "Pilih lokasi",
"select_team": "Pilih team", "select_team": "Pilih tim",
"team_collections": "Koleksi Tim" "team_collections": "Koleksi Tim"
}, },
"confirm": { "confirm": {
@@ -147,7 +147,7 @@
"remove_team": "Apakah Anda yakin ingin menghapus tim ini?", "remove_team": "Apakah Anda yakin ingin menghapus tim ini?",
"remove_telemetry": "Apakah Anda yakin ingin menyisih dari Telemetri?", "remove_telemetry": "Apakah Anda yakin ingin menyisih dari Telemetri?",
"request_change": "Apakah Anda yakin ingin membuang permintaan saat ini, perubahan yang belum disimpan akan hilang.", "request_change": "Apakah Anda yakin ingin membuang permintaan saat ini, perubahan yang belum disimpan akan hilang.",
"save_unsaved_tab": "Do you want to save changes made in this tab?", "save_unsaved_tab": "Apakah Anda ingin menyimpan perubahan yang dibuat di tab ini?",
"sync": "Apakah Anda ingin memulihkan ruang kerja Anda dari cloud? Ini akan membuang kemajuan lokal Anda." "sync": "Apakah Anda ingin memulihkan ruang kerja Anda dari cloud? Ini akan membuang kemajuan lokal Anda."
}, },
"count": { "count": {
@@ -180,8 +180,8 @@
"profile": "Masuk untuk melihat profil Anda", "profile": "Masuk untuk melihat profil Anda",
"protocols": "Protokol kosong", "protocols": "Protokol kosong",
"schema": "Hubungkan ke endpoint GraphQL untuk melihat skema", "schema": "Hubungkan ke endpoint GraphQL untuk melihat skema",
"shortcodes": "Shortcodes are empty", "shortcodes": "Shortcodes kosong",
"subscription": "Subscriptions are empty", "subscription": "Langganan kosong",
"team_name": "Nama team kosong", "team_name": "Nama team kosong",
"teams": "Kamu bukan di team manapun", "teams": "Kamu bukan di team manapun",
"tests": "Tidak ada tes untuk permintaan ini" "tests": "Tidak ada tes untuk permintaan ini"
@@ -189,19 +189,19 @@
"environment": { "environment": {
"add_to_global": "Tambahkan ke Global", "add_to_global": "Tambahkan ke Global",
"added": "Tambahan Environment", "added": "Tambahan Environment",
"create_new": "Membuat baru environment", "create_new": "Membuat environment baru",
"created": "Environment dibuat", "created": "Environment dibuat",
"deleted": "Environment dihapus", "deleted": "Environment dihapus",
"edit": "Sunting Environment", "edit": "Sunting Environment",
"invalid_name": "Tolong beri nama untuk environment", "invalid_name": "Tolong beri nama untuk environment",
"my_environments": "My Environments", "my_environments": "Environment Saya",
"nested_overflow": "variabel environment bersarang dibatasi hingga 10 level", "nested_overflow": "Variabel environment bersarang dibatasi hingga 10 level",
"new": "Environment Baru", "new": "Environment Baru",
"no_environment": "No environment", "no_environment": "No environment",
"no_environment_description": "Tidak ada environment yang dipilih. Pilih apa yang harus dilakukan dengan variabel berikut.", "no_environment_description": "Tidak ada environment yang dipilih. Pilih apa yang harus dilakukan dengan variabel berikut.",
"select": "Pilih environment", "select": "Pilih environment",
"team_environments": "Team Environments", "team_environments": "Environment Tim",
"title": "Environments", "title": "Environment",
"updated": "Environment diperbarui", "updated": "Environment diperbarui",
"variable_list": "Daftar Variable" "variable_list": "Daftar Variable"
}, },
@@ -210,8 +210,8 @@
"check_console_details": "Periksa console log untuk detailnya.", "check_console_details": "Periksa console log untuk detailnya.",
"curl_invalid_format": "cURL tidak diformat dengan benar", "curl_invalid_format": "cURL tidak diformat dengan benar",
"danger_zone": "Danger zone", "danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:", "delete_account": "Akun Anda saat ini merupakan pemilik dalam tim-tim ini:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", "delete_account_description": "Anda harus menghapus diri Anda dari tim-tim ini, mentransfer kepemilikan, atau menghapus tim-tim ini sebelum Anda dapat menghapus akun Anda.",
"empty_req_name": "Nama Permintaan Kosong", "empty_req_name": "Nama Permintaan Kosong",
"f12_details": "(F12 untuk detailnya)", "f12_details": "(F12 untuk detailnya)",
"gql_prettify_invalid_query": "Tidak dapat prettify kueri yang tidak valid, menyelesaikan kesalahan sintaksis kueri, dan coba lagi", "gql_prettify_invalid_query": "Tidak dapat prettify kueri yang tidak valid, menyelesaikan kesalahan sintaksis kueri, dan coba lagi",
@@ -294,7 +294,7 @@
"from_json_description": "Impor dari Hoppscotch berkas koleksi", "from_json_description": "Impor dari Hoppscotch berkas koleksi",
"from_my_collections": "Impor dari Koleksi Saya", "from_my_collections": "Impor dari Koleksi Saya",
"from_my_collections_description": "Impor dari Berkas Koleksi Saya", "from_my_collections_description": "Impor dari Berkas Koleksi Saya",
"from_openapi": "Import dari OpenAPI", "from_openapi": "Impor dari OpenAPI",
"from_openapi_description": "Impor dari OpenAPI syarat berkas (YML/JSON)", "from_openapi_description": "Impor dari OpenAPI syarat berkas (YML/JSON)",
"from_postman": "Impor dari Postman", "from_postman": "Impor dari Postman",
"from_postman_description": "Impor dari Koleksi Postman", "from_postman_description": "Impor dari Koleksi Postman",
@@ -316,23 +316,23 @@
"zen_mode": "Zen mode" "zen_mode": "Zen mode"
}, },
"modal": { "modal": {
"close_unsaved_tab": "You have unsaved changes", "close_unsaved_tab": "Anda memiliki perubahan yang belum disimpan",
"collections": "Koleksi", "collections": "Koleksi",
"confirm": "Mengonfirmasi", "confirm": "Mengonfirmasi",
"edit_request": "Edit Request", "edit_request": "Edit Request",
"import_export": "Impor / Ekspor" "import_export": "Impor / Ekspor"
}, },
"mqtt": { "mqtt": {
"already_subscribed": "You are already subscribed to this topic.", "already_subscribed": "Anda sudah berlangganan topik ini.",
"clean_session": "Clean Session", "clean_session": "Sesi Bersih",
"clear_input": "Clear input", "clear_input": "Hapus input",
"clear_input_on_send": "Clear input on send", "clear_input_on_send": "Hapus input saat mengirim",
"client_id": "Client ID", "client_id": "Client ID",
"color": "Pick a color", "color": "Pilih warna",
"communication": "Komunikasi", "communication": "Komunikasi",
"connection_config": "Connection Config", "connection_config": "Konfigurasi Koneksi",
"connection_not_authorized": "This MQTT connection does not use any authentication.", "connection_not_authorized": "Koneksi MQTT ini tidak menggunakan otentikasi",
"invalid_topic": "Please provide a topic for the subscription", "invalid_topic": "Harap berikan topik untuk langganan",
"keep_alive": "Keep Alive", "keep_alive": "Keep Alive",
"log": "Log", "log": "Log",
"lw_message": "Last-Will Message", "lw_message": "Last-Will Message",
@@ -340,8 +340,8 @@
"lw_retain": "Last-Will Retain", "lw_retain": "Last-Will Retain",
"lw_topic": "Last-Will Topic", "lw_topic": "Last-Will Topic",
"message": "Pesan", "message": "Pesan",
"new": "New Subscription", "new": "Langganan Baru",
"not_connected": "Please start a MQTT connection first.", "not_connected": "Mulai koneksi MQTT terlebih dahulu",
"publish": "Menerbitkan", "publish": "Menerbitkan",
"qos": "QoS", "qos": "QoS",
"ssl": "SSL", "ssl": "SSL",
@@ -396,19 +396,19 @@
"text": "Text" "text": "Text"
}, },
"copy_link": "Salin tautan", "copy_link": "Salin tautan",
"different_collection": "Cannot reorder requests from different collections", "different_collection": "Tidak dapat mengubah urutan permintaan dari koleksi yang berbeda",
"duplicated": "Request duplicated", "duplicated": "Request duplicated",
"duration": "Durasi", "duration": "Durasi",
"enter_curl": "Masukkan cURL", "enter_curl": "Masukkan cURL",
"generate_code": "Generate code", "generate_code": "Hasilkan kode",
"generated_code": "Generated code", "generated_code": "Hasilkan kode",
"header_list": "Daftar Header", "header_list": "Daftar Header",
"invalid_name": "Harap berikan nama untuk request", "invalid_name": "Harap berikan nama untuk request",
"method": "Method", "method": "Method",
"moved": "Request moved", "moved": "Request moved",
"name": "Request nama", "name": "Request nama",
"new": "Request baru", "new": "Request baru",
"order_changed": "Request Order Updated", "order_changed": "Urutan Request Diperbarui",
"override": "Membatalkan", "override": "Membatalkan",
"override_help": "Set <kbd>Content-Type</kbd> in Headers", "override_help": "Set <kbd>Content-Type</kbd> in Headers",
"overriden": "Diganti", "overriden": "Diganti",
@@ -453,7 +453,7 @@
"settings": { "settings": {
"accent_color": "Accent color", "accent_color": "Accent color",
"account": "Akun", "account": "Akun",
"account_deleted": "Your account has been deleted", "account_deleted": "Akun Anda telah dihapus",
"account_description": "Sesuaikan pengaturan akun Anda.", "account_description": "Sesuaikan pengaturan akun Anda.",
"account_email_description": "Alamat surel utama Anda.", "account_email_description": "Alamat surel utama Anda.",
"account_name_description": "Ini adalah nama tampilan Anda.", "account_name_description": "Ini adalah nama tampilan Anda.",
@@ -609,7 +609,7 @@
"file_imported": "File diimpor", "file_imported": "File diimpor",
"finished_in": "Selesai dalam {duration} ms", "finished_in": "Selesai dalam {duration} ms",
"history_deleted": "Riwayat dihapus", "history_deleted": "Riwayat dihapus",
"linewrap": "Wrap lines", "linewrap": "Bungkus baris",
"loading": "Memuat...", "loading": "Memuat...",
"message_received": "Pesan: {message} tiba di topik: {topic}", "message_received": "Pesan: {message} tiba di topik: {topic}",
"mqtt_subscription_failed": "Terjadi masalah saat berlangganan topik: {topic}", "mqtt_subscription_failed": "Terjadi masalah saat berlangganan topik: {topic}",
@@ -666,7 +666,7 @@
"email_do_not_match": "Surel tidak cocok dengan detail akun Anda. Hubungi pemilik tim Anda.", "email_do_not_match": "Surel tidak cocok dengan detail akun Anda. Hubungi pemilik tim Anda.",
"exit": "Keluar dari Tim", "exit": "Keluar dari Tim",
"exit_disabled": "Hanya pemilik yang tidak dapat keluar dari tim", "exit_disabled": "Hanya pemilik yang tidak dapat keluar dari tim",
"invalid_coll_id": "Invalid collection ID", "invalid_coll_id": "ID koleksi tidak valid",
"invalid_email_format": "Format surel tidak valid", "invalid_email_format": "Format surel tidak valid",
"invalid_id": "ID tim tidak valid. Hubungi pemilik tim Anda.", "invalid_id": "ID tim tidak valid. Hubungi pemilik tim Anda.",
"invalid_invite_link": "Tautan undangan tidak valid", "invalid_invite_link": "Tautan undangan tidak valid",
@@ -690,7 +690,7 @@
"member_removed": "Pengguna dihapus", "member_removed": "Pengguna dihapus",
"member_role_updated": "Peran pengguna diperbarui", "member_role_updated": "Peran pengguna diperbarui",
"members": "Anggota", "members": "Anggota",
"more_members": "+{count} more", "more_members": "+{count} lebih",
"name_length_insufficient": "Nama tim harus setidaknya 6 karakter", "name_length_insufficient": "Nama tim harus setidaknya 6 karakter",
"name_updated": "Nama tim diperbarui", "name_updated": "Nama tim diperbarui",
"new": "Tim Baru", "new": "Tim Baru",
@@ -698,13 +698,13 @@
"new_name": "Tim baru saya", "new_name": "Tim baru saya",
"no_access": "Anda tidak memiliki akses edit ke collections ini", "no_access": "Anda tidak memiliki akses edit ke collections ini",
"no_invite_found": "Undangan tidak ditemukan. Hubungi pemilik tim Anda.", "no_invite_found": "Undangan tidak ditemukan. Hubungi pemilik tim Anda.",
"no_request_found": "Request not found.", "no_request_found": "Request tidak ditemukan.",
"not_found": "Tim tidak ditemukan. Hubungi pemilik tim Anda.", "not_found": "Tim tidak ditemukan. Hubungi pemilik tim Anda.",
"not_valid_viewer": "Anda bukan penonton yang valid. Hubungi pemilik tim Anda.", "not_valid_viewer": "Anda bukan penonton yang valid. Hubungi pemilik tim Anda.",
"parent_coll_move": "Cannot move collection to a child collection", "parent_coll_move": "Tidak dapat memindahkan koleksi ke dalam koleksi anak",
"pending_invites": "Undangan tertunda", "pending_invites": "Undangan tertunda",
"permissions": "Izin", "permissions": "Izin",
"same_target_destination": "Same target and destination", "same_target_destination": "Sama tujuan dan destinasi",
"saved": "Tim disimpan", "saved": "Tim disimpan",
"select_a_team": "Pilih tim", "select_a_team": "Pilih tim",
"title": "tim", "title": "tim",
@@ -712,9 +712,9 @@
"we_sent_invite_link_description": "Minta semua undangan untuk memeriksa kotak masuk mereka. Klik tautan untuk bergabung dengan tim." "we_sent_invite_link_description": "Minta semua undangan untuk memeriksa kotak masuk mereka. Klik tautan untuk bergabung dengan tim."
}, },
"team_environment": { "team_environment": {
"deleted": "Environment Deleted", "deleted": "Environment dihapus",
"duplicate": "Environment Duplicated", "duplicate": "Environment diduplikasi",
"not_found": "Environment not found." "not_found": "Environment tidak ditemukan."
}, },
"test": { "test": {
"failed": "Tes gagal", "failed": "Tes gagal",
@@ -734,9 +734,9 @@
"url": "URL" "url": "URL"
}, },
"workspace": { "workspace": {
"change": "Change workspace", "change": "Beralih workspace",
"personal": "My Workspace", "personal": "Workspace Saya",
"team": "Team Workspace", "team": "Workspace Tim",
"title": "Workspaces" "title": "Workspaces"
} }
} }

View File

@@ -1,48 +1,50 @@
{ {
"action": { "action": {
"autoscroll": "Autoscroll", "autoscroll": "Автоскрол",
"cancel": "Отменить", "cancel": "Отменить",
"choose_file": "Выберите файл", "choose_file": "Выберите файл",
"clear": "Очистить", "clear": "Очистить",
"clear_all": "Очистить все", "clear_all": "Очистить все",
"close": "Close", "clear_history": "Очистить всю историю",
"close": "Закрыть",
"connect": "Подключиться", "connect": "Подключиться",
"connecting": "Connecting", "connecting": "Соединение...",
"copy": "Скопировать", "copy": "Скопировать",
"delete": "Удалить", "delete": "Удалить",
"disconnect": "Отключиться", "disconnect": "Отключиться",
"dismiss": "Скрыть", "dismiss": "Скрыть",
"dont_save": "Don't save", "dont_save": "Не сохранять",
"download_file": "Скачать файл", "download_file": "Скачать файл",
"drag_to_reorder": "Drag to reorder", "drag_to_reorder": "Перетягивайте для сортировки",
"duplicate": "Дублировать", "duplicate": "Дублировать",
"edit": "Редактировать", "edit": "Редактировать",
"filter": "Filter", "filter": "Фильтр",
"go_back": "Вернуться", "go_back": "Вернуться",
"go_forward": "Go forward", "go_forward": "Вперёд",
"group_by": "Group by", "group_by": "Сгруппировать по",
"label": "Название", "label": "Название",
"learn_more": "Узнать больше", "learn_more": "Узнать больше",
"less": "Less", "less": "Меньше",
"more": "Больше", "more": "Больше",
"new": "Создать новый", "new": "Создать новый",
"no": "Нет", "no": "Нет",
"open_workspace": "Open workspace", "open_workspace": "Открыть пространство",
"paste": "Paste", "paste": "Вставить",
"prettify": "Форматировать", "prettify": "Форматировать",
"rename": "Переименовать",
"remove": "Удалить", "remove": "Удалить",
"restore": "Восстановить", "restore": "Восстановить",
"save": "Сохранить", "save": "Сохранить",
"scroll_to_bottom": "Scroll to bottom", "scroll_to_bottom": "Вниз",
"scroll_to_top": "Scroll to top", "scroll_to_top": "Вверх",
"search": "Поиск", "search": "Поиск",
"send": "Отправить", "send": "Отправить",
"start": "Начать", "start": "Начать",
"starting": "Starting", "starting": "Запускаю",
"stop": "Стоп", "stop": "Стоп",
"to_close": "to close", "to_close": "что бы закрыть",
"to_navigate": "to navigate", "to_navigate": "для навигации",
"to_select": "to select", "to_select": "выборать",
"turn_off": "Выключить", "turn_off": "Выключить",
"turn_on": "Включить", "turn_on": "Включить",
"undo": "Отменить", "undo": "Отменить",
@@ -56,9 +58,9 @@
"chat_with_us": "Связаться с нами", "chat_with_us": "Связаться с нами",
"contact_us": "Свяжитесь с нами", "contact_us": "Свяжитесь с нами",
"copy": "Копировать", "copy": "Копировать",
"copy_user_id": "Copy User Auth Token", "copy_user_id": "Копировать токен пользователя",
"developer_option": "Developer options", "developer_option": "Настройки разработчика",
"developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", "developer_option_description": "Инструмент разработчика помогает обслуживить и развивить Hoppscotch",
"discord": "Discord", "discord": "Discord",
"documentation": "Документация", "documentation": "Документация",
"github": "GitHub", "github": "GitHub",
@@ -67,11 +69,13 @@
"invite": "Пригласить", "invite": "Пригласить",
"invite_description": "В Hoppscotch мы разработали простой и интуитивно понятный интерфейс для создания и управления вашими API. Hoppscotch - это инструмент, который помогает создавать, тестировать, документировать и делиться своими API.", "invite_description": "В Hoppscotch мы разработали простой и интуитивно понятный интерфейс для создания и управления вашими API. Hoppscotch - это инструмент, который помогает создавать, тестировать, документировать и делиться своими API.",
"invite_your_friends": "Пригласить своих друзей", "invite_your_friends": "Пригласить своих друзей",
"social_links": "Социальные сети",
"social_description": "Подписывайся на наши соц. сети и оставайся всегда в курсе последних новостей, обновлений и релизов.",
"join_discord_community": "Присоединяйтесь к нашему сообществу Discord", "join_discord_community": "Присоединяйтесь к нашему сообществу Discord",
"keyboard_shortcuts": "Горячие клавиши", "keyboard_shortcuts": "Горячие клавиши",
"name": "Hoppscotch", "name": "Hoppscotch",
"new_version_found": "Найдена новая версия. Перезагрузите для обновления.", "new_version_found": "Найдена новая версия. Перезагрузите для обновления.",
"options": "Options", "options": "Настройки",
"proxy_privacy_policy": "Политика конфиденциальности прокси", "proxy_privacy_policy": "Политика конфиденциальности прокси",
"reload": "Перезагрузить", "reload": "Перезагрузить",
"search": "Поиск", "search": "Поиск",
@@ -79,7 +83,7 @@
"shortcuts": "Ярлыки", "shortcuts": "Ярлыки",
"spotlight": "Прожектор", "spotlight": "Прожектор",
"status": "Статус", "status": "Статус",
"status_description": "Check the status of the website", "status_description": "Проверить состояние сайта",
"terms_and_privacy": "Условия и конфиденциальность", "terms_and_privacy": "Условия и конфиденциальность",
"twitter": "Twitter", "twitter": "Twitter",
"type_a_command_search": "Введите команду или выполните поиск…", "type_a_command_search": "Введите команду или выполните поиск…",
@@ -93,7 +97,7 @@
"continue_with_email": "Продолжить с электронной почтой", "continue_with_email": "Продолжить с электронной почтой",
"continue_with_github": "Продолжить с GitHub", "continue_with_github": "Продолжить с GitHub",
"continue_with_google": "Продолжить с Google", "continue_with_google": "Продолжить с Google",
"continue_with_microsoft": "Continue with Microsoft", "continue_with_microsoft": "Продолжить с Microsoft",
"email": "Электронное письмо", "email": "Электронное письмо",
"logged_out": "Вышли из", "logged_out": "Вышли из",
"login": "Авторизоваться", "login": "Авторизоваться",
@@ -118,19 +122,20 @@
}, },
"collection": { "collection": {
"created": "Коллекция создана", "created": "Коллекция создана",
"different_parent": "Cannot reorder collection with different parent", "different_parent": "Нельзя сортировать коллекцию с разной родительской коллекцией",
"edit": "Редактировать коллекцию", "edit": "Редактировать коллекцию",
"invalid_name": "Укажите допустимое название коллекции", "invalid_name": "Укажите допустимое название коллекции",
"invalid_root_move": "Collection already in the root", "invalid_root_move": "Коллекция уже в корне",
"moved": "Moved Successfully", "moved": "Перемещено успешно",
"my_collections": "Мои коллекции", "my_collections": "Мои коллекции",
"name": "Новая коллекция", "name": "Новая коллекция",
"name_length_insufficient": "Collection name should be at least 3 characters long", "name_length_insufficient": "Имя коллекции должно иметь 3 или более символов",
"new": "Создать коллекцию", "new": "Создать коллекцию",
"order_changed": "Collection Order Updated", "order_changed": "Порядок коллекции обновлён",
"renamed": "Коллекция переименована", "renamed": "Коллекция переименована",
"request_in_use": "Запрос обрабатывается", "request_in_use": "Запрос обрабатывается",
"save_as": "Сохранить как", "save_as": "Сохранить как",
"save_to_collection": "Сохранить в коллекцию",
"select": "Выбрать коллекцию", "select": "Выбрать коллекцию",
"select_location": "Выберите местоположение", "select_location": "Выберите местоположение",
"select_team": "Выберите команду", "select_team": "Выберите команду",
@@ -146,10 +151,17 @@
"remove_request": "Вы уверены, что хотите навсегда удалить этот запрос?", "remove_request": "Вы уверены, что хотите навсегда удалить этот запрос?",
"remove_team": "Вы уверены, что хотите удалить эту команду?", "remove_team": "Вы уверены, что хотите удалить эту команду?",
"remove_telemetry": "Вы действительно хотите отказаться от телеметрии?", "remove_telemetry": "Вы действительно хотите отказаться от телеметрии?",
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", "request_change": "Вы уверены что хотите сбросить текущий запрос, все не сохранённые данные будт утеряны?",
"save_unsaved_tab": "Do you want to save changes made in this tab?", "save_unsaved_tab": "Вы хотите сохранить изменения в этой вкладке?",
"close_unsaved_tab": "Вы уверены что хотите закрыть эту вкладку?",
"close_unsaved_tabs": "ВЫ уверены что хотите закрыть все эти вкладки? Несохранённые данные {count} вкладок будут утеряны.",
"sync": "Вы уверены, что хотите синхронизировать это рабочее пространство?" "sync": "Вы уверены, что хотите синхронизировать это рабочее пространство?"
}, },
"context_menu": {
"set_environment_variable": "Назначить как переменную",
"add_parameters": "Добавить в параметры",
"open_request_in_new_tab": "Открыть в новой вкладке"
},
"count": { "count": {
"header": "Заголовок {count}", "header": "Заголовок {count}",
"message": "Тело {count}", "message": "Тело {count}",
@@ -180,83 +192,102 @@
"profile": "Войдите, чтобы просмотреть свой профиль", "profile": "Войдите, чтобы просмотреть свой профиль",
"protocols": "Протоколы пустые", "protocols": "Протоколы пустые",
"schema": "Подключиться к конечной точке GraphQL", "schema": "Подключиться к конечной точке GraphQL",
"shortcodes": "Shortcodes are empty", "shortcodes": "Нет коротких ссылок",
"subscription": "Subscriptions are empty", "subscription": "Нет подписок",
"team_name": "Название команды пусто", "team_name": "Название команды пусто",
"teams": "Команды пустые", "teams": "Команды пустые",
"tests": "Для этого запроса нет тестов" "tests": "Для этого запроса нет тестов"
}, },
"environment": { "environment": {
"add_to_global": "Add to Global", "add_to_global": "Добавить в глобальное окружение",
"added": "Environment addition", "added": "Окружение добавлено",
"create_new": "Создать новую среду", "create_new": "Создать новое окружение",
"created": "Environment created", "created": "Окружение создано",
"deleted": "Environment deletion", "deleted": "Окружение удалено",
"edit": "Редактировать среду", "duplicated": "Окружение скопировано",
"invalid_name": "Укажите допустимое имя для среды", "global": "Глобальное окружение",
"my_environments": "My Environments", "empty_variables": "Нет переменных",
"nested_overflow": "nested environment variables are limited to 10 levels", "global_variables": "Глобальные переменные",
"edit": "Редактировать окружение",
"invalid_name": "Укажите допустимое имя для окружения",
"list": "Переменные окружения",
"my_environments": "Мои окружения",
"name": "Название",
"nested_overflow": "максимальный уровень вложения переменных окружения - 10",
"new": "Новая среда", "new": "Новая среда",
"no_environment": "Нет окружающей среды", "no_active_environment": "Нет активных окружений",
"no_environment_description": "No environments were selected. Choose what to do with the following variables.", "no_environment": "Нет окружения",
"no_environment_description": "Не выбрано окружение, выберите что делать с переменными.",
"quick_peek": "Быстрый просмотр окружения",
"replace_with_variable": "Заменить переменной",
"scope": "Scope",
"select": "Выберите среду", "select": "Выберите среду",
"team_environments": "Team Environments", "set": "Выбрать окружение",
"title": "Среды", "set_as_environment": "Установить как окружение",
"updated": "Environment updation", "team_environments": "Окружения команды",
"title": "Окружения",
"updated": "Окружение обновлено",
"value": "Значение",
"variable": "Переменная",
"variable_list": "Список переменных" "variable_list": "Список переменных"
}, },
"error": { "error": {
"browser_support_sse": "Похоже, в этом браузере нет поддержки событий, отправленных сервером.", "browser_support_sse": "Похоже, в этом браузере нет поддержки событий, отправленных сервером.",
"check_console_details": "Подробности смотрите в журнале консоли.", "check_console_details": "Подробности смотрите в журнале консоли.",
"curl_invalid_format": "cURL неправильно отформатирован", "curl_invalid_format": "cURL неправильно отформатирован",
"danger_zone": "Danger zone", "danger_zone": "Опасная зона",
"delete_account": "Your account is currently an owner in these teams:", "delete_account": "Вы являетесь владельцем этой команды:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", "delete_account_description": "Прежде чем удалить аккаунт вам необходимо либо назначить владельцом другого пользователя, либо удалить команды в которых вы являетесь владельцем.",
"empty_req_name": "Пустое имя запроса", "empty_req_name": "Пустое имя запроса",
"f12_details": "(F12 для подробностей)", "f12_details": "(F12 для подробностей)",
"gql_prettify_invalid_query": "Не удалось определить недопустимый запрос, устранить синтаксические ошибки запроса и повторить попытку.", "gql_prettify_invalid_query": "Не удалось определить недопустимый запрос, устранить синтаксические ошибки запроса и повторить попытку.",
"incomplete_config_urls": "Incomplete configuration URLs", "incomplete_config_urls": "Не заполнены URL конфигурации",
"incorrect_email": "Incorrect email", "incorrect_email": "Не корректный Email",
"invalid_link": "Invalid link", "invalid_link": "Не корректная ссылка",
"invalid_link_description": "Ссылка, по которой вы перешли, - недействительна, либо срок ее действия истек.", "invalid_link_description": "Ссылка, по которой вы перешли, - недействительна, либо срок ее действия истек.",
"json_parsing_failed": "Invalid JSON", "json_parsing_failed": "Не корректный JSON",
"json_prettify_invalid_body": "Не удалось определить недопустимое тело, устранить синтаксические ошибки json и повторить попытку.", "json_prettify_invalid_body": "Не удалось определить недопустимое тело, устранить синтаксические ошибки json и повторить попытку.",
"network_error": "Похоже, возникла проблема с соединением. Попробуйте еще раз.", "network_error": "Похоже, возникла проблема с соединением. Попробуйте еще раз.",
"network_fail": "Не удалось отправить запрос", "network_fail": "Не удалось отправить запрос",
"no_duration": "Без продолжительности", "no_duration": "Без продолжительности",
"no_results_found": "No matches found", "no_results_found": "Совпадения не найдены",
"page_not_found": "This page could not be found", "page_not_found": "Эта страница не найдена",
"proxy_error": "Ошибка прокси",
"script_fail": "Не удалось выполнить сценарий предварительного запроса", "script_fail": "Не удалось выполнить сценарий предварительного запроса",
"something_went_wrong": "Что-то пошло не так", "something_went_wrong": "Что-то пошло не так",
"test_script_fail": "Could not execute post-request script" "test_script_fail": "Не удалось выполнить тестирование запроса"
}, },
"export": { "export": {
"as_json": "Экспорт как JSON", "as_json": "Экспорт как JSON",
"create_secret_gist": "Создать секретный Gist", "create_secret_gist": "Создать секретный Gist",
"gist_created": "Gist создан", "gist_created": "Gist создан",
"require_github": "Войдите через GitHub, чтобы создать секретную суть", "require_github": "Войдите через GitHub, чтобы создать секретную суть",
"title": "Export" "title": "Экспорт"
}, },
"filter": { "filter": {
"all": "All", "all": "Все",
"none": "None", "none": "Не указано",
"starred": "Starred" "starred": "Отмечено"
}, },
"folder": { "folder": {
"created": "Папка создана", "created": "Папка создана",
"edit": "Редактировать папку", "edit": "Редактировать папку",
"invalid_name": "Укажите имя для папки", "invalid_name": "Укажите имя для папки",
"name_length_insufficient": "Folder name should be at least 3 characters long", "name_length_insufficient": "Имя папки должно содержать 3 или более символов",
"new": "Новая папка", "new": "Новая папка",
"renamed": "Папка переименована" "renamed": "Папка переименована"
}, },
"graphql": { "graphql": {
"mutations": "Мутации", "mutations": "Мутации",
"schema": "Схема", "schema": "Схема",
"subscriptions": "Подписки" "subscriptions": "Подписки",
"switch_connection": "Изменить соединение",
"connection_switch_url": "Вы присоединились к GraphQL, URL соединения",
"connection_switch_new_url": "Смена вкладки разорвёт текущее GraphQL соединение. Новый URL соединения будет",
"connection_switch_confirm": "Вы желаете соединиться с последним GraphQL сервером?"
}, },
"group": { "group": {
"time": "Time", "time": "Время",
"url": "URL" "url": "URL"
}, },
"header": { "header": {
@@ -273,11 +304,11 @@
"post_request_tests": "Сценарии тестирования написаны на JavaScript и запускаются после получения ответа.", "post_request_tests": "Сценарии тестирования написаны на JavaScript и запускаются после получения ответа.",
"pre_request_script": "Скрипты предварительного запроса написаны на JavaScript и запускаются перед отправкой запроса.", "pre_request_script": "Скрипты предварительного запроса написаны на JavaScript и запускаются перед отправкой запроса.",
"script_fail": "Похоже, в скрипте предварительного запроса есть сбой. Проверьте ошибку ниже и исправьте скрипт соответствующим образом.", "script_fail": "Похоже, в скрипте предварительного запроса есть сбой. Проверьте ошибку ниже и исправьте скрипт соответствующим образом.",
"test_script_fail": "There seems to be an error with test script. Please fix the errors and run tests again", "test_script_fail": "Похоже, что скрипт тестирования содержит ошибку. Пожалуйста исправьте её и попробуйте снова",
"tests": "Напишите тестовый сценарий для автоматизации отладки." "tests": "Напишите тестовый сценарий для автоматизации отладки."
}, },
"hide": { "hide": {
"collection": "Collapse Collection Panel", "collection": "Свернуть панель соединения",
"more": "Скрыть больше", "more": "Скрыть больше",
"preview": "Скрыть предварительный просмотр", "preview": "Скрыть предварительный просмотр",
"sidebar": "Скрыть боковую панель" "sidebar": "Скрыть боковую панель"
@@ -287,61 +318,85 @@
"curl": "Импортировать cURL", "curl": "Импортировать cURL",
"failed": "Ошибка импорта", "failed": "Ошибка импорта",
"from_gist": "Импорт из Gist", "from_gist": "Импорт из Gist",
"from_gist_description": "Import from Gist URL", "from_gist_description": "Импортировать через Gist URL",
"from_insomnia": "Import from Insomnia", "from_insomnia": "Импортировать с Insomnia",
"from_insomnia_description": "Import from Insomnia collection", "from_insomnia_description": "Импортировать из коллекции Insomnia",
"from_json": "Import from Hoppscotch", "from_json": "Импортировать из Hoppscotch",
"from_json_description": "Import from Hoppscotch collection file", "from_json_description": "Импортировать из файла коллекции Hoppscotch",
"from_my_collections": "Импортировать из моих коллекций", "from_my_collections": "Импортировать из моих коллекций",
"from_my_collections_description": "Import from My Collections file", "from_my_collections_description": "Импортировать коллекции из моего файла",
"from_openapi": "Import from OpenAPI", "from_openapi": "Импортировать из OpenAPI",
"from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", "from_openapi_description": "Импортировать из OpenAPI файла описания API (YML/JSON)",
"from_postman": "Import from Postman", "from_postman": "Импортировать из Postman",
"from_postman_description": "Import from Postman collection", "from_postman_description": "Импортировать из коллекции Postman",
"from_url": "Import from URL", "from_url": "Импортировать из URL",
"gist_url": "Введите URL-адрес Gist", "gist_url": "Введите URL-адрес Gist",
"import_from_url_invalid_fetch": "Couldn't get data from the url", "import_from_url_invalid_fetch": "Не удалить получить данные по этому URL",
"import_from_url_invalid_file_format": "Error while importing collections", "import_from_url_invalid_file_format": "Ошибка при импорте коллекций",
"import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'", "import_from_url_invalid_type": "Неподдерживаемый тип. Поддерживаемые типы: 'hoppscotch', 'openapi', 'postman', 'insomnia'",
"import_from_url_success": "Collections Imported", "import_from_url_success": "Коллекция импортирована",
"json_description": "Import collections from a Hoppscotch Collections JSON file", "json_description": "Импортировать из коллекции Hoppscotch",
"title": "Импортировать" "title": "Импортировать"
}, },
"inspections": {
"title": "Инспектор",
"description": "Помогает обноружить возможные проблемы",
"environment": {
"add_environment": "Добавить в окружение",
"not_found": "Переменная окружения “{environment}” не найдена."
},
"header": {
"cookie": "Браузерная версия Hoppscotch не может использовать Cookie Header. Пока мы работаем над созданием десктоп версии Hoppscotch, пожалуйста используйте Authorization Header."
},
"response": {
"401_error": "Пожалуйста проверьте ваши параметры авторизации",
"404_error": "Пожалуйста проверьте URL и Метод вашего запроса",
"network_error": "Пожалуйста проверьте соединение",
"cors_error": "Пожалуйста проверьте вашу Cross-Origin Resource Sharing настройку сервера.",
"default_error": "Проверьте ваш запрос."
},
"url": {
"extension_not_installed": "Расширение не установлено",
"extention_not_enabled": "Расширение не включено",
"extention_enable_action": "Включить расширение браузера",
"extension_unknown_origin": "Убедитесь что вы добавили адрес сервера в Hoppscotch расширение."
}
},
"layout": { "layout": {
"collapse_collection": "Collapse or Expand Collections", "collapse_collection": "Свернуть или развернуть коллекции",
"collapse_sidebar": "Collapse or Expand the sidebar", "collapse_sidebar": "Свернуть или развернуть боковую панель",
"column": "Вертикальное оформление", "column": "Вертикальная развёртка",
"name": "Layout", "name": "Развёртка",
"row": "Горизонтальное оформление", "row": "Горизонтальная развертка",
"zen_mode": "Спокойный режим" "zen_mode": "Спокойный режим"
}, },
"modal": { "modal": {
"close_unsaved_tab": "You have unsaved changes", "close_unsaved_tab": "У вас есть не сохранённые изменения",
"collections": "Коллекции", "collections": "Коллекции",
"confirm": "Подтверждать", "confirm": "Подтверждать",
"edit_request": "Изменить запрос", "edit_request": "Изменить запрос",
"import_export": "Импорт Экспорт" "import_export": "Импорт Экспорт"
}, },
"mqtt": { "mqtt": {
"already_subscribed": "You are already subscribed to this topic.", "already_subscribed": "Вы уже подписаны на этот топик",
"clean_session": "Clean Session", "clean_session": "Очистить сессию",
"clear_input": "Clear input", "clear_input": "Очистить ввод",
"clear_input_on_send": "Clear input on send", "clear_input_on_send": "Очистить ввод перед отправкой",
"client_id": "Client ID", "client_id": "Client ID",
"color": "Pick a color", "color": "Выбрать цвет",
"communication": "Коммуникация", "communication": "Коммуникация",
"connection_config": "Connection Config", "connection_config": "Конфигурация соединения",
"connection_not_authorized": "This MQTT connection does not use any authentication.", "connection_not_authorized": "Это соединение MQTT не использует какую-либо авторизацию.",
"invalid_topic": "Please provide a topic for the subscription", "invalid_topic": "Пожалуйста выберите topic для подписки",
"keep_alive": "Keep Alive", "keep_alive": "Поддерживать соединение",
"log": "Лог", "log": "Лог",
"lw_message": "Last-Will Message", "lw_message": "Last-Will Message",
"lw_qos": "Last-Will QoS", "lw_qos": "Last-Will QoS",
"lw_retain": "Last-Will Retain", "lw_retain": "Last-Will Retain",
"lw_topic": "Last-Will Topic", "lw_topic": "Last-Will Topic",
"message": "Сообщение", "message": "Сообщение",
"new": "New Subscription", "new": "Новая подписка",
"not_connected": "Please start a MQTT connection first.", "not_connected": "Пожалуйста, сначала запустите MQTT соединение.",
"publish": "Публиковать", "publish": "Публиковать",
"qos": "QoS", "qos": "QoS",
"ssl": "SSL", "ssl": "SSL",
@@ -355,7 +410,7 @@
"navigation": { "navigation": {
"doc": "Документы", "doc": "Документы",
"graphql": "GraphQL", "graphql": "GraphQL",
"profile": "Profile", "profile": "Профиль",
"realtime": "В реальном времени", "realtime": "В реальном времени",
"rest": "REST", "rest": "REST",
"settings": "Настройки" "settings": "Настройки"
@@ -363,12 +418,12 @@
"preRequest": { "preRequest": {
"javascript_code": "Код JavaScript", "javascript_code": "Код JavaScript",
"learn": "Читать документацию", "learn": "Читать документацию",
"script": "Сценарий предварительного запроса", "script": "Предворительный скрипт запроса",
"snippets": "Фрагменты" "snippets": "Готовый код"
}, },
"profile": { "profile": {
"app_settings": "Настройки приложения", "app_settings": "Настройки приложения",
"default_hopp_displayname": "Unnamed User", "default_hopp_displayname": "Безымянный",
"editor": "Редактор", "editor": "Редактор",
"editor_description": "Редакторы могут добавлять, редактировать, а так же удалять запросы.", "editor_description": "Редакторы могут добавлять, редактировать, а так же удалять запросы.",
"email_verification_mail": "На вашу электронную почту отправлено письмо для подтверждения. Перейдите по ссылке из письма, чтобы подтвердить свой электронный адрес.", "email_verification_mail": "На вашу электронную почту отправлено письмо для подтверждения. Перейдите по ссылке из письма, чтобы подтвердить свой электронный адрес.",
@@ -391,13 +446,13 @@
"choose_language": "Выберите язык", "choose_language": "Выберите язык",
"content_type": "Тип содержимого", "content_type": "Тип содержимого",
"content_type_titles": { "content_type_titles": {
"others": "Others", "others": "Другие",
"structured": "Structured", "structured": "Структурированный",
"text": "Text" "text": "Текст"
}, },
"copy_link": "Копировать ссылку", "copy_link": "Копировать ссылку",
"different_collection": "Cannot reorder requests from different collections", "different_collection": "Нельзя изменять порядок запросов из разных коллекций",
"duplicated": "Request duplicated", "duplicated": "Запрос скопирован",
"duration": "Продолжительность", "duration": "Продолжительность",
"enter_curl": "Введите cURL", "enter_curl": "Введите cURL",
"generate_code": "Сгенерировать код", "generate_code": "Сгенерировать код",
@@ -405,13 +460,13 @@
"header_list": "Список заголовков", "header_list": "Список заголовков",
"invalid_name": "Укажите имя для запроса", "invalid_name": "Укажите имя для запроса",
"method": "Методика", "method": "Методика",
"moved": "Request moved", "moved": "Запрос перемещён",
"name": "Имя запроса", "name": "Имя запроса",
"new": "New Request", "new": "Новый запрос",
"order_changed": "Request Order Updated", "order_changed": "Порядок запроса изменён",
"override": "Override", "override": "Переопределить",
"override_help": "Set <kbd>Content-Type</kbd> in Headers", "override_help": "Установить <kbd>Content-Type</kbd> в Заголовках",
"overriden": "Overridden", "overriden": "Переопределено",
"parameter_list": "Параметры запроса", "parameter_list": "Параметры запроса",
"parameters": "Параметры", "parameters": "Параметры",
"path": "Путь", "path": "Путь",
@@ -424,17 +479,17 @@
"save_as": "Сохранить как", "save_as": "Сохранить как",
"saved": "Запрос сохранен", "saved": "Запрос сохранен",
"share": "Делиться", "share": "Делиться",
"share_description": "Share Hoppscotch with your friends", "share_description": "Поделиться Hoppscotch с друзьями",
"title": "Запрос", "title": "Запрос",
"type": "Тип запроса", "type": "Тип запроса",
"url": "URL", "url": "URL",
"variables": "Переменные", "variables": "Переменные",
"view_my_links": "View my links" "view_my_links": "Посмотреть мои ссылки"
}, },
"response": { "response": {
"audio": "Audio", "audio": "Аудио",
"body": "Тело ответа", "body": "Тело ответа",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Отфильтровать ответ в формате JSON (используется синтаксис JSONPath)",
"headers": "Заголовки", "headers": "Заголовки",
"html": "HTML", "html": "HTML",
"image": "Изображение", "image": "Изображение",
@@ -446,14 +501,14 @@
"status": "Статус", "status": "Статус",
"time": "Время", "time": "Время",
"title": "Ответ", "title": "Ответ",
"video": "Video", "video": "Видео",
"waiting_for_connection": "Ожидание соединения", "waiting_for_connection": "Ожидание соединения",
"xml": "XML" "xml": "XML"
}, },
"settings": { "settings": {
"accent_color": "Основной цвет", "accent_color": "Основной цвет",
"account": "Счет", "account": "Счет",
"account_deleted": "Your account has been deleted", "account_deleted": "Ваш аккаунт был удалён",
"account_description": "Настройте параметры своей учетной записи.", "account_description": "Настройте параметры своей учетной записи.",
"account_email_description": "Ваш основной адрес электронной почты.", "account_email_description": "Ваш основной адрес электронной почты.",
"account_name_description": "Это ваше отображаемое имя.", "account_name_description": "Это ваше отображаемое имя.",
@@ -462,8 +517,8 @@
"change_font_size": "Изменить размер шрифта", "change_font_size": "Изменить размер шрифта",
"choose_language": "Выберите язык", "choose_language": "Выберите язык",
"dark_mode": "Темный", "dark_mode": "Темный",
"delete_account": "Delete account", "delete_account": "Удалить аккаунт",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", "delete_account_description": "Удаление аккаунта нельзя отменить",
"expand_navigation": "Раскрыть панель навигации", "expand_navigation": "Раскрыть панель навигации",
"experiments": "Эксперименты", "experiments": "Эксперименты",
"experiments_notice": "Это набор экспериментов, над которыми мы работаем, которые могут оказаться полезными, интересными, и тем, и другим, или ни тем, ни другим. Они не окончательные и могут быть нестабильными, поэтому, если произойдет что-то слишком странное, не паникуйте. Просто выключи эту чертову штуку. Шутки в сторону,", "experiments_notice": "Это набор экспериментов, над которыми мы работаем, которые могут оказаться полезными, интересными, и тем, и другим, или ни тем, ни другим. Они не окончательные и могут быть нестабильными, поэтому, если произойдет что-то слишком странное, не паникуйте. Просто выключи эту чертову штуку. Шутки в сторону,",
@@ -490,8 +545,8 @@
"proxy_use_toggle": "Используйте промежуточное ПО прокси для отправки запросов", "proxy_use_toggle": "Используйте промежуточное ПО прокси для отправки запросов",
"read_the": "Прочтите", "read_the": "Прочтите",
"reset_default": "Восстановление значений по умолчанию", "reset_default": "Восстановление значений по умолчанию",
"short_codes": "Short codes", "short_codes": "Короткие ссылки",
"short_codes_description": "Short codes which were created by you.", "short_codes_description": "Короткие ссылки, созданные вами",
"sidebar_on_left": "Панель слева", "sidebar_on_left": "Панель слева",
"sync": "Синхронизировать", "sync": "Синхронизировать",
"sync_collections": "Коллекции", "sync_collections": "Коллекции",
@@ -505,16 +560,16 @@
"theme_description": "Настройте тему своего приложения.", "theme_description": "Настройте тему своего приложения.",
"use_experimental_url_bar": "Использовать экспериментальную строку URL с выделением среды", "use_experimental_url_bar": "Использовать экспериментальную строку URL с выделением среды",
"user": "Пользователь", "user": "Пользователь",
"verified_email": "Verified email", "verified_email": "Проверенный Email",
"verify_email": "Подтвердить почту" "verify_email": "Подтвердить Email"
}, },
"shortcodes": { "shortcodes": {
"actions": "Actions", "actions": "Действия",
"created_on": "Created on", "created_on": "Создано",
"deleted": "Shortcode deleted", "deleted": "Удалёна",
"method": "Method", "method": "Метод",
"not_found": "Shortcode not found", "not_found": "Короткая ссылка не найдена",
"short_code": "Short code", "short_code": "Короткая ссылка",
"url": "URL" "url": "URL"
}, },
"shortcut": { "shortcut": {
@@ -525,6 +580,10 @@
"show_all": "Горячие клавиши", "show_all": "Горячие клавиши",
"title": "Общий" "title": "Общий"
}, },
"others": {
"title": "Другие",
"prettify": "Прекрасные редакторы"
},
"miscellaneous": { "miscellaneous": {
"invite": "Пригласите людей в Hoppscotch", "invite": "Пригласите людей в Hoppscotch",
"title": "Разное" "title": "Разное"
@@ -545,6 +604,9 @@
"delete_method": "Выберите метод DELETE", "delete_method": "Выберите метод DELETE",
"get_method": "Выберите метод GET", "get_method": "Выберите метод GET",
"head_method": "Выберите метод HEAD", "head_method": "Выберите метод HEAD",
"rename": "Переименовать запрос",
"import_curl": "Импорт из cURL",
"show_code": "Сгенерировать готовый код",
"method": "Методика", "method": "Методика",
"next_method": "Выберите следующий метод", "next_method": "Выберите следующий метод",
"post_method": "Выберите метод POST", "post_method": "Выберите метод POST",
@@ -553,35 +615,120 @@
"reset_request": "Сбросить запрос", "reset_request": "Сбросить запрос",
"save_to_collections": "Сохранить в коллекции", "save_to_collections": "Сохранить в коллекции",
"send_request": "Послать запрос", "send_request": "Послать запрос",
"save_request": "Сохарнить запрос",
"title": "Запрос" "title": "Запрос"
}, },
"response": { "response": {
"copy": "Copy response to clipboard", "copy": "Копировать запрос в буфер обмена",
"download": "Download response as file", "download": "Скачать запрос как файл",
"title": "Response" "title": "Запрос"
}, },
"theme": { "theme": {
"black": "Switch theme to black mode", "black": "Черный режим",
"dark": "Switch theme to dark mode", "dark": "Тёмный режим",
"light": "Switch theme to light mode", "light": "Светлый режим",
"system": "Switch theme to system mode", "system": "Определяется системой",
"title": "Theme" "title": "Тема"
} }
}, },
"show": { "show": {
"code": "Показать код", "code": "Показать код",
"collection": "Expand Collection Panel", "collection": "Развернуть панель коллекций",
"more": "Показать больше", "more": "Показать больше",
"sidebar": "Показать боковую панель" "sidebar": "Показать боковую панель"
}, },
"socketio": { "socketio": {
"communication": "Коммуникация", "communication": "Коммуникация",
"connection_not_authorized": "This SocketIO connection does not use any authentication.", "connection_not_authorized": "Это SocketIO соединение не использует какую-либо авторизацию.",
"event_name": "Название события", "event_name": "Название события",
"events": "События", "events": "События",
"log": "Лог", "log": "Лог",
"url": "URL" "url": "URL"
}, },
"spotlight": {
"general": {
"help_menu": "Помощь и поддержка",
"chat": "Чат с поддержкой",
"open_docs": "Прочитать документацию",
"open_keybindings": "Горячие клавиши",
"open_github": "Открыть GitHub репозиторий",
"social": "Соц. сети",
"title": "Основное"
},
"miscellaneous": {
"invite": "Пригласите друзей в Hoppscotch",
"title": "Разное"
},
"request": {
"switch_to": "Перейти к",
"select_method": "Выбрать метод",
"save_as_new": "Сохранить как новый запрос",
"tab_parameters": "Параметры",
"tab_body": "Тело",
"tab_headers": "Заголовки",
"tab_authorization": "Авторизация",
"tab_pre_request_script": "Пред-скрипт",
"tab_tests": "Тесты",
"tab_query": "Запрос",
"tab_variables": "Переменные"
},
"graphql": {
"connect": "Соединиться",
"disconnect": "Разъединить"
},
"response": {
"copy": "Копировать ответ",
"download": "Скачать ответ",
"title": "Ответ"
},
"environments": {
"new": "Создать новое окружение",
"new_variable": "Создать новую переменную в окружении",
"edit": "Изменить текущее окружение",
"delete": "Удалить текущее окружение",
"duplicate": "Скопировать текущее окружение",
"edit_global": "Изменить глобальное окружение",
"duplicate_global": "Скопировать глобальное окружение",
"title": "Окружения"
},
"workspace": {
"new": "Создать новую команду",
"edit": "Изменить текущую команду",
"delete": "Удалить текущую команду",
"invite": "Пригласить людей в команду",
"switch_to_personal": "Переключиться на личное пространство",
"title": "Команды"
},
"tab": {
"duplicate": "Скопировать вкладку",
"close_current": "Закрыть вкладку",
"close_others": "Закрыть все кроме этой",
"new_tab": "Открыть в новой вкладке",
"title": "Вкладки"
},
"section": {
"user": "Пользователь",
"theme": "Тема",
"interface": "Интерфейс",
"interceptor": "Перехватчик"
},
"change_language": "Изменить язык",
"settings": {
"theme": {
"black": "Чёрная",
"dark": "Тёмная",
"light": "Светлая",
"system": "Определяется системой"
},
"font": {
"size_sm": "Маленький",
"size_md": "Средний",
"size_lg": "Большой"
},
"change_interceptor": "Изменить перехватчик",
"change_language": "Изменить язык"
}
},
"sse": { "sse": {
"event_type": "Тип события", "event_type": "Тип события",
"log": "Лог", "log": "Лог",
@@ -589,14 +736,14 @@
}, },
"state": { "state": {
"bulk_mode": "Множественное редактирование", "bulk_mode": "Множественное редактирование",
"bulk_mode_placeholder": "Entries are separated by newline\nKeys and values are separated by :\nPrepend # to any row you want to add but keep disabled", "bulk_mode_placeholder": "Каждый параметр должен начинаться с новой строки\nКлючи и значения разедляются двоеточием\nИспользуйте # для комментария",
"cleared": "Очищено", "cleared": "Очищено",
"connected": "Связаны", "connected": "Связаны",
"connected_to": "Подключено к {name}", "connected_to": "Подключено к {name}",
"connecting_to": "Подключение к {name} ...", "connecting_to": "Подключение к {name} ...",
"connection_error": "Failed to connect", "connection_error": "Ошибка подключения",
"connection_failed": "Connection failed", "connection_failed": "Не удалось установить соединение",
"connection_lost": "Connection lost", "connection_lost": "Соединение утеряно",
"copied_to_clipboard": "Скопировано в буфер обмена", "copied_to_clipboard": "Скопировано в буфер обмена",
"deleted": "Удалено", "deleted": "Удалено",
"deprecated": "УСТАРЕЛО", "deprecated": "УСТАРЕЛО",
@@ -611,17 +758,17 @@
"history_deleted": "История удалена", "history_deleted": "История удалена",
"linewrap": "Обернуть линии", "linewrap": "Обернуть линии",
"loading": "Загрузка...", "loading": "Загрузка...",
"message_received": "Message: {message} arrived on topic: {topic}", "message_received": "Сообщение: {message} получено по топику: {topic}",
"mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", "mqtt_subscription_failed": "Что-то пошло не так, при попытке подписаться на топик: {topic}",
"none": "Никто", "none": "Никто",
"nothing_found": "Ничего не найдено для", "nothing_found": "Ничего не найдено для",
"published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", "published_error": "Что-то пошло не так при попытке опубликовать сообщение в топик {topic}: {message}",
"published_message": "Published message: {message} to topic: {topic}", "published_message": "Опубликовано сообщение: {message} в топик: {topic}",
"reconnection_error": "Failed to reconnect", "reconnection_error": "Не удалось переподключиться",
"subscribed_failed": "Failed to subscribe to topic: {topic}", "subscribed_failed": "Не удалось подписаться на топик: {topic}",
"subscribed_success": "Successfully subscribed to topic: {topic}", "subscribed_success": "Успешно подписался на топик: {topic}",
"unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", "unsubscribed_failed": "Не удалось отписаться от топика: {topic}",
"unsubscribed_success": "Successfully unsubscribed from topic: {topic}", "unsubscribed_success": "Успешно отписался от топика: {topic}",
"waiting_send_request": "Ожидание отправки запроса" "waiting_send_request": "Ожидание отправки запроса"
}, },
"support": { "support": {
@@ -630,7 +777,7 @@
"community": "Задавайте вопросы и помогайте другим", "community": "Задавайте вопросы и помогайте другим",
"documentation": "Узнать больше о Hoppscotch", "documentation": "Узнать больше о Hoppscotch",
"forum": "Задавайте вопросы и получайте ответы", "forum": "Задавайте вопросы и получайте ответы",
"github": "Follow us on Github", "github": "Подпишитесь на нас на Github",
"shortcuts": "Просматривайте приложение быстрее", "shortcuts": "Просматривайте приложение быстрее",
"team": "Свяжитесь с командой", "team": "Свяжитесь с командой",
"title": "Служба поддержки", "title": "Служба поддержки",
@@ -641,15 +788,15 @@
"body": "Тело", "body": "Тело",
"collections": "Коллекции", "collections": "Коллекции",
"documentation": "Документация", "documentation": "Документация",
"environments": "Environments", "environments": "Окружения",
"headers": "Заголовки", "headers": "Заголовки",
"history": "История", "history": "История",
"mqtt": "MQTT", "mqtt": "MQTT",
"parameters": "Параметры", "parameters": "Параметры",
"pre_request_script": "Скрипт предварительного запроса", "pre_request_script": "Пред-скрипт",
"queries": "Запросы", "queries": "Запросы",
"query": "Запрос", "query": "Запрос",
"schema": "Schema", "schema": "Схема",
"socketio": "Socket.IO", "socketio": "Socket.IO",
"sse": "SSE", "sse": "SSE",
"tests": "Тесты", "tests": "Тесты",
@@ -666,7 +813,7 @@
"email_do_not_match": "Электронная почта, которой Вы воспользовались не соответсвует указанной в данных Вашей учетной записи.", "email_do_not_match": "Электронная почта, которой Вы воспользовались не соответсвует указанной в данных Вашей учетной записи.",
"exit": "Выйти из команды", "exit": "Выйти из команды",
"exit_disabled": "Только владелец не может выйти из команды", "exit_disabled": "Только владелец не может выйти из команды",
"invalid_coll_id": "Invalid collection ID", "invalid_coll_id": "Не верный идентификатор коллекции",
"invalid_email_format": "Формат электронной почты недействителен", "invalid_email_format": "Формат электронной почты недействителен",
"invalid_id": "Некорректный ID команды. Свяжитесь с руководителем команды.", "invalid_id": "Некорректный ID команды. Свяжитесь с руководителем команды.",
"invalid_invite_link": "Ссылка недействительна", "invalid_invite_link": "Ссылка недействительна",
@@ -690,7 +837,7 @@
"member_removed": "Пользователь удален", "member_removed": "Пользователь удален",
"member_role_updated": "Роли пользователей обновлены", "member_role_updated": "Роли пользователей обновлены",
"members": "Участники", "members": "Участники",
"more_members": "+{count} more", "more_members": "+{count}",
"name_length_insufficient": "Название команды должно быть не менее 6 символов.", "name_length_insufficient": "Название команды должно быть не менее 6 символов.",
"name_updated": "Название команды обновлено", "name_updated": "Название команды обновлено",
"new": "Новая команда", "new": "Новая команда",
@@ -698,13 +845,13 @@
"new_name": "Моя новая команда", "new_name": "Моя новая команда",
"no_access": "У вас нет прав на редактирование этих коллекций", "no_access": "У вас нет прав на редактирование этих коллекций",
"no_invite_found": "Такое приглашение мы не смогли найти. Свяжитесь с руководителем команды.", "no_invite_found": "Такое приглашение мы не смогли найти. Свяжитесь с руководителем команды.",
"no_request_found": "Request not found.", "no_request_found": "Запрос не найден",
"not_found": "Team not found. Contact your team owner.", "not_found": "Команда не найдена, свяжитесь с владельцем команды",
"not_valid_viewer": "У Вас нет прав просматривать это. Свяжитесь с руководителем команды.", "not_valid_viewer": "У Вас нет прав просматривать это. Свяжитесь с руководителем команды.",
"parent_coll_move": "Cannot move collection to a child collection", "parent_coll_move": "Не удалось переместить коллекцию в дочернюю",
"pending_invites": "Ожидающие приглашения", "pending_invites": "Ожидающие приглашения",
"permissions": "Разрешения", "permissions": "Разрешения",
"same_target_destination": "Same target and destination", "same_target_destination": "Таже цель и конечная точка",
"saved": "Команда сохранена", "saved": "Команда сохранена",
"select_a_team": "Выбрать команду", "select_a_team": "Выбрать команду",
"title": "Команды", "title": "Команды",
@@ -712,9 +859,9 @@
"we_sent_invite_link_description": "Попросите тех, кого Вы пригласили, проверить их почтовые ящики. Им нужно перейди по ссылке, чтобы подтвердить вступление в эту команду." "we_sent_invite_link_description": "Попросите тех, кого Вы пригласили, проверить их почтовые ящики. Им нужно перейди по ссылке, чтобы подтвердить вступление в эту команду."
}, },
"team_environment": { "team_environment": {
"deleted": "Environment Deleted", "deleted": "Окружение удалено",
"duplicate": "Environment Duplicated", "duplicate": "Окружение скопировано",
"not_found": "Environment not found." "not_found": "Окружение не найдено"
}, },
"test": { "test": {
"failed": "Тест не пройден", "failed": "Тест не пройден",
@@ -734,9 +881,9 @@
"url": "URL" "url": "URL"
}, },
"workspace": { "workspace": {
"change": "Change workspace", "change": "Изменить пространство",
"personal": "My Workspace", "personal": "Моё пространство",
"team": "Team Workspace", "team": "Пространство команды",
"title": "Workspaces" "title": "Рабочие пространства"
} }
} }

View File

@@ -19,7 +19,7 @@
"edit": "編輯", "edit": "編輯",
"filter": "篩選回應", "filter": "篩選回應",
"go_back": "返回", "go_back": "返回",
"go_forward": "Go forward", "go_forward": "向前",
"group_by": "分組方式", "group_by": "分組方式",
"label": "標籤", "label": "標籤",
"learn_more": "瞭解更多", "learn_more": "瞭解更多",
@@ -117,37 +117,37 @@
"username": "使用者名稱" "username": "使用者名稱"
}, },
"collection": { "collection": {
"created": "合已建立", "created": "合已建立",
"different_parent": "Cannot reorder collection with different parent", "different_parent": "無法為父集合不同的集合重新排序",
"edit": "編輯合", "edit": "編輯合",
"invalid_name": "請提供有效的合名稱", "invalid_name": "請提供有效的合名稱",
"invalid_root_move": "Collection already in the root", "invalid_root_move": "集合已在根目錄",
"moved": "Moved Successfully", "moved": "移動成功",
"my_collections": "我的合", "my_collections": "我的合",
"name": "我的新合", "name": "我的新合",
"name_length_insufficient": "合名稱至少要有 3 個字元。", "name_length_insufficient": "合名稱至少要有 3 個字元。",
"new": "建立合", "new": "建立合",
"order_changed": "Collection Order Updated", "order_changed": "集合順序已更新",
"renamed": "合已重新命名", "renamed": "合已重新命名",
"request_in_use": "請求正在使用中", "request_in_use": "請求正在使用中",
"save_as": "另存為", "save_as": "另存為",
"select": "選擇一個合", "select": "選擇一個合",
"select_location": "選擇位置", "select_location": "選擇位置",
"select_team": "選擇一個團隊", "select_team": "選擇一個團隊",
"team_collections": "團隊合" "team_collections": "團隊合"
}, },
"confirm": { "confirm": {
"exit_team": "您確定要離開此團隊嗎?", "exit_team": "您確定要離開此團隊嗎?",
"logout": "您確定要登出嗎?", "logout": "您確定要登出嗎?",
"remove_collection": "您確定要永久刪除該合嗎?", "remove_collection": "您確定要永久刪除該合嗎?",
"remove_environment": "您確定要永久刪除該環境嗎?", "remove_environment": "您確定要永久刪除該環境嗎?",
"remove_folder": "您確定要永久刪除該資料夾嗎?", "remove_folder": "您確定要永久刪除該資料夾嗎?",
"remove_history": "您確定要永久刪除全部歷史記錄嗎?", "remove_history": "您確定要永久刪除全部歷史記錄嗎?",
"remove_request": "您確定要永久刪除該請求嗎?", "remove_request": "您確定要永久刪除該請求嗎?",
"remove_team": "您確定要刪除該團隊嗎?", "remove_team": "您確定要刪除該團隊嗎?",
"remove_telemetry": "您確定要退出遙測服務嗎?", "remove_telemetry": "您確定要退出遙測服務嗎?",
"request_change": "您確定要捨棄當前請求嗎?未儲存的變更將遺失。", "request_change": "您確定要捨棄目前的請求嗎?未儲存的變更將遺失。",
"save_unsaved_tab": "Do you want to save changes made in this tab?", "save_unsaved_tab": "您要儲存在此分頁做出的改動嗎?",
"sync": "您想從雲端恢復您的工作區嗎?這將丟棄您的本地進度。" "sync": "您想從雲端恢復您的工作區嗎?這將丟棄您的本地進度。"
}, },
"count": { "count": {
@@ -160,13 +160,13 @@
}, },
"documentation": { "documentation": {
"generate": "產生文件", "generate": "產生文件",
"generate_message": "匯入 Hoppscotch 合以隨時隨地產生 API 文件。" "generate_message": "匯入 Hoppscotch 合以隨時隨地產生 API 文件。"
}, },
"empty": { "empty": {
"authorization": "該請求沒有使用任何授權", "authorization": "該請求沒有使用任何授權",
"body": "該請求沒有任何請求主體", "body": "該請求沒有任何請求主體",
"collection": "合為空", "collection": "合為空",
"collections": "合為空", "collections": "合為空",
"documentation": "連線到 GraphQL 端點以檢視文件", "documentation": "連線到 GraphQL 端點以檢視文件",
"endpoint": "端點不能留空", "endpoint": "端點不能留空",
"environments": "環境為空", "environments": "環境為空",
@@ -209,7 +209,7 @@
"browser_support_sse": "此瀏覽器似乎不支援 SSE。", "browser_support_sse": "此瀏覽器似乎不支援 SSE。",
"check_console_details": "檢查控制台日誌以獲悉詳情", "check_console_details": "檢查控制台日誌以獲悉詳情",
"curl_invalid_format": "cURL 格式不正確", "curl_invalid_format": "cURL 格式不正確",
"danger_zone": "Danger zone", "danger_zone": "危險地帶",
"delete_account": "您的帳號目前為這些團隊的擁有者:", "delete_account": "您的帳號目前為這些團隊的擁有者:",
"delete_account_description": "您在刪除帳號前必須先將您自己從團隊中移除、轉移擁有權,或是刪除團隊。", "delete_account_description": "您在刪除帳號前必須先將您自己從團隊中移除、轉移擁有權,或是刪除團隊。",
"empty_req_name": "空請求名稱", "empty_req_name": "空請求名稱",
@@ -277,38 +277,38 @@
"tests": "編寫測試指令碼以自動除錯。" "tests": "編寫測試指令碼以自動除錯。"
}, },
"hide": { "hide": {
"collection": "隱藏合面板", "collection": "隱藏合面板",
"more": "隱藏更多", "more": "隱藏更多",
"preview": "隱藏預覽", "preview": "隱藏預覽",
"sidebar": "隱藏側邊欄" "sidebar": "隱藏側邊欄"
}, },
"import": { "import": {
"collections": "匯入合", "collections": "匯入合",
"curl": "匯入 cURL", "curl": "匯入 cURL",
"failed": "匯入失敗", "failed": "匯入失敗",
"from_gist": "從 Gist 匯入", "from_gist": "從 Gist 匯入",
"from_gist_description": "從 Gist 網址匯入", "from_gist_description": "從 Gist 網址匯入",
"from_insomnia": "從 Insomnia 匯入", "from_insomnia": "從 Insomnia 匯入",
"from_insomnia_description": "從 Insomnia 合匯入", "from_insomnia_description": "從 Insomnia 合匯入",
"from_json": "從 Hoppscotch 匯入", "from_json": "從 Hoppscotch 匯入",
"from_json_description": "從 Hoppscotch 合檔匯入", "from_json_description": "從 Hoppscotch 合檔匯入",
"from_my_collections": "從我的合匯入", "from_my_collections": "從我的合匯入",
"from_my_collections_description": "從我的合檔匯入", "from_my_collections_description": "從我的合檔匯入",
"from_openapi": "從 OpenAPI 匯入", "from_openapi": "從 OpenAPI 匯入",
"from_openapi_description": "從 OpenAPI 規格檔 (YML/JSON) 匯入", "from_openapi_description": "從 OpenAPI 規格檔 (YML/JSON) 匯入",
"from_postman": "從 Postman 匯入", "from_postman": "從 Postman 匯入",
"from_postman_description": "從 Postman 合匯入", "from_postman_description": "從 Postman 合匯入",
"from_url": "從網址匯入", "from_url": "從網址匯入",
"gist_url": "輸入 Gist 網址", "gist_url": "輸入 Gist 網址",
"import_from_url_invalid_fetch": "無法從網址取得資料", "import_from_url_invalid_fetch": "無法從網址取得資料",
"import_from_url_invalid_file_format": "匯入合時發生錯誤", "import_from_url_invalid_file_format": "匯入合時發生錯誤",
"import_from_url_invalid_type": "不支援此類型。可接受的值為 'hoppscotch'、'openapi'、'postman'、'insomnia'", "import_from_url_invalid_type": "不支援此類型。可接受的值為 'hoppscotch'、'openapi'、'postman'、'insomnia'",
"import_from_url_success": "已匯入合", "import_from_url_success": "已匯入合",
"json_description": "從 Hoppscotch 合 JSON 檔匯入合", "json_description": "從 Hoppscotch 合 JSON 檔匯入合",
"title": "匯入" "title": "匯入"
}, },
"layout": { "layout": {
"collapse_collection": "隱藏或顯示合", "collapse_collection": "隱藏或顯示合",
"collapse_sidebar": "隱藏或顯示側邊欄", "collapse_sidebar": "隱藏或顯示側邊欄",
"column": "垂直版面", "column": "垂直版面",
"name": "配置", "name": "配置",
@@ -316,8 +316,8 @@
"zen_mode": "專注模式" "zen_mode": "專注模式"
}, },
"modal": { "modal": {
"close_unsaved_tab": "You have unsaved changes", "close_unsaved_tab": "您有未儲存的改動",
"collections": "合", "collections": "合",
"confirm": "確認", "confirm": "確認",
"edit_request": "編輯請求", "edit_request": "編輯請求",
"import_export": "匯入/匯出" "import_export": "匯入/匯出"
@@ -374,9 +374,9 @@
"email_verification_mail": "已將驗證信寄送至您的電子郵件地址。請點擊信中連結以驗證您的電子郵件地址。", "email_verification_mail": "已將驗證信寄送至您的電子郵件地址。請點擊信中連結以驗證您的電子郵件地址。",
"no_permission": "您沒有權限執行此操作。", "no_permission": "您沒有權限執行此操作。",
"owner": "擁有者", "owner": "擁有者",
"owner_description": "擁有者可以新增、編輯和刪除請求、合和團隊成員。", "owner_description": "擁有者可以新增、編輯和刪除請求、合和團隊成員。",
"roles": "角色", "roles": "角色",
"roles_description": "角色用來控制對共用合的存取權。", "roles_description": "角色用來控制對共用合的存取權。",
"updated": "已更新個人檔案", "updated": "已更新個人檔案",
"viewer": "檢視者", "viewer": "檢視者",
"viewer_description": "檢視者只能檢視和使用請求。" "viewer_description": "檢視者只能檢視和使用請求。"
@@ -396,8 +396,8 @@
"text": "文字" "text": "文字"
}, },
"copy_link": "複製連結", "copy_link": "複製連結",
"different_collection": "Cannot reorder requests from different collections", "different_collection": "無法重新排列來自不同集合的請求",
"duplicated": "Request duplicated", "duplicated": "已複製請求",
"duration": "持續時間", "duration": "持續時間",
"enter_curl": "輸入 cURL", "enter_curl": "輸入 cURL",
"generate_code": "產生程式碼", "generate_code": "產生程式碼",
@@ -405,10 +405,10 @@
"header_list": "請求標頭列表", "header_list": "請求標頭列表",
"invalid_name": "請提供請求名稱", "invalid_name": "請提供請求名稱",
"method": "方法", "method": "方法",
"moved": "Request moved", "moved": "已移動請求",
"name": "請求名稱", "name": "請求名稱",
"new": "新請求", "new": "新請求",
"order_changed": "Request Order Updated", "order_changed": "已更新請求順序",
"override": "覆寫", "override": "覆寫",
"override_help": "在標頭設置 <kbd>Content-Type</kbd>", "override_help": "在標頭設置 <kbd>Content-Type</kbd>",
"overriden": "已覆寫", "overriden": "已覆寫",
@@ -432,7 +432,7 @@
"view_my_links": "檢視我的連結" "view_my_links": "檢視我的連結"
}, },
"response": { "response": {
"audio": "Audio", "audio": "音訊",
"body": "回應本體", "body": "回應本體",
"filter_response_body": "篩選 JSON 回應本體 (使用 JSONPath 語法)", "filter_response_body": "篩選 JSON 回應本體 (使用 JSONPath 語法)",
"headers": "回應標頭", "headers": "回應標頭",
@@ -446,7 +446,7 @@
"status": "狀態", "status": "狀態",
"time": "時間", "time": "時間",
"title": "回應", "title": "回應",
"video": "Video", "video": "視訊",
"waiting_for_connection": "等待連線", "waiting_for_connection": "等待連線",
"xml": "XML" "xml": "XML"
}, },
@@ -494,7 +494,7 @@
"short_codes_description": "我們為您打造的快捷碼。", "short_codes_description": "我們為您打造的快捷碼。",
"sidebar_on_left": "左側邊欄", "sidebar_on_left": "左側邊欄",
"sync": "同步", "sync": "同步",
"sync_collections": "合", "sync_collections": "合",
"sync_description": "這些設定會同步到雲端。", "sync_description": "這些設定會同步到雲端。",
"sync_environments": "環境", "sync_environments": "環境",
"sync_history": "歷史", "sync_history": "歷史",
@@ -551,7 +551,7 @@
"previous_method": "選擇上一個方法", "previous_method": "選擇上一個方法",
"put_method": "選擇 PUT 方法", "put_method": "選擇 PUT 方法",
"reset_request": "重置請求", "reset_request": "重置請求",
"save_to_collections": "儲存到合", "save_to_collections": "儲存到合",
"send_request": "傳送請求", "send_request": "傳送請求",
"title": "請求" "title": "請求"
}, },
@@ -570,7 +570,7 @@
}, },
"show": { "show": {
"code": "顯示程式碼", "code": "顯示程式碼",
"collection": "顯示合面板", "collection": "顯示合面板",
"more": "顯示更多", "more": "顯示更多",
"sidebar": "顯示側邊欄" "sidebar": "顯示側邊欄"
}, },
@@ -639,9 +639,9 @@
"tab": { "tab": {
"authorization": "授權", "authorization": "授權",
"body": "請求本體", "body": "請求本體",
"collections": "合", "collections": "合",
"documentation": "幫助文件", "documentation": "幫助文件",
"environments": "Environments", "environments": "環境",
"headers": "請求標頭", "headers": "請求標頭",
"history": "歷史記錄", "history": "歷史記錄",
"mqtt": "MQTT", "mqtt": "MQTT",
@@ -666,7 +666,7 @@
"email_do_not_match": "電子信箱與您的帳號資料不一致。請聯絡您的團隊擁有者。", "email_do_not_match": "電子信箱與您的帳號資料不一致。請聯絡您的團隊擁有者。",
"exit": "退出團隊", "exit": "退出團隊",
"exit_disabled": "團隊擁有者無法退出團隊", "exit_disabled": "團隊擁有者無法退出團隊",
"invalid_coll_id": "Invalid collection ID", "invalid_coll_id": "集合 ID 無效",
"invalid_email_format": "電子信箱格式無效", "invalid_email_format": "電子信箱格式無效",
"invalid_id": "團隊 ID 無效。請聯絡您的團隊擁有者。", "invalid_id": "團隊 ID 無效。請聯絡您的團隊擁有者。",
"invalid_invite_link": "邀請連結無效", "invalid_invite_link": "邀請連結無效",
@@ -690,21 +690,21 @@
"member_removed": "使用者已移除", "member_removed": "使用者已移除",
"member_role_updated": "使用者角色已更新", "member_role_updated": "使用者角色已更新",
"members": "成員", "members": "成員",
"more_members": "+{count} more", "more_members": "還有 {count} ",
"name_length_insufficient": "團隊名稱至少為 6 個字元", "name_length_insufficient": "團隊名稱至少為 6 個字元",
"name_updated": "團隊名稱已更新", "name_updated": "團隊名稱已更新",
"new": "新團隊", "new": "新團隊",
"new_created": "已建立新團隊", "new_created": "已建立新團隊",
"new_name": "我的新團隊", "new_name": "我的新團隊",
"no_access": "您沒有編輯合的許可權", "no_access": "您沒有編輯合的許可權",
"no_invite_found": "未找到邀請。請聯絡您的團隊擁有者。", "no_invite_found": "未找到邀請。請聯絡您的團隊擁有者。",
"no_request_found": "Request not found.", "no_request_found": "找不到請求。",
"not_found": "找不到團隊。請聯絡您的團隊擁有者。", "not_found": "找不到團隊。請聯絡您的團隊擁有者。",
"not_valid_viewer": "您不是一個有效的檢視者。請聯絡您的團隊擁有者。", "not_valid_viewer": "您不是一個有效的檢視者。請聯絡您的團隊擁有者。",
"parent_coll_move": "Cannot move collection to a child collection", "parent_coll_move": "無法將集合移動至子集合",
"pending_invites": "待定邀請", "pending_invites": "待定邀請",
"permissions": "許可權", "permissions": "許可權",
"same_target_destination": "Same target and destination", "same_target_destination": "目標和目的地相同",
"saved": "團隊已儲存", "saved": "團隊已儲存",
"select_a_team": "選擇團隊", "select_a_team": "選擇團隊",
"title": "團隊", "title": "團隊",
@@ -734,9 +734,9 @@
"url": "網址" "url": "網址"
}, },
"workspace": { "workspace": {
"change": "Change workspace", "change": "切換工作區",
"personal": "My Workspace", "personal": "我的工作區",
"team": "Team Workspace", "team": "團隊工作區",
"title": "Workspaces" "title": "工作區"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,11 @@
{ {
"name": "@hoppscotch/common", "name": "@hoppscotch/common",
"private": true, "private": true,
"version": "2023.4.6", "version": "2023.8.0",
"scripts": { "scripts": {
"dev": "pnpm exec npm-run-all -p -l dev:*", "dev": "pnpm exec npm-run-all -p -l dev:*",
"test": "vitest --run",
"test:watch": "vitest",
"dev:vite": "vite", "dev:vite": "vite",
"dev:gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml --watch dotenv_config_path=\"../../.env\"", "dev:gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml --watch dotenv_config_path=\"../../.env\"",
"lint": "eslint src --ext .ts,.js,.vue --ignore-path .gitignore .", "lint": "eslint src --ext .ts,.js,.vue --ignore-path .gitignore .",
@@ -13,140 +15,148 @@
"preview": "vite preview", "preview": "vite preview",
"gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\"", "gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\"",
"postinstall": "pnpm run gql-codegen", "postinstall": "pnpm run gql-codegen",
"do-test": "pnpm run test",
"do-lint": "pnpm run prod-lint", "do-lint": "pnpm run prod-lint",
"do-typecheck": "pnpm run lint", "do-typecheck": "pnpm run lint",
"do-lintfix": "pnpm run lintfix" "do-lintfix": "pnpm run lintfix"
}, },
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "^10.1.0", "@apidevtools/swagger-parser": "^10.1.0",
"@codemirror/autocomplete": "^6.0.3", "@codemirror/autocomplete": "^6.9.0",
"@codemirror/commands": "^6.0.1", "@codemirror/commands": "^6.2.4",
"@codemirror/lang-javascript": "^6.0.1", "@codemirror/lang-javascript": "^6.1.9",
"@codemirror/lang-json": "^6.0.0", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "^6.2.0", "@codemirror/language": "^6.9.0",
"@codemirror/legacy-modes": "^6.1.0", "@codemirror/legacy-modes": "^6.3.3",
"@codemirror/lint": "^6.0.0", "@codemirror/lint": "^6.4.0",
"@codemirror/search": "^6.0.0", "@codemirror/search": "^6.5.1",
"@codemirror/state": "^6.1.0", "@codemirror/state": "^6.2.1",
"@codemirror/view": "^6.0.2", "@codemirror/view": "^6.16.0",
"@fontsource-variable/inter": "^5.0.8",
"@fontsource-variable/material-symbols-rounded": "^5.0.7",
"@fontsource-variable/roboto-mono": "^5.0.9",
"@hoppscotch/codemirror-lang-graphql": "workspace:^", "@hoppscotch/codemirror-lang-graphql": "workspace:^",
"@hoppscotch/data": "workspace:^", "@hoppscotch/data": "workspace:^",
"@hoppscotch/js-sandbox": "workspace:^", "@hoppscotch/js-sandbox": "workspace:^",
"@hoppscotch/ui": "workspace:^", "@hoppscotch/ui": "workspace:^",
"@hoppscotch/vue-toasted": "^0.1.0", "@hoppscotch/vue-toasted": "^0.1.0",
"@lezer/highlight": "^1.0.0", "@lezer/highlight": "^1.1.6",
"@sentry/tracing": "^7.13.0", "@sentry/tracing": "^7.64.0",
"@sentry/vue": "^7.13.0", "@sentry/vue": "^7.64.0",
"@urql/core": "^2.5.0", "@urql/core": "^4.1.1",
"@urql/devtools": "^2.0.3", "@urql/devtools": "^2.0.3",
"@urql/exchange-auth": "^0.1.7", "@urql/exchange-auth": "^2.1.6",
"@urql/exchange-graphcache": "^4.4.3", "@urql/exchange-graphcache": "^6.3.2",
"@vitejs/plugin-legacy": "^2.3.0", "@vitejs/plugin-legacy": "^4.1.1",
"@vueuse/core": "^8.7.5", "@vueuse/core": "^10.3.0",
"@vueuse/head": "^0.7.9", "@vueuse/head": "^1.3.1",
"acorn-walk": "^8.2.0", "acorn-walk": "^8.2.0",
"axios": "^0.21.4", "axios": "^1.4.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"dioc": "workspace:^",
"esprima": "^4.0.1", "esprima": "^4.0.1",
"events": "^3.3.0", "events": "^3.3.0",
"fp-ts": "^2.12.1", "fp-ts": "^2.16.1",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
"globalthis": "^1.0.3", "globalthis": "^1.0.3",
"graphql": "^15.5.0", "graphql": "^16.8.0",
"graphql-language-service-interface": "^2.9.1", "graphql-language-service-interface": "^2.9.1",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"httpsnippet": "^2.0.0", "httpsnippet": "^3.0.1",
"insomnia-importers": "^3.3.0", "insomnia-importers": "^3.6.0",
"io-ts": "^2.2.16", "io-ts": "^2.2.20",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonpath-plus": "^7.0.0", "jsonpath-plus": "^7.2.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lossless-json": "^2.0.8", "lossless-json": "^2.0.11",
"minisearch": "^6.1.0",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"paho-mqtt": "^1.1.0", "paho-mqtt": "^1.1.0",
"path": "^0.12.7", "path": "^0.12.7",
"postman-collection": "^4.1.4", "postman-collection": "^4.2.0",
"process": "^0.11.10", "process": "^0.11.10",
"qs": "^6.10.3", "qs": "^6.11.2",
"rxjs": "^7.5.5", "rxjs": "^7.8.1",
"socket.io-client-v2": "npm:socket.io-client@^2.4.0", "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-v3": "npm:socket.io-client@^3.1.3",
"socket.io-client-v4": "npm:socket.io-client@^4.4.1", "socket.io-client-v4": "npm:socket.io-client@^4.4.1",
"socketio-wildcard": "^2.0.0", "socketio-wildcard": "^2.0.0",
"splitpanes": "^3.1.1", "splitpanes": "^3.1.5",
"stream-browserify": "^3.0.0", "stream-browserify": "^3.0.0",
"subscriptions-transport-ws": "^0.11.0", "subscriptions-transport-ws": "^0.11.0",
"tern": "^0.24.3", "tern": "^0.24.3",
"timers": "^0.1.1", "timers": "^0.1.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"url": "^0.11.0", "url": "^0.11.1",
"util": "^0.12.4", "util": "^0.12.5",
"uuid": "^8.3.2", "uuid": "^9.0.0",
"vue": "^3.2.25", "vue": "^3.3.4",
"vue-github-button": "^3.0.3",
"vue-i18n": "^9.2.2", "vue-i18n": "^9.2.2",
"vue-pdf-embed": "^1.1.4", "vue-pdf-embed": "^1.1.6",
"vue-router": "^4.0.16", "vue-router": "^4.2.4",
"vue-tippy": "6.0.0-alpha.58", "vue-tippy": "6.3.1",
"vuedraggable-es": "^4.1.1", "vuedraggable-es": "^4.1.1",
"wonka": "^4.0.15", "wonka": "^6.3.4",
"workbox-window": "^6.5.4", "workbox-window": "^7.0.0",
"xml-formatter": "^3.4.1", "xml-formatter": "^3.5.0",
"yargs-parser": "^21.1.1" "yargs-parser": "^21.1.1"
}, },
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.1.1", "@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4", "@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@graphql-codegen/add": "^3.2.0", "@graphql-codegen/add": "^5.0.0",
"@graphql-codegen/cli": "^2.8.0", "@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/typed-document-node": "^2.3.1", "@graphql-codegen/typed-document-node": "^5.0.1",
"@graphql-codegen/typescript": "^2.7.1", "@graphql-codegen/typescript": "^4.0.1",
"@graphql-codegen/typescript-operations": "^2.5.1", "@graphql-codegen/typescript-operations": "^4.0.1",
"@graphql-codegen/typescript-urql-graphcache": "^2.3.1", "@graphql-codegen/typescript-urql-graphcache": "^2.4.5",
"@graphql-codegen/urql-introspection": "^2.2.0", "@graphql-codegen/urql-introspection": "^2.2.1",
"@graphql-typed-document-node/core": "^3.1.1", "@graphql-typed-document-node/core": "^3.2.0",
"@iconify-json/lucide": "^1.1.40", "@iconify-json/lucide": "^1.1.119",
"@intlify/vite-plugin-vue-i18n": "^7.0.0", "@intlify/vite-plugin-vue-i18n": "^7.0.0",
"@rushstack/eslint-patch": "^1.1.4", "@relmify/jest-fp-ts": "^2.1.1",
"@rushstack/eslint-patch": "^1.3.3",
"@types/har-format": "^1.2.12",
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.5",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.8",
"@types/lossless-json": "^1.0.1", "@types/lossless-json": "^1.0.1",
"@types/nprogress": "^0.2.0", "@types/nprogress": "^0.2.0",
"@types/paho-mqtt": "^1.0.6", "@types/paho-mqtt": "^1.0.7",
"@types/postman-collection": "^3.5.7", "@types/postman-collection": "^3.5.7",
"@types/splitpanes": "^2.2.1", "@types/splitpanes": "^2.2.1",
"@types/uuid": "^8.3.4", "@types/uuid": "^9.0.2",
"@types/yargs-parser": "^21.0.0", "@types/yargs-parser": "^21.0.0",
"@typescript-eslint/eslint-plugin": "^5.19.0", "@typescript-eslint/eslint-plugin": "^6.4.0",
"@typescript-eslint/parser": "^5.19.0", "@typescript-eslint/parser": "^6.4.0",
"@vitejs/plugin-vue": "^3.1.0", "@vitejs/plugin-vue": "^4.3.1",
"@vue/compiler-sfc": "^3.2.39", "@vue/compiler-sfc": "^3.3.4",
"@vue/eslint-config-typescript": "^11.0.1", "@vue/eslint-config-typescript": "^11.0.3",
"@vue/runtime-core": "^3.2.39", "@vue/runtime-core": "^3.3.4",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.0.3", "dotenv": "^16.3.1",
"eslint": "^8.24.0", "eslint": "^8.47.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.5.1", "eslint-plugin-vue": "^9.17.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"openapi-types": "^12.0.0", "openapi-types": "^12.1.3",
"rollup-plugin-polyfill-node": "^0.10.1", "rollup-plugin-polyfill-node": "^0.12.0",
"sass": "^1.53.0", "sass": "^1.66.0",
"typescript": "^4.5.4", "typescript": "^5.1.6",
"unplugin-icons": "^0.14.9", "unplugin-fonts": "^1.0.3",
"unplugin-vue-components": "^0.21.0", "unplugin-icons": "^0.16.5",
"vite": "^3.1.4", "unplugin-vue-components": "^0.25.1",
"vite-plugin-checker": "^0.5.1", "vite": "^4.4.9",
"vite-plugin-fonts": "^0.6.0", "vite-plugin-checker": "^0.6.1",
"vite-plugin-html-config": "^1.0.10", "vite-plugin-html-config": "^1.0.11",
"vite-plugin-inspect": "^0.7.4", "vite-plugin-inspect": "^0.7.38",
"vite-plugin-pages": "^0.26.0", "vite-plugin-pages": "^0.31.0",
"vite-plugin-pages-sitemap": "^1.4.0", "vite-plugin-pages-sitemap": "^1.6.1",
"vite-plugin-pwa": "^0.13.1", "vite-plugin-pwa": "^0.16.4",
"vite-plugin-vue-layouts": "^0.7.0", "vite-plugin-vue-layouts": "^0.8.0",
"vite-plugin-windicss": "^1.8.8", "vite-plugin-windicss": "^1.9.1",
"vue-tsc": "^0.38.2", "vitest": "^0.34.2",
"vue-tsc": "^1.8.8",
"windicss": "^3.5.6" "windicss": "^3.5.6"
} }
} }

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="#6366f1" 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: 389 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 KiB

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 535 KiB

View File

@@ -18,6 +18,7 @@ import { HOPP_MODULES } from "@modules/."
import { isLoadingInitialRoute } from "@modules/router" import { isLoadingInitialRoute } from "@modules/router"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { APP_IS_IN_DEV_MODE } from "@helpers/dev" import { APP_IS_IN_DEV_MODE } from "@helpers/dev"
import { platform } from "./platform"
const t = useI18n() const t = useI18n()
@@ -45,4 +46,5 @@ if (APP_IS_IN_DEV_MODE) {
// Run module root component setup code // Run module root component setup code
HOPP_MODULES.forEach((mod) => mod.onRootSetup?.()) HOPP_MODULES.forEach((mod) => mod.onRootSetup?.())
platform.addedHoppModules?.forEach((mod) => mod.onRootSetup?.())
</script> </script>

View File

@@ -1,30 +1,37 @@
// generated by unplugin-vue-components /* eslint-disable */
// We suggest you to commit this file into source control /* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {} export {}
declare module '@vue/runtime-core' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default'] AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default'] AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default'] AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
AppFooter: typeof import('./components/app/Footer.vue')['default'] AppFooter: typeof import('./components/app/Footer.vue')['default']
AppFuse: typeof import('./components/app/Fuse.vue')['default']
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default'] AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
AppHeader: typeof import('./components/app/Header.vue')['default'] AppHeader: typeof import('./components/app/Header.vue')['default']
AppInspection: typeof import('./components/app/Inspection.vue')['default']
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default'] AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
AppLogo: typeof import('./components/app/Logo.vue')['default'] AppLogo: typeof import('./components/app/Logo.vue')['default']
AppOptions: typeof import('./components/app/Options.vue')['default'] AppOptions: typeof import('./components/app/Options.vue')['default']
AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default'] AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default']
AppPowerSearch: typeof import('./components/app/PowerSearch.vue')['default']
AppPowerSearchEntry: typeof import('./components/app/PowerSearchEntry.vue')['default']
AppShare: typeof import('./components/app/Share.vue')['default'] AppShare: typeof import('./components/app/Share.vue')['default']
AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default'] AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default']
AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default'] AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default']
AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default'] AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default']
AppSidenav: typeof import('./components/app/Sidenav.vue')['default'] AppSidenav: typeof import('./components/app/Sidenav.vue')['default']
AppSocial: typeof import('./components/app/Social.vue')['default']
AppSpotlight: typeof import('./components/app/spotlight/index.vue')['default']
AppSpotlightEntry: typeof import('./components/app/spotlight/Entry.vue')['default']
AppSpotlightEntryGQLHistory: typeof import('./components/app/spotlight/entry/GQLHistory.vue')['default']
AppSpotlightEntryGQLRequest: typeof import('./components/app/spotlight/entry/GQLRequest.vue')['default']
AppSpotlightEntryIconSelected: typeof import('./components/app/spotlight/entry/IconSelected.vue')['default']
AppSpotlightEntryRESTHistory: typeof import('./components/app/spotlight/entry/RESTHistory.vue')['default']
AppSpotlightEntryRESTRequest: typeof import('./components/app/spotlight/entry/RESTRequest.vue')['default']
AppSupport: typeof import('./components/app/Support.vue')['default'] AppSupport: typeof import('./components/app/Support.vue')['default']
ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default'] ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default']
ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default'] ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default']
@@ -53,6 +60,7 @@ declare module '@vue/runtime-core' {
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default'] CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default'] CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
Environments: typeof import('./components/environments/index.vue')['default'] Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default'] EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default'] EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default']
EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default'] EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default']
@@ -65,12 +73,18 @@ declare module '@vue/runtime-core' {
FirebaseLogout: typeof import('./components/firebase/Logout.vue')['default'] FirebaseLogout: typeof import('./components/firebase/Logout.vue')['default']
GraphqlAuthorization: typeof import('./components/graphql/Authorization.vue')['default'] GraphqlAuthorization: typeof import('./components/graphql/Authorization.vue')['default']
GraphqlField: typeof import('./components/graphql/Field.vue')['default'] GraphqlField: typeof import('./components/graphql/Field.vue')['default']
GraphqlHeaders: typeof import('./components/graphql/Headers.vue')['default']
GraphqlQuery: typeof import('./components/graphql/Query.vue')['default']
GraphqlRequest: typeof import('./components/graphql/Request.vue')['default'] GraphqlRequest: typeof import('./components/graphql/Request.vue')['default']
GraphqlRequestOptions: typeof import('./components/graphql/RequestOptions.vue')['default'] GraphqlRequestOptions: typeof import('./components/graphql/RequestOptions.vue')['default']
GraphqlRequestTab: typeof import('./components/graphql/RequestTab.vue')['default']
GraphqlResponse: typeof import('./components/graphql/Response.vue')['default'] GraphqlResponse: typeof import('./components/graphql/Response.vue')['default']
GraphqlSidebar: typeof import('./components/graphql/Sidebar.vue')['default'] GraphqlSidebar: typeof import('./components/graphql/Sidebar.vue')['default']
GraphqlSubscriptionLog: typeof import('./components/graphql/SubscriptionLog.vue')['default']
GraphqlTabHead: typeof import('./components/graphql/TabHead.vue')['default']
GraphqlType: typeof import('./components/graphql/Type.vue')['default'] GraphqlType: typeof import('./components/graphql/Type.vue')['default']
GraphqlTypeLink: typeof import('./components/graphql/TypeLink.vue')['default'] GraphqlTypeLink: typeof import('./components/graphql/TypeLink.vue')['default']
GraphqlVariable: typeof import('./components/graphql/Variable.vue')['default']
History: typeof import('./components/history/index.vue')['default'] History: typeof import('./components/history/index.vue')['default']
HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default'] HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default']
HistoryRestCard: typeof import('./components/history/rest/Card.vue')['default'] HistoryRestCard: typeof import('./components/history/rest/Card.vue')['default']
@@ -82,16 +96,22 @@ declare module '@vue/runtime-core' {
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'] HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand'] HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand']
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip'] HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'] HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink'] HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal'] HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'] HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder']
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing'] HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup'] HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver'] HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'] HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs'] HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
HoppSmartTree: typeof import('@hoppscotch/ui')['HoppSmartTree']
HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow'] HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow']
HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows'] HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows']
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default'] HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
@@ -113,14 +133,17 @@ declare module '@vue/runtime-core' {
HttpResponse: typeof import('./components/http/Response.vue')['default'] HttpResponse: typeof import('./components/http/Response.vue')['default']
HttpResponseMeta: typeof import('./components/http/ResponseMeta.vue')['default'] HttpResponseMeta: typeof import('./components/http/ResponseMeta.vue')['default']
HttpSidebar: typeof import('./components/http/Sidebar.vue')['default'] HttpSidebar: typeof import('./components/http/Sidebar.vue')['default']
HttpTabHead: typeof import('./components/http/TabHead.vue')['default']
HttpTestResult: typeof import('./components/http/TestResult.vue')['default'] HttpTestResult: typeof import('./components/http/TestResult.vue')['default']
HttpTestResultEntry: typeof import('./components/http/TestResultEntry.vue')['default'] HttpTestResultEntry: typeof import('./components/http/TestResultEntry.vue')['default']
HttpTestResultEnv: typeof import('./components/http/TestResultEnv.vue')['default'] HttpTestResultEnv: typeof import('./components/http/TestResultEnv.vue')['default']
HttpTestResultReport: typeof import('./components/http/TestResultReport.vue')['default'] HttpTestResultReport: typeof import('./components/http/TestResultReport.vue')['default']
HttpTests: typeof import('./components/http/Tests.vue')['default'] HttpTests: typeof import('./components/http/Tests.vue')['default']
HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default'] HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default']
IconLucideActivity: typeof import('~icons/lucide/activity')['default']
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default'] IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
@@ -130,8 +153,11 @@ declare module '@vue/runtime-core' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default'] IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default'] IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default'] LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default'] LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default'] LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default']
@@ -151,6 +177,8 @@ declare module '@vue/runtime-core' {
RealtimeLog: typeof import('./components/realtime/Log.vue')['default'] RealtimeLog: typeof import('./components/realtime/Log.vue')['default']
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default'] RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default'] RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default'] SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default']
SmartAnchor: typeof import('./../../hoppscotch-ui/src/components/smart/Anchor.vue')['default'] SmartAnchor: typeof import('./../../hoppscotch-ui/src/components/smart/Anchor.vue')['default']
SmartAutoComplete: typeof import('./../../hoppscotch-ui/src/components/smart/AutoComplete.vue')['default'] SmartAutoComplete: typeof import('./../../hoppscotch-ui/src/components/smart/AutoComplete.vue')['default']
@@ -162,11 +190,13 @@ declare module '@vue/runtime-core' {
SmartExpand: typeof import('./../../hoppscotch-ui/src/components/smart/Expand.vue')['default'] SmartExpand: typeof import('./../../hoppscotch-ui/src/components/smart/Expand.vue')['default']
SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default'] SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default']
SmartFontSizePicker: typeof import('./components/smart/FontSizePicker.vue')['default'] SmartFontSizePicker: typeof import('./components/smart/FontSizePicker.vue')['default']
SmartInput: typeof import('./../../hoppscotch-ui/src/components/smart/Input.vue')['default']
SmartIntersection: typeof import('./../../hoppscotch-ui/src/components/smart/Intersection.vue')['default'] SmartIntersection: typeof import('./../../hoppscotch-ui/src/components/smart/Intersection.vue')['default']
SmartItem: typeof import('./../../hoppscotch-ui/src/components/smart/Item.vue')['default'] SmartItem: typeof import('./../../hoppscotch-ui/src/components/smart/Item.vue')['default']
SmartLink: typeof import('./../../hoppscotch-ui/src/components/smart/Link.vue')['default'] SmartLink: typeof import('./../../hoppscotch-ui/src/components/smart/Link.vue')['default']
SmartModal: typeof import('./../../hoppscotch-ui/src/components/smart/Modal.vue')['default'] SmartModal: typeof import('./../../hoppscotch-ui/src/components/smart/Modal.vue')['default']
SmartPicture: typeof import('./../../hoppscotch-ui/src/components/smart/Picture.vue')['default'] SmartPicture: typeof import('./../../hoppscotch-ui/src/components/smart/Picture.vue')['default']
SmartPlaceholder: typeof import('./../../hoppscotch-ui/src/components/smart/Placeholder.vue')['default']
SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default'] SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default']
SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default'] SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default']
SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.vue')['default'] SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.vue')['default']
@@ -175,8 +205,8 @@ declare module '@vue/runtime-core' {
SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default'] SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default']
SmartTabs: typeof import('./../../hoppscotch-ui/src/components/smart/Tabs.vue')['default'] SmartTabs: typeof import('./../../hoppscotch-ui/src/components/smart/Tabs.vue')['default']
SmartToggle: typeof import('./../../hoppscotch-ui/src/components/smart/Toggle.vue')['default'] SmartToggle: typeof import('./../../hoppscotch-ui/src/components/smart/Toggle.vue')['default']
SmartTree: typeof import('./components/smart/Tree.vue')['default'] SmartTree: typeof import('./../../hoppscotch-ui/src/components/smart/Tree.vue')['default']
SmartTreeBranch: typeof import('./components/smart/TreeBranch.vue')['default'] SmartTreeBranch: typeof import('./../../hoppscotch-ui/src/components/smart/TreeBranch.vue')['default']
SmartWindow: typeof import('./../../hoppscotch-ui/src/components/smart/Window.vue')['default'] SmartWindow: typeof import('./../../hoppscotch-ui/src/components/smart/Window.vue')['default']
SmartWindows: typeof import('./../../hoppscotch-ui/src/components/smart/Windows.vue')['default'] SmartWindows: typeof import('./../../hoppscotch-ui/src/components/smart/Windows.vue')['default']
TabPrimary: typeof import('./components/tab/Primary.vue')['default'] TabPrimary: typeof import('./components/tab/Primary.vue')['default']
@@ -192,5 +222,4 @@ declare module '@vue/runtime-core' {
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default'] WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default'] WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
} }
} }

View File

@@ -2,16 +2,53 @@
<AppShortcuts :show="showShortcuts" @close="showShortcuts = false" /> <AppShortcuts :show="showShortcuts" @close="showShortcuts = false" />
<AppShare :show="showShare" @hide-modal="showShare = false" /> <AppShare :show="showShare" @hide-modal="showShare = false" />
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" /> <FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
<HoppSmartConfirmModal
:show="confirmRemove"
:title="t('confirm.remove_team')"
@hide-modal="confirmRemove = false"
@resolve="deleteTeam()"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue" import { ref } from "vue"
import { defineActionHandler } from "~/helpers/actions" import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { deleteTeam as backendDeleteTeam } from "~/helpers/backend/mutations/Team"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { useToast } from "~/composables/toast"
import { useI18n } from "~/composables/i18n"
const toast = useToast()
const t = useI18n()
const showShortcuts = ref(false) const showShortcuts = ref(false)
const showShare = ref(false) const showShare = ref(false)
const showLogin = ref(false) const showLogin = ref(false)
const confirmRemove = ref(false)
const teamID = ref<string | null>(null)
const deleteTeam = () => {
if (!teamID.value) return
pipe(
backendDeleteTeam(teamID.value),
TE.match(
(err) => {
// TODO: Better errors ? We know the possible errors now
toast.error(`${t("error.something_went_wrong")}`)
console.error(err)
},
() => {
invokeAction("workspace.switch.personal")
toast.success(`${t("team.deleted")}`)
}
)
)() // Tasks (and TEs) are lazy, so call the function returned
}
defineActionHandler("flyouts.keybinds.toggle", () => { defineActionHandler("flyouts.keybinds.toggle", () => {
showShortcuts.value = !showShortcuts.value showShortcuts.value = !showShortcuts.value
}) })
@@ -23,4 +60,9 @@ defineActionHandler("modals.share.toggle", () => {
defineActionHandler("modals.login.toggle", () => { defineActionHandler("modals.login.toggle", () => {
showLogin.value = !showLogin.value showLogin.value = !showLogin.value
}) })
defineActionHandler("modals.team.delete", ({ teamId }) => {
teamID.value = teamId
confirmRemove.value = true
})
</script> </script>

View File

@@ -0,0 +1,76 @@
<template>
<div
ref="contextMenuRef"
class="fixed bg-popover shadow-lg transform translate-y-8 border border-dividerDark p-2 rounded"
:style="`top: ${position.top}px; left: ${position.left}px; z-index: 1000;`"
>
<div v-if="contextMenuOptions" class="flex flex-col">
<div
v-for="option in contextMenuOptions"
:key="option.id"
class="flex flex-col space-y-2"
>
<HoppSmartItem
v-if="option.text.type === 'text' && option.text"
:icon="option.icon"
:label="option.text.text"
@click="handleClick(option)"
/>
<component
:is="option.text.component"
v-else-if="option.text.type === 'custom'"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onClickOutside } from "@vueuse/core"
import { useService } from "dioc/vue"
import { ref, watch } from "vue"
import { ContextMenuResult, ContextMenuService } from "~/services/context-menu"
import { EnvironmentMenuService } from "~/services/context-menu/menu/environment.menu"
import { ParameterMenuService } from "~/services/context-menu/menu/parameter.menu"
import { URLMenuService } from "~/services/context-menu/menu/url.menu"
const props = defineProps<{
show: boolean
position: { top: number; left: number }
text: string | null
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const contextMenuRef = ref<any | null>(null)
const contextMenuOptions = ref<ContextMenuResult[]>([])
onClickOutside(contextMenuRef, () => {
emit("hide-modal")
})
const contextMenuService = useService(ContextMenuService)
useService(EnvironmentMenuService)
useService(ParameterMenuService)
useService(URLMenuService)
const handleClick = (option: { action: () => void }) => {
option.action()
emit("hide-modal")
}
watch(
() => [props.show, props.text],
(val) => {
if (val && props.text) {
const options = contextMenuService.getMenuFor(props.text)
contextMenuOptions.value = options
}
},
{ immediate: true }
)
</script>

View File

@@ -10,18 +10,6 @@
:class="{ '-rotate-180': !EXPAND_NAVIGATION }" :class="{ '-rotate-180': !EXPAND_NAVIGATION }"
@click="EXPAND_NAVIGATION = !EXPAND_NAVIGATION" @click="EXPAND_NAVIGATION = !EXPAND_NAVIGATION"
/> />
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="`${ZEN_MODE ? t('action.turn_off') : t('action.turn_on')} ${t(
'layout.zen_mode'
)}`"
:icon="ZEN_MODE ? IconMinimize : IconMaximize"
:class="{
'!text-accent !focus-visible:text-accentDark !hover:text-accentDark':
ZEN_MODE,
}"
@click="ZEN_MODE = !ZEN_MODE"
/>
<tippy interactive trigger="click" theme="popover"> <tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@@ -76,6 +64,7 @@
} }
" "
/> />
<!--
<HoppSmartItem <HoppSmartItem
ref="chat" ref="chat"
:icon="IconMessageCircle" :icon="IconMessageCircle"
@@ -88,20 +77,34 @@
} }
" "
/> />
-->
<template
v-for="footerItem in platform.ui?.additionalFooterMenuItems"
:key="footerItem.id"
>
<template v-if="footerItem.action.type === 'link'">
<HoppSmartItem <HoppSmartItem
:icon="IconGift" :icon="footerItem.icon"
:label="`${t('app.whats_new')}`" :label="footerItem.text(t)"
to="https://docs.hoppscotch.io/documentation/changelog" :to="footerItem.action.href"
blank blank
@click="hide()" @click="hide()"
/> />
</template>
<HoppSmartItem <HoppSmartItem
:icon="IconActivity" v-else
:label="t('app.status')" :icon="footerItem.icon"
to="https://status.hoppscotch.io" :label="footerItem.text(t)"
blank blank
@click="hide()" @click="
() => {
// @ts-expect-error TypeScript not understanding the type
footerItem.action.do()
hide()
}
"
/> />
</template>
<hr /> <hr />
<HoppSmartItem <HoppSmartItem
:icon="IconGithub" :icon="IconGithub"
@@ -152,7 +155,7 @@
v-tippy="{ theme: 'tooltip', allowHTML: true }" v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t( :title="`${t(
'app.shortcuts' 'app.shortcuts'
)} <kbd>${getSpecialKey()}</kbd><kbd>K</kbd>`" )} <kbd>${getSpecialKey()}</kbd><kbd>/</kbd>`"
:icon="IconZap" :icon="IconZap"
@click="invokeAction('flyouts.keybinds.toggle')" @click="invokeAction('flyouts.keybinds.toggle')"
/> />
@@ -196,26 +199,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue" import { ref } from "vue"
import { version } from "~/../package.json" import { version } from "~/../package.json"
import IconSidebar from "~icons/lucide/sidebar" import IconSidebar from "~icons/lucide/sidebar"
import IconMinimize from "~icons/lucide/minimize"
import IconMaximize from "~icons/lucide/maximize"
import IconZap from "~icons/lucide/zap" import IconZap from "~icons/lucide/zap"
import IconShare2 from "~icons/lucide/share-2" import IconShare2 from "~icons/lucide/share-2"
import IconColumns from "~icons/lucide/columns" import IconColumns from "~icons/lucide/columns"
import IconSidebarOpen from "~icons/lucide/sidebar-open" import IconSidebarOpen from "~icons/lucide/sidebar-open"
import IconShieldCheck from "~icons/lucide/shield-check" import IconShieldCheck from "~icons/lucide/shield-check"
import IconBook from "~icons/lucide/book" import IconBook from "~icons/lucide/book"
import IconMessageCircle from "~icons/lucide/message-circle"
import IconGift from "~icons/lucide/gift"
import IconActivity from "~icons/lucide/activity"
import IconGithub from "~icons/lucide/github" import IconGithub from "~icons/lucide/github"
import IconTwitter from "~icons/lucide/twitter" import IconTwitter from "~icons/lucide/twitter"
import IconUserPlus from "~icons/lucide/user-plus" import IconUserPlus from "~icons/lucide/user-plus"
import IconLock from "~icons/lucide/lock" import IconLock from "~icons/lucide/lock"
import IconLifeBuoy from "~icons/lucide/life-buoy" import IconLifeBuoy from "~icons/lucide/life-buoy"
import { showChat } from "@modules/crisp"
import { useSetting } from "@composables/settings" import { useSetting } from "@composables/settings"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream" import { useReadonlyStream } from "@composables/stream"
@@ -230,7 +227,6 @@ const showDeveloperOptions = ref(false)
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION") const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
const SIDEBAR = useSetting("SIDEBAR") const SIDEBAR = useSetting("SIDEBAR")
const ZEN_MODE = useSetting("ZEN_MODE")
const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT") const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT") const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
@@ -241,13 +237,6 @@ const currentUser = useReadonlyStream(
platform.auth.getCurrentUser() platform.auth.getCurrentUser()
) )
watch(
() => ZEN_MODE.value,
() => {
EXPAND_NAVIGATION.value = !ZEN_MODE.value
}
)
const nativeShare = () => { const nativeShare = () => {
if (navigator.share) { if (navigator.share) {
navigator navigator
@@ -262,10 +251,6 @@ const nativeShare = () => {
} }
} }
const chatWithUs = () => {
showChat()
}
const showDeveloperOptionModal = () => { const showDeveloperOptionModal = () => {
if (currentUser.value) { if (currentUser.value) {
showDeveloperOptions.value = true showDeveloperOptions.value = true

View File

@@ -1,70 +0,0 @@
<template>
<div key="outputHash" class="flex flex-col flex-1 overflow-auto">
<div class="flex flex-col">
<AppPowerSearchEntry
v-for="(shortcut, shortcutIndex) in searchResults"
:key="`shortcut-${shortcutIndex}`"
:active="shortcutIndex === selectedEntry"
:shortcut="shortcut.item"
@action="emit('action', shortcut.item.action)"
@mouseover="selectedEntry = shortcutIndex"
/>
</div>
<div
v-if="searchResults.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center">
{{ t("state.nothing_found") }} "{{ search }}"
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onUnmounted, onMounted } from "vue"
import Fuse from "fuse.js"
import { useArrowKeysNavigation } from "~/helpers/powerSearchNavigation"
import { HoppAction } from "~/helpers/actions"
import { useI18n } from "@composables/i18n"
const t = useI18n()
const props = defineProps<{
input: Record<string, any>[]
search: string
}>()
const emit = defineEmits<{
(e: "action", action: HoppAction): void
}>()
const options = {
keys: ["keys", "label", "action", "tags"],
}
const fuse = new Fuse(props.input, options)
const searchResults = computed(() => fuse.search(props.search))
const searchResultsItems = computed(() =>
searchResults.value.map((searchResult) => searchResult.item)
)
const emitSearchAction = (action: HoppAction) => emit("action", action)
const { bindArrowKeysListeners, unbindArrowKeysListeners, selectedEntry } =
useArrowKeysNavigation(searchResultsItems, {
onEnter: emitSearchAction,
stopPropagation: true,
})
onMounted(() => {
bindArrowKeysListeners()
})
onUnmounted(() => {
unbindArrowKeysListeners()
})
</script>

View File

@@ -15,16 +15,21 @@
:label="t('app.name')" :label="t('app.name')"
to="/" to="/"
/> />
<!-- <AppGitHubStarButton class="mt-1.5 transition" /> -->
</div> </div>
<div class="inline-flex items-center space-x-2"> <div class="inline-flex items-center justify-center flex-1 space-x-2">
<HoppButtonSecondary <button
v-tippy="{ theme: 'tooltip', allowHTML: true }" class="flex flex-1 items-center justify-between px-2 py-1 self-stretch bg-primaryDark transition text-secondaryLight cursor-text rounded border border-dividerDark max-w-60 hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
:title="`${t('app.search')} <kbd>/</kbd>`"
:icon="IconSearch"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.search.toggle')" @click="invokeAction('modals.search.toggle')"
/> >
<span class="inline-flex flex-1 items-center">
<icon-lucide-search class="mr-2 svg-icons" />
{{ t("app.search") }}
</span>
<span class="flex space-x-1">
<kbd class="shortcut-key">{{ getPlatformSpecialKey() }}</kbd>
<kbd class="shortcut-key">K</kbd>
</span>
</button>
<HoppButtonSecondary <HoppButtonSecondary
v-if="showInstallButton" v-if="showInstallButton"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@@ -42,6 +47,8 @@
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark" class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.support.toggle')" @click="invokeAction('modals.support.toggle')"
/> />
</div>
<div class="inline-flex items-center justify-end flex-1 space-x-2">
<div <div
v-if="currentUser === null" v-if="currentUser === null"
class="inline-flex items-center space-x-2" class="inline-flex items-center space-x-2"
@@ -236,19 +243,21 @@ import IconDownload from "~icons/lucide/download"
import IconUploadCloud from "~icons/lucide/upload-cloud" import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUserPlus from "~icons/lucide/user-plus" import IconUserPlus from "~icons/lucide/user-plus"
import IconLifeBuoy from "~icons/lucide/life-buoy" import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconSearch from "~icons/lucide/search"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core" import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { pwaDefferedPrompt, installPWA } from "@modules/pwa" import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
import { platform } from "~/platform" import { platform } from "~/platform"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream" import { useReadonlyStream } from "@composables/stream"
import { invokeAction } from "@helpers/actions" import { defineActionHandler, invokeAction } from "@helpers/actions"
import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace" import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter" import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { onLoggedIn } from "~/composables/auth" import { onLoggedIn } from "~/composables/auth"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql" import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { getPlatformSpecialKey } from "~/helpers/platformutils"
import { useToast } from "~/composables/toast"
const t = useI18n() const t = useI18n()
const toast = useToast()
/** /**
* Once the PWA code is initialized, this holds a method * Once the PWA code is initialized, this holds a method
@@ -365,6 +374,8 @@ const handleTeamEdit = () => {
editingTeamID.value = workspace.value.teamID editingTeamID.value = workspace.value.teamID
editingTeamName.value = { name: selectedTeam.value.name } editingTeamName.value = { name: selectedTeam.value.name }
displayModalEdit(true) displayModalEdit(true)
} else {
noPermission()
} }
} }
@@ -374,4 +385,29 @@ const profile = ref<any | null>(null)
const settings = ref<any | null>(null) const settings = ref<any | null>(null)
const logout = ref<any | null>(null) const logout = ref<any | null>(null)
const accountActions = ref<any | null>(null) const accountActions = ref<any | null>(null)
defineActionHandler("modals.team.edit", handleTeamEdit)
defineActionHandler("modals.team.invite", () => {
if (
selectedTeam.value?.myRole === "OWNER" ||
selectedTeam.value?.myRole === "EDITOR"
) {
inviteTeam({ name: selectedTeam.value.name }, selectedTeam.value.id)
} else {
noPermission()
}
})
defineActionHandler(
"user.login",
() => {
invokeAction("modals.login.toggle")
},
computed(() => !currentUser.value)
)
const noPermission = () => {
toast.error(`${t("profile.no_permission")}`)
}
</script> </script>

View File

@@ -0,0 +1,112 @@
<template>
<div v-if="inspectionResults && inspectionResults.length > 0">
<tippy interactive trigger="click" theme="popover">
<div class="flex justify-center items-center flex-1 flex-col">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconAlertTriangle"
:class="severityColor(getHighestSeverity.severity)"
:title="t('inspections.description')"
/>
</div>
<template #content="{ hide }">
<div class="flex flex-col space-y-2 items-start flex-1">
<div
class="flex justify-between border rounded pl-2 border-divider bg-popover sticky top-0 self-stretch"
>
<span class="flex items-center flex-1">
<icon-lucide-activity class="mr-2 svg-icons text-accent" />
<span class="font-bold">
{{ t("inspections.title") }}
</span>
</span>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/inspections"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
</div>
<div
v-for="(inspector, index) in inspectionResults"
:key="index"
class="flex self-stretch max-w-md w-full"
>
<div
class="flex flex-col flex-1 rounded border border-dashed border-dividerDark divide-y divide-dashed divide-dividerDark"
>
<span
v-if="inspector.text.type === 'text'"
class="flex-1 px-3 py-2"
>
{{ inspector.text.text }}
<HoppSmartLink
blank
:to="inspector.doc.link"
class="text-accent hover:text-accentDark transition"
>
{{ inspector.doc.text }}
<icon-lucide-arrow-up-right class="svg-icons" />
</HoppSmartLink>
</span>
<span v-if="inspector.action" class="flex p-2 space-x-2">
<HoppButtonSecondary
:label="inspector.action.text"
outline
filled
@click="
() => {
inspector.action?.apply()
hide()
}
"
/>
</span>
</div>
</div>
</div>
</template>
</tippy>
</div>
</template>
<script lang="ts" setup>
import { InspectorResult } from "~/services/inspection"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
import IconHelpCircle from "~icons/lucide/help-circle"
import { computed } from "vue"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
const props = defineProps<{
inspectionResults: InspectorResult[] | undefined
}>()
const getHighestSeverity = computed(() => {
if (props.inspectionResults) {
return props.inspectionResults.reduce(
(prev, curr) => {
return prev.severity > curr.severity ? prev : curr
},
{ severity: 0 }
)
} else {
return { severity: 0 }
}
})
const severityColor = (severity: number) => {
switch (severity) {
case 1:
return "!text-green-500 hover:!text-green-600"
case 2:
return "!text-yellow-500 hover:!text-yellow-600"
case 3:
return "!text-red-500 hover:!text-red-600"
default:
return "!text-gray-500 hover:!text-gray-600"
}
}
</script>

View File

@@ -8,91 +8,41 @@
{{ t("settings.interceptor_description") }} {{ t("settings.interceptor_description") }}
</p> </p>
</div> </div>
<HoppSmartRadioGroup
v-model="interceptorSelection" <div>
:radios="interceptors"
/>
<div <div
v-if="interceptorSelection == 'EXTENSIONS_ENABLED' && !extensionVersion" v-for="interceptor in interceptors"
class="flex space-x-2" :key="interceptor.interceptorID"
class="flex flex-col"
> >
<HoppButtonSecondary <HoppSmartRadio
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld" :value="interceptor.interceptorID"
blank :label="unref(interceptor.name(t))"
:icon="IconChrome" :selected="interceptorSelection === interceptor.interceptorID"
label="Chrome" @change="interceptorSelection = interceptor.interceptorID"
outline
class="!flex-1"
/> />
<HoppButtonSecondary
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch" <component
blank :is="interceptor.selectorSubtitle"
:icon="IconFirefox" v-if="interceptor.selectorSubtitle"
label="Firefox"
outline
class="!flex-1"
/> />
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import IconChrome from "~icons/brands/chrome"
import IconFirefox from "~icons/brands/firefox"
import { computed } from "vue"
import { applySetting, toggleSetting } from "~/newstore/settings"
import { useSetting } from "@composables/settings"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream" import { useService } from "dioc/vue"
import { extensionStatus$ } from "~/newstore/HoppExtension" import { Ref, unref } from "vue"
import { InterceptorService } from "~/services/interceptor.service"
const t = useI18n() const t = useI18n()
const PROXY_ENABLED = useSetting("PROXY_ENABLED") const interceptorService = useService(InterceptorService)
const EXTENSIONS_ENABLED = useSetting("EXTENSIONS_ENABLED")
const currentExtensionStatus = useReadonlyStream(extensionStatus$, null) const interceptorSelection =
interceptorService.currentInterceptorID as Ref<string>
const extensionVersion = computed(() => { const interceptors = interceptorService.availableInterceptors
return currentExtensionStatus.value === "available"
? window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion() ?? null
: null
})
const interceptors = computed(() => [
{ value: "BROWSER_ENABLED" as const, label: t("state.none") },
{ value: "PROXY_ENABLED" as const, label: t("settings.proxy") },
{
value: "EXTENSIONS_ENABLED" as const,
label:
`${t("settings.extensions")}: ` +
(extensionVersion.value !== null
? `v${extensionVersion.value.major}.${extensionVersion.value.minor}`
: t("settings.extension_ver_not_reported")),
},
])
type InterceptorMode = (typeof interceptors)["value"][number]["value"]
const interceptorSelection = computed<InterceptorMode>({
get() {
if (PROXY_ENABLED.value) return "PROXY_ENABLED"
if (EXTENSIONS_ENABLED.value) return "EXTENSIONS_ENABLED"
return "BROWSER_ENABLED"
},
set(val) {
if (val === "EXTENSIONS_ENABLED") {
applySetting("EXTENSIONS_ENABLED", true)
if (PROXY_ENABLED.value) toggleSetting("PROXY_ENABLED")
}
if (val === "PROXY_ENABLED") {
applySetting("PROXY_ENABLED", true)
if (EXTENSIONS_ENABLED.value) toggleSetting("EXTENSIONS_ENABLED")
}
if (val === "BROWSER_ENABLED") {
applySetting("PROXY_ENABLED", false)
applySetting("EXTENSIONS_ENABLED", false)
}
},
})
</script> </script>

View File

@@ -30,163 +30,63 @@
<h2 class="p-4 font-semibold font-bold text-secondaryDark"> <h2 class="p-4 font-semibold font-bold text-secondaryDark">
{{ t("support.title") }} {{ t("support.title") }}
</h2> </h2>
<template
v-for="item in platform.ui?.additionalSupportOptionsMenuItems"
:key="item.id"
>
<HoppSmartItem <HoppSmartItem
:icon="IconBook" v-if="item.action.type === 'link'"
:label="t('app.documentation')" :icon="item.icon"
to="https://docs.hoppscotch.io" :label="item.text(t)"
:description="t('support.documentation')" :to="item.action.href"
:description="item.subtitle(t)"
:info-icon="IconChevronRight" :info-icon="IconChevronRight"
active active
blank blank
@click="hideModal()" @click="hideModal()"
/> />
<HoppSmartItem <HoppSmartItem
:icon="IconGift" v-else
:label="t('app.whats_new')" :icon="item.icon"
to="https://docs.hoppscotch.io/documentation/changelog" :label="item.text(t)"
:description="t('support.changelog')" :description="item.subtitle(t)"
:info-icon="IconChevronRight" :info-icon="IconChevronRight"
active active
blank @click="
@click="hideModal()" () => {
/> // @ts-expect-error Typescript isn't able to understand
<HoppSmartItem item.action.do()
:icon="IconActivity" hideModal()
:label="t('app.status')" }
to="https://status.hoppscotch.io" "
blank
:description="t('app.status_description')"
:info-icon="IconChevronRight"
active
@click="hideModal()"
/>
<HoppSmartItem
:icon="IconLock"
:label="`${t('app.terms_and_privacy')}`"
to="https://docs.hoppscotch.io/support/privacy"
blank
:description="t('app.terms_and_privacy')"
:info-icon="IconChevronRight"
active
@click="hideModal()"
/>
<h2 class="p-4 font-semibold font-bold text-secondaryDark">
{{ t("settings.follow") }}
</h2>
<HoppSmartItem
:icon="IconDiscord"
:label="t('app.discord')"
to="https://hoppscotch.io/discord"
blank
:description="t('app.join_discord_community')"
:info-icon="IconChevronRight"
active
@click="hideModal()"
/>
<HoppSmartItem
:icon="IconTwitter"
:label="t('app.twitter')"
to="https://hoppscotch.io/twitter"
blank
:description="t('support.twitter')"
:info-icon="IconChevronRight"
active
@click="hideModal()"
/>
<HoppSmartItem
:icon="IconGithub"
:label="`${t('app.github')}`"
to="https://github.com/hoppscotch/hoppscotch"
blank
:description="t('support.github')"
:info-icon="IconChevronRight"
active
@click="hideModal()"
/>
<HoppSmartItem
:icon="IconMessageCircle"
:label="t('app.chat_with_us')"
:description="t('support.chat')"
:info-icon="IconChevronRight"
active
@click="chatWithUs()"
/>
<HoppSmartItem
:icon="IconUserPlus"
:label="`${t('app.invite')}`"
:description="t('shortcut.miscellaneous.invite')"
:info-icon="IconChevronRight"
active
@click="expandInvite()"
/>
<HoppSmartItem
v-if="navigatorShare"
v-tippy="{ theme: 'tooltip' }"
:icon="IconShare2"
:label="`${t('request.share')}`"
:description="t('request.share_description')"
:info-icon="IconChevronRight"
active
@click="nativeShare()"
/> />
</template>
</div> </div>
<AppShare :show="showShare" @hide-modal="showShare = false" />
</template> </template>
</HoppSmartModal> </HoppSmartModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue"
import IconSidebar from "~icons/lucide/sidebar" import IconSidebar from "~icons/lucide/sidebar"
import IconSidebarOpen from "~icons/lucide/sidebar-open" import IconSidebarOpen from "~icons/lucide/sidebar-open"
import IconBook from "~icons/lucide/book"
import IconGift from "~icons/lucide/gift"
import IconActivity from "~icons/lucide/activity"
import IconLock from "~icons/lucide/lock"
import IconDiscord from "~icons/brands/discord"
import IconTwitter from "~icons/brands/twitter"
import IconGithub from "~icons/lucide/github"
import IconMessageCircle from "~icons/lucide/message-circle"
import IconUserPlus from "~icons/lucide/user-plus"
import IconShare2 from "~icons/lucide/share-2"
import IconChevronRight from "~icons/lucide/chevron-right" import IconChevronRight from "~icons/lucide/chevron-right"
import { useSetting } from "@composables/settings" import { useSetting } from "@composables/settings"
import { defineActionHandler } from "~/helpers/actions"
import { showChat } from "@modules/crisp"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { platform } from "~/platform"
const t = useI18n() const t = useI18n()
const navigatorShare = !!navigator.share
const showShare = ref(false)
const ZEN_MODE = useSetting("ZEN_MODE")
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION") const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
const SIDEBAR = useSetting("SIDEBAR") const SIDEBAR = useSetting("SIDEBAR")
watch(
() => ZEN_MODE.value,
() => {
EXPAND_NAVIGATION.value = !ZEN_MODE.value
}
)
defineProps<{ defineProps<{
show: boolean show: boolean
}>() }>()
defineActionHandler("modals.share.toggle", () => {
showShare.value = !showShare.value
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: "hide-modal"): void (e: "hide-modal"): void
}>() }>()
const chatWithUs = () => {
showChat()
hideModal()
}
const expandNavigation = () => { const expandNavigation = () => {
EXPAND_NAVIGATION.value = !EXPAND_NAVIGATION.value EXPAND_NAVIGATION.value = !EXPAND_NAVIGATION.value
hideModal() hideModal()
@@ -197,24 +97,6 @@ const expandCollection = () => {
hideModal() hideModal()
} }
const expandInvite = () => {
showShare.value = true
}
const nativeShare = () => {
if (navigator.share) {
navigator
.share({
title: "Hoppscotch",
text: "Hoppscotch • Open source API development ecosystem - Helps you create requests faster, saving precious time on development.",
url: "https://hoppscotch.io",
})
.catch(console.error)
} else {
// fallback
}
}
const hideModal = () => { const hideModal = () => {
emit("hide-modal") emit("hide-modal")
} }

View File

@@ -18,13 +18,18 @@
:horizontal="COLUMN_LAYOUT" :horizontal="COLUMN_LAYOUT"
@resize="setPaneEvent($event, 'horizontal')" @resize="setPaneEvent($event, 'horizontal')"
> >
<Pane :size="PANE_MAIN_TOP_SIZE" class="flex flex-col !overflow-auto"> <Pane
:size="PANE_MAIN_TOP_SIZE"
class="flex flex-col !overflow-auto"
min-size="25"
>
<slot name="primary" /> <slot name="primary" />
</Pane> </Pane>
<Pane <Pane
v-if="hasSecondary" v-if="hasSecondary"
:size="PANE_MAIN_BOTTOM_SIZE" :size="PANE_MAIN_BOTTOM_SIZE"
class="flex flex-col !overflow-auto" class="flex flex-col !overflow-auto"
min-size="25"
> >
<slot name="secondary" /> <slot name="secondary" />
</Pane> </Pane>
@@ -33,7 +38,7 @@
<Pane <Pane
v-if="SIDEBAR && hasSidebar" v-if="SIDEBAR && hasSidebar"
:size="PANE_SIDEBAR_SIZE" :size="PANE_SIDEBAR_SIZE"
min-size="20" min-size="25"
class="flex flex-col !overflow-auto bg-primaryContrast" class="flex flex-col !overflow-auto bg-primaryContrast"
> >
<slot name="sidebar" /> <slot name="sidebar" />
@@ -78,10 +83,10 @@ type PaneEvent = {
size: number size: number
} }
const PANE_MAIN_SIZE = ref(74) const PANE_MAIN_SIZE = ref(70)
const PANE_SIDEBAR_SIZE = ref(26) const PANE_SIDEBAR_SIZE = ref(30)
const PANE_MAIN_TOP_SIZE = ref(42) const PANE_MAIN_TOP_SIZE = ref(35)
const PANE_MAIN_BOTTOM_SIZE = ref(58) const PANE_MAIN_BOTTOM_SIZE = ref(65)
if (!COLUMN_LAYOUT.value) { if (!COLUMN_LAYOUT.value) {
PANE_MAIN_TOP_SIZE.value = 50 PANE_MAIN_TOP_SIZE.value = 50

View File

@@ -1,122 +0,0 @@
<template>
<HoppSmartModal
v-if="show"
styles="sm:max-w-lg"
full-width
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col border-b transition border-dividerLight">
<input
id="command"
v-model="search"
v-focus
type="text"
autocomplete="off"
name="command"
:placeholder="`${t('app.type_a_command_search')}`"
class="flex flex-shrink-0 p-6 text-base bg-transparent text-secondaryDark"
/>
<div
class="flex flex-shrink-0 text-tiny text-secondaryLight px-4 pb-4 justify-between whitespace-nowrap overflow-auto <sm:hidden"
>
<div class="flex items-center">
<kbd class="shortcut-key"></kbd>
<kbd class="shortcut-key"></kbd>
<span class="ml-2 truncate">
{{ t("action.to_navigate") }}
</span>
<kbd class="shortcut-key"></kbd>
<span class="ml-2 truncate">
{{ t("action.to_select") }}
</span>
</div>
<div class="flex items-center">
<kbd class="shortcut-key">ESC</kbd>
<span class="ml-2 truncate">
{{ t("action.to_close") }}
</span>
</div>
</div>
</div>
<AppFuse
v-if="search && show"
:input="fuse"
:search="search"
@action="runAction"
/>
<div
v-else
class="flex flex-col flex-1 overflow-auto space-y-4 divide-y divide-dividerLight"
>
<div
v-for="(map, mapIndex) in mappings"
:key="`map-${mapIndex}`"
class="flex flex-col"
>
<h5 class="px-6 py-2 my-2 text-secondaryLight">
{{ t(map.section) }}
</h5>
<AppPowerSearchEntry
v-for="(shortcut, shortcutIndex) in map.shortcuts"
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`"
:shortcut="shortcut"
:active="shortcutsItems.indexOf(shortcut) === selectedEntry"
@action="runAction"
@mouseover="selectedEntry = shortcutsItems.indexOf(shortcut)"
/>
</div>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue"
import { HoppAction, invokeAction } from "~/helpers/actions"
import { spotlight as mappings, fuse } from "@helpers/shortcuts"
import { useArrowKeysNavigation } from "~/helpers/powerSearchNavigation"
import { useI18n } from "@composables/i18n"
const t = useI18n()
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const search = ref("")
const hideModal = () => {
search.value = ""
emit("hide-modal")
}
const runAction = (command: HoppAction) => {
invokeAction(command)
hideModal()
}
const shortcutsItems = computed(() =>
mappings.reduce(
(shortcuts, section) => [...shortcuts, ...section.shortcuts],
[]
)
)
const { bindArrowKeysListeners, unbindArrowKeysListeners, selectedEntry } =
useArrowKeysNavigation(shortcutsItems, {
onEnter: runAction,
})
watch(
() => props.show,
(show) => {
if (show) bindArrowKeysListeners()
else unbindArrowKeysListeners()
}
)
</script>

View File

@@ -1,68 +0,0 @@
<template>
<button
class="flex items-center flex-1 px-6 py-3 font-medium transition cursor-pointer search-entry focus:outline-none"
:class="{ active: active }"
tabindex="-1"
@click="emit('action', shortcut.action)"
@keydown.enter="emit('action', shortcut.action)"
>
<component
:is="shortcut.icon"
class="mr-4 transition opacity-50 svg-icons"
:class="{ 'opacity-100 text-secondaryDark': active }"
/>
<span
class="flex flex-1 mr-4 transition"
:class="{ 'text-secondaryDark': active }"
>
{{ t(shortcut.label) }}
</span>
<kbd
v-for="(key, keyIndex) in shortcut.keys"
:key="`key-${String(keyIndex)}`"
class="shortcut-key"
>
{{ key }}
</kbd>
</button>
</template>
<script setup lang="ts">
import type { Component } from "vue"
import { useI18n } from "@composables/i18n"
const t = useI18n()
defineProps<{
shortcut: {
label: string
keys: string[]
action: string
icon: object | Component
}
active: boolean
}>()
const emit = defineEmits<{
(e: "action", action: string): void
}>()
</script>
<style lang="scss" scoped>
.search-entry {
@apply relative;
@apply after:absolute;
@apply after:top-0;
@apply after:left-0;
@apply after:bottom-0;
@apply after:bg-transparent;
@apply after:z-2;
@apply after:w-0.5;
@apply after:content-DEFAULT;
&.active {
@apply bg-primaryLight;
@apply after:bg-accentLight;
}
}
</style>

View File

@@ -4,55 +4,26 @@
<div <div
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto bg-primary" class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto bg-primary"
> >
<div class="flex flex-col px-6 py-4 border-b border-dividerLight"> <HoppSmartInput
<input
v-model="filterText" v-model="filterText"
type="search" type="search"
autocomplete="off" styles="px-6 py-4 border-b border-dividerLight"
class="flex px-4 py-2 border rounded bg-primaryContrast border-divider hover:border-dividerDark focus-visible:border-dividerDark"
:placeholder="`${t('action.search')}`" :placeholder="`${t('action.search')}`"
input-styles="flex px-4 py-2 border rounded bg-primaryContrast border-divider hover:border-dividerDark focus-visible:border-dividerDark"
/> />
</div> </div>
</div> <div class="flex flex-col divide-y divide-dividerLight">
<div v-if="filterText" class="flex flex-col divide-y divide-dividerLight"> <HoppSmartPlaceholder
<details v-if="isEmpty(shortcutsResults)"
v-for="(map, mapIndex) in searchResults" :text="`${t('state.nothing_found')} ‟${filterText}”`"
:key="`map-${mapIndex}`"
class="flex flex-col"
open
>
<summary
class="flex items-center flex-1 min-w-0 px-6 py-4 font-semibold transition cursor-pointer focus:outline-none text-secondaryLight hover:text-secondaryDark"
>
<icon-lucide-chevron-right class="mr-2 indicator" />
<span
class="font-semibold truncate capitalize-first text-secondaryDark"
>
{{ t(map.item.section) }}
</span>
</summary>
<div class="flex flex-col px-6 pb-4 space-y-2">
<AppShortcutsEntry
v-for="(shortcut, index) in map.item.shortcuts"
:key="`shortcut-${index}`"
:shortcut="shortcut"
/>
</div>
</details>
<div
v-if="searchResults.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
> >
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center"> </HoppSmartPlaceholder>
{{ t("state.nothing_found") }} "{{ filterText }}"
</span>
</div>
</div>
<div v-else class="flex flex-col divide-y divide-dividerLight">
<details <details
v-for="(map, mapIndex) in mappings" v-for="(sectionResults, sectionTitle) in shortcutsResults"
:key="`map-${mapIndex}`" v-else
:key="`section-${sectionTitle}`"
class="flex flex-col" class="flex flex-col"
open open
> >
@@ -63,13 +34,13 @@
<span <span
class="font-semibold truncate capitalize-first text-secondaryDark" class="font-semibold truncate capitalize-first text-secondaryDark"
> >
{{ t(map.section) }} {{ sectionTitle }}
</span> </span>
</summary> </summary>
<div class="flex flex-col px-6 pb-4 space-y-2"> <div class="flex flex-col px-6 pb-4 space-y-2">
<AppShortcutsEntry <AppShortcutsEntry
v-for="(shortcut, shortcutIndex) in map.shortcuts" v-for="(shortcut, index) in sectionResults"
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`" :key="`shortcut-${index}`"
:shortcut="shortcut" :shortcut="shortcut"
/> />
</div> </div>
@@ -80,10 +51,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue" import { computed, onBeforeMount, ref } from "vue"
import Fuse from "fuse.js" import { ShortcutDef, getShortcuts } from "~/helpers/shortcuts"
import mappings from "~/helpers/shortcuts" import MiniSearch from "minisearch"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { groupBy, isEmpty } from "lodash-es"
const t = useI18n() const t = useI18n()
@@ -91,15 +63,33 @@ defineProps<{
show: boolean show: boolean
}>() }>()
const options = { const minisearch = new MiniSearch({
keys: ["shortcuts.label"], fields: ["label", "keys", "section"],
} idField: "label",
storeFields: ["label", "keys", "section"],
searchOptions: {
fuzzy: true,
prefix: true,
},
})
const fuse = new Fuse(mappings, options) const shortcuts = getShortcuts(t)
onBeforeMount(() => {
minisearch.addAllAsync(shortcuts)
})
const filterText = ref("") const filterText = ref("")
const searchResults = computed(() => fuse.search(filterText.value)) const shortcutsResults = computed(() => {
// If there are no search text, return all the shortcuts
const results =
filterText.value.length > 0
? minisearch.search(filterText.value)
: shortcuts
return groupBy(results, "section") as Record<string, ShortcutDef[]>
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: "close"): void (e: "close"): void

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