Compare commits

...

45 Commits

Author SHA1 Message Date
lohxt1
bad5f0003c Merge remote-tracking branch 'upstream/main' into feat/oauth2-improvements 2025-03-19 18:03:58 +05:30
lohxt1
ee5e260890 postman import useBrowser prop check 2025-03-19 17:44:33 +05:30
lohxt1
c3f8959c54 updates 2025-03-19 17:09:40 +05:30
lohxt1
cabd520ef1 authorize in browser - updates 2025-03-19 17:05:33 +05:30
lohxt1
0e7b0a0ce5 option for oauth2 authroization using default browser 2025-03-19 16:20:52 +05:30
Anoop M D
ccd4a14da6 feat: refactored about menu + added static path updates for win build 2025-03-18 21:49:35 +05:30
lohxt1
98bd997665 chore: fix font not loading issue, fix about menu item, fix padding for preferences modal 2025-03-18 21:49:35 +05:30
Anoop M D
a7cf24278e style: update font-family for improved typography consistency 2025-03-18 21:18:10 +05:30
Anoop M D
039c157f33 feat: improved item info ux 2025-03-18 19:51:36 +05:30
Anoop M D
1009d42f92 chore: fix caret color 2025-03-18 00:50:59 +05:30
Anoop M D
1be0e8d31c feat: custom filename can be shown by clicking advanced options 2025-03-18 00:50:59 +05:30
Ed Brannin
ab9befd773 UI: Change the default request auth mode from "none" to "inherit"
Fix #2315
2025-03-17 17:02:51 +05:30
lohit
7506f83800 Merge pull request #4250 from lohxt1/browse_files_fn_fix
fix incorrect vars in browse_files function
2025-03-17 16:58:22 +05:30
lohxt1
74d9b0aafe fix browse_files function 2025-03-17 16:55:56 +05:30
lohxt1
d3fcb42a8f timeline ui updates wip 2025-03-17 14:09:36 +05:30
Anoop M D
3808089e60 feat: added helptips for file and folder custom names 2025-03-17 02:54:36 +05:30
lohit
cd2f5d5233 filename support ui updates, regex update for generating validation error for the last char, getEncoding function headers props optional chaining validation (#4243) 2025-03-17 01:36:39 +05:30
lohxt1
51be153527 fix bruno-electron unit tests 2025-03-16 14:28:21 +05:30
lohxt1
5728b7c8a8 fix bruno-lang unit tests 2025-03-16 14:24:04 +05:30
lohxt1
71b6907c31 fix bruno-cli unit tests 2025-03-16 14:11:19 +05:30
lohxt1
eead96ca26 Merge remote-tracking branch 'upstream/main' into feat/oauth2-improvements 2025-03-16 14:02:12 +05:30
lohxt1
f99e8770f0 ~ reverting the bruno-electron ipc-network files refactoring work to keep the diff minimal 2025-03-16 13:37:59 +05:30
lohit
7ae33d05c9 Merge pull request #4241 from usebruno/feat/file-location-ux-improvements
Feat/file location ux improvements
2025-03-15 14:15:49 +05:30
Pieter Oliver
ccb951dadd [DX]: Document running test suite for all workspaces (#4095) 2025-03-14 21:19:33 +05:30
naman-bruno
8cda05c431 updated timeline to show body in oauth (#4168) 2025-03-06 17:04:07 +05:30
naman-bruno
7af7ff92bf Fix: redirect to relative path (#4167) 2025-03-06 14:26:02 +05:30
naman-bruno
3169e6cdf4 Oauth2 folder (#4105) 2025-03-06 11:03:34 +05:30
pooja-bruno
4e88cbf318 feat: add refresh url for oauth2 (#4028) 2025-02-18 17:24:56 +05:30
lohit
413b121ce1 Merge pull request #3989 from lohxt1/feat/oauth2__improvements
oauth2 improvements
2025-02-11 12:33:24 +05:30
lohit
90dff3d1e1 Merge branch 'feat/oauth2-improvements' into feat/oauth2__improvements 2025-02-11 12:32:04 +05:30
lohxt1
3fc0b0a668 oauth2 improvements - collection import default type 2025-02-11 12:27:58 +05:30
lohxt1
b5e53ec25c include oauth2 request data along with headers in the access token url call 2025-02-10 20:20:40 +05:30
lohxt1
01a62d66cc oauth2 postman import fix and include client certs and proxy config while fetching access token 2025-02-05 19:06:23 +05:30
lohxt1
f668e93f52 oauth2 postman import fix and include client certs and proxy config while fetching access token 2025-02-05 16:06:41 +05:30
lohit
c5eeb190d3 oauth2 updates (#3876)
~ changed tokenPrefix to tokenHeaderPrefix
~ updated the logic for token timer component
2025-01-24 19:39:29 +05:30
lohit
1d1e701ccb oauth2 workflow improvements (#3874)
~ basic auth credentials should be assigned to `request.basicAuth` instead `request.auth` object
~ added credentials_placement option, fixed headers issue client credentials flow
~ cache input field values when grant type select box value changes
~ updated logic for - cache input field values when grant type select box value changes
~ updated token expiry timer component logic
2025-01-24 18:44:02 +05:30
lohit
f38c7ae03a oauth2 ui/ux improvements (#3868) 2025-01-23 22:06:50 +05:30
Anoop M D
8f754142c7 Merge branch 'pr-2077' into feat/oauth2-improvements 2025-01-23 16:45:51 +05:30
Mateusz Pietryga
3bd8f09c88 feat: OAuth2 - Supported at the collection level (#1704) 2024-09-23 21:59:16 +02:00
Mateusz Pietryga
dd9cb21f8c feat: OAuth2 - UI for OAuth2 Credentials independent of the Request Output pane
fix: typo - rename OAuth2PasswordCredentials component
fix: typo - Use the same name for AuthMode - OAuth 2.0 in collection and request level
2024-09-23 21:59:16 +02:00
Mateusz Pietryga
2064cc88ab feat: OAuth2 - automatically handle Bearer token type only
According to RFC6749 Section 7.1, The client MUST NOT use an access token
if it does not understand the token type.
At this point bruno only understands 'bearer' token_type.
2024-09-23 21:59:16 +02:00
Mateusz Pietryga
d982e35a17 feat: OAuth2 - Do not make axios request when executing collection level Get Access Token action
The actual the authorization request is now part of request preparation, and its response is returned for post-request script processing.
2024-09-23 21:59:16 +02:00
Mateusz Pietryga
4afcd44216 feat: OAuth2 - Include resolved authorization details in req object to be usable by scripts
The new variable 'credentials' is now available in 'req' object. It is added automatically during request preparation if oauth2 method is used and is value is either evaluated or retrieved from collection oauth2 cache.
2024-09-23 21:59:16 +02:00
Mateusz Pietryga
63252d3ee2 feat: OAuth2 - Store authorization information
Results of oauth2 authorization flow (i.e. access_token but also refresh_token, id_token, scope or any other information returned from token request) are stored in a collection specific cache. It is persisted in the file system, and will be automatically reused when executing requests until the cache is purged (using Clear Cache button available in all related views).
2024-09-23 20:50:41 +02:00
Mateusz Pietryga
22a9502976 fix: OAuth2 - auth is successful but token endpoint is returned instead of api endpoint (#1999)
Setting oauth2 authorization no longer equals overwriting user-specified data in a request. The pre-requests made to obtain oauth2 access_token are now separated from actual API request.
2024-09-23 20:50:37 +02:00
145 changed files with 7591 additions and 2125 deletions

View File

@@ -86,11 +86,11 @@ find . -type f -name "package-lock.json" -delete
### Testing
```bash
# bruno-schema
# run bruno-schema tests
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# run tests over all workspaces
npm test --workspaces --if-present
```
### Raising Pull Requests

View File

@@ -70,11 +70,11 @@ find . -type f -name "package-lock.json" -delete
### Testing (পরীক্ষা)
```bash
# bruno-schema
# ব্রুনো-স্কিমা পরীক্ষা চালান
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# সমস্ত কর্মক্ষেত্রে পরীক্ষা চালান
npm test --workspaces --if-present
```
### Raising Pull Request (পুল অনুরোধ উত্থাপন)

View File

@@ -70,11 +70,11 @@ find . -type f -name "package-lock.json" -delete
### 测试
```bash
# bruno-schema
# 运行 bruno-schema 测试
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# 在所有工作区上运行测试
npm test --workspaces --if-present
```
### 提交 Pull Request

View File

@@ -83,9 +83,9 @@ find . -type f -name "package-lock.json" -delete
### Testen
```bash
# bruno-schema
# Führen Sie Bruno-Schema-Tests aus
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# Führen Sie Tests für alle Arbeitsbereiche durch
npm test --workspaces --if-present
```

View File

@@ -70,11 +70,11 @@ find . -type f -name "package-lock.json" -delete
### Pruebas
```bash
# bruno-schema
# ejecutar pruebas de esquema bruno
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# ejecutar pruebas en todos los espacios de trabajo
npm test --workspaces --if-present
```
### Crea un Pull Request

View File

@@ -73,11 +73,11 @@ find . -type f -name "package-lock.json" -delete
### Tests
```bash
# bruno-schema
# exécuter des tests de schéma bruno
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# exécuter des tests sur tous les espaces de travail
npm test --workspaces --if-present
```
### Ouvrir une Pull Request

View File

@@ -65,11 +65,11 @@ find . -type f -name "package-lock.json" -delete
### परिक्षण
```bash
# bruno-schema
# ब्रूनो-स्कीमा परीक्षण चलाएँ
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# सभी कार्यस्थानों पर परीक्षण चलाएँ
npm test --workspaces --if-present
```
### पुल अनुरोध प्रक्रिया

View File

@@ -83,9 +83,9 @@ find . -type f -name "package-lock.json" -delete
### Tests
```bash
# bruno-schema
# esegui i test dello schema bruno
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# esegui test su tutti gli spazi di lavoro
npm test --workspaces --if-present
```

View File

@@ -65,11 +65,11 @@ find . -type f -name "package-lock.json" -delete
### テストを動かすには
```bash
# bruno-schema
# ブルーノスキーマのテストを実行します
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# すべてのワークスペースでテストを実行します
npm test --workspaces --if-present
```
### プルリクエストの手順

View File

@@ -66,11 +66,11 @@ find . -type f -name "package-lock.json" -delete
### 테스팅
```bash
# bruno-schema
# bruno-schema 테스트 실행
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# 모든 작업 공간에서 테스트 실행
npm test --workspaces --if-present
```
### Pull Requests 요청

View File

@@ -65,11 +65,11 @@ find . -type f -name "package-lock.json" -delete
### Testen
```bash
# bruno-schema
# voer bruno-schema tests uit
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# voer tests uit over alle werkruimten
npm test --workspaces --if-present
```
### Pull Requests indienen

View File

@@ -71,11 +71,11 @@ find . -type f -name "package-lock.json" -delete
### Testowanie
```bash
# bruno-schema
# uruchom testy bruno-schema
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# uruchom testy we wszystkich przestrzeniach roboczych
npm test --workspaces --if-present
```
### Tworzenie Pull Request

View File

@@ -70,11 +70,11 @@ find . -type f -name "package-lock.json" -delete
### Testando
```bash
# bruno-schema
# executar testes do bruno-schema
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# executar testes em todos os ambientes de trabalho
npm test --workspaces --if-present
```
### Envio de Pull Request

View File

@@ -64,11 +64,11 @@ find . -type f -name "package-lock.json" -delete
### Testarea
```shell
# bruno-schema
# executați teste bruno-schema
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# executați teste peste toate spațiile de lucru
npm test --workspaces --if-present
```
### Crearea unui Pull Request

View File

@@ -83,9 +83,9 @@ find . -type f -name "package-lock.json" -delete
### Тестирование
```bash
# bruno-schema
# запустите тесты bruno-schema
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# запустите тесты во всех рабочих пространствах
npm test --workspaces --if-present
```

View File

@@ -67,11 +67,11 @@ find . -type f -name "package-lock.json" -delete
### Testovanie
````bash
# bruno-schema
# spustiť bruno-schema testy
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# spustiť testy vo všetkých pracovných priestoroch
npm test --workspaces --if-present
```
### Vyrobenie Pull Request

View File

@@ -70,11 +70,11 @@ find . -type f -name "package-lock.json" -delete
### Test
```bash
# bruno-schema
# bruno-schema testlerini çalıştır
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# tüm çalışma alanlarında testleri çalıştır
npm test --workspaces --if-present
```
### Pull Request Oluşturma

View File

@@ -83,9 +83,9 @@ find . -type f -name "package-lock.json" -delete
### Тестування
```bash
# bruno-schema
# запустити тести bruno-schema
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# запустити тести у всіх робочих просторах
npm test --workspaces --if-present
```

View File

@@ -70,11 +70,11 @@ find . -type f -name "package-lock.json" -delete
### 測試
```bash
# bruno-schema
# 執行布魯諾架構測試
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
# 對所有工作區執行測試
npm test --workspaces --if-present
```
### 發送 Pull Request

630
package-lock.json generated
View File

@@ -18,6 +18,10 @@
"packages/bruno-toml",
"packages/bruno-graphql-docs"
],
"dependencies": {
"find-process": "^1.4.10",
"pid-port": "^1.0.2"
},
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
@@ -50,7 +54,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -1471,7 +1474,6 @@
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz",
"integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
@@ -1502,7 +1504,6 @@
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1520,7 +1521,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/generator": {
@@ -1800,7 +1800,6 @@
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz",
"integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.25.9",
@@ -6831,6 +6830,12 @@
}
}
},
"node_modules/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
"license": "MIT"
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -6851,6 +6856,18 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
"integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@sinonjs/commons": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
@@ -7767,7 +7784,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
@@ -7780,7 +7796,6 @@
"version": "12.2.3",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz",
"integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "*",
@@ -7791,7 +7806,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
@@ -8291,6 +8305,15 @@
"node": ">=0.4.0"
}
},
"node_modules/address": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/address/-/address-2.0.3.tgz",
"integrity": "sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==",
"license": "MIT",
"engines": {
"node": ">= 16.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
@@ -11028,7 +11051,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
@@ -11266,7 +11288,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -11913,6 +11934,22 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/detect-port": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-port/-/detect-port-2.1.0.tgz",
"integrity": "sha512-epZuWb/6Q62L+nDHJc/hQAqf8pylsqgk3BpZXVBx1CDnr3nkrVNn73Uu1rXcFzkNcc+hkP3whuOg7JZYaQB65Q==",
"license": "MIT",
"dependencies": {
"address": "^2.0.1"
},
"bin": {
"detect": "dist/commonjs/bin/detect-port.js",
"detect-port": "dist/commonjs/bin/detect-port.js"
},
"engines": {
"node": ">= 16.0.0"
}
},
"node_modules/dev-null": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/dev-null/-/dev-null-0.1.1.tgz",
@@ -12639,7 +12676,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -13017,6 +13053,52 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express-basic-auth": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/express-basic-auth/-/express-basic-auth-1.2.1.tgz",
@@ -13026,6 +13108,30 @@
"basic-auth": "^2.0.1"
}
},
"node_modules/express/node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -13195,6 +13301,33 @@
"pend": "~1.2.0"
}
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
"integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
"license": "MIT",
"dependencies": {
"is-unicode-supported": "^2.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/figures/node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/file": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/file/-/file-0.2.2.tgz",
@@ -13296,6 +13429,45 @@
"node": ">= 0.8"
}
},
"node_modules/find-process": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/find-process/-/find-process-1.4.10.tgz",
"integrity": "sha512-ncYFnWEIwL7PzmrK1yZtaccN8GhethD37RzBHG6iOZoFYB4vSmLLXfeWJjeN5nMvCJMjOtBvBBF8OgxEcikiZg==",
"license": "MIT",
"dependencies": {
"chalk": "~4.1.2",
"commander": "^12.1.0",
"loglevel": "^1.9.2"
},
"bin": {
"find-process": "bin/find-process.js"
}
},
"node_modules/find-process/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/find-process/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -13607,7 +13779,6 @@
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -15076,6 +15247,18 @@
"node": ">=8"
}
},
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
@@ -15199,7 +15382,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/isobject": {
@@ -16943,6 +17125,19 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -17423,7 +17618,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true,
"license": "MIT"
},
"node_modules/merge2": {
@@ -18636,6 +18830,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-ms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
"integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
@@ -18803,7 +19009,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -18849,6 +19054,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/path/node_modules/inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
@@ -18971,6 +19182,155 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pid-port": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pid-port/-/pid-port-1.0.2.tgz",
"integrity": "sha512-Khqp07zX8IJpmIg56bHrLxS3M0iSL4cq6wnMq8YE7r/hSw3Kn4QxYS6QJg8Bs22Z7CSVj7eSsxFuigYVIFWmjg==",
"license": "MIT",
"dependencies": {
"execa": "^8.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pid-port/node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
"integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^8.0.1",
"human-signals": "^5.0.0",
"is-stream": "^3.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^5.1.0",
"onetime": "^6.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^3.0.0"
},
"engines": {
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/pid-port/node_modules/get-stream": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pid-port/node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
"integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/pid-port/node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pid-port/node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pid-port/node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
"integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pid-port/node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
"integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
"license": "MIT",
"dependencies": {
"mimic-fn": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pid-port/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pid-port/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pid-port/node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
"integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pify": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz",
@@ -20042,6 +20402,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/pretty-ms": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz",
"integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==",
"license": "MIT",
"dependencies": {
"parse-ms": "^4.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pretty-quick": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/pretty-quick/-/pretty-quick-3.3.1.tgz",
@@ -22393,7 +22768,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -22406,7 +22780,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -24167,7 +24540,7 @@
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -24239,6 +24612,18 @@
"node": ">=4"
}
},
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unique-filename": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz",
@@ -24755,7 +25140,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -25005,6 +25389,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoctocolors": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz",
"integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yup": {
"version": "0.32.11",
"resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz",
@@ -26293,11 +26689,15 @@
"chokidar": "^3.5.3",
"content-disposition": "^0.5.4",
"decomment": "^0.9.5",
"detect-port": "^2.1.0",
"dotenv": "^16.0.3",
"electron-is-dev": "^2.0.0",
"electron-notarize": "^1.2.2",
"electron-store": "^8.1.0",
"electron-util": "^0.17.2",
"execa": "^9.5.2",
"express": "^4.21.2",
"find-process": "^1.4.10",
"form-data": "^4.0.0",
"fs-extra": "^10.1.0",
"graphql": "^16.6.0",
@@ -26309,6 +26709,7 @@
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
"pid-port": "^1.0.2",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^4.1.3",
@@ -27362,6 +27763,32 @@
"proxy-from-env": "^1.1.0"
}
},
"packages/bruno-electron/node_modules/execa": {
"version": "9.5.2",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.5.2.tgz",
"integrity": "sha512-EHlpxMCpHWSAh1dgS6bVeoLAXGnJNdR93aabr4QCGbzOM73o5XmRfM/e5FUqsw3aagP8S8XEWUWFAxnRBnAF0Q==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
"cross-spawn": "^7.0.3",
"figures": "^6.1.0",
"get-stream": "^9.0.0",
"human-signals": "^8.0.0",
"is-plain-obj": "^4.1.0",
"is-stream": "^4.0.1",
"npm-run-path": "^6.0.0",
"pretty-ms": "^9.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^4.0.0",
"yoctocolors": "^2.0.0"
},
"engines": {
"node": "^18.19.0 || >=20.5.0"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"packages/bruno-electron/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@@ -27376,6 +27803,43 @@
"node": ">=12"
}
},
"packages/bruno-electron/node_modules/get-stream": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
"integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
"license": "MIT",
"dependencies": {
"@sec-ant/readable-stream": "^0.4.1",
"is-stream": "^4.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/bruno-electron/node_modules/human-signals": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.0.tgz",
"integrity": "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"packages/bruno-electron/node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/bruno-electron/node_modules/nanoid": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
@@ -27394,6 +27858,58 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"packages/bruno-electron/node_modules/npm-run-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
"integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/bruno-electron/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/bruno-electron/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/bruno-electron/node_modules/strip-final-newline": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
"integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/bruno-graphql-docs": {
"name": "@usebruno/graphql-docs",
"version": "0.1.0",
@@ -27598,61 +28114,6 @@
"proxy-from-env": "^1.1.0"
}
},
"packages/bruno-tests/node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"packages/bruno-tests/node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"packages/bruno-tests/node_modules/fast-xml-parser": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.0.9.tgz",
@@ -27671,27 +28132,6 @@
"fxparser": "src/cli/cli.js"
}
},
"packages/bruno-tests/node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"packages/bruno-tests/node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/bruno-tests/node_modules/strnum": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.0.5.tgz",

View File

@@ -95,7 +95,7 @@ const AuthMode = ({ collection }) => {
onModeChange('oauth2');
}}
>
Oauth2
OAuth 2.0
</div>
<div
className="dropdown-item"

View File

@@ -1,120 +0,0 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { saveCollectionRoot, sendCollectionOauth2Request } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/index';
import { clearOauth2Cache } from 'utils/network/index';
import toast from 'react-hot-toast';
const OAuth2AuthorizationCode = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const oAuth = get(collection, 'root.request.auth.oauth2', {});
const handleRun = async () => {
dispatch(sendCollectionOauth2Request(collection.uid));
};
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const { callbackUrl, authorizationUrl, accessTokenUrl, clientId, clientSecret, scope, state, pkce } = oAuth;
const handleChange = (key, value) => {
dispatch(
updateCollectionAuth({
mode: 'oauth2',
collectionUid: collection.uid,
content: {
grantType: 'authorization_code',
callbackUrl,
authorizationUrl,
accessTokenUrl,
clientId,
clientSecret,
scope,
state,
pkce,
[key]: value
}
})
);
};
const handlePKCEToggle = (e) => {
dispatch(
updateCollectionAuth({
mode: 'oauth2',
collectionUid: collection.uid,
content: {
grantType: 'authorization_code',
callbackUrl,
authorizationUrl,
accessTokenUrl,
clientId,
clientSecret,
scope,
state,
pkce: !Boolean(oAuth?.['pkce'])
}
})
);
};
const handleClearCache = (e) => {
clearOauth2Cache(collection?.uid)
.then(() => {
toast.success('cleared cache successfully');
})
.catch((err) => {
toast.error(err.message);
});
};
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={oAuth[key] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
onRun={handleRun}
collection={collection}
isSecret={isSecret}
/>
</div>
</div>
);
})}
<div className="flex flex-row w-full gap-4" key="pkce">
<label className="block font-medium">Use PKCE</label>
<input
className="cursor-pointer"
type="checkbox"
checked={Boolean(oAuth?.['pkce'])}
onChange={handlePKCEToggle}
/>
</div>
<div className="flex flex-row gap-4">
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
</button>
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Cache
</button>
</div>
</StyledWrapper>
);
};
export default OAuth2AuthorizationCode;

View File

@@ -1,33 +0,0 @@
const inputsConfig = [
{
key: 'callbackUrl',
label: 'Callback URL'
},
{
key: 'authorizationUrl',
label: 'Authorization URL'
},
{
key: 'accessTokenUrl',
label: 'Access Token URL'
},
{
key: 'clientId',
label: 'Client ID'
},
{
key: 'clientSecret',
label: 'Client Secret',
isSecret: true
},
{
key: 'scope',
label: 'Scope'
},
{
key: 'state',
label: 'State'
}
];
export { inputsConfig };

View File

@@ -1,16 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
max-width: 400px;
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
`;
export default Wrapper;

View File

@@ -1,70 +0,0 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { saveCollectionRoot, sendCollectionOauth2Request } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/index';
const OAuth2ClientCredentials = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const oAuth = get(collection, 'root.request.auth.oauth2', {});
const handleRun = async () => {
dispatch(sendCollectionOauth2Request(collection.uid));
};
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const { accessTokenUrl, clientId, clientSecret, scope } = oAuth;
const handleChange = (key, value) => {
dispatch(
updateCollectionAuth({
mode: 'oauth2',
collectionUid: collection.uid,
content: {
grantType: 'client_credentials',
accessTokenUrl,
clientId,
clientSecret,
scope,
[key]: value
}
})
);
};
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={oAuth[key] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
onRun={handleRun}
collection={collection}
isSecret={isSecret}
/>
</div>
</div>
);
})}
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
</button>
</StyledWrapper>
);
};
export default OAuth2ClientCredentials;

View File

@@ -1,21 +0,0 @@
const inputsConfig = [
{
key: 'accessTokenUrl',
label: 'Access Token URL'
},
{
key: 'clientId',
label: 'Client ID'
},
{
key: 'clientSecret',
label: 'Client Secret',
isSecret: true
},
{
key: 'scope',
label: 'Scope'
}
];
export { inputsConfig };

View File

@@ -1,54 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 0.8125rem;
.grant-type-mode-selector {
padding: 0.5rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
.dropdown {
width: fit-content;
div[data-tippy-root] {
width: fit-content;
}
.tippy-box {
width: fit-content;
max-width: none !important;
.tippy-content: {
width: fit-content;
max-width: none !important;
}
}
}
.grant-type-label {
width: fit-content;
color: ${(props) => props.theme.colors.text.yellow};
justify-content: space-between;
padding: 0 0.5rem;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
label {
font-size: 0.8125rem;
}
`;
export default Wrapper;

View File

@@ -1,98 +0,0 @@
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { IconCaretDown } from '@tabler/icons';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { humanizeGrantType } from 'utils/collections';
import { useEffect } from 'react';
import { updateCollectionAuth, updateCollectionAuthMode } from 'providers/ReduxStore/slices/collections/index';
const GrantTypeSelector = ({ collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = get(collection, 'root.request.auth.oauth2', {});
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end grant-type-label select-none">
{humanizeGrantType(oAuth?.grantType)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onGrantTypeChange = (grantType) => {
dispatch(
updateCollectionAuth({
mode: 'oauth2',
collectionUid: collection.uid,
content: {
grantType
}
})
);
};
useEffect(() => {
// initialize redux state with a default oauth2 grant type
// authorization_code - default option
!oAuth?.grantType &&
dispatch(
updateCollectionAuthMode({
mode: 'oauth2',
collectionUid: collection.uid
})
);
!oAuth?.grantType &&
dispatch(
updateCollectionAuth({
mode: 'oauth2',
collectionUid: collection.uid,
content: {
grantType: 'authorization_code'
}
})
);
}, [oAuth]);
return (
<StyledWrapper>
<label className="block font-medium mb-2">Grant Type</label>
<div className="inline-flex items-center cursor-pointer grant-type-mode-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('password');
}}
>
Password Credentials
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('authorization_code');
}}
>
Authorization Code
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('client_credentials');
}}
>
Client Credentials
</div>
</Dropdown>
</div>
</StyledWrapper>
);
};
export default GrantTypeSelector;

View File

@@ -1,72 +0,0 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { saveCollectionRoot, sendCollectionOauth2Request } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/index';
const OAuth2AuthorizationCode = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const oAuth = get(collection, 'root.request.auth.oauth2', {});
const handleRun = async () => {
dispatch(sendCollectionOauth2Request(collection.uid));
};
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const { accessTokenUrl, username, password, clientId, clientSecret, scope } = oAuth;
const handleChange = (key, value) => {
dispatch(
updateCollectionAuth({
mode: 'oauth2',
collectionUid: collection.uid,
content: {
grantType: 'password',
accessTokenUrl,
username,
password,
clientId,
clientSecret,
scope,
[key]: value
}
})
);
};
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={oAuth[key] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
onRun={handleRun}
collection={collection}
isSecret={isSecret}
/>
</div>
</div>
);
})}
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
</button>
</StyledWrapper>
);
};
export default OAuth2AuthorizationCode;

View File

@@ -1,29 +0,0 @@
const inputsConfig = [
{
key: 'accessTokenUrl',
label: 'Access Token URL'
},
{
key: 'username',
label: 'Username'
},
{
key: 'password',
label: 'Password'
},
{
key: 'clientId',
label: 'Client ID'
},
{
key: 'clientSecret',
label: 'Client Secret',
isSecret: true
},
{
key: 'scope',
label: 'Scope'
}
];
export { inputsConfig };

View File

@@ -1,21 +1,33 @@
import React from 'react';
import get from 'lodash/get';
import StyledWrapper from './StyledWrapper';
import GrantTypeSelector from './GrantTypeSelector/index';
import OAuth2PasswordCredentials from './PasswordCredentials/index';
import OAuth2AuthorizationCode from './AuthorizationCode/index';
import OAuth2ClientCredentials from './ClientCredentials/index';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/index';
import { useDispatch } from 'react-redux';
import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
const grantTypeComponentMap = (collection) => {
const dispatch = useDispatch();
const save = () => {
dispatch(saveCollectionRoot(collection.uid));
};
let request = collection.draft ? get(collection, 'draft.request', {}) : get(collection, 'root.request', {});
const grantType = get(request, 'auth.oauth2.grantType', {});
const grantTypeComponentMap = (grantType, collection) => {
switch (grantType) {
case 'password':
return <OAuth2PasswordCredentials collection={collection} />;
return <OAuth2PasswordCredentials save={save} request={request} updateAuth={updateCollectionAuth} collection={collection} />;
break;
case 'authorization_code':
return <OAuth2AuthorizationCode collection={collection} />;
return <OAuth2AuthorizationCode save={save} request={request} updateAuth={updateCollectionAuth} collection={collection} />;
break;
case 'client_credentials':
return <OAuth2ClientCredentials collection={collection} />;
return <OAuth2ClientCredentials save={save} request={request} updateAuth={updateCollectionAuth} collection={collection} />;
break;
default:
return <div>TBD</div>;
@@ -24,12 +36,12 @@ const grantTypeComponentMap = (grantType, collection) => {
};
const OAuth2 = ({ collection }) => {
const oAuth = get(collection, 'root.request.auth.oauth2', {});
let request = collection.draft ? get(collection, 'draft.request', {}) : get(collection, 'root.request', {});
return (
<StyledWrapper className="mt-2 w-full">
<GrantTypeSelector collection={collection} />
{grantTypeComponentMap(oAuth?.grantType, collection)}
<GrantTypeSelector request={request} updateAuth={updateCollectionAuth} collection={collection} />
{grantTypeComponentMap(collection)}
</StyledWrapper>
);
};

View File

@@ -0,0 +1,86 @@
import React from 'react';
import get from 'lodash/get';
import StyledWrapper from './StyledWrapper';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
import { updateFolderAuth } from 'providers/ReduxStore/slices/collections';
import { useDispatch } from 'react-redux';
import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
import AuthMode from '../AuthMode';
const grantTypeComponentMap = (collection, folder) => {
const dispatch = useDispatch();
const save = () => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
let request = get(folder, 'root.request', {});
const grantType = get(request, 'auth.oauth2.grantType', 'authorization_code');
switch (grantType) {
case 'password':
return <OAuth2PasswordCredentials save={save} item={folder} request={request} updateAuth={updateFolderAuth} collection={collection} folder={folder} />;
case 'authorization_code':
return <OAuth2AuthorizationCode save={save} item={folder} request={request} updateAuth={updateFolderAuth} collection={collection} folder={folder} />;
case 'client_credentials':
return <OAuth2ClientCredentials save={save} item={folder} request={request} updateAuth={updateFolderAuth} collection={collection} folder={folder} />;
default:
return <div>TBD</div>;
}
};
const Auth = ({ collection, folder }) => {
const dispatch = useDispatch();
let request = get(folder, 'root.request', {});
const authMode = get(folder, 'root.request.auth.mode');
const handleSave = () => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const getAuthView = () => {
switch (authMode) {
case 'oauth2': {
return (
<>
<GrantTypeSelector
request={request}
updateAuth={updateFolderAuth}
collection={collection}
folder={folder}
/>
{grantTypeComponentMap(collection, folder)}
</>
);
}
case 'none': {
return null;
}
default:
return null;
}
};
return (
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">
Configures authentication for the entire folder. This applies to all requests using the{' '}
<span className="font-medium">Inherit</span> option in the <span className="font-medium">Auth</span> tab.
</div>
<div className="flex flex-grow justify-start items-center mb-4">
<AuthMode collection={collection} folder={folder} />
</div>
{getAuthView()}
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save
</button>
</div>
</StyledWrapper>
);
};
export default Auth;

View File

@@ -0,0 +1,16 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.auth-mode-selector {
border: 1px solid ${({ theme }) => theme.colors.border};
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8125rem;
}
.auth-mode-label {
color: ${({ theme }) => theme.colors.text};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,62 @@
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateFolderAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const AuthMode = ({ collection, folder }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = get(folder, 'root.request.auth.mode');
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(
updateFolderAuthMode({
mode: value,
collectionUid: collection.uid,
folderUid: folder.uid
})
);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('oauth2');
}}
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
No Auth
</div>
</Dropdown>
</div>
</StyledWrapper>
);
};
export default AuthMode;

View File

@@ -8,7 +8,9 @@ import Tests from './Tests';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars';
import Documentation from './Documentation';
import Auth from './Auth';
import DotIcon from 'components/Icons/Dot';
import get from 'lodash/get';
const ContentIndicator = () => {
return (
@@ -37,6 +39,9 @@ const FolderSettings = ({ collection, folder }) => {
const responseVars = folderRoot?.request?.vars?.res || [];
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const auth = get(folderRoot, 'request.auth.mode');
const hasAuth = auth && auth !== 'none';
const setTab = (tab) => {
dispatch(
updatedFolderSettingsSelectedTab({
@@ -61,6 +66,9 @@ const FolderSettings = ({ collection, folder }) => {
case 'vars': {
return <Vars collection={collection} folder={folder} />;
}
case 'auth': {
return <Auth collection={collection} folder={folder} />;
}
case 'docs': {
return <Documentation collection={collection} folder={folder} />;
}
@@ -93,6 +101,10 @@ const FolderSettings = ({ collection, folder }) => {
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{hasAuth && <ContentIndicator />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
Docs
</div>

View File

@@ -1,6 +1,7 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
width: 100%;
.path-display {
background: ${(props) => props.theme.requestTabPanel.url.bg};
border-radius: 4px;
@@ -23,14 +24,14 @@ const StyledWrapper = styled.div`
}
.filename, .file-extension {
.name-container, .file-extension {
color: ${(props) => props.theme.colors.text.yellow};
}
.separator {
color: ${(props) => props.theme.text};
opacity: 0.6;
margin: 0 1px;
margin: 0 2px;
}
}
`;

View File

@@ -1,63 +1,22 @@
import React from 'react';
import { IconEdit, IconFolder, IconFile } from '@tabler/icons';
import { IconFolder, IconFile } from '@tabler/icons';
import path from 'utils/common/path';
import StyledWrapper from './StyledWrapper';
const PathDisplay = ({
collection,
item,
filename,
extension = '.bru',
showExtension = true,
toggleEditingFilename,
showDirectory = false
baseName = '',
iconType = 'file'
}) => {
const relativePath = item?.pathname && path.relative(collection?.pathname, showDirectory ? path.dirname(item?.pathname) : item?.pathname);
const pathSegments = relativePath?.split(path.sep).filter(Boolean);
return (
<StyledWrapper>
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<label className="block font-medium">Location</label>
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(true)}
/>
</div>
<div className="path-display">
<div className="path-layout flex">
<div className="icon-column flex">
{showExtension ? <IconFile size={16} /> : <IconFolder size={16} />}
</div>
<div className="path-container flex font-mono items-center">
<div className="path-segment collection-segment">
{collection?.name}
</div>
{pathSegments?.length > 0 && pathSegments?.map((segment, index) => (
<React.Fragment key={index}>
<span className="separator">/</span>
<div className="path-segment">
{segment}
</div>
</React.Fragment>
))}
{collection && (
<span className="separator">/</span>
)}
<span className="filename">
{filename}
{showExtension && filename?.length ? (
<span className="file-extension">{extension}</span>
) : null}
</span>
</div>
<div className="path-display mt-2">
<div className="path-layout flex font-mono">
<div className="icon-column flex">
{iconType === 'file' ? <IconFile size={16} /> : <IconFolder size={16} />}
</div>
<span className="name-container">
{baseName}
</span>
</div>
</div>
</StyledWrapper>

View File

@@ -46,7 +46,7 @@ const Preferences = ({ onClose }) => {
return (
<StyledWrapper>
<Modal size="lg" title="Preferences" handleCancel={onClose} hideFooter={true}>
<div className='flex flex-row gap-2 mx-[-1rem] !my-[-1.5rem]'>
<div className='flex flex-row gap-2 mx-[-1rem] !my-[-1.5rem] py-2'>
<div className="flex flex-col items-center tabs" role="tablist">
<div className={getTabClassname('general')} role="tab" onClick={() => setTab('general')}>
General

View File

@@ -11,6 +11,47 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.token-placement-selector {
padding: 0.5rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
min-width: 100px;
.dropdown {
width: fit-content;
min-width: 100px;
div[data-tippy-root] {
width: fit-content;
min-width: 100px;
}
.tippy-box {
width: fit-content;
max-width: none !important;
min-width: 100px;
.tippy-content: {
width: fit-content;
max-width: none !important;
min-width: 100px;
}
}
}
.token-placement-label {
width: fit-content;
// color: ${(props) => props.theme.colors.text.yellow};
justify-content: space-between;
padding: 0 0.5rem;
min-width: 100px;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
`;
export default Wrapper;

View File

@@ -1,28 +1,112 @@
import React from 'react';
import React, { useRef, forwardRef, useState, useEffect } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { clearOauth2Cache, fetchOauth2Credentials, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import { clearOauth2Cache } from 'utils/network/index';
import toast from 'react-hot-toast';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import { cloneDeep, find } from 'lodash';
import { interpolateStringUsingCollectionAndItem } from 'utils/collections/index';
import { BRUNO_OAUTH2_SERVER_CALLBACK_URL } from 'utils/common/index';
const OAuth2AuthorizationCode = ({ item, collection }) => {
const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const [fetchingToken, toggleFetchingToken] = useState(false);
const [refreshingToken, toggleRefreshingToken] = useState(false);
const [showRefreshButton, setShowRefreshButton] = useState(false);
const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
const oAuth = get(request, 'auth.oauth2', {});
const {
callbackUrl,
authorizationUrl,
accessTokenUrl,
clientId,
clientSecret,
scope,
credentialsPlacement,
state,
pkce,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshUrl,
autoRefreshToken,
autoFetchToken,
authorizeInDefaultBrowser
} = oAuth;
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid));
};
const refreshUrlAvailable = refreshUrl?.trim() !== '';
const isAutoRefreshDisabled = !refreshUrlAvailable;
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const { callbackUrl, authorizationUrl, accessTokenUrl, clientId, clientSecret, scope, state, pkce } = oAuth;
const TokenPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const CredentialsPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleFetchOauth2Credentials = async () => {
let requestCopy = cloneDeep(request);
requestCopy.oauth2 = requestCopy?.auth.oauth2;
requestCopy.headers = {};
toggleFetchingToken(true);
try {
await dispatch(fetchOauth2Credentials({
itemUid: item.uid,
request: requestCopy,
collection,
folderUid: folder?.uid || null
}));
toggleFetchingToken(false);
toast.success('token fetched successfully!');
}
catch (error) {
console.error(error);
toggleFetchingToken(false);
toast.error('An error occured while fetching token!');
}
}
const handleRefreshAccessToken = async () => {
let requestCopy = cloneDeep(request);
requestCopy.oauth2 = requestCopy?.auth.oauth2;
requestCopy.headers = {};
toggleRefreshingToken(true);
try {
await dispatch(refreshOauth2Credentials({ request: requestCopy, collection }));
toggleRefreshingToken(false);
toast.success('token refreshed successfully!');
}
catch (error) {
console.error(error);
toggleRefreshingToken(false);
toast.error('An error occured while refreshing token!');
}
}
const handleSave = () => { save(); };
const handleChange = (key, value) => {
dispatch(
@@ -40,7 +124,16 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
state,
scope,
pkce,
[key]: value
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshUrl,
autoRefreshToken,
autoFetchToken,
authorizeInDefaultBrowser,
[key]: value,
}
})
);
@@ -61,6 +154,13 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
clientSecret,
state,
scope,
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken,
authorizeInDefaultBrowser,
pkce: !Boolean(oAuth?.['pkce'])
}
})
@@ -68,7 +168,8 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
};
const handleClearCache = (e) => {
clearOauth2Cache(collection?.uid)
const interpolatedAccessTokenUrl = interpolateStringUsingCollectionAndItem({ collection, item, string: accessTokenUrl });
dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAccessTokenUrl, credentialsId }))
.then(() => {
toast.success('cleared cache successfully');
})
@@ -77,16 +178,58 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
});
};
const { uid: collectionUid } = collection;
const interpolatedUrl = interpolateStringUsingCollectionAndItem({ collection, item, string: accessTokenUrl });
const credentialsData = find(collection?.oauth2Credentials, creds => creds?.url == interpolatedUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId);
const creds = credentialsData?.credentials || {};
useEffect(() => {
// Update visibility whenever credentials change
setShowRefreshButton(Boolean(creds?.refresh_token && creds?.access_token));
}, [creds?.refresh_token, creds?.access_token, credentialsData]);
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={accessTokenUrl} credentialsId={credentialsId} />
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">
Configuration
</span>
</div>
{/* Authorize in default browser */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(authorizeInDefaultBrowser)}
onChange={(e) => handleChange('authorizeInDefaultBrowser', e.target.checked)}
className={`cursor-pointer ml-1`}
/>
<label className={`block min-w-[140px]`}>Authorize In Default Browser</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Use the default browser to authorize the user.
</span>
</div>
</div>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
let { key, label, isSecret } = input;
let value = oAuth[key] || '';
let shouldAuthorizeInDefaultBrowser = key == 'callbackUrl' && Boolean(authorizeInDefaultBrowser);
if (shouldAuthorizeInDefaultBrowser) {
value = BRUNO_OAUTH2_SERVER_CALLBACK_URL;
}
return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label>
<div className="single-line-editor-wrapper">
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className={`single-line-editor-wrapper flex-1`}>
<SingleLineEditor
value={oAuth[key] || ''}
value={value}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
@@ -94,13 +237,39 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
collection={collection}
item={item}
isSecret={isSecret}
readOnly={shouldAuthorizeInDefaultBrowser}
/>
</div>
</div>
);
})}
<div className="flex items-center gap-4 w-full" key={`input-credentials-placement`}>
<label className="block min-w-[140px]">Add Credentials to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</div>
</div>
<div className="flex flex-row w-full gap-4" key="pkce">
<label className="block font-medium">Use PKCE</label>
<label className="block">Use PKCE</label>
<input
className="cursor-pointer"
type="checkbox"
@@ -108,10 +277,160 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
onChange={handlePKCEToggle}
/>
</div>
<div className="flex flex-row gap-4">
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconKey size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-name`}>
<label className="block min-w-[140px]">Token ID</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['credentialsId'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('credentialsId', val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-placement`}>
<label className="block min-w-[140px]">Add token to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{
tokenPlacement === 'header' ?
<div className="flex items-center gap-4 w-full" key={`input-token-prefix`}>
<label className="block min-w-[140px]">Header Prefix</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['tokenHeaderPrefix'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenHeaderPrefix', val)}
onRun={handleRun}
collection={collection}
/>
</div>
</div>
:
<div className="flex items-center gap-4 w-full" key={`input-token-query-param-key`}>
<label className="block font-medium min-w-[140px]">Query Param Key</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['tokenQueryKey'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenQueryKey', val)}
onRun={handleRun}
collection={collection}
/>
</div>
</div>
}
<div className="flex items-center gap-2.5 mt-4 mb-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconAdjustmentsHorizontal size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Advanced Settings
</span>
</div>
<div className="flex items-center gap-4 w-full mb-4">
<label className="block min-w-[140px]">Refresh Token URL</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={get(request, 'auth.oauth2.refreshUrl', '')}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange("refreshUrl", val)}
collection={collection}
item={item}
/>
</div>
</div>
<div className="flex items-center gap-2.5 mt-4">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">Settings</span>
</div>
{/* Automatically Fetch Token */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(autoFetchToken)}
onChange={(e) => handleChange('autoFetchToken', e.target.checked)}
className="cursor-pointer ml-1"
/>
<label className="block min-w-[140px]">Automatically fetch token if not found</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically fetch a new token when you try to access a resource and don't have one.
</span>
</div>
</div>
</div>
{/* Auto Refresh Token (With Refresh URL) */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(autoRefreshToken)}
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={isAutoRefreshDisabled}
/>
<label className={`block min-w-[140px] ${isAutoRefreshDisabled ? 'text-gray-500' : ''}`}>Auto refresh token (with refresh URL)</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically refresh your token using the refresh URL when it expires.
</span>
</div>
</div>
</div>
<div className="flex flex-row gap-4 mt-4">
<button onClick={handleFetchOauth2Credentials} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
Get Access Token{fetchingToken ? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
</button>
{showRefreshButton && (
<button onClick={handleRefreshAccessToken} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
Refresh Token{refreshingToken ? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
</button>
)}
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Cache
</button>
@@ -120,4 +439,4 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
);
};
export default OAuth2AuthorizationCode;
export default OAuth2AuthorizationCode;

View File

@@ -11,6 +11,47 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.token-placement-selector {
padding: 0.5rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
min-width: 100px;
.dropdown {
width: fit-content;
min-width: 100px;
div[data-tippy-root] {
width: fit-content;
min-width: 100px;
}
.tippy-box {
width: fit-content;
max-width: none !important;
min-width: 100px;
.tippy-content: {
width: fit-content;
max-width: none !important;
min-width: 100px;
}
}
}
.token-placement-label {
width: fit-content;
// color: ${(props) => props.theme.colors.text.yellow};
justify-content: space-between;
padding: 0 0.5rem;
min-width: 100px;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
`;
export default Wrapper;

View File

@@ -1,26 +1,100 @@
import React from 'react';
import React, { useRef, forwardRef, useState } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHelp } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import Dropdown from 'components/Dropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import toast from 'react-hot-toast';
import { cloneDeep } from 'lodash';
import { interpolateStringUsingCollectionAndItem } from 'utils/collections/index';
const OAuth2ClientCredentials = ({ item, collection }) => {
const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const [fetchingToken, toggleFetchingToken] = useState(false);
const [refreshingToken, toggleRefreshingToken] = useState(false);
const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
const oAuth = get(request, 'auth.oauth2', {});
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid));
const {
accessTokenUrl,
clientId,
clientSecret,
scope,
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshUrl,
autoRefreshToken,
autoFetchToken
} = oAuth;
const refreshUrlAvailable = refreshUrl?.trim() !== '';
const isAutoRefreshDisabled = !refreshUrlAvailable;
const handleFetchOauth2Credentials = async () => {
let requestCopy = cloneDeep(request);
requestCopy.oauth2 = requestCopy?.auth.oauth2;
requestCopy.headers = {};
toggleFetchingToken(true);
try {
await dispatch(fetchOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection }));
toggleFetchingToken(false);
toast.success('Token fetched successfully!');
}
catch (error) {
console.error('could not fetch the token!');
console.error(error);
toggleFetchingToken(false);
toast.error('An error occured while fetching token!');
}
}
const handleRefreshAccessToken = async () => {
let requestCopy = cloneDeep(request);
requestCopy.oauth2 = requestCopy?.auth.oauth2;
requestCopy.headers = {};
toggleRefreshingToken(true);
try {
await dispatch(refreshOauth2Credentials({ request: requestCopy, collection }));
toggleRefreshingToken(false);
toast.success('token refreshed successfully!');
}
catch(error) {
console.error(error);
toggleRefreshingToken(false);
toast.error('An error occured while refreshing token!');
}
};
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => { save(); };
const { accessTokenUrl, clientId, clientSecret, scope } = oAuth;
const TokenPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const CredentialsPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleChange = (key, value) => {
dispatch(
@@ -34,20 +108,48 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
clientId,
clientSecret,
scope,
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshUrl,
autoRefreshToken,
autoFetchToken,
[key]: value
}
})
);
};
const handleClearCache = (e) => {
const interpolatedAccessTokenUrl = interpolateStringUsingCollectionAndItem({ collection, item, string: accessTokenUrl });
dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAccessTokenUrl, credentialsId }))
.then(() => {
toast.success('cleared cache successfully');
})
.catch((err) => {
toast.error(err.message);
});
};
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={accessTokenUrl} credentialsId={credentialsId} />
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">
Configuration
</span>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label>
<div className="single-line-editor-wrapper">
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth[key] || ''}
theme={storedTheme}
@@ -62,9 +164,199 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
</div>
);
})}
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
</button>
<div className="flex items-center gap-4 w-full" key={`input-credentials-placement`}>
<label className="block min-w-[140px]">Add Credentials to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</div>
</div>
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconKey size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-name`}>
<label className="block min-w-[140px]">Token ID</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['credentialsId'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('credentialsId', val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-placement`}>
<label className="block min-w-[140px]">Add token to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{
tokenPlacement === 'header' ?
<div className="flex items-center gap-4 w-full" key={`input-token-prefix`}>
<label className="block min-w-[140px]">Header Prefix</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['tokenHeaderPrefix'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenHeaderPrefix', val)}
onRun={handleRun}
collection={collection}
/>
</div>
</div>
:
<div className="flex items-center gap-4 w-full" key={`input-token-query-param-key`}>
<label className="block font-medium min-w-[140px]">Query Param Key</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['tokenQueryKey'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenQueryKey', val)}
onRun={handleRun}
collection={collection}
/>
</div>
</div>
}
<div className="flex items-center gap-2.5 mt-4 mb-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconAdjustmentsHorizontal size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Advanced Settings
</span>
</div>
<div className="flex items-center gap-4 w-full mb-4">
<label className="block min-w-[140px]">Refresh Token URL</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={get(request, 'auth.oauth2.refreshUrl', '')}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange("refreshUrl", val)}
collection={collection}
item={item}
/>
</div>
</div>
<div className="flex items-center gap-4 w-full mb-4">
<label className="block min-w-[140px]">Auto-refresh token</label>
<input
type="checkbox"
className="cursor-pointer w-4 h-4 accent-indigo-600"
checked={get(request, 'auth.oauth2.autoRefreshToken', false)}
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
/>
<span className="text-xs text-gray-500">Automatically refresh the token when it expires</span>
</div>
<div className="flex items-center gap-2.5 mt-4">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">Settings</span>
</div>
{/* Automatically Fetch Token */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(autoFetchToken)}
onChange={(e) => handleChange('autoFetchToken', e.target.checked)}
className="cursor-pointer ml-1"
/>
<label className="block min-w-[140px]">Automatically fetch token if not found</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically fetch a new token when you try to access a resource and don't have one.
</span>
</div>
</div>
</div>
{/* Auto Refresh Token (With Refresh URL) */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(autoRefreshToken)}
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={isAutoRefreshDisabled}
/>
<label className={`block min-w-[140px] ${isAutoRefreshDisabled ? 'text-gray-500' : ''}`}>Auto refresh token (with refresh URL)</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically refresh your token using the refresh URL when it expires.
</span>
</div>
</div>
</div>
<div className="flex flex-row gap-4 mt-4">
<button onClick={handleFetchOauth2Credentials} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
Get Access Token{fetchingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
</button>
<button onClick={handleRefreshAccessToken} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
Refresh Token{refreshingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
</button>
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Cache
</button>
</div>
</StyledWrapper>
);
};

View File

@@ -2,12 +2,13 @@ import styled from 'styled-components';
const Wrapper = styled.div`
label {
display: block;
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
textarea {
height: fit-content;
max-width: 400px;
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}

View File

@@ -0,0 +1,80 @@
import React, { useEffect, useState } from 'react';
import { readOauth2CachedCredentials } from 'utils/network';
import { sendCollectionOauth2Request, sendRequest, clearOauth2Cache } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
const CredentialsPreview = ({ item, collection }) => {
const oauth2CredentialsAreaRef = React.createRef();
const [oauth2Credentials, setOauth2Credentials] = useState({});
const dispatch = useDispatch();
useEffect(() => {
oauth2CredentialsAreaRef.current.value = oauth2Credentials;
readOauth2CachedCredentials(collection.uid).then((credentials) => setOauth2Credentials(credentials));
}, [oauth2CredentialsAreaRef]);
const handleRun = async () => {
if (item) {
dispatch(sendRequest(item, collection.uid));
} else {
dispatch(sendCollectionOauth2Request(collection.uid));
}
};
const handleClearCache = (e) => {
dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: '' }))
.then(() => {
readOauth2CachedCredentials(collection.uid).then((credentials) => {
setOauth2Credentials(credentials);
toast.success('Cleared cache successfully');
});
})
.catch((err) => {
toast.error(err.message);
});
};
const sortedFields = () => {
const tokens = {};
const extras = {};
Object.entries(oauth2Credentials).forEach(([key, value]) => {
if (key.endsWith('_token')) {
tokens[key] = value;
} else {
extras[key] = value;
}
});
return { ...tokens, ...extras };
};
return (
<StyledWrapper className="flex flex-col w-full gap-1 mt-4">
<div className="credential-item-wrapper" ref={oauth2CredentialsAreaRef}>
{Object.entries(oauth2Credentials).length > 0 ? (
<>
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Access Token Cache
</button>
<details className="cursor-pointer flex flex-row w-full mt-2 gap-2">
<summary>Cached OAuth2 Credentials</summary>
{Object.entries(sortedFields()).map(([field, value]) => (
<div key={field}>
<label className="text-xs">{field}</label>
<textarea className="w-full h-24 p-2 text-xs border rounded" value={value} readOnly />
</div>
))}
</details>
</>
) : (
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
</button>
)}
</div>
</StyledWrapper>
);
};
export default CredentialsPreview;

View File

@@ -3,18 +3,20 @@ import get from 'lodash/get';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { IconCaretDown } from '@tabler/icons';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { IconCaretDown, IconKey } from '@tabler/icons';
import { humanizeGrantType } from 'utils/collections';
import { useEffect } from 'react';
import { useState } from 'react';
const GrantTypeSelector = ({ item, collection }) => {
const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const oAuth = get(request, 'auth.oauth2', {});
const [valuesCache, setValuesCache] = useState({
...oAuth
});
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end grant-type-label select-none">
@@ -24,13 +26,19 @@ const GrantTypeSelector = ({ item, collection }) => {
});
const onGrantTypeChange = (grantType) => {
let updatedValues = {
...valuesCache,
...oAuth,
grantType
};
setValuesCache(updatedValues);
dispatch(
updateAuth({
mode: 'oauth2',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
grantType
...updatedValues
}
})
);
@@ -46,7 +54,18 @@ const GrantTypeSelector = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
grantType: 'authorization_code'
grantType: 'authorization_code',
accessTokenUrl: '',
username: '',
password: '',
clientId: '',
clientSecret: '',
scope: '',
credentialsPlacement: 'body',
credentialsId: 'credentials',
tokenPlacement: 'header',
tokenHeaderPrefix: 'Bearer',
tokenQueryKey: 'access_token',
}
})
);
@@ -54,7 +73,14 @@ const GrantTypeSelector = ({ item, collection }) => {
return (
<StyledWrapper>
<label className="block font-medium mb-2">Grant Type</label>
<div className="flex items-center gap-2.5 my-4">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconKey size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">
Grant Type
</span>
</div>
<div className="inline-flex items-center cursor-pointer grant-type-mode-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
@@ -89,4 +115,4 @@ const GrantTypeSelector = ({ item, collection }) => {
</StyledWrapper>
);
};
export default GrantTypeSelector;
export default GrantTypeSelector;

View File

@@ -0,0 +1,12 @@
import styled from 'styled-components';
const Wrapper = styled.div`
ol[role="tree"] {
overflow: hidden;
}
ol[role="group"] span {
line-break: anywhere;
}
`;
export default Wrapper;

View File

@@ -0,0 +1,162 @@
import { find } from "lodash";
import { interpolateStringUsingCollectionAndItem } from "utils/collections/index";
import StyledWrapper from "./StyledWrapper";
import { useState, useEffect } from "react";
import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/icons';
const TokenSection = ({ title, token }) => {
if (!token) return null;
const [isExpanded, setIsExpanded] = useState(false);
const [decodedToken, setDecodedToken] = useState(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (token) {
try {
const parts = token.split('.');
if (parts.length === 3) {
const payload = JSON.parse(atob(parts[1]));
setDecodedToken(payload);
}
} catch (err) {
console.error('Error decoding token:', err);
}
}
}, [token]);
const handleCopy = async (text) => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="mb-2 border dark:border-gray-700 rounded-lg overflow-hidden">
<div
className="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-2 w-full">
{isExpanded ?
<IconChevronDown size={18} className="text-gray-500" /> :
<IconChevronRight size={18} className="text-gray-500" />
}
<div className="flex flex-row justify-between w-full">
<h3 className="text-sm font-medium">{title}</h3>
{decodedToken?.exp && <ExpiryTimer expiresIn={decodedToken?.exp} />}
</div>
</div>
</div>
{isExpanded && (
<div className="p-3 text-sm">
<div className="relative group">
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleCopy(token)}
className="p-1 bg-indigo-100 dark:hover:bg-indigo-200 rounded"
title="Copy token"
>
{copied ?
<IconCheck size={16} className="text-green-700" /> :
<IconCopy size={16} className="text-gray-500" />
}
</button>
</div>
<div className="font-mono text-xs bg-gray-50 dark:bg-gray-800 p-2 rounded break-all">
{token}
</div>
</div>
{decodedToken && (
<div className="mt-3">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Decoded Payload</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
{Object.entries(decodedToken).map(([key, value]) => (
<div key={key} className="overflow-hidden text-ellipsis">
<span className="font-medium text-xs">{key}: </span>
<span className="text-xs text-gray-600 dark:text-gray-300">
{typeof value === 'object' ? JSON.stringify(value) : value.toString()}
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
};
const formatExpiryTime = (seconds) => {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
};
const ExpiryTimer = ({ expiresIn }) => {
if (!expiresIn) return null;
const calculateTimeLeft = () => Math.max(0, Math.floor(expiresIn - Date.now() / 1000));
const [timeLeft, setTimeLeft] = useState(calculateTimeLeft);
useEffect(() => {
setTimeLeft(calculateTimeLeft());
const timer = setInterval(() => {
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
}, 1000);
return () => clearInterval(timer);
}, [expiresIn]);
return (
<div
className={`text-xs px-2 py-1 rounded-full min-w-[120px] text-center ${timeLeft <= 30
? "bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400"
: "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
}`}
>
{timeLeft > 0 ? `Expires in ${formatExpiryTime(timeLeft)}` : `Expired`}
</div>
);
};
const Oauth2TokenViewer = ({ collection, item, url, credentialsId, handleRun }) => {
const { uid: collectionUid } = collection;
const interpolatedUrl = interpolateStringUsingCollectionAndItem({ collection, item, string: url });
const credentialsData = find(collection?.oauth2Credentials, creds => creds?.url == interpolatedUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId);
const creds = credentialsData?.credentials || {};
return (
<StyledWrapper className="relative w-auto h-fit mt-2">
{Object.keys(creds)?.length ? (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-sm">
<TokenSection title="Access Token" token={creds.access_token} />
<TokenSection title="Refresh Token" token={creds.refresh_token} />
<TokenSection title="ID Token" token={creds.id_token} />
{(creds.token_type || creds.scope) ? <div className="mt-3 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg text-xs">
<div className="grid grid-cols-2 gap-2">
{creds.token_type ? <div className="flex items-center space-x-1">
<span className="font-medium">Token Type:</span>
<span className="text-gray-600 dark:text-gray-300">{creds.token_type}</span>
</div> : null}
{creds?.scope ? <div className="flex items-center space-x-1 min-w-0">
<span className="font-medium flex-shrink-0">Scope:</span>
<span className="text-gray-600 dark:text-gray-300 truncate" title={creds.scope}>
{creds.scope}
</span>
</div> : null}
</div>
</div> : null}
</div>
) : (
<div className="text-sm text-gray-500 dark:text-gray-400">No token found</div>
)}
</StyledWrapper>
);
};
export default Oauth2TokenViewer;

View File

@@ -11,6 +11,47 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.token-placement-selector {
padding: 0.5rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
min-width: 100px;
.dropdown {
width: fit-content;
min-width: 100px;
div[data-tippy-root] {
width: fit-content;
min-width: 100px;
}
.tippy-box {
width: fit-content;
max-width: none !important;
min-width: 100px;
.tippy-content: {
width: fit-content;
max-width: none !important;
min-width: 100px;
}
}
}
.token-placement-label {
width: fit-content;
// color: ${(props) => props.theme.colors.text.yellow};
justify-content: space-between;
padding: 0 0.5rem;
min-width: 100px;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
`;
export default Wrapper;

View File

@@ -1,26 +1,101 @@
import React from 'react';
import React, { useRef, forwardRef, useState } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHelp } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import Dropdown from 'components/Dropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import toast from 'react-hot-toast';
import { interpolateStringUsingCollectionAndItem } from 'utils/collections/index';
import { cloneDeep } from 'lodash';
const OAuth2AuthorizationCode = ({ item, collection }) => {
const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const [fetchingToken, toggleFetchingToken] = useState(false);
const [refreshingToken, toggleRefreshingToken] = useState(false);
const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
const oAuth = get(request, 'auth.oauth2', {});
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid));
const {
accessTokenUrl,
username,
password,
clientId,
clientSecret,
scope,
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshUrl,
autoRefreshToken,
autoFetchToken
} = oAuth;
const refreshUrlAvailable = refreshUrl?.trim() !== '';
const isAutoRefreshDisabled = !refreshUrlAvailable;
const handleFetchOauth2Credentials = async () => {
let requestCopy = cloneDeep(request);
requestCopy.oauth2 = requestCopy?.auth.oauth2;
requestCopy.headers = {};
toggleFetchingToken(true);
try {
await dispatch(fetchOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection }));
toggleFetchingToken(false);
toast.success('Token fetched successfully!');
}
catch (error) {
console.error(error);
toggleFetchingToken(false);
toast.error('An error occured while fetching token!');
}
}
const handleRefreshAccessToken = async () => {
let requestCopy = cloneDeep(request);
requestCopy.oauth2 = requestCopy?.auth.oauth2;
requestCopy.headers = {};
toggleRefreshingToken(true);
try {
await dispatch(refreshOauth2Credentials({ request: requestCopy, collection }));
toggleRefreshingToken(false);
toast.success('token refreshed successfully!');
}
catch(error) {
console.error(error);
toggleRefreshingToken(false);
toast.error('An error occured while refreshing token!');
}
};
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => { save(); }
const { accessTokenUrl, username, password, clientId, clientSecret, scope } = oAuth;
const TokenPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const CredentialsPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleChange = (key, value) => {
dispatch(
@@ -36,20 +111,48 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
clientId,
clientSecret,
scope,
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshUrl,
autoRefreshToken,
autoFetchToken,
[key]: value
}
})
);
};
const handleClearCache = (e) => {
const interpolatedAccessTokenUrl = interpolateStringUsingCollectionAndItem({ collection, item, string: accessTokenUrl });
dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAccessTokenUrl, credentialsId }))
.then(() => {
toast.success('cleared cache successfully');
})
.catch((err) => {
toast.error(err.message);
});
};
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={accessTokenUrl} credentialsId={credentialsId} />
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">
Configuration
</span>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label>
<div className="single-line-editor-wrapper">
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth[key] || ''}
theme={storedTheme}
@@ -64,11 +167,201 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
</div>
);
})}
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
</button>
<div className="flex items-center gap-4 w-full" key={`input-credentials-placement`}>
<label className="block min-w-[140px]">Add Credentials to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</div>
</div>
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconKey size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-name`}>
<label className="block min-w-[140px]">Token ID</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['credentialsId'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('credentialsId', val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-placement`}>
<label className="block min-w-[140px]">Add token to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{
tokenPlacement === 'header' ?
<div className="flex items-center gap-4 w-full" key={`input-token-prefix`}>
<label className="block min-w-[140px]">Header Prefix</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['tokenHeaderPrefix'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenHeaderPrefix', val)}
onRun={handleRun}
collection={collection}
/>
</div>
</div>
:
<div className="flex items-center gap-4 w-full" key={`input-token-query-param-key`}>
<label className="block font-medium min-w-[140px]">Query Param Key</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['tokenQueryKey'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenQueryKey', val)}
onRun={handleRun}
collection={collection}
/>
</div>
</div>
}
<div className="flex items-center gap-2.5 mt-4 mb-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconAdjustmentsHorizontal size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Advanced Settings
</span>
</div>
<div className="flex items-center gap-4 w-full mb-4">
<label className="block min-w-[140px]">Refresh Token URL</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={get(request, 'auth.oauth2.refreshUrl', '')}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange("refreshUrl", val)}
collection={collection}
item={item}
/>
</div>
</div>
<div className="flex items-center gap-4 w-full mb-4">
<label className="block min-w-[140px]">Auto-refresh token</label>
<input
type="checkbox"
className="cursor-pointer w-4 h-4 accent-indigo-600"
checked={get(request, 'auth.oauth2.autoRefreshToken', false)}
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
/>
<span className="text-xs text-gray-500">Automatically refresh the token when it expires</span>
</div>
<div className="flex items-center gap-2.5 mt-4">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">Settings</span>
</div>
{/* Automatically Fetch Token */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(autoFetchToken)}
onChange={(e) => handleChange('autoFetchToken', e.target.checked)}
className="cursor-pointer ml-1"
/>
<label className="block min-w-[140px]">Automatically fetch token if not found</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically fetch a new token when you try to access a resource and don't have one.
</span>
</div>
</div>
</div>
{/* Auto Refresh Token (With Refresh URL) */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(autoRefreshToken)}
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={isAutoRefreshDisabled}
/>
<label className={`block min-w-[140px] ${isAutoRefreshDisabled ? 'text-gray-500' : ''}`}>Auto refresh token (with refresh URL)</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically refresh your token using the refresh URL when it expires.
</span>
</div>
</div>
</div>
<div className="flex flex-row gap-4 mt-4">
<button onClick={handleFetchOauth2Credentials} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
Get Access Token{fetchingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
</button>
<button onClick={handleRefreshAccessToken} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
Refresh Token{refreshingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
</button>
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Cache
</button>
</div>
</StyledWrapper>
);
};
export default OAuth2AuthorizationCode;
export default OAuth2PasswordCredentials;

View File

@@ -5,17 +5,34 @@ import GrantTypeSelector from './GrantTypeSelector/index';
import OAuth2PasswordCredentials from './PasswordCredentials/index';
import OAuth2AuthorizationCode from './AuthorizationCode/index';
import OAuth2ClientCredentials from './ClientCredentials/index';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
const grantTypeComponentMap = (item, collection) => {
const dispatch = useDispatch();
const save = () => {
dispatch(saveRequest(item.uid, collection.uid));
};
let request = item.draft ? get(item, 'draft.request', {}) : get(item, 'request', {});
const grantType = get(request, 'auth.oauth2.grantType', {});
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid));
};
const grantTypeComponentMap = (grantType, item, collection) => {
switch (grantType) {
case 'password':
return <OAuth2PasswordCredentials item={item} collection={collection} />;
return <OAuth2PasswordCredentials item={item} save={save} request={request} handleRun={handleRun} updateAuth={updateAuth} collection={collection} />;
break;
case 'authorization_code':
return <OAuth2AuthorizationCode item={item} collection={collection} />;
return <OAuth2AuthorizationCode item={item} save={save} request={request} handleRun={handleRun} updateAuth={updateAuth} collection={collection} />;
break;
case 'client_credentials':
return <OAuth2ClientCredentials item={item} collection={collection} />;
return <OAuth2ClientCredentials item={item} save={save} request={request} handleRun={handleRun} updateAuth={updateAuth} collection={collection} />;
break;
default:
return <div>TBD</div>;
@@ -24,12 +41,12 @@ const grantTypeComponentMap = (grantType, item, collection) => {
};
const OAuth2 = ({ item, collection }) => {
const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
let request = item.draft ? get(item, 'draft.request', {}) : get(item, 'request', {});
return (
<StyledWrapper className="mt-2 w-full">
<GrantTypeSelector item={item} collection={collection} />
{grantTypeComponentMap(oAuth?.grantType, item, collection)}
<GrantTypeSelector item={item} request={request} updateAuth={updateAuth} collection={collection} />
{grantTypeComponentMap(item, collection)}
</StyledWrapper>
);
};

View File

@@ -10,14 +10,51 @@ import NTLMAuth from './NTLMAuth';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections/index';
import { humanizeRequestAuthMode } from 'utils/collections';
import OAuth2 from './OAuth2/index';
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
const getTreePathFromCollectionToItem = (collection, _item) => {
let path = [];
let item = findItemInCollection(collection, _item?.uid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
const Auth = ({ item, collection }) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
const collectionRoot = get(collection, 'root', {});
const collectionAuth = get(collectionRoot, 'request.auth');
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: i.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const getAuthView = () => {
switch (authMode) {
@@ -46,32 +83,21 @@ const Auth = ({ item, collection }) => {
return <ApiKeyAuth collection={collection} item={item} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();
return (
<div className="flex flex-row w-full mt-2 gap-2">
{collectionAuth?.mode === 'oauth2' ? (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-1">
<div>Collection level auth is: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(collectionAuth?.mode)}</div>
</div>
<div className="text-sm opacity-50">
Note: You need to use scripting to set the access token in the request headers.
</div>
</div>
) : (
<>
<div>Auth inherited from the Collection: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(collectionAuth?.mode)}</div>
</>
)}
</div>
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
</>
);
}
}
};
return (
<StyledWrapper className="w-full mt-1">
<StyledWrapper className="w-full mt-1 overflow-y-scroll">
<div className="flex flex-grow justify-start items-center">
<AuthMode item={item} collection={collection} />
</div>

View File

@@ -139,7 +139,7 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
return (
<StyledWrapper
className="w-full h-full relative"
className="w-full h-full relative flex"
style={{ maxWidth: width }}
queryFilterEnabled={queryFilterEnabled}
>

View File

@@ -0,0 +1,24 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.line {
white-space: pre-line;
word-wrap: break-word;
word-break: break-all;
font-family: Inter, sans-serif !important;
.arrow {
opacity: 0.5;
}
&.request {
color: ${(props) => props.theme.colors.text.green};
}
&.response {
color: ${(props) => props.theme.colors.text.purple};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import forOwn from 'lodash/forOwn';
import { safeStringifyJSON } from 'utils/common';
import StyledWrapper from './StyledWrapper';
const RunnerTimeline = ({ request, response }) => {
const requestHeaders = [];
const responseHeaders = typeof response.headers === 'object' ? Object.entries(response.headers) : [];
request = request || {};
response = response || {};
forOwn(request.headers, (value, key) => {
requestHeaders.push({
name: key,
value
});
});
let requestData = typeof request?.data === "string" ? request?.data : safeStringifyJSON(request?.data, true);
return (
<StyledWrapper className="pb-4 w-full">
<div>
<pre className="line request font-bold">
<span className="arrow">{'>'}</span> {request.method} {request.url}
</pre>
{requestHeaders.map((h) => {
return (
<pre className="line request" key={h.name}>
<span className="arrow">{'>'}</span> {h.name}: {h.value}
</pre>
);
})}
{requestData ? (
<pre className="line request">
<span className="arrow">{'>'}</span> data{' '}
<pre className="text-sm flex flex-wrap whitespace-break-spaces">{requestData}</pre>
</pre>
) : null}
</div>
<div className="mt-4">
<pre className="line response font-bold">
<span className="arrow">{'<'}</span> {response.status} - {response.statusText}
</pre>
{responseHeaders.map((h) => {
return (
<pre className="line response" key={h[0]}>
<span className="arrow">{'<'}</span> {h[0]}: {h[1]}
</pre>
);
})}
</div>
</StyledWrapper>
);
};
export default RunnerTimeline;

View File

@@ -1,11 +1,109 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.timeline-event {
padding: 8px 0 0 0;
cursor: pointer;
}
.timeline-event-content {
border-radius: 4px;
padding: 12px;
margin-top: 0.5rem;
}
.timeline-event-header {
color: ${(props) => props.theme.text};
}
.method-label {
font-weight: 600;
}
.status-code {
font-weight: 600;
}
.url-text {
color: ${(props) => props.theme.colors.text.muted};
font-size: 0.875rem;
margin-top: 0.25rem;
}
.timestamp {
color: ${(props) => props.theme.colors.text.muted};
font-size: 0.875rem;
}
.meta-info {
color: ${(props) => props.theme.colors.text.muted};
font-size: 0.875rem;
}
.oauth-section {
.oauth-header {
display: flex;
align-items: center;
color: ${(props) => props.theme.text};
font-weight: 600;
span {
margin-left: 0.5rem;
}
}
}
.tabs-switcher {
border-bottom: 1px solid ${(props) => props.theme.modal.input.border};
margin-bottom: 16px;
button {
position: relative;
padding: 8px 16px;
color: ${(props) => props.theme.colors.text.muted};
&.active {
color: ${(props) => props.theme.tabs.active.color};
&:after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: ${(props) => props.theme.tabs.active.border};
}
}
}
}
.network-logs {
background: ${(props) => props.theme.codemirror.bg};
color: ${(props) => props.theme.text};
border-radius: 4px;
}
.oauth-request-item-content {
border-radius: 4px;
margin-top: 0.5rem;
}
.collapsible-section {
margin-bottom: 12px;
.section-header {
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
}
.line {
white-space: pre-line;
word-wrap: break-word;
word-break: break-all;
font-family: Inter, sans-serif !important;
font-family: ${(props) => props.theme.font || 'Inter, sans-serif'} !important;
.arrow {
opacity: 0.5;
@@ -19,6 +117,35 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.purple};
}
}
.request-label {
font-size: 0.75rem;
padding: 2px 6px;
border-radius: 3px;
margin-left: 8px;
background: ${(props) => props.theme.requestTabs.bg};
}
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,37 @@
import QueryResult from "components/ResponsePane/QueryResult/index";
import { useState } from "react";
const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, width }) => {
const [isBodyCollapsed, toggleBody] = useState(true);
return (
<div className="collapsible-section">
<div className="section-header" onClick={() => toggleBody(!isBodyCollapsed)}>
<pre className="flex flex-row items-center text-lg text-indigo-500/80 dark:text-indigo-500/80">
<div className="opacity-70">{isBodyCollapsed ? '▼' : '▶'}</div> Body
</pre>
</div>
{isBodyCollapsed && (
<div className="mt-2">
{data || dataBuffer ? (
<div className="h-96 overflow-auto">
<QueryResult
item={item}
collection={collection}
width={width}
data={data}
dataBuffer={dataBuffer}
headers={headers}
error={error}
key={item?.uid}
/>
</div>
) : (
<div className="text-gray-500">No Body found</div>
)}
</div>
)}
</div>
)
}
export default BodyBlock;

View File

@@ -0,0 +1,54 @@
import { useState } from "react";
const HeadersBlock = ({ headers, type }) => {
const [areHeadersCollapsed, toggleHeaders] = useState(true);
return (
<div className="collapsible-section mt-2">
<div className="section-header" onClick={() => toggleHeaders(!areHeadersCollapsed)}>
<pre className="flex flex-row items-center text-lg text-indigo-500/80 dark:text-indigo-500/80">
<div className="opacity-70">{areHeadersCollapsed ? '▼' : '▶'}</div> Headers
{headers && Object.keys(headers).length > 0 &&
<div className="ml-1">({Object.keys(headers).length})</div>
}
</pre>
</div>
{areHeadersCollapsed && (
<div className="mt-1">
{headers && Object.keys(headers).length > 0
? <Headers headers={headers} type={type} />
: <div className="text-gray-500">No Headers found</div>
}
</div>
)}
</div>
)
};
const Headers = ({ headers, type }) => {
if (Array.isArray(headers)) {
return (
<div className="mt-1 text-sm">
{headers.map((header, index) => (
<pre key={index} className="mb-1 whitespace-pre-wrap">
{type === 'request' ? '>' : '<'}&nbsp;<span className="opacity-60">{header?.name}:</span>
<span className="whitespace-pre-wrap">{String(header?.value)}</span>
</pre>
))}
</div>
);
} else {
return (
<div className="mt-1 text-sm">
{Object.entries(headers).map(([key, value], index) => (
<pre key={index} className="mb-1 whitespace-pre-wrap">
{type === 'request' ? '>' : '<'}&nbsp;<span className="opacity-60">{key}:</span>
<span>{String(value)}</span>
</pre>
))}
</div>
);
}
};
export default HeadersBlock;

View File

@@ -0,0 +1,19 @@
const Method = ({ method }) => {
return (
<span className={`${methodColors[method?.toUpperCase()] || 'text-white'} font-bold`}>
{method?.toUpperCase()}
</span>
)
}
const methodColors = {
GET: 'text-green-500',
POST: 'text-blue-500',
PUT: 'text-yellow-500',
DELETE: 'text-red-500',
PATCH: 'text-purple-500',
OPTIONS: 'text-gray-500',
HEAD: 'text-gray-500',
};
export default Method;

View File

@@ -0,0 +1,26 @@
const Status = ({ statusCode, statusText }) => {
return (
<span
className={`${
statusColor(statusCode) || 'text-white'
} font-bold`}
>
{statusCode}{' '}
{statusText || ''}
</span>
)
}
const statusColor = (statusCode) => {
if (statusCode >= 200 && statusCode < 300) {
return 'text-green-500';
} else if (statusCode >= 300 && statusCode < 400) {
return 'text-yellow-500';
} else if (statusCode >= 400 && statusCode < 600) {
return 'text-red-500';
} else {
return 'text-gray-500';
}
};
export default Status;

View File

@@ -0,0 +1,36 @@
import { useState, useEffect } from "react";
const getRelativeTime = (date) => {
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
const diff = (date - new Date()) / 1000;
const timeUnits = [
{ unit: 'year', seconds: 31536000 },
{ unit: 'month', seconds: 2592000 },
{ unit: 'week', seconds: 604800 },
{ unit: 'day', seconds: 86400 },
{ unit: 'hour', seconds: 3600 },
{ unit: 'minute', seconds: 60 },
{ unit: 'second', seconds: 1 }
];
for (const { unit, seconds } of timeUnits) {
if (Math.abs(diff) >= seconds || unit === 'second') {
return rtf.format(Math.round(diff / seconds), unit);
}
}
};
export const RelativeTime = ({ timestamp }) => {
const [relativeTime, setRelativeTime] = useState(getRelativeTime(new Date(timestamp)));
useEffect(() => {
const interval = setInterval(() => {
setRelativeTime(getRelativeTime(new Date(timestamp)));
}, 1000);
return () => clearInterval(interval);
}, [timestamp]);
return <pre className="text-xs">{relativeTime}</pre>;
};

View File

@@ -0,0 +1,46 @@
const Network = ({ logs }) => {
return (
<div className="bg-black/5 text-white network-logs rounded overflow-auto h-96">
<pre className="whitespace-pre-wrap">
{logs.map((entry, index) => (
<NetworkLogsEntry key={index} entry={entry} />
))}
</pre>
</div>
)
}
const NetworkLogsEntry = ({ entry }) => {
const { type, message } = entry;
let className = '';
switch (type) {
case 'request':
className = 'text-blue-500';
break;
case 'response':
className = 'text-green-500';
break;
case 'error':
className = 'text-red-500';
break;
case 'tls':
className = 'text-purple-500';
break;
case 'info':
className = 'text-yellow-500';
break;
default:
className = 'text-gray-400';
break;
}
return (
<div className={`${className}`}>
<div>{message}</div>
</div>
);
};
export default Network;

View File

@@ -0,0 +1,23 @@
import Headers from "../Common/Headers/index";
import BodyBlock from "../Common/Body/index";
const Request = ({ collection, request, item, width }) => {
const { url, headers, data, dataBuffer, error } = request || {};
return (
<div>
{/* Method and URL */}
<div className="mb-1 flex gap-2">
<pre className="whitespace-pre-wrap">{url}</pre>
</div>
{/* Headers */}
<Headers headers={headers} type={'request'} />
{/* Body */}
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} width={width} />
</div>
)
}
export default Request;

View File

@@ -0,0 +1,26 @@
import BodyBlock from "../Common/Body/index";
import Headers from "../Common/Headers/index";
import Status from "../Common/Status/index";
const Response = ({ collection, response, item, width }) => {
const { status, statusCode, statusText, headers, data, dataBuffer, error } = response || {};
return (
<div>
{/* Status */}
<div className="mb-1">
<Status statusCode={status || statusCode} statusText={statusText} />
{response.duration && <span className="text-sm text-gray-400 ml-2">{response.duration}ms</span>}
{response.size && <span className="text-sm text-gray-400 ml-2">{response.size}B</span>}
</div>
{/* Headers */}
<Headers headers={headers} type={'response'} />
{/* Body */}
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} width={width} />
</div>
)
}
export default Response;

View File

@@ -0,0 +1,79 @@
import { useState } from "react";
import Network from "./Network/index";
import Request from "./Request/index";
import Response from "./Response/index";
import Method from "./Common/Method/index";
import Status from "./Common/Status/index";
import { RelativeTime } from "./Common/Time/index";
const TimelineItem = ({ timestamp, request, response, item, collection, width, isOauth2 }) => {
const [isCollapsed, _toggleCollapse] = useState(false);
const [activeTab, setActiveTab] = useState('request');
const toggleCollapse = () => _toggleCollapse(prev => !prev);
const { method, status, statusCode, statusText, url = '' } = request || {};
const showNetworkLogs = response.timeline && response.timeline.length > 0;
return (
<div className={`border-b-2 ${isOauth2 ? 'border-indigo-700/50' : 'border-amber-700/50' } py-2`}>
<div className="oauth-request-item-header cursor-pointer" onClick={toggleCollapse}>
<div className="flex justify-between items-center min-w-0">
<div className="flex items-center space-x-2 min-w-0">
<Method method={method} />
<Status statusCode={status || statusCode} statusText={statusText} />
{isOauth2 ? <pre className="opacity-50">[oauth2.0]</pre> : null}
<pre className="opacity-70">[{new Date(timestamp).toISOString()}]</pre>
</div>
<span className="text-sm text-gray-400 flex-shrink-0 overflow-hidden text-ellipsis whitespace-nowrap">
<RelativeTime timestamp={timestamp} />
</span>
</div>
<div className="truncate text-sm mt-1">{url}</div>
</div>
{isCollapsed && (<div className="text-sm overflow-hidden">
{/* Tabs */}
<div className="tabs-switcher flex mb-4">
<button
className={`mr-4 ${activeTab === 'request' ? 'active' : 'text-gray-400'}`}
onClick={() => setActiveTab('request')}
>
Request
</button>
<button
className={`mr-4 ${activeTab === 'response' ? 'active' : 'text-gray-400'}`}
onClick={() => setActiveTab('response')}
>
Response
</button>
{showNetworkLogs && (
<button
className={`${activeTab === 'networkLogs' ? 'active' : 'text-gray-400'}`}
onClick={() => setActiveTab('networkLogs')}
>
Network Logs
</button>
)}
</div>
{/* Tab Content */}
<div className="tab-content">
{/* Request Tab */}
{activeTab === 'request' && (
<Request request={request} item={item} collection={collection} width={width} />
)}
{/* Response Tab */}
{activeTab === 'response' && (
<Response response={response} item={item} collection={collection} width={width} />
)}
{/* Network Logs Tab */}
{activeTab === 'networkLogs' && showNetworkLogs && (
<Network logs={response?.timeline} />
)}
</div>
</div>)}
</div>
);
};
export default TimelineItem;

View File

@@ -1,61 +1,123 @@
import React from 'react';
import forOwn from 'lodash/forOwn';
import { safeStringifyJSON } from 'utils/common';
import React, { useState } from 'react';
import StyledWrapper from './StyledWrapper';
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
import { get } from 'lodash';
import TimelineItem from './TimelineItem/index';
const Timeline = ({ request, response }) => {
const requestHeaders = [];
const responseHeaders = typeof response.headers === 'object' ? Object.entries(response.headers) : [];
const getEffectiveAuthSource = (collection, item) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
if (authMode !== 'inherit') return null;
request = request || {};
response = response || {};
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
uid: collection.uid,
auth: collectionAuth
};
forOwn(request.headers, (value, key) => {
requestHeaders.push({
name: key,
value
});
});
// Get path from collection to item
let path = [];
let currentItem = findItemInCollection(collection, item?.uid);
while (currentItem) {
path.unshift(currentItem);
currentItem = findParentItemInCollection(collection, currentItem?.uid);
}
let requestData = typeof request?.data === "string" ? request?.data : safeStringifyJSON(request?.data, true);
// Check folders in reverse to find the closest auth configuration
for (let i of [...path].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
uid: i.uid,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const Timeline = ({ collection, item, width }) => {
// Get the effective auth source if auth mode is inherit
const authSource = getEffectiveAuthSource(collection, item);
// Filter timeline entries based on new rules
const combinedTimeline = ([...(collection.timeline || [])]).filter(obj => {
// Always show entries for this item
if (obj.itemUid === item.uid) return true;
// For OAuth2 entries, also show if auth is inherited
if (obj.type === 'oauth2' && authSource) {
if (authSource.type === 'folder' && obj.folderUid === authSource.uid) return true;
if (authSource.type === 'collection' && !obj.folderUid) return true;
}
return false;
}).sort((a, b) => b.timestamp - a.timestamp);
return (
<StyledWrapper className="pb-4 w-full">
<div>
<pre className="line request font-bold">
<span className="arrow">{'>'}</span> {request.method} {request.url}
</pre>
{requestHeaders.map((h) => {
<StyledWrapper
className="pb-4 w-full flex flex-grow flex-col"
style={{ maxWidth: width - 60, overflowWrap: 'break-word' }}
>
{combinedTimeline.map((event, index) => {
if (event.type === 'request') {
const { data, timestamp } = event;
const { request, response } = data;
return (
<pre className="line request" key={h.name}>
<span className="arrow">{'>'}</span> {h.name}: {h.value}
</pre>
<div key={index} className="timeline-event mb-2">
<TimelineItem
timestamp={timestamp}
request={request}
response={response}
item={item}
collection={collection}
width={width}
/>
</div>
);
})}
{requestData ? (
<pre className="line request">
<span className="arrow">{'>'}</span> data{' '}
<pre className="text-sm flex flex-wrap whitespace-break-spaces">{requestData}</pre>
</pre>
) : null}
</div>
<div className="mt-4">
<pre className="line response font-bold">
<span className="arrow">{'<'}</span> {response.status} - {response.statusText}
</pre>
{responseHeaders.map((h) => {
} else if (event.type === 'oauth2') {
const { data, timestamp } = event;
const { debugInfo } = data;
return (
<pre className="line response" key={h[0]}>
<span className="arrow">{'<'}</span> {h[0]}: {h[1]}
</pre>
<div key={index} className="timeline-event">
<div className="timeline-event-header cursor-pointer flex items-center">
<div className="flex items-center">
<span className="font-bold">OAuth2.0 Calls</span>
</div>
</div>
<div className="mt-2">
{debugInfo && debugInfo.length > 0 ? (
debugInfo.map((data, idx) => (
<div className='ml-4'>
<TimelineItem
key={idx}
timestamp={timestamp}
request={data?.request}
response={data?.response}
item={item}
collection={collection}
width={width - 50}
isOauth2={true}
/>
</div>
))
) : (
<div>No debug information available.</div>
)}
</div>
</div>
);
})}
</div>
}
return null;
})}
</StyledWrapper>
);
};
export default Timeline;
export default Timeline;

View File

@@ -63,7 +63,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return <ResponseHeaders headers={response.headers} />;
}
case 'timeline': {
return <Timeline request={item.requestSent} response={item.response} />;
return <Timeline collection={collection} item={item} width={rightPaneWidth} />;
}
case 'tests': {
return <TestResults results={item.testResults} assertionResults={item.assertionResults} />;

View File

@@ -7,10 +7,10 @@ import ResponseHeaders from 'components/ResponsePane/ResponseHeaders';
import StatusCode from 'components/ResponsePane/StatusCode';
import ResponseTime from 'components/ResponsePane/ResponseTime';
import ResponseSize from 'components/ResponsePane/ResponseSize';
import Timeline from 'components/ResponsePane/Timeline';
import TestResults from 'components/ResponsePane/TestResults';
import TestResultsLabel from 'components/ResponsePane/TestResultsLabel';
import StyledWrapper from './StyledWrapper';
import RunnerTimeline from 'components/ResponsePane/RunnerTimeline';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const [selectedTab, setSelectedTab] = useState('response');
@@ -45,7 +45,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return <ResponseHeaders headers={headers} />;
}
case 'timeline': {
return <Timeline request={requestSent} response={responseReceived} />;
return <RunnerTimeline request={requestSent} response={responseReceived} />;
}
case 'tests': {
return <TestResults results={testResults} assertionResults={assertionResults} />;

View File

@@ -7,20 +7,22 @@ import { cloneCollection } from 'providers/ReduxStore/slices/collections/actions
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import PathDisplay from 'components/PathDisplay/index';
import Help from 'components/Help';
import PathDisplay from 'components/PathDisplay';
import { useState } from 'react';
import { IconArrowBackUp } from "@tabler/icons";
import { IconArrowBackUp, IconEdit } from "@tabler/icons";
const CloneCollection = ({ onClose, collection }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const [isEditing, toggleEditing] = useState(false);
const { name } = collection;
const formik = useFormik({
enableReinitialize: true,
initialValues: {
collectionName: '',
collectionFolderName: '',
collectionName: `${name} copy`,
collectionFolderName: `${sanitizeName(name)} copy`,
collectionLocation: ''
},
validationSchema: Yup.object({
@@ -31,7 +33,7 @@ const CloneCollection = ({ onClose, collection }) => {
collectionFolderName: Yup.string()
.min(1, 'must be at least 1 character')
.max(255, 'must be 255 characters or less')
.test('is-valid-dir-name', function(value) {
.test('is-valid-collection-name', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
@@ -92,7 +94,7 @@ const CloneCollection = ({ onClose, collection }) => {
className="block textbox mt-2 w-full"
onChange={(e) => {
formik.handleChange(e);
!isEditingFilename && formik.setFieldValue('collectionFolderName', sanitizeName(e.target.value));
!isEditing && formik.setFieldValue('collectionFolderName', sanitizeName(e.target.value));
}}
autoComplete="off"
autoCorrect="off"
@@ -124,49 +126,70 @@ const CloneCollection = ({ onClose, collection }) => {
<div className="text-red-500">{formik.errors.collectionLocation}</div>
) : null}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
<span
className="text-link cursor-pointer hover:underline" onClick={browse}
style={{
fontSize: '0.8125rem'
}}
>
Browse
</span>
</div>
{isEditingFilename ?
<>
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="block font-semibold">
Directory Name
</label>
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(false)}
/>
</div>
<input
id="collection-folder-name"
type="text"
name="collectionFolderName"
className="block textbox mt-2 w-full"
onChange={formik.handleChange}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionFolderName || ''}
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="flex items-center font-semibold">
Folder Name
<Help width="300">
<p>
The name of the folder used to store the collection.
</p>
<p className="mt-2">
You can choose a folder name different from your collection's name or one compatible with filesystem rules.
</p>
</Help>
</label>
{isEditing ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(false)}
/>
) : (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(true)}
/>
)}
</div>
{isEditing ? (
<input
id="collection-folder-name"
type="text"
name="collectionFolderName"
className="block textbox mt-2 w-full"
onChange={formik.handleChange}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionFolderName || ''}
/>
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
baseName={formik.values.collectionFolderName}
/>
</div>
</>
:
<PathDisplay
filename={formik.values.collectionFolderName}
showExtension={false}
isEditingFilename={isEditingFilename}
toggleEditingFilename={toggleEditingFilename}
/>
}
{formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
<div className="text-red-500">{formik.errors.collectionFolderName}</div>
) : null}
)}
{formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
<div className="text-red-500">{formik.errors.collectionFolderName}</div>
) : null}
</div>
</div>
</form>
</Modal>

View File

@@ -0,0 +1,12 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.advanced-options {
.caret {
color: ${(props) => props.theme.textLink};
fill: ${(props) => props.theme.textLink};
}
}
`;
export default StyledWrapper;

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, forwardRef } from 'react';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
@@ -6,24 +6,32 @@ import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
import { cloneItem } from 'providers/ReduxStore/slices/collections/actions';
import { IconArrowBackUp } from '@tabler/icons';
import * as path from 'path';
import path from "utils/common/path";
import { IconArrowBackUp, IconEdit, IconCaretDown } from "@tabler/icons";
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import Help from 'components/Help';
import PathDisplay from 'components/PathDisplay/index';
import Portal from 'components/Portal';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
const CloneCollectionItem = ({ collection, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const [isEditing, toggleEditing] = useState(false);
const itemName = item?.name;
const itemType = item?.type;
const itemFilename = item?.filename ? path.parse(item?.filename).name : '';
const [showFilesystemName, toggleShowFilesystemName] = useState(false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: itemName,
filename: sanitizeName(itemFilename)
name: `${itemName} copy`,
filename: `${sanitizeName(itemName)} copy`
},
validationSchema: Yup.object({
name: Yup.string()
@@ -34,7 +42,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
.min(1, 'must be at least 1 character')
.max(255, 'must be 255 characters or less')
.required('name is required')
.test('is-valid-filename', function(value) {
.test('is-valid-name', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
@@ -58,86 +66,157 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
}
}, [inputRef]);
const onSubmit = () => formik.handleSubmit();
const AdvancedOptions = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex mr-2 text-link cursor-pointer items-center">
<button
className="btn-advanced"
type="button"
>
Options
</button>
<IconCaretDown className="caret ml-1" size={14} strokeWidth={2}/>
</div>
);
});
return (
<Modal
size="md"
title={`Clone ${isFolder ? 'Folder' : 'Request'}`}
confirmText="Clone"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-semibold">
{isFolder ? 'Folder' : 'Request'} Name
</label>
<input
id="collection-item-name"
type="text"
name="name"
placeholder='Enter Item name'
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={e => {
formik.setFieldValue('name', e.target.value);
!isEditingFilename && formik.setFieldValue('filename', sanitizeName(e.target.value));
}}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? <div className="text-red-500">{formik.errors.name}</div> : null}
</div>
{isEditingFilename ? (
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="block font-semibold">
{isFolder ? 'Directory' : 'File'} Name
</label>
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(false)}
/>
<Portal>
<StyledWrapper>
<Modal
size="md"
title={`Clone ${isFolder ? 'Folder' : 'Request'}`}
handleCancel={onClose}
hideFooter
>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div>
<label htmlFor="name" className="block font-semibold">
{isFolder ? 'Folder' : 'Request'} Name
</label>
<input
id="collection-item-name"
type="text"
name="name"
placeholder='Enter Item name'
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={e => {
formik.setFieldValue('name', e.target.value);
!isEditing && formik.setFieldValue('filename', sanitizeName(e.target.value));
}}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? <div className="text-red-500">{formik.errors.name}</div> : null}
</div>
{showFilesystemName && (
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="flex items-center font-semibold">
{isFolder ? 'Folder' : 'File'} Name <small className='font-normal text-muted ml-1'>(on filesystem)</small>
{ isFolder ? (
<Help width="300">
<p>
You can choose to save the folder as a different name on your file system versus what is displayed in the app.
</p>
</Help>
) : (
<Help width="300">
<p>
Bruno saves each request as a file in your collection's folder.
</p>
<p className="mt-2">
You can choose a file name different from your request's name or one compatible with filesystem rules.
</p>
</Help>
)}
</label>
{isEditing ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(false)}
/>
) : (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(true)}
/>
)}
</div>
{isEditing ? (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<input
id="file-name"
type="text"
name="filename"
placeholder={isFolder ? 'Folder Name' : 'File Name'}
className={`!pr-10 block textbox mt-2 w-full`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.filename || ''}
/>
{itemType !== 'folder' && <span className='absolute right-2 top-4 flex justify-center items-center file-extension'>.bru</span>}
</div>
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
baseName={formik.values.filename}
/>
</div>
)}
{formik.touched.filename && formik.errors.filename ? (
<div className="text-red-500">{formik.errors.filename}</div>
) : null}
</div>
<div className='relative flex flex-row gap-1 items-center justify-between'>
<input
id="file-name"
type="text"
name="filename"
placeholder="File Name"
className={`!pr-10 block textbox mt-2 w-full`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.filename || ''}
/>
{itemType !== 'folder' && <span className='absolute right-2 top-4 flex justify-center items-center file-extension'>.bru</span>}
)}
<div className="flex justify-between items-center mt-8 bruno-modal-footer">
<div className='flex advanced-options'>
<Dropdown onCreate={onDropdownCreate} icon={<AdvancedOptions />} placement="bottom-start">
<div
className="dropdown-item"
key="show-filesystem-name"
onClick={(e) => {
dropdownTippyRef.current.hide();
toggleShowFilesystemName(!showFilesystemName);
}}
>
{showFilesystemName ? 'Hide Filesystem Name' : 'Show Filesystem Name'}
</div>
</Dropdown>
</div>
<div className='flex justify-end'>
<span className='mr-2'>
<button type="button" onClick={onClose} className="btn btn-md btn-close">
Cancel
</button>
</span>
<span>
<button
type="submit"
className="submit btn btn-md btn-secondary"
>
Clone
</button>
</span>
</div>
</div>
) : (
<PathDisplay
collection={collection}
item={item}
filename={formik.values.filename}
showExtension={itemType !== 'folder'}
isEditingFilename={isEditingFilename}
toggleEditingFilename={toggleEditingFilename}
showDirectory={true}
/>
)}
{formik.touched.filename && formik.errors.filename ? (
<div className="text-red-500">{formik.errors.filename}</div>
) : null}
</form>
</Modal>
</form>
</Modal>
</StyledWrapper>
</Portal>
);
};

View File

@@ -1,11 +1,10 @@
import React from 'react';
import Modal from 'components/Modal';
import * as path from 'path';
import Help from 'components/Help';
const CollectionItemInfo = ({ item, onClose }) => {
const { name, filename, type } = item;
const CollectionItemInfo = ({ collection, item, onClose }) => {
const { pathname: collectionPathname } = collection;
const { name, filename, pathname, type } = item;
const relativePathname = path.relative(collectionPathname, pathname);
return (
<Modal
size="md"
@@ -14,24 +13,43 @@ const CollectionItemInfo = ({ collection, item, onClose }) => {
hideCancel={true}
hideFooter={true}
>
<div className="w-fit flex flex-col h-full">
<table className="w-full border-collapse">
<tbody>
<tr className="">
<td className="py-2 px-2 text-right opacity-50">Name&nbsp;:</td>
<td className="py-2 px-2 text-nowrap truncate max-w-[500px]" title={name}>{name}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right opacity-50">{type=='folder' ? 'Directory Name' : 'File Name'}&nbsp;:</td>
<td className="py-2 px-2 break-all text-nowrap truncate max-w-[500px]" title={filename}>{filename}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right opacity-50">Pathname&nbsp;:</td>
<td className="py-2 px-2 break-all text-nowrap truncate max-w-[500px]" title={relativePathname}>{relativePathname}</td>
</tr>
</tbody>
</table>
</div>
<div className="w-fit flex flex-col h-full">
<table className="w-full border-collapse">
<tbody>
<tr className="">
<td className="py-2 px-2 text-left text-muted ">
{type=='folder' ? 'Folder Name' : 'Request Name'}
</td>
<td className="py-2 px-2 text-nowrap truncate max-w-[500px]" title={name}>
<span className="mr-2">:</span>{name}
</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-left text-muted flex items-center">
{type == 'folder' ? 'Folder Name' : 'File Name'}
<small className='font-normal text-muted ml-1'>(on filesystem)</small>
{type == 'folder' ? (
<Help width="300">
<p>
The name of the folder on your filesystem.
</p>
</Help>
) : (
<Help width="300">
<p>
Bruno saves each request as a file in your collection's folder.
</p>
</Help>
)}
</td>
<td className="py-2 px-2 break-all text-nowrap truncate max-w-[500px]" title={filename}>
<span className="mr-2">:</span>
{filename}
</td>
</tr>
</tbody>
</table>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,12 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.advanced-options {
.caret {
color: ${(props) => props.theme.textLink};
fill: ${(props) => props.theme.textLink};
}
}
`;
export default StyledWrapper;

View File

@@ -1,4 +1,4 @@
import React, { useRef, useEffect, useState } from 'react';
import React, { useRef, useEffect, useState, forwardRef } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal';
@@ -6,20 +6,29 @@ import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import path from 'utils/common/path';
import { IconArrowBackUp } from '@tabler/icons';
import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import Help from 'components/Help';
import PathDisplay from 'components/PathDisplay';
import Portal from 'components/Portal';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
const RenameCollectionItem = ({ collection, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const [isEditing, toggleEditing] = useState(false);
const itemName = item?.name;
const itemType = item?.type;
const itemFilename = item?.filename ? path.parse(item?.filename).name : '';
const [showFilesystemName, toggleShowFilesystemName] = useState(false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const formik = useFormik({
enableReinitialize: true,
initialValues: {
@@ -35,7 +44,7 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
.min(1, 'must be at least 1 character')
.max(255, 'must be 255 characters or less')
.required('name is required')
.test('is-valid-filename', function(value) {
.test('is-valid-name', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
@@ -77,86 +86,157 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
}
}, [inputRef]);
const onSubmit = () => formik.handleSubmit();
const AdvancedOptions = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex mr-2 text-link cursor-pointer items-center">
<button
className="btn-advanced"
type="button"
>
Options
</button>
<IconCaretDown className="caret ml-1" size={14} strokeWidth={2}/>
</div>
);
});
return (
<Modal
size="md"
title={`Rename ${isFolder ? 'Folder' : 'Request'}`}
confirmText="Rename"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={e => {e.preventDefault()}}>
<div className='flex flex-col mt-2'>
<label htmlFor="name" className="block font-semibold">
{isFolder ? 'Folder' : 'Request'} Name
</label>
<input
id="collection-item-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={e => {
formik.setFieldValue('name', e.target.value);
!isEditingFilename && formik.setFieldValue('filename', sanitizeName(e.target.value));
}}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? <div className="text-red-500">{formik.errors.name}</div> : null}
</div>
{isEditingFilename ? (
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="block font-semibold">
{isFolder ? 'Directory' : 'File'} Name
<Portal>
<StyledWrapper>
<Modal
size="md"
title={`Rename ${isFolder ? 'Folder' : 'Request'}`}
handleCancel={onClose}
hideFooter
>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className='flex flex-col mt-2'>
<label htmlFor="name" className="block font-semibold">
{isFolder ? 'Folder' : 'Request'} Name
</label>
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(false)}
/>
</div>
<div className='relative flex flex-row gap-1 items-center justify-between'>
<input
id="file-name"
id="collection-item-name"
type="text"
name="filename"
placeholder="File Name"
className={`!pr-10 block textbox mt-2 w-full`}
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.filename || ''}
onChange={e => {
formik.setFieldValue('name', e.target.value);
!isEditing && formik.setFieldValue('filename', sanitizeName(e.target.value));
}}
value={formik.values.name || ''}
/>
{itemType !== 'folder' && <span className='absolute right-2 top-4 flex justify-center items-center file-extension'>.bru</span>}
{formik.touched.name && formik.errors.name ? <div className="text-red-500">{formik.errors.name}</div> : null}
</div>
</div>
) : (
<PathDisplay
collection={collection}
item={item}
filename={formik.values.filename}
showExtension={itemType !== 'folder'}
isEditingFilename={isEditingFilename}
toggleEditingFilename={toggleEditingFilename}
showDirectory={true}
/>
)}
{formik.touched.filename && formik.errors.filename ? (
<div className="text-red-500">{formik.errors.filename}</div>
) : null}
</form>
</Modal>
{showFilesystemName && (
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="flex items-center font-semibold">
{isFolder ? 'Folder' : 'File'} Name <small className='font-normal text-muted ml-1'>(on filesystem)</small>
{ isFolder ? (
<Help width="300">
<p>
You can choose to save the folder as a different name on your file system versus what is displayed in the app.
</p>
</Help>
) : (
<Help width="300">
<p>
Bruno saves each request as a file in your collection's folder.
</p>
<p className="mt-2">
You can choose a file name different from your request's name or one compatible with filesystem rules.
</p>
</Help>
)}
</label>
{isEditing ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(false)}
/>
) : (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(true)}
/>
)}
</div>
{isEditing ? (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<input
id="file-name"
type="text"
name="filename"
placeholder={isFolder ? 'Folder Name' : 'File Name'}
className={`!pr-10 block textbox mt-2 w-full`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.filename || ''}
/>
{itemType !== 'folder' && <span className='absolute right-2 top-4 flex justify-center items-center file-extension'>.bru</span>}
</div>
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
baseName={formik.values.filename}
/>
</div>
)}
{formik.touched.filename && formik.errors.filename ? (
<div className="text-red-500">{formik.errors.filename}</div>
) : null}
</div>
)}
<div className="flex justify-between items-center mt-8 bruno-modal-footer">
<div className='flex advanced-options'>
<Dropdown onCreate={onDropdownCreate} icon={<AdvancedOptions />} placement="bottom-start">
<div
className="dropdown-item"
key="show-filesystem-name"
onClick={(e) => {
dropdownTippyRef.current.hide();
toggleShowFilesystemName(!showFilesystemName);
}}
>
{showFilesystemName ? 'Hide Filesystem Name' : 'Show Filesystem Name'}
</div>
</Dropdown>
</div>
<div className='flex justify-end'>
<span className='mr-2'>
<button type="button" onClick={onClose} className="btn btn-md btn-close">
Cancel
</button>
</span>
<span>
<button
type="submit"
className="submit btn btn-md btn-secondary"
>
Rename
</button>
</span>
</div>
</div>
</form>
</Modal>
</StyledWrapper>
</Portal>
);
};

View File

@@ -24,7 +24,6 @@ import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemInfo from './CollectionItemInfo/index';
import { findItemInCollection } from 'utils/collections';
import CollectionItemIcon from './CollectionItemIcon';
import { scrollToTheActiveTab } from 'utils/tabs';

View File

@@ -9,13 +9,13 @@ import Modal from 'components/Modal';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import PathDisplay from 'components/PathDisplay/index';
import { useState } from 'react';
import { IconArrowBackUp } from '@tabler/icons';
import { IconArrowBackUp, IconEdit } from '@tabler/icons';
import Help from 'components/Help';
const CreateCollection = ({ onClose }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const [isEditing, toggleEditing] = useState(false);
const formik = useFormik({
enableReinitialize: true,
@@ -32,7 +32,7 @@ const CreateCollection = ({ onClose }) => {
collectionFolderName: Yup.string()
.min(1, 'must be at least 1 character')
.max(255, 'must be 255 characters or less')
.test('is-valid-dir-name', function(value) {
.test('is-valid-collection-name', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
@@ -86,7 +86,7 @@ const CreateCollection = ({ onClose }) => {
className="block textbox mt-2 w-full"
onChange={(e) => {
formik.handleChange(e);
!isEditingFilename && formik.setFieldValue('collectionFolderName', sanitizeName(e.target.value));
!isEditing && formik.setFieldValue('collectionFolderName', sanitizeName(e.target.value));
}}
autoComplete="off"
autoCorrect="off"
@@ -105,7 +105,7 @@ const CreateCollection = ({ onClose }) => {
Bruno stores your collections on your computer's filesystem.
</p>
<p className="mt-2">
Choose where you want to store this collection.
Choose the location where you want to store this collection.
</p>
</Help>
</label>
@@ -126,24 +126,46 @@ const CreateCollection = ({ onClose }) => {
<div className="text-red-500">{formik.errors.collectionLocation}</div>
) : null}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
<span
className="text-link cursor-pointer hover:underline" onClick={browse}
style={{
fontSize: '0.8125rem'
}}
>
Browse
</span>
</div>
{isEditingFilename ?
<>
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="block font-semibold">
Directory Name
</label>
{formik.values.collectionName?.trim()?.length > 0 && (
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="flex items-center font-semibold">
Folder Name
<Help width="300">
<p>
The name of the folder used to store the collection.
</p>
<p className="mt-2">
You can choose a folder name different from your collection's name or one compatible with filesystem rules.
</p>
</Help>
</label>
{isEditing ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(false)}
onClick={() => toggleEditing(false)}
/>
</div>
) : (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(true)}
/>
)}
</div>
{isEditing ? (
<input
id="collection-folder-name"
type="text"
@@ -156,19 +178,18 @@ const CreateCollection = ({ onClose }) => {
spellCheck="false"
value={formik.values.collectionFolderName || ''}
/>
</div>
</>
:
<PathDisplay
filename={formik.values.collectionFolderName}
showExtension={false}
isEditingFilename={isEditingFilename}
toggleEditingFilename={toggleEditingFilename}
/>
}
{formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
<div className="text-red-500">{formik.errors.collectionFolderName}</div>
) : null}
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
baseName={formik.values.collectionFolderName}
/>
</div>
)}
{formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
<div className="text-red-500">{formik.errors.collectionFolderName}</div>
) : null}
</div>
)}
</div>
</form>
</Modal>

View File

@@ -0,0 +1,12 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.advanced-options {
.caret {
color: ${(props) => props.theme.textLink};
fill: ${(props) => props.theme.textLink};
}
}
`;
export default StyledWrapper;

View File

@@ -1,18 +1,27 @@
import React, { useRef, useEffect, useState } from 'react';
import React, { useRef, useEffect, useState, forwardRef } from 'react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import * as Yup from 'yup';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
import { IconArrowBackUp } from '@tabler/icons';
import { IconArrowBackUp, IconEdit} from '@tabler/icons';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import PathDisplay from 'components/PathDisplay';
import PathDisplay from 'components/PathDisplay/index';
import Help from 'components/Help';
import Dropdown from "components/Dropdown";
import { IconCaretDown } from "@tabler/icons";
import StyledWrapper from './StyledWrapper';
const NewFolder = ({ collection, item, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const [isEditing, toggleEditing] = useState(false);
const [showFilesystemName, toggleShowFilesystemName] = useState(false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const formik = useFormik({
enableReinitialize: true,
@@ -58,80 +67,139 @@ const NewFolder = ({ collection, item, onClose }) => {
}
}, [inputRef]);
const onSubmit = () => formik.handleSubmit();
const AdvancedOptions = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex mr-2 text-link cursor-pointer items-center">
<button
className="btn-advanced"
type="button"
>
Options
</button>
<IconCaretDown className="caret ml-1" size={14} strokeWidth={2}/>
</div>
);
});
return (
<Modal size="md" title="New Folder" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div>
<label htmlFor="folderName" className="block font-semibold">
Folder Name
</label>
<input
id="collection-name"
type="text"
name="folderName"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={e => {
formik.setFieldValue('folderName', e.target.value);
!isEditingFilename && formik.setFieldValue('directoryName', sanitizeName(e.target.value));
}}
value={formik.values.folderName || ''}
/>
{formik.touched.folderName && formik.errors.folderName ? (
<div className="text-red-500">{formik.errors.folderName}</div>
) : null}
</div>
{isEditingFilename ? (
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="directoryName" className="block font-semibold">
Directory Name
</label>
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(false)}
/>
<Portal>
<StyledWrapper>
<Modal size="md" title="New Folder" hideFooter={true} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<label htmlFor="folderName" className="block font-semibold">
Folder Name
</label>
<input
id="collection-name"
type="text"
name="folderName"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={e => {
formik.setFieldValue('folderName', e.target.value);
!isEditing && formik.setFieldValue('directoryName', sanitizeName(e.target.value));
}}
value={formik.values.folderName || ''}
/>
{formik.touched.folderName && formik.errors.folderName ? (
<div className="text-red-500">{formik.errors.folderName}</div>
) : null}
{showFilesystemName && (
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="directoryName" className="flex items-center font-semibold">
Folder Name <small className='font-normal text-muted ml-1'>(on filesystem)</small>
<Help width="300">
<p>
You can choose to save the folder as a different name on your file system versus what is displayed in the app.
</p>
</Help>
</label>
{isEditing ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(false)}
/>
): (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(true)}
/>
)}
</div>
{isEditing ? (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<input
id="file-name"
type="text"
name="directoryName"
placeholder="Folder Name"
className={`block textbox mt-2 w-full`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.directoryName || ''}
/>
</div>
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
iconType="folder"
baseName={formik.values.directoryName}
/>
</div>
)}
{formik.touched.directoryName && formik.errors.directoryName ? (
<div className="text-red-500">{formik.errors.directoryName}</div>
) : null}
</div>
)}
<div className="flex justify-between items-center mt-8 bruno-modal-footer">
<div className='flex advanced-options'>
<Dropdown onCreate={onDropdownCreate} icon={<AdvancedOptions />} placement="bottom-start">
<div
className="dropdown-item"
key="show-filesystem-name"
onClick={(e) => {
dropdownTippyRef.current.hide();
toggleShowFilesystemName(!showFilesystemName);
}}
>
{showFilesystemName ? 'Hide Filesystem Name' : 'Show Filesystem Name'}
</div>
</Dropdown>
</div>
<div className='flex justify-end'>
<span className='mr-2'>
<button type="button" onClick={onClose} className="btn btn-md btn-close">
Cancel
</button>
</span>
<span>
<button
type="submit"
className="submit btn btn-md btn-secondary"
>
Create
</button>
</span>
</div>
</div>
<div className='relative flex flex-row gap-1 items-center justify-between'>
<input
id="file-name"
type="text"
name="directoryName"
placeholder="Directory Name"
className={`block textbox mt-2 w-full`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.directoryName || ''}
/>
</div>
</div>
) : (
<PathDisplay
collection={collection}
item={item}
filename={formik.values.directoryName}
showExtension={false}
isEditingFilename={isEditingFilename}
toggleEditingFilename={toggleEditingFilename}
/>
)}
{formik.touched.directoryName && formik.errors.directoryName ? (
<div className="text-red-500">{formik.errors.directoryName}</div>
) : null}
</form>
</Modal>
</form>
</Modal>
</StyledWrapper>
</Portal>
);
};

View File

@@ -1,45 +1,53 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.method-selector-container {
border: solid 1px ${(props) => props.theme.modal.input.border};
border-right: none;
background-color: ${(props) => props.theme.modal.input.bg};
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
.method-selector {
min-width: 80px;
}
}
div.method-selector-container,
div.input-container {
background-color: ${(props) => props.theme.modal.input.bg};
height: 2.3rem;
}
div.input-container {
border: solid 1px ${(props) => props.theme.modal.input.border};
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
input {
background-color: ${(props) => props.theme.modal.input.bg};
outline: none;
box-shadow: none;
&:focus {
outline: none !important;
box-shadow: none !important;
}
}
}
textarea.curl-command {
min-height: 150px;
}
.dropdown {
width: fit-content;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
`;
const StyledWrapper = styled.div`
div.method-selector-container {
border: solid 1px ${(props) => props.theme.modal.input.border};
border-right: none;
background-color: ${(props) => props.theme.modal.input.bg};
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
.method-selector {
min-width: 80px;
}
}
div.method-selector-container,
div.input-container {
background-color: ${(props) => props.theme.modal.input.bg};
height: 2.3rem;
}
div.input-container {
border: solid 1px ${(props) => props.theme.modal.input.border};
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
input {
background-color: ${(props) => props.theme.modal.input.bg};
outline: none;
box-shadow: none;
&:focus {
outline: none !important;
box-shadow: none !important;
}
}
}
export default StyledWrapper;
textarea.curl-command {
min-height: 150px;
}
.dropdown {
width: fit-content;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
.advanced-options {
.caret {
color: ${(props) => props.theme.textLink};
fill: ${(props) => props.theme.textLink};
}
}
`;
export default StyledWrapper;

View File

@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useCallback, forwardRef, useState } from 'rea
import { useFormik } from 'formik';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
import path from 'utils/common/path';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
@@ -11,10 +12,12 @@ import { addTab } from 'providers/ReduxStore/slices/tabs';
import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { getRequestFromCurlCommand } from 'utils/curl';
import { IconArrowBackUp, IconCaretDown } from '@tabler/icons';
import { IconArrowBackUp, IconCaretDown, IconEdit } from '@tabler/icons';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import Dropdown from 'components/Dropdown';
import PathDisplay from 'components/PathDisplay';
import Portal from 'components/Portal';
import Help from 'components/Help';
import StyledWrapper from './StyledWrapper';
const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
@@ -24,10 +27,14 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
brunoConfig: { presets: collectionPresets = {} }
} = collection;
const [curlRequestTypeDetected, setCurlRequestTypeDetected] = useState(null);
const [showFilesystemName, toggleShowFilesystemName] = useState(false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const advancedDropdownTippyRef = useRef();
const onAdvancedDropdownCreate = (ref) => (advancedDropdownTippyRef.current = ref);
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end auth-type-label select-none">
@@ -57,7 +64,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
setCurlRequestTypeDetected(type);
};
const [isEditingFilename, toggleEditingFilename] = useState(false);
const [isEditing, toggleEditing] = useState(false);
const getRequestType = (collectionPresets) => {
if (!collectionPresets || !collectionPresets.requestType) {
@@ -229,213 +236,279 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
}
};
return (
<StyledWrapper>
<Modal size="md" title="New Request" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form
className="bruno-form"
onSubmit={formik.handleSubmit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.handleSubmit();
}
}}
const AdvancedOptions = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex mr-2 text-link cursor-pointer items-center">
<button
className="btn-advanced"
type="button"
>
<div>
<label htmlFor="requestName" className="block font-semibold">
Type
</label>
Options
</button>
<IconCaretDown className="caret ml-1" size={14} strokeWidth={2}/>
</div>
);
});
<div className="flex items-center mt-2">
<input
id="http-request"
className="cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="http-request"
checked={formik.values.requestType === 'http-request'}
/>
<label htmlFor="http-request" className="ml-1 cursor-pointer select-none">
HTTP
return (
<Portal>
<StyledWrapper>
<Modal size="md" title="New Request" hideFooter handleCancel={onClose}>
<form
className="bruno-form"
onSubmit={formik.handleSubmit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.handleSubmit();
}
}}
>
<div>
<label htmlFor="requestName" className="block font-semibold">
Type
</label>
<input
id="graphql-request"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={(event) => {
formik.setFieldValue('requestMethod', 'POST');
formik.handleChange(event);
}}
value="graphql-request"
checked={formik.values.requestType === 'graphql-request'}
/>
<label htmlFor="graphql-request" className="ml-1 cursor-pointer select-none">
GraphQL
</label>
<input
id="from-curl"
className="cursor-pointer ml-auto"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="from-curl"
checked={formik.values.requestType === 'from-curl'}
/>
<label htmlFor="from-curl" className="ml-1 cursor-pointer select-none">
From cURL
</label>
</div>
</div>
<div className="mt-4">
<label htmlFor="requestName" className="block font-semibold">
Name
</label>
<input
id="request-name"
type="text"
name="requestName"
placeholder="Request Name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={e => {
formik.setFieldValue('requestName', e.target.value);
!isEditingFilename && formik.setFieldValue('filename', sanitizeName(e.target.value));
}}
value={formik.values.requestName || ''}
/>
{formik.touched.requestName && formik.errors.requestName ? (
<div className="text-red-500">{formik.errors.requestName}</div>
) : null}
</div>
{isEditingFilename ? (
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="block font-semibold">
File Name
</label>
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(false)}
/>
</div>
<div className='relative flex flex-row gap-1 items-center justify-between'>
<div className="flex items-center mt-2">
<input
id="file-name"
type="text"
name="filename"
placeholder="File Name"
className={`!pr-10 block textbox mt-2 w-full`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
id="http-request"
className="cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
value={formik.values.filename || ''}
value="http-request"
checked={formik.values.requestType === 'http-request'}
/>
<span className='absolute right-2 top-4 flex justify-center items-center file-extension'>.bru</span>
<label htmlFor="http-request" className="ml-1 cursor-pointer select-none">
HTTP
</label>
<input
id="graphql-request"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={(event) => {
formik.setFieldValue('requestMethod', 'POST');
formik.handleChange(event);
}}
value="graphql-request"
checked={formik.values.requestType === 'graphql-request'}
/>
<label htmlFor="graphql-request" className="ml-1 cursor-pointer select-none">
GraphQL
</label>
<input
id="from-curl"
className="cursor-pointer ml-auto"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="from-curl"
checked={formik.values.requestType === 'from-curl'}
/>
<label htmlFor="from-curl" className="ml-1 cursor-pointer select-none">
From cURL
</label>
</div>
</div>
) : (
<PathDisplay
collection={collection}
item={item}
filename={formik.values.filename}
isEditingFilename={isEditingFilename}
toggleEditingFilename={toggleEditingFilename}
/>
)}
{formik.touched.filename && formik.errors.filename ? (
<div className="text-red-500">{formik.errors.filename}</div>
) : null}
{formik.values.requestType !== 'from-curl' ? (
<>
<div className="mt-4">
<label htmlFor="requestName" className="block font-semibold">
Request Name
</label>
<input
id="request-name"
type="text"
name="requestName"
placeholder="Request Name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={e => {
formik.setFieldValue('requestName', e.target.value);
!isEditing && formik.setFieldValue('filename', sanitizeName(e.target.value));
}}
value={formik.values.requestName || ''}
/>
{formik.touched.requestName && formik.errors.requestName ? (
<div className="text-red-500">{formik.errors.requestName}</div>
) : null}
</div>
{showFilesystemName && (
<div className="mt-4">
<label htmlFor="request-url" className="block font-semibold">
URL
</label>
<div className="flex items-center mt-2 ">
<div className="flex items-center h-full method-selector-container">
<HttpMethodSelector
method={formik.values.requestMethod}
onMethodSelect={(val) => formik.setFieldValue('requestMethod', val)}
<div className="flex items-center justify-between">
<label htmlFor="filename" className="flex items-center font-semibold">
File Name <small className='font-normal text-muted ml-1'>(on filesystem)</small>
<Help width="300">
<p>
Bruno saves each request as a file in your collection's folder.
</p>
<p className="mt-2">
You can choose a file name different from your request's name or one compatible with filesystem rules.
</p>
</Help>
</label>
{isEditing ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(false)}
/>
</div>
<div className="flex items-center flex-grow input-container h-full">
) : (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(true)}
/>
)}
</div>
{isEditing ? (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<input
id="request-url"
id="file-name"
type="text"
name="requestUrl"
placeholder="Request URL"
className="px-3 w-full "
name="filename"
placeholder="File Name"
className={`!pr-10 block textbox mt-2 w-full`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.requestUrl || ''}
onPaste={handlePaste}
value={formik.values.filename || ''}
/>
<span className='absolute right-2 top-4 flex justify-center items-center file-extension'>.bru</span>
</div>
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, item?.pathname || collection?.pathname)}
baseName={formik.values.filename? `${formik.values.filename}.bru` : ''}
/>
</div>
</div>
{formik.touched.requestUrl && formik.errors.requestUrl ? (
<div className="text-red-500">{formik.errors.requestUrl}</div>
)}
{formik.touched.filename && formik.errors.filename ? (
<div className="text-red-500">{formik.errors.filename}</div>
) : null}
</div>
</>
) : (
<div className="mt-4">
<div className="flex justify-between">
<label htmlFor="request-url" className="block font-semibold">
cURL Command
</label>
<Dropdown className="dropdown" onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
curlRequestTypeChange('http-request');
}}
>
HTTP
)}
{formik.values.requestType !== 'from-curl' ? (
<>
<div className="mt-4">
<label htmlFor="request-url" className="block font-semibold">
URL
</label>
<div className="flex items-center mt-2 ">
<div className="flex items-center h-full method-selector-container">
<HttpMethodSelector
method={formik.values.requestMethod}
onMethodSelect={(val) => formik.setFieldValue('requestMethod', val)}
/>
</div>
<div className="flex items-center flex-grow input-container h-full">
<input
id="request-url"
type="text"
name="requestUrl"
placeholder="Request URL"
className="px-3 w-full "
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.requestUrl || ''}
onPaste={handlePaste}
/>
</div>
</div>
<div
{formik.touched.requestUrl && formik.errors.requestUrl ? (
<div className="text-red-500">{formik.errors.requestUrl}</div>
) : null}
</div>
</>
) : (
<div className="mt-4">
<div className="flex justify-between">
<label htmlFor="request-url" className="block font-semibold">
cURL Command
</label>
<Dropdown className="dropdown" onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
curlRequestTypeChange('http-request');
}}
>
HTTP
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
curlRequestTypeChange('graphql-request');
}}
>
GraphQL
</div>
</Dropdown>
</div>
<textarea
name="curlCommand"
placeholder="Enter cURL request here.."
className="block textbox w-full mt-4 curl-command"
value={formik.values.curlCommand}
onChange={handleCurlCommandChange}
></textarea>
{formik.touched.curlCommand && formik.errors.curlCommand ? (
<div className="text-red-500">{formik.errors.curlCommand}</div>
) : null}
</div>
)}
<div className="flex justify-between items-center mt-8 bruno-modal-footer">
<div className='flex advanced-options'>
<Dropdown onCreate={onAdvancedDropdownCreate} icon={<AdvancedOptions />} placement="bottom-start">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
curlRequestTypeChange('graphql-request');
key="show-filesystem-name"
onClick={(e) => {
advancedDropdownTippyRef.current.hide();
toggleShowFilesystemName(!showFilesystemName);
}}
>
GraphQL
{showFilesystemName ? 'Hide Filesystem Name' : 'Show Filesystem Name'}
</div>
</Dropdown>
</div>
<textarea
name="curlCommand"
placeholder="Enter cURL request here.."
className="block textbox w-full mt-4 curl-command"
value={formik.values.curlCommand}
onChange={handleCurlCommandChange}
></textarea>
{formik.touched.curlCommand && formik.errors.curlCommand ? (
<div className="text-red-500">{formik.errors.curlCommand}</div>
) : null}
<div className='flex justify-end'>
<span className='mr-2'>
<button type="button" onClick={onClose} className="btn btn-md btn-close">
Cancel
</button>
</span>
<span>
<button
type="submit"
className="submit btn btn-md btn-secondary"
>
Create
</button>
</span>
</div>
</div>
)}
</form>
</Modal>
</StyledWrapper>
</form>
</Modal>
</StyledWrapper>
</Portal>
);
};

View File

@@ -5,6 +5,10 @@ const StyledWrapper = styled.div`
height: 30px;
overflow-y: hidden;
overflow-x: hidden;
&.disabled {
opacity: 0.5;
}
.CodeMirror {
background: transparent;

View File

@@ -53,6 +53,7 @@ class SingleLineEditor extends Component {
},
scrollbarStyle: null,
tabindex: 0,
readOnly: this.props.readOnly,
extraKeys: {
Enter: runHandler,
'Ctrl-Enter': runHandler,
@@ -127,6 +128,9 @@ class SingleLineEditor extends Component {
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
this.editor.setOption('readOnly', this.props.readOnly);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
@@ -175,7 +179,7 @@ class SingleLineEditor extends Component {
render() {
return (
<div className="flex flex-row justify-between w-full overflow-x-auto">
<StyledWrapper ref={this.editorRef} className="single-line-editor grow" />
<StyledWrapper ref={this.editorRef} className={`single-line-editor grow ${this.props.readOnly? 'disabled' : ''}`} />
{this.secretEye(this.props.isSecret)}
</div>
);

View File

@@ -24,6 +24,7 @@ import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { isElectron } from 'utils/common/platform';
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
import { collectionAddOauth2CredentialsByUrl } from 'providers/ReduxStore/slices/collections/index';
const useIpcEvents = () => {
const dispatch = useDispatch();
@@ -160,7 +161,17 @@ const useIpcEvents = () => {
const removeSnapshotHydrationListener = ipcRenderer.on('main:hydrate-app-with-ui-state-snapshot', (val) => {
dispatch(hydrateCollectionWithUiStateSnapshot(val));
})
});
const removeCollectionOauth2CredentialsUpdatesListener = ipcRenderer.on('main:credentials-update', (val) => {
const payload = {
...val,
itemUid: val.itemUid || null,
folderUid: val.folderUid || null,
credentialsId: val.credentialsId || 'credentials'
};
dispatch(collectionAddOauth2CredentialsByUrl(payload));
});
return () => {
removeCollectionTreeUpdateListener();
@@ -181,6 +192,7 @@ const useIpcEvents = () => {
removeSystemProxyEnvUpdatesListener();
removeGlobalEnvironmentsUpdatesListener();
removeSnapshotHydrationListener();
removeCollectionOauth2CredentialsUpdatesListener();
};
}, [isElectron]);
};

View File

@@ -37,7 +37,9 @@ import {
resetRunResults,
responseReceived,
updateLastAction,
setCollectionSecurityConfig
setCollectionSecurityConfig,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl
} from './index';
import { each } from 'lodash';
@@ -237,11 +239,20 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid);
sendNetworkRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables)
.then((response) => {
// Ensure any timestamps in the response are converted to numbers
const serializedResponse = {
...response,
timeline: response.timeline?.map(entry => ({
...entry,
timestamp: entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp
}))
};
return dispatch(
responseReceived({
itemUid: item.uid,
collectionUid: collectionUid,
response: response
response: serializedResponse
})
);
})
@@ -799,7 +810,7 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
file: null
},
auth: auth ?? {
mode: 'none'
mode: 'inherit'
}
}
};
@@ -1230,33 +1241,92 @@ export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (
export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getState) => {
const collectionSnapshotData = payload;
return new Promise((resolve, reject) => {
const state = getState();
try {
if(!collectionSnapshotData) resolve();
const { pathname, selectedEnvironment } = collectionSnapshotData;
const collection = findCollectionByPathname(state.collections.collections, pathname);
const collectionCopy = cloneDeep(collection);
const collectionUid = collectionCopy?.uid;
const collectionSnapshotData = payload;
return new Promise((resolve, reject) => {
const state = getState();
try {
if(!collectionSnapshotData) resolve();
const { pathname, selectedEnvironment } = collectionSnapshotData;
const collection = findCollectionByPathname(state.collections.collections, pathname);
const collectionCopy = cloneDeep(collection);
const collectionUid = collectionCopy?.uid;
// update selected environment
if (selectedEnvironment) {
const environment = findEnvironmentInCollectionByName(collectionCopy, selectedEnvironment);
if (environment) {
dispatch(_selectEnvironment({ environmentUid: environment?.uid, collectionUid }));
}
// update selected environment
if (selectedEnvironment) {
const environment = findEnvironmentInCollectionByName(collectionCopy, selectedEnvironment);
if (environment) {
dispatch(_selectEnvironment({ environmentUid: environment?.uid, collectionUid }));
}
}
// todo: add any other redux state that you want to save
// todo: add any other redux state that you want to save
resolve();
}
catch(error) {
reject(error);
}
});
};
export const fetchOauth2Credentials = (payload) => async (dispatch, getState) => {
const { request, collection, itemUid, folderUid } = payload;
return new Promise((resolve, reject) => {
window.ipcRenderer
.invoke('renderer:fetch-oauth2-credentials', { itemUid, request, collection })
.then(({ credentials, url, collectionUid, credentialsId, debugInfo }) => {
dispatch(
collectionAddOauth2CredentialsByUrl({
credentials,
url,
collectionUid,
credentialsId,
debugInfo,
folderUid: folderUid || null,
itemUid: !folderUid ? itemUid : null
})
);
resolve(credentials);
})
.catch(reject);
});
};
export const refreshOauth2Credentials = (payload) => async (dispatch, getState) => {
const { request, collection, folderUid, itemId } = payload;
return new Promise((resolve, reject) => {
window.ipcRenderer
.invoke('renderer:refresh-oauth2-credentials', { request, collection })
.then(({ credentials, url, collectionUid, debugInfo }) => {
dispatch(
collectionAddOauth2CredentialsByUrl({
credentials,
url,
collectionUid,
debugInfo,
folderUid: folderUid || null,
itemId: !folderUid ? itemId : null
})
);
resolve(credentials);
})
.catch(reject);
});
};
export const clearOauth2Cache = (payload) => async (dispatch, getState) => {
const { collectionUid, url, credentialsId } = payload;
return new Promise((resolve, reject) => {
window.ipcRenderer
.invoke('clear-oauth2-cache', collectionUid, url, credentialsId)
.then(() => {
// We do not dispatch any action to modify the Redux store,
// since we are only clearing the session on the main process side.
resolve();
}
catch(error) {
reject(error);
}
});
};
})
.catch(reject);
});
};
export const loadRequestViaWorker = ({ collectionUid, pathname }) => (dispatch, getState) => {
return new Promise(async (resolve, reject) => {

View File

@@ -281,13 +281,38 @@ export const collectionsSlice = createSlice({
},
responseReceived: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item) {
item.requestState = 'received';
item.response = action.payload.response;
item.cancelTokenUid = null;
if (!collection.timeline) {
collection.timeline = [];
}
// Ensure timestamp is a number (milliseconds since epoch)
const timestamp = item?.requestSent?.timestamp instanceof Date
? item.requestSent.timestamp.getTime()
: item?.requestSent?.timestamp || Date.now();
console.log("response reieved", JSON.stringify(item), JSON.stringify(item.requestSent));
// Append the new timeline entry with numeric timestamp
collection.timeline.push({
type: "request",
collectionUid: collection.uid,
folderUid: null,
itemUid: item.uid,
timestamp: timestamp,
data: {
request: item.requestSent || item.request,
response: action.payload.response,
timestamp: timestamp,
}
});
}
}
},
@@ -1539,6 +1564,26 @@ export const collectionsSlice = createSlice({
set(folder, 'root.request.tests', action.payload.tests);
}
},
updateFolderAuth: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
console.log('action.payload.content inside bro', action.payload);
if (!collection) return;
const folder = collection ? findItemInCollection(collection, action.payload.itemUid) : null;
console.log('folder inside bro', folder);
if (!folder) return;
if (folder) {
set(folder, 'root.request.auth', {});
set(folder, 'root.request.auth.mode', action.payload.mode);
switch (action.payload.mode) {
case 'oauth2':
set(folder, 'root.request.auth.oauth2', action.payload.content);
break;
}
}
},
addCollectionHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -2019,7 +2064,85 @@ export const collectionsSlice = createSlice({
set(folder, 'root.docs', action.payload.docs);
}
}
}
},
collectionAddOauth2CredentialsByUrl: (state, action) => {
const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
// Update oauth2Credentials (latest token)
if (!collection.oauth2Credentials) {
collection.oauth2Credentials = [];
}
let collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials);
// Remove existing credentials for the same combination
const filteredOauth2Credentials = filter(
collectionOauth2Credentials,
(creds) =>
!(creds.url === url && creds.collectionUid === collectionUid && creds.credentialsId === credentialsId)
);
// Add the new credential with folderUid and itemUid
filteredOauth2Credentials.push({
collectionUid,
folderUid,
itemUid,
url,
credentials,
credentialsId,
debugInfo
});
collection.oauth2Credentials = filteredOauth2Credentials;
if (!collection.timeline) {
collection.timeline = [];
}
if(debugInfo) {
collection.timeline.push({
type: "oauth2",
collectionUid,
folderUid,
itemUid,
timestamp: Date.now(),
data: {
collectionUid,
folderUid,
itemUid,
url,
credentials,
credentialsId,
debugInfo: debugInfo.data,
}
});
}
},
collectionClearOauth2CredentialsByUrl: (state, action) => {
// Since we don't want to remove tokens from oauth2Credentials or timeline,
},
collectionGetOauth2CredentialsByUrl: (state, action) => {
const { collectionUid, url, credentialsId } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
const oauth2Credential = find(
collection?.oauth2Credentials || [],
(creds) =>
creds.url === url && creds.collectionUid === collectionUid && creds.credentialsId === credentialsId
);
return oauth2Credential;
},
updateFolderAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
set(folder, 'root.request.auth', {});
set(folder, 'root.request.auth.mode', action.payload.mode);
}
},
}
});
@@ -2124,6 +2247,11 @@ export const {
resetCollectionRunner,
updateRequestDocs,
updateFolderDocs,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl,
collectionGetOauth2CredentialsByUrl,
updateFolderAuth,
updateFolderAuthMode,
moveCollection
} = collectionsSlice.actions;

View File

@@ -145,7 +145,7 @@ body {
font-kerning: none;
text-rendering: optimizeSpeed;
letter-spacing: normal;
font-family: Inter, sans-serif !important;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
overflow-x: hidden;
}

View File

@@ -1,6 +1,8 @@
import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
import { uuid } from 'utils/common';
import path from 'utils/common/path';
import brunoCommon from '@usebruno/common';
const { interpolate } = brunoCommon;
const replaceTabsWithSpaces = (str, numSpaces = 2) => {
if (!str || !str.length || !isString(str)) {
@@ -381,11 +383,19 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
di.request.auth.oauth2 = {
grantType: grantType,
accessTokenUrl: get(si.request, 'auth.oauth2.accessTokenUrl', ''),
refreshUrl: get(si.request, 'auth.oauth2.refreshUrl', ''),
username: get(si.request, 'auth.oauth2.username', ''),
password: get(si.request, 'auth.oauth2.password', ''),
clientId: get(si.request, 'auth.oauth2.clientId', ''),
clientSecret: get(si.request, 'auth.oauth2.clientSecret', ''),
scope: get(si.request, 'auth.oauth2.scope', '')
scope: get(si.request, 'auth.oauth2.scope', ''),
credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'),
credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'),
tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'),
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'),
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
};
break;
case 'authorization_code':
@@ -394,19 +404,36 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
callbackUrl: get(si.request, 'auth.oauth2.callbackUrl', ''),
authorizationUrl: get(si.request, 'auth.oauth2.authorizationUrl', ''),
accessTokenUrl: get(si.request, 'auth.oauth2.accessTokenUrl', ''),
refreshUrl: get(si.request, 'auth.oauth2.refreshUrl', ''),
clientId: get(si.request, 'auth.oauth2.clientId', ''),
clientSecret: get(si.request, 'auth.oauth2.clientSecret', ''),
scope: get(si.request, 'auth.oauth2.scope', ''),
pkce: get(si.request, 'auth.oauth2.pkce', false)
credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'),
pkce: get(si.request, 'auth.oauth2.pkce', false),
credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'),
tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'),
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'),
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
authorizeInDefaultBrowser: get(si.request, 'auth.oauth2.authorizeInDefaultBrowser', ''),
};
break;
case 'client_credentials':
di.request.auth.oauth2 = {
grantType: grantType,
accessTokenUrl: get(si.request, 'auth.oauth2.accessTokenUrl', ''),
refreshUrl: get(si.request, 'auth.oauth2.refreshUrl', ''),
clientId: get(si.request, 'auth.oauth2.clientId', ''),
clientSecret: get(si.request, 'auth.oauth2.clientSecret', ''),
scope: get(si.request, 'auth.oauth2.scope', '')
scope: get(si.request, 'auth.oauth2.scope', ''),
credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'),
credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'),
tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'),
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'),
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
};
break;
}
@@ -953,12 +980,15 @@ export const getAllVariables = (collection, item) => {
const uniqueMaskedVariables = [...new Set([...filteredMaskedEnvVariables, ...filteredMaskedGlobalEnvVariables])];
const oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials })
return {
...globalEnvironmentVariables,
...collectionVariables,
...envVariables,
...folderVariables,
...requestVariables,
...oauth2CredentialVariables,
...runtimeVariables,
pathParams: {
...pathParams
@@ -1025,4 +1055,44 @@ const mergeVars = (collection, requestTreePath = []) => {
folderVariables,
requestVariables
};
};
};
export const interpolateStringUsingCollectionAndItem = ({ collection, item, string }) => {
const variables = getAllVariables(collection, item);
const value = interpolate(string, variables);
return value;
}
export const getEnvVars = (environment = {}) => {
const variables = environment.variables;
if (!variables || !variables.length) {
return {
__name__: environment.name
};
}
const envVars = {};
each(variables, (variable) => {
if (variable.enabled) {
envVars[variable.name] = variable.value;
}
});
return {
...envVars,
__name__: environment.name
};
};
export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = [] }) => {
let credentialsVariables = {};
oauth2Credentials.forEach(({ credentialsId, credentials }) => {
if (credentials) {
Object.entries(credentials).forEach(([key, value]) => {
credentialsVariables[`$oauth2.${credentialsId}.${key}`] = value;
});
}
});
return credentialsVariables;
};

View File

@@ -187,6 +187,8 @@ export const stringifyIfNot = v => typeof v === 'string' ? v : String(v);
export const getEncoding = (headers) => {
// Parse the charset from content type: https://stackoverflow.com/a/33192813
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(headers['content-type'] || '');
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(headers?.['content-type'] || '');
return charsetMatch?.[1];
}
export const BRUNO_OAUTH2_SERVER_CALLBACK_URL = `http://localhost:9876/callback`;

View File

@@ -2,7 +2,7 @@ const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // replace invalid character
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
const lastCharacter = /[^.\s]$/; // no dot or space at end, hyphen allowed
const lastCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot or space at end, hyphen allowed
export const variableNameRegex = /^[\w-.]*$/;
@@ -29,6 +29,7 @@ export const validateName = (name) => {
export const validateNameError = (name) => {
if (!name) return "Name cannot be empty.";
if (name.length > 255) {
return "Name cannot exceed 255 characters.";
}

View File

@@ -9,7 +9,6 @@
import parseCurlCommand from './parse-curl';
import * as querystring from 'query-string';
import * as jsesc from 'jsesc';
import * as path from 'path';
function getContentType(headers = {}) {
const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type');

View File

@@ -5,6 +5,7 @@ import { BrunoError } from 'utils/common/error';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';
import { postmanTranslation } from 'utils/importers/translators/postman_translation';
import each from 'lodash/each';
import { BRUNO_OAUTH2_SERVER_CALLBACK_URL } from 'utils/common/index';
const readFile = (files) => {
return new Promise((resolve, reject) => {
@@ -431,6 +432,67 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
value: authValues.value?.toString(), // Convert the value to a string as Postman's schema does not rigidly define the type of it,
placement: "header" //By default we are placing the apikey values in headers!
}
} else if (auth.type === 'oauth2'){
const findValueUsingKey = (key) => {
return auth?.oauth2?.find(v => v?.key == key)?.value ?? ''
}
const oauth2GrantTypeMaps = {
'authorization_code_with_pkce': 'authorization_code',
'authorization_code': 'authorization_code',
'client_credentials': 'client_credentials',
'password_credentials': 'password_credentials'
}
const grantType = oauth2GrantTypeMaps[findValueUsingKey('grant_type')] || 'authorization_code';
if (grantType) {
brunoRequestItem.request.auth.mode = 'oauth2';
switch(grantType) {
case 'authorization_code':
brunoRequestItem.request.auth.oauth2 = {
grantType: 'authorization_code',
authorizationUrl: findValueUsingKey('authUrl'),
callbackUrl: Boolean(findValueUsingKey('useBrowser')) ? BRUNO_OAUTH2_SERVER_CALLBACK_URL : findValueUsingKey('redirect_uri'),
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshUrl: findValueUsingKey('refreshTokenUrl'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
pkce: Boolean(findValueUsingKey('grant_type') == 'authorization_code_with_pkce'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header',
authorizeInDefaultBrowser: Boolean(findValueUsingKey('useBrowser')),
};
break;
case 'password_credentials':
brunoRequestItem.request.auth.oauth2 = {
grantType: 'password',
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshUrl: findValueUsingKey('refreshTokenUrl'),
username: findValueUsingKey('username'),
password: findValueUsingKey('password'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
break;
case 'client_credentials':
brunoRequestItem.request.auth.oauth2 = {
grantType: 'client_credentials',
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshUrl: findValueUsingKey('refreshTokenUrl'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
break;
}
}
}
}

View File

@@ -14,7 +14,8 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
size: response.size,
status: response.status,
statusText: response.statusText,
duration: response.duration
duration: response.duration,
timeline: response.timeline
});
})
.catch((err) => reject(err));
@@ -36,17 +37,14 @@ const sendHttpRequest = async (item, collection, environment, runtimeVariables)
export const sendCollectionOauth2Request = async (collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer
.invoke('send-collection-oauth2-request', collection, environment, runtimeVariables)
.then(resolve)
.catch(reject);
resolve({});
});
};
export const clearOauth2Cache = async (uid) => {
export const readOauth2CachedCredentials = async (uid) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('clear-oauth2-cache', uid).then(resolve).catch(reject);
ipcRenderer.invoke('read-oauth2-cached-credentials', uid).then(resolve).catch(reject);
});
};

View File

@@ -147,13 +147,13 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
// todo: we have things happening in two places w.r.t basic auth
// need to refactor this in the future
// the request.auth (basic auth) object gets set inside the prepare-request.js file
if (request.auth) {
const username = _interpolate(request.auth.username) || '';
const password = _interpolate(request.auth.password) || '';
if (request.basicAuth) {
const username = _interpolate(request.basicAuth.username) || '';
const password = _interpolate(request.basicAuth.password) || '';
// use auth header based approach and delete the request.auth object
request.headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
delete request.auth;
delete request.basicAuth;
}
if (request.awsv4config) {
@@ -165,12 +165,14 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
request.awsv4config.profileName = _interpolate(request.awsv4config.profileName) || '';
}
// interpolate vars for ntlmConfig auth
if (request.ntlmConfig) {
request.ntlmConfig.username = _interpolate(request.ntlmConfig.username) || '';
request.ntlmConfig.password = _interpolate(request.ntlmConfig.password) || '';
request.ntlmConfig.domain = _interpolate(request.ntlmConfig.domain) || '';
}
// interpolate vars for ntlmConfig auth
if (request.ntlmConfig) {
request.ntlmConfig.username = _interpolate(request.ntlmConfig.username) || '';
request.ntlmConfig.password = _interpolate(request.ntlmConfig.password) || '';
request.ntlmConfig.domain = _interpolate(request.ntlmConfig.domain) || '';
}
if(request?.auth) delete request.auth;
if (request) return request;
};

View File

@@ -38,7 +38,7 @@ const prepareRequest = (item = {}, collection = {}) => {
const collectionAuth = get(collection, 'root.request.auth');
if (collectionAuth && request.auth?.mode === 'inherit') {
if (collectionAuth.mode === 'basic') {
axiosRequest.auth = {
axiosRequest.basicAuth = {
username: get(collectionAuth, 'basic.username'),
password: get(collectionAuth, 'basic.password')
};
@@ -69,7 +69,7 @@ const prepareRequest = (item = {}, collection = {}) => {
if (request.auth && request.auth.mode !== 'inherit') {
if (request.auth.mode === 'basic') {
axiosRequest.auth = {
axiosRequest.basicAuth = {
username: get(request, 'auth.basic.username'),
password: get(request, 'auth.basic.password')
};

View File

@@ -117,7 +117,7 @@ describe('prepare-request: prepareRequest', () => {
const result = prepareRequest(item, collection);
const expected = { username: 'testUser', password: 'testPass123' };
expect(result.auth).toEqual(expected);
expect(result.basicAuth).toEqual(expected);
});
});

View File

@@ -45,6 +45,7 @@
"electron-notarize": "^1.2.2",
"electron-store": "^8.1.0",
"electron-util": "^0.17.2",
"express": "^4.21.2",
"form-data": "^4.0.0",
"fs-extra": "^10.1.0",
"graphql": "^16.6.0",

View File

@@ -0,0 +1,176 @@
module.exports = function aboutBruno({version}) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1, user-scalable=yes">
<title>About Bruno</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
margin: 0;
padding: 10px;
background-color: #f4f4f4;
color: #333;
}
.logo {
margin-top: 0px;
}
.title {
font-size: 24px;
margin-top: 5px;
font-weight: bold;
color: #222;
}
.description {
font-size: 12px;
color: #222;
margin-top: 5px;
}
.buttons {
margin-top: 5px;
}
.footer {
margin-top: 5px;
padding: 5px;
font-size: 14px;
color: #555;
}
.link {
display: inline-block;
margin-top: 10px;
padding: 10px 15px;
background-color: #F4AA41;
color: white;
text-decoration: none;
border-radius: 5px;
cursor: pointer;
transition: background 0.3s;
}
.link:hover {
background-color: #F4AA41;
}
</style>
</head>
<body>
<div class="logo">
</div>
<svg id="emoji" width="100" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="color">
<path
fill="#F4AA41"
stroke="none"
d="M23.5,14.5855l-4.5,1.75l-7.25,8.5l-4.5,10.75l2,5.25c1.2554,3.7911,3.5231,7.1832,7.25,10l2.5-3.3333 c0,0,3.8218,7.7098,10.7384,8.9598c0,0,10.2616,1.936,15.5949-0.8765c3.4203-1.8037,4.4167-4.4167,4.4167-4.4167l3.4167-3.4167 l1.5833,2.3333l2.0833-0.0833l5.4167-7.25L64,37.3355l-0.1667-4.5l-2.3333-5.5l-4.8333-7.4167c0,0-2.6667-4.9167-8.1667-3.9167 c0,0-6.5-4.8333-11.8333-4.0833S32.0833,10.6688,23.5,14.5855z"
/>
<polygon
fill="#EA5A47"
stroke="none"
points="36,47.2521 32.9167,49.6688 30.4167,49.6688 30.3333,53.5021 31.0833,57.0021 32.1667,58.9188 35,60.4188 39.5833,59.8355 41.1667,58.0855 42.1667,53.8355 41.9167,49.8355 39.9167,50.0855"
/>
<polygon
fill="#3F3F3F"
stroke="none"
points="32.5,36.9188 30.9167,40.6688 33.0833,41.9188 34.3333,42.4188 38.6667,42.5855 41.5833,40.3355 39.8333,37.0855"
/>
</g>
<g id="hair" />
<g id="skin" />
<g id="skin-shadow" />
<g id="line">
<path
fill="#000000"
stroke="none"
d="M29.5059,30.1088c0,0-1.8051,1.2424-2.7484,0.6679c-0.9434-0.5745-1.2424-1.8051-0.6679-2.7484 s1.805-1.2424,2.7484-0.6679S29.5059,30.1088,29.5059,30.1088z"
/>
<path
fill="none"
stroke="#000000"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M33.1089,37.006h6.1457c0.4011,0,0.7634,0.2397,0.9203,0.6089l1.1579,2.7245l-2.1792,1.1456 c-0.6156,0.3236-1.3654-0.0645-1.4567-0.754"
/>
<path
fill="none"
stroke="#000000"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M34.7606,40.763c-0.1132,0.6268-0.7757,0.9895-1.3647,0.7471l-2.3132-0.952l1.0899-2.9035 c0.1465-0.3901,0.5195-0.6486,0.9362-0.6486"
/>
<path
fill="none"
stroke="#000000"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M30.4364,50.0268c0,0-0.7187,8.7934,3.0072,9.9375c2.6459,0.8125,5.1497,0.5324,6.0625-0.25 c0.875-0.75,2.6323-4.4741,1.8267-9.6875"
/>
<path
fill="#000000"
stroke="none"
d="M44.2636,30.1088c0,0,1.805,1.2424,2.7484,0.6679c0.9434-0.5745,1.2424-1.8051,0.6679-2.7484 c-0.5745-0.9434-1.805-1.2424-2.7484-0.6679C43.9881,27.9349,44.2636,30.1088,44.2636,30.1088z"
/>
<path
fill="none"
stroke="#000000"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M25.6245,42.8393c-0.475,3.6024,2.2343,5.7505,4.2847,6.8414c1.1968,0.6367,2.6508,0.5182,3.7176-0.3181l2.581-2.0233l2.581,2.0233 c1.0669,0.8363,2.5209,0.9548,3.7176,0.3181c2.0504-1.0909,4.7597-3.239,4.2847-6.8414"
/>
<path
fill="none"
stroke="#000000"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M19.9509,28.3572c-2.3166,5.1597-0.5084,13.0249,0.119,15.3759c0.122,0.4571,0.0755,0.9355-0.1271,1.3631l-1.9874,4.1937 c-0.623,1.3146-2.3934,1.5533-3.331,0.4409c-3.1921-3.7871-8.5584-11.3899-6.5486-16.686 c7.0625-18.6104,15.8677-18.1429,15.8677-18.1429c2.8453-1.9336,13.1042-6.9375,24.8125,0.875c0,0,8.6323-1.7175,14.9375,16.9375 c1.8036,5.3362-3.4297,12.8668-6.5506,16.6442c-0.9312,1.127-2.7162,0.8939-3.3423-0.4272l-1.9741-4.1656 c-0.2026-0.4275-0.2491-0.906-0.1271-1.3631c0.6275-2.3509,2.4356-10.2161,0.119-15.3759"
/>
<path
fill="none"
stroke="#000000"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M52.6309,46.4628c0,0-3.0781,6.7216-7.8049,8.2712"
/>
<path
fill="none"
stroke="#000000"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M19.437,46.969c0,0,3.0781,6.0823,7.8049,7.632"
/>
<line
x1="36.2078"
x2="36.2078"
y1="47.3393"
y2="44.3093"
fill="none"
stroke="#000000"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
/>
</g>
</svg>
<h2 class="title">Bruno ${version}</h2>
<footer class="footer">
©2025 Bruno Software Inc
</footer>
</body>
</html>
`;
};

View File

@@ -1,7 +1,8 @@
const { ipcMain } = require('electron');
const os = require('os');
const openAboutWindow = require('about-window').default;
const { join } = require('path');
const { BrowserWindow } = require('electron');
const { version } = require('../../package.json');
const aboutBruno = require('./about-bruno');
const template = [
{
@@ -77,14 +78,16 @@ const template = [
submenu: [
{
label: 'About Bruno',
click: () =>
openAboutWindow({
product_name: 'Bruno',
icon_path: join(__dirname, '../about/256x256.png'),
css_path: join(__dirname, '../about/about.css'),
homepage: 'https://www.usebruno.com/',
package_json_dir: join(__dirname, '../..')
})
click: () => {
const aboutWindow = new BrowserWindow({
width: 350,
height: 250,
webPreferences: {
nodeIntegration: true,
},
});
aboutWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(aboutBruno({version}))}`);
}
},
{ label: 'Documentation', click: () => ipcMain.emit('main:open-docs') }
]

View File

@@ -54,6 +54,7 @@ const jsonToCollectionBru = async (json, isFolder) => {
res: _.get(json, 'request.vars.res', [])
},
tests: _.get(json, 'request.tests', ''),
auth: _.get(json, 'request.auth', {}),
docs: _.get(json, 'docs', '')
};
@@ -66,10 +67,6 @@ const jsonToCollectionBru = async (json, isFolder) => {
};
}
if (!isFolder) {
collectionBruJson.auth = _.get(json, 'request.auth', {});
}
return _jsonToCollectionBru(collectionBruJson);
} catch (error) {
return Promise.reject(error);

View File

@@ -31,7 +31,7 @@ const lastOpenedCollections = new LastOpenedCollections();
const contentSecurityPolicy = [
"default-src 'self'",
"connect-src 'self' https://*.posthog.com",
"font-src 'self' https:",
"font-src 'self' https: data:;",
"frame-src data:",
// this has been commented out to make oauth2 work
// "form-action 'none'",

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