Compare commits
497 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e75391cdf1 | ||
|
|
a213c0c26c | ||
|
|
15424903ed | ||
|
|
1cce117b0a | ||
|
|
abc7b4b6f3 | ||
|
|
05e32ef9e4 | ||
|
|
f0a1fc319c | ||
|
|
385cabc6aa | ||
|
|
397b26a9f3 | ||
|
|
9a40058329 | ||
|
|
7ec2380ed5 | ||
|
|
3d4825305d | ||
|
|
26e564288b | ||
|
|
385a587cfd | ||
|
|
215df02783 | ||
|
|
7c7ed68b20 | ||
|
|
c910a0314a | ||
|
|
ddaec1b9ac | ||
|
|
9dbdef9286 | ||
|
|
e77eef1532 | ||
|
|
1fe0b8861d | ||
|
|
aeb9172144 | ||
|
|
1b413e2f47 | ||
|
|
d6c8400116 | ||
|
|
4a0205e622 | ||
|
|
c2520006ac | ||
|
|
99817fd8bd | ||
|
|
3f35fedd9d | ||
|
|
b7c2d13992 | ||
|
|
a6426587fb | ||
|
|
5f68356278 | ||
|
|
08f61e7408 | ||
|
|
9beda15f00 | ||
|
|
09d1663f81 | ||
|
|
f43b6e7cff | ||
|
|
6581eb4fd1 | ||
|
|
caedfe5c1e | ||
|
|
f6a234aaf9 | ||
|
|
8450fb6596 | ||
|
|
41fa3b5a8c | ||
|
|
522de45a62 | ||
|
|
4acc4b2dda | ||
|
|
c1f4855daf | ||
|
|
3506e96cfd | ||
|
|
b42a94ed77 | ||
|
|
80da790a3c | ||
|
|
d6c706d0f9 | ||
|
|
bd09a6ac45 | ||
|
|
4ada31b20e | ||
|
|
5d8b55e96b | ||
|
|
eab4893aa2 | ||
|
|
4806499040 | ||
|
|
1b1c02ceaa | ||
|
|
a8f0a8a253 | ||
|
|
b68115d3b2 | ||
|
|
c353d60ddc | ||
|
|
5d1337f15d | ||
|
|
eeee8af806 | ||
|
|
61b9aca746 | ||
|
|
c4358b91a2 | ||
|
|
4ce9e67460 | ||
|
|
8e25598a78 | ||
|
|
e88e6a7bcd | ||
|
|
971dfc4c14 | ||
|
|
9d9bf84c3f | ||
|
|
f7e170865d | ||
|
|
134441a6e7 | ||
|
|
80a5d21576 | ||
|
|
45c84beb81 | ||
|
|
4a4ee19ba9 | ||
|
|
f1a812dae2 | ||
|
|
a4781d5882 | ||
|
|
0dba28c388 | ||
|
|
67f7e6a6d2 | ||
|
|
7668be50ae | ||
|
|
100664f77e | ||
|
|
e54f837b83 | ||
|
|
a33337ae0c | ||
|
|
13aa456c3c | ||
|
|
65a194a6d2 | ||
|
|
55e3dd3c18 | ||
|
|
b88f496f4e | ||
|
|
696cf8490b | ||
|
|
ffc08227dd | ||
|
|
6cb3a2de43 | ||
|
|
47543e46f2 | ||
|
|
abd7b4f0f4 | ||
|
|
8caf9f110b | ||
|
|
1370b53726 | ||
|
|
2435436580 | ||
|
|
22aa8ee334 | ||
|
|
6d688ed2bc | ||
|
|
46e204165d | ||
|
|
8590a9a110 | ||
|
|
62058d5dfe | ||
|
|
9bfb965e63 | ||
|
|
1d397af674 | ||
|
|
141a468808 | ||
|
|
47bfef958b | ||
|
|
a24d724e2b | ||
|
|
dd72eacd21 | ||
|
|
e27dc1f7a2 | ||
|
|
ea847d7d32 | ||
|
|
87be0ef073 | ||
|
|
c3c3fc6720 | ||
|
|
8bdb9a657f | ||
|
|
71e1ada641 | ||
|
|
37a3b72025 | ||
|
|
c49573db65 | ||
|
|
97c3e6089d | ||
|
|
8586ced3cc | ||
|
|
2b44ede92b | ||
|
|
86a12e2d28 | ||
|
|
9d7509b4dd | ||
|
|
defece95fc | ||
|
|
7b78d99ac4 | ||
|
|
dbb45e7253 | ||
|
|
7286d3b94f | ||
|
|
cc802b1e9f | ||
|
|
a66a2f5645 | ||
|
|
885c0dc500 | ||
|
|
b826b53cee | ||
|
|
ea93162056 | ||
|
|
39afeab5f8 | ||
|
|
b6950332ad | ||
|
|
ccdce37f88 | ||
|
|
9d6a7f709c | ||
|
|
96a4125f15 | ||
|
|
b16e90c10d | ||
|
|
5164315243 | ||
|
|
3df0492275 | ||
|
|
fa8ca0569d | ||
|
|
f78354a377 | ||
|
|
8b1d8e6a90 | ||
|
|
2244fb0523 | ||
|
|
c611b39f52 | ||
|
|
73a0255ae8 | ||
|
|
e978541bf1 | ||
|
|
ae77c60c53 | ||
|
|
b0d9a934d9 | ||
|
|
1583c86c78 | ||
|
|
a779ba5c0e | ||
|
|
be46ed2686 | ||
|
|
e5002b4ef3 | ||
|
|
1372681b87 | ||
|
|
2179ce6fff | ||
|
|
28dbaf317e | ||
|
|
753db25e4c | ||
|
|
65719b560b | ||
|
|
44402ac6e1 | ||
|
|
7e1b26c6a9 | ||
|
|
8550c92e37 | ||
|
|
7d3b2c064a | ||
|
|
2a715d5348 | ||
|
|
9b76d62753 | ||
|
|
80898407c3 | ||
|
|
40208a13e0 | ||
|
|
ae9b7183b5 | ||
|
|
90569192b7 | ||
|
|
223150550f | ||
|
|
80c6f600db | ||
|
|
a938be3712 | ||
|
|
31c6b0664f | ||
|
|
3fa4052538 | ||
|
|
1780f3858d | ||
|
|
f2de0dc673 | ||
|
|
5eb85fd99c | ||
|
|
3f59597864 | ||
|
|
2ba05a46ee | ||
|
|
292ed87201 | ||
|
|
7e686a8882 | ||
|
|
4ca6e9ec3a | ||
|
|
bd5f95b1c5 | ||
|
|
167dfc3847 | ||
|
|
dcd441f15e | ||
|
|
90c8fbeee4 | ||
|
|
cae1840506 | ||
|
|
1860057a25 | ||
|
|
82c6f6f6bc | ||
|
|
2545262fc2 | ||
|
|
24dd535d9e | ||
|
|
b27fe871c4 | ||
|
|
cb5fff0310 | ||
|
|
b60d45ba76 | ||
|
|
7336a3d9c7 | ||
|
|
c7829201e1 | ||
|
|
63b6c76f51 | ||
|
|
056a5df4e1 | ||
|
|
757d1add5b | ||
|
|
3cf3feb2ae | ||
|
|
46579900cd | ||
|
|
2d4a5a30f7 | ||
|
|
2ed5a045de | ||
|
|
4b42496273 | ||
|
|
c5d8a446ae | ||
|
|
9bee62ada9 | ||
|
|
d15caba4a6 | ||
|
|
536c8128dd | ||
|
|
420359066e | ||
|
|
99918ee0c0 | ||
|
|
480e9ea3ec | ||
|
|
2ee4029e04 | ||
|
|
edd186bdfe | ||
|
|
864d40d934 | ||
|
|
856752db21 | ||
|
|
7fde6db9d1 | ||
|
|
0aac046a0e | ||
|
|
1ad11adb94 | ||
|
|
505adea0ef | ||
|
|
9c64721bf0 | ||
|
|
965fdad8b1 | ||
|
|
a6d6589811 | ||
|
|
a227af05d9 | ||
|
|
3b7a16c439 | ||
|
|
ce0898956d | ||
|
|
cd72851289 | ||
|
|
6711d752e2 | ||
|
|
65472bed54 | ||
|
|
a188ad68ed | ||
|
|
bb01afeb99 | ||
|
|
a1be3a3e77 | ||
|
|
b5e7877912 | ||
|
|
587e7118c9 | ||
|
|
8c5ffb88a3 | ||
|
|
2a00f41ef8 | ||
|
|
f676f94278 | ||
|
|
4ca762344c | ||
|
|
5c5ab5bad5 | ||
|
|
cd6e40f01c | ||
|
|
59a8a22e8a | ||
|
|
2910164d5a | ||
|
|
3afc89db6b | ||
|
|
bfc45993f8 | ||
|
|
b95e2b365a | ||
|
|
a8d50223aa | ||
|
|
73e788b513 | ||
|
|
15d135c11b | ||
|
|
0fcda0be1a | ||
|
|
1bbcd638b8 | ||
|
|
fe73750d66 | ||
|
|
ca4c576f78 | ||
|
|
648637a1a1 | ||
|
|
91adf379da | ||
|
|
08ca57cba2 | ||
|
|
e6fcb1272a | ||
|
|
60a5acdb9d | ||
|
|
2221261ec2 | ||
|
|
6627514e88 | ||
|
|
d7b02da719 | ||
|
|
ebbe015bbc | ||
|
|
e03a92f8d8 | ||
|
|
7d98c1b355 | ||
|
|
e78040a376 | ||
|
|
97eedb568c | ||
|
|
161f1db40e | ||
|
|
b50b97a4d1 | ||
|
|
e8e176ed40 | ||
|
|
bc82e9c7fa | ||
|
|
73ace77305 | ||
|
|
4023dcf09d | ||
|
|
ebf236b387 | ||
|
|
e40d77420c | ||
|
|
27b9f57d7a | ||
|
|
3cd9639f34 | ||
|
|
a5a14f6c76 | ||
|
|
bfac3f8ad0 | ||
|
|
dcadbac4d5 | ||
|
|
626d703d77 | ||
|
|
25b7ef3d2e | ||
|
|
d812e6ab96 | ||
|
|
74e4a77ce6 | ||
|
|
863e1ee113 | ||
|
|
33e4a15830 | ||
|
|
bf09786423 | ||
|
|
f7070dd3f7 | ||
|
|
523c650c9d | ||
|
|
469d408b09 | ||
|
|
cb1b13bdb4 | ||
|
|
480a34c0f7 | ||
|
|
93479320ee | ||
|
|
b2acd5511c | ||
|
|
96ed2f2119 | ||
|
|
606e0120ee | ||
|
|
6da85fd286 | ||
|
|
298b960ef7 | ||
|
|
ee3fbabece | ||
|
|
0bed5cd99a | ||
|
|
bc55af27a7 | ||
|
|
a0006f73ac | ||
|
|
f79070fe60 | ||
|
|
b238f3d060 | ||
|
|
a6ad86bd59 | ||
|
|
60e2ef7cda | ||
|
|
cde0ba11fa | ||
|
|
da9fcd1087 | ||
|
|
8929b37dbe | ||
|
|
509604833e | ||
|
|
08ac9680d7 | ||
|
|
ca5404a93b | ||
|
|
f6f4547af3 | ||
|
|
669f8b0431 | ||
|
|
86aa0251ab | ||
|
|
2252048d2e | ||
|
|
53571a7d72 | ||
|
|
0a469f4ccf | ||
|
|
d10ed664bf | ||
|
|
4aad8d36a9 | ||
|
|
3e9295f313 | ||
|
|
6aa66e99b5 | ||
|
|
c38ad89cd7 | ||
|
|
8fdcc5dd50 | ||
|
|
364381f017 | ||
|
|
9433aa503b | ||
|
|
82dee95cd0 | ||
|
|
813db4a985 | ||
|
|
c63bc28ca0 | ||
|
|
29e74a2c9e | ||
|
|
80fdc6005b | ||
|
|
9e25aa1f9f | ||
|
|
f58d5d28cf | ||
|
|
81cb0d43d7 | ||
|
|
9d7052c626 | ||
|
|
4edd0e0ab7 | ||
|
|
a3d60d393b | ||
|
|
311ab67ebe | ||
|
|
1f581e7b51 | ||
|
|
5fe934110e | ||
|
|
f4df8873be | ||
|
|
6f4c5d7195 | ||
|
|
06f1c2fba2 | ||
|
|
36b32a1813 | ||
|
|
d3a43cb65f | ||
|
|
d98e7b9416 | ||
|
|
fc284fd0a2 | ||
|
|
5841d2eb66 | ||
|
|
0c154be04e | ||
|
|
90bc0483ae | ||
|
|
32765b2d34 | ||
|
|
d9e80ebef9 | ||
|
|
445102226e | ||
|
|
a6ce882511 | ||
|
|
9d20c4c4a9 | ||
|
|
e2d8ea0a70 | ||
|
|
b33d003ba5 | ||
|
|
ee07a90b5e | ||
|
|
55f79507fe | ||
|
|
70d2f1e3d9 | ||
|
|
acafc072db | ||
|
|
51e40581b0 | ||
|
|
a372cf0178 | ||
|
|
d863aa7aa6 | ||
|
|
b31e54b3e5 | ||
|
|
9b5734f2ff | ||
|
|
6b59b9988c | ||
|
|
f9de546d14 | ||
|
|
9e304b947b | ||
|
|
c11a219c62 | ||
|
|
3cc22575cb | ||
|
|
c42b6e2fdb | ||
|
|
1e5dd1cc53 | ||
|
|
877532559e | ||
|
|
cd4750fcce | ||
|
|
fc2be71e1f | ||
|
|
0c9aa2f681 | ||
|
|
1883be95d5 | ||
|
|
f7dadda52a | ||
|
|
2a8fd24504 | ||
|
|
71c70a1b36 | ||
|
|
c34379d936 | ||
|
|
2dde29c628 | ||
|
|
818e71d49c | ||
|
|
6bbeb5ef87 | ||
|
|
7ebed70316 | ||
|
|
130237fc87 | ||
|
|
a28774c2c4 | ||
|
|
b9ade5d2a3 | ||
|
|
b677aa1715 | ||
|
|
7a036883e8 | ||
|
|
e665df21da | ||
|
|
d066b9c913 | ||
|
|
b4290c24b3 | ||
|
|
b66656ad84 | ||
|
|
5c032e84be | ||
|
|
83437ae4ba | ||
|
|
24434cc61a | ||
|
|
53dc40e8c7 | ||
|
|
4affb2bc5b | ||
|
|
3d7b057026 | ||
|
|
d36ab337d7 | ||
|
|
c87690f378 | ||
|
|
9fb9fd4568 | ||
|
|
6bd4fd91ff | ||
|
|
164c2463f5 | ||
|
|
73532e41c5 | ||
|
|
3392b1a1ca | ||
|
|
08cc7114ac | ||
|
|
012f9b5314 | ||
|
|
ba6069324f | ||
|
|
0d26d4cdbd | ||
|
|
4b920feffa | ||
|
|
8e038f6944 | ||
|
|
ce94255a9e | ||
|
|
b4b63f86d9 | ||
|
|
830373efb3 | ||
|
|
c3f18671ec | ||
|
|
2901fb0d72 | ||
|
|
c5466edf71 | ||
|
|
bfc5bfe973 | ||
|
|
4da11955f1 | ||
|
|
d4c775a537 | ||
|
|
ef95a8a305 | ||
|
|
a173d2c808 | ||
|
|
ee002df110 | ||
|
|
06ef17048a | ||
|
|
9487348ba8 | ||
|
|
757060b11f | ||
|
|
ab1f8437ea | ||
|
|
d7afd31572 | ||
|
|
cd3178224a | ||
|
|
9193a1a5d6 | ||
|
|
0d33758ba4 | ||
|
|
e7e8c397ef | ||
|
|
0f3e36a447 | ||
|
|
333dbba393 | ||
|
|
b04b12c7a0 | ||
|
|
1dc804a2b9 | ||
|
|
75219d457a | ||
|
|
a1d69b3210 | ||
|
|
dcbc2f1145 | ||
|
|
36903b338a | ||
|
|
9d8d6832af | ||
|
|
3d004f2322 | ||
|
|
fb827e3586 | ||
|
|
ccca183e08 | ||
|
|
237455ab21 | ||
|
|
6141073137 | ||
|
|
740691417f | ||
|
|
2ed709796a | ||
|
|
75c0350584 | ||
|
|
17d72b9922 | ||
|
|
1796fae3d1 | ||
|
|
1ecd22204d | ||
|
|
356fe4591f | ||
|
|
0230942a3d | ||
|
|
325793eebc | ||
|
|
39d1256f68 | ||
|
|
fd2472d34b | ||
|
|
5e8fbc6552 | ||
|
|
3084a40729 | ||
|
|
0069f51ea4 | ||
|
|
696c612489 | ||
|
|
4a5a4077af | ||
|
|
28bcb899e7 | ||
|
|
4062a7089a | ||
|
|
ad86221c7d | ||
|
|
53938248de | ||
|
|
c018b639ad | ||
|
|
eb2145c7da | ||
|
|
2f4c39d310 | ||
|
|
79ada82223 | ||
|
|
c67463fb3b | ||
|
|
d162471555 | ||
|
|
c99797bcef | ||
|
|
9739cdbbaa | ||
|
|
7f6db561f5 | ||
|
|
9eac00b303 | ||
|
|
b61df04c1b | ||
|
|
b587e21c90 | ||
|
|
02d66ee9fd | ||
|
|
a950e08ef1 | ||
|
|
6e7d28db7b | ||
|
|
a0ea00d0a3 | ||
|
|
beb5606862 | ||
|
|
44f11f93a4 | ||
|
|
7b61f267dd | ||
|
|
971238cedb | ||
|
|
4d19b9249b | ||
|
|
4046b91609 | ||
|
|
e6652109c5 | ||
|
|
e9cfc066a5 | ||
|
|
9ce078c1d3 | ||
|
|
2bcc1675e8 | ||
|
|
a87c2347c9 | ||
|
|
8f2810db30 | ||
|
|
528d0b0429 | ||
|
|
f759014315 | ||
|
|
a568610c28 | ||
|
|
c35a85db12 | ||
|
|
fcd61436c8 | ||
|
|
0798063213 | ||
|
|
80de63323d | ||
|
|
7d0219b11d | ||
|
|
d42434ddc0 | ||
|
|
cbf6d23c24 | ||
|
|
568c05b4b0 | ||
|
|
59ee4babeb | ||
|
|
604ef4d004 |
9
.devcontainer/devcontainer.json
Normal file
9
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Hoppscotch",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:18",
|
||||
"forwardPorts": [3000],
|
||||
"features": {
|
||||
"ghcr.io/NicoVIII/devcontainer-features/pnpm:1": {}
|
||||
},
|
||||
"postCreateCommand": "mv .env.example .env && pnpm i"
|
||||
}
|
||||
104
.dockerignore
104
.dockerignore
@@ -1,104 +0,0 @@
|
||||
Dockerfile
|
||||
.vscode
|
||||
.github
|
||||
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
|
||||
# Firebase
|
||||
.firebase
|
||||
|
||||
### Node template
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# Nuxt generate
|
||||
dist
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless
|
||||
|
||||
# IDE / Editor
|
||||
.idea
|
||||
|
||||
# Service worker
|
||||
sw.*
|
||||
|
||||
# Mac OSX
|
||||
.DS_Store
|
||||
|
||||
# Vim swap files
|
||||
*.swp
|
||||
|
||||
# Build data
|
||||
.hoppscotch
|
||||
|
||||
# File explorer
|
||||
.directory
|
||||
59
.env.example
Normal file
59
.env.example
Normal file
@@ -0,0 +1,59 @@
|
||||
#-----------------------Backend Config------------------------------#
|
||||
# Prisma Config
|
||||
DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
|
||||
|
||||
# Auth Tokens Config
|
||||
JWT_SECRET="secret1233"
|
||||
TOKEN_SALT_COMPLEXITY=10
|
||||
MAGIC_LINK_TOKEN_VALIDITY= 3
|
||||
REFRESH_TOKEN_VALIDITY="604800000" # Default validity is 7 days (604800000 ms) in ms
|
||||
ACCESS_TOKEN_VALIDITY="86400000" # Default validity is 1 day (86400000 ms) in ms
|
||||
SESSION_SECRET='add some secret here'
|
||||
|
||||
# Hoppscotch App Domain Config
|
||||
REDIRECT_URL="http://localhost:3000"
|
||||
WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
|
||||
|
||||
# Google Auth Config
|
||||
GOOGLE_CLIENT_ID="************************************************"
|
||||
GOOGLE_CLIENT_SECRET="************************************************"
|
||||
GOOGLE_CALLBACK_URL="http://localhost:3170/v1/auth/google/callback"
|
||||
GOOGLE_SCOPE="email,profile"
|
||||
|
||||
# Github Auth Config
|
||||
GITHUB_CLIENT_ID="************************************************"
|
||||
GITHUB_CLIENT_SECRET="************************************************"
|
||||
GITHUB_CALLBACK_URL="http://localhost:3170/v1/auth/github/callback"
|
||||
GITHUB_SCOPE="user:email"
|
||||
|
||||
# Microsoft Auth Config
|
||||
MICROSOFT_CLIENT_ID="************************************************"
|
||||
MICROSOFT_CLIENT_SECRET="************************************************"
|
||||
MICROSOFT_CALLBACK_URL="http://localhost:3170/v1/auth/microsoft/callback"
|
||||
MICROSOFT_SCOPE="user.read"
|
||||
|
||||
# Mailer config
|
||||
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com"
|
||||
MAILER_ADDRESS_FROM='"From Name Here" <from@example.com>'
|
||||
|
||||
# Rate Limit Config
|
||||
RATE_LIMIT_TTL=60 # In seconds
|
||||
RATE_LIMIT_MAX=100 # Max requests per IP
|
||||
|
||||
|
||||
#-----------------------Frontend Config------------------------------#
|
||||
|
||||
|
||||
# Base URLs
|
||||
VITE_BASE_URL=http://localhost:3000
|
||||
VITE_SHORTCODE_BASE_URL=http://localhost:3000
|
||||
VITE_ADMIN_URL=http://localhost:3100
|
||||
|
||||
# Backend URLs
|
||||
VITE_BACKEND_GQL_URL=http://localhost:3170/graphql
|
||||
VITE_BACKEND_WS_URL=ws://localhost:3170/graphql
|
||||
VITE_BACKEND_API_URL=http://localhost:3170/v1
|
||||
|
||||
# Terms Of Service And Privacy Policy Links (Optional)
|
||||
VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms
|
||||
VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy
|
||||
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -5,6 +5,6 @@ updates:
|
||||
schedule:
|
||||
interval: weekly
|
||||
time: '00:00'
|
||||
open-pull-requests-limit: 10
|
||||
open-pull-requests-limit: 0
|
||||
reviewers:
|
||||
- liyasthomas
|
||||
|
||||
93
.github/workflows/codeql-analysis.yml
vendored
93
.github/workflows/codeql-analysis.yml
vendored
@@ -1,72 +1,63 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
name: "CodeQL analysis"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '39 7 * * 2'
|
||||
# ┌───────────── minute (0 - 59)
|
||||
# │ ┌───────────── hour (0 - 23)
|
||||
# │ │ ┌───────────── day of the month (1 - 31)
|
||||
# │ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
|
||||
# │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: '30 1 * * 0'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
# Run extended queries including queries using machine learning
|
||||
queries: security-extended
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
# Run extended queries including queries using machine learning
|
||||
queries: security-extended
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below).
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following
|
||||
# three lines and modify them (or add more) to build your code if your
|
||||
# project uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
48
.github/workflows/deploy-netlify.yml
vendored
48
.github/workflows/deploy-netlify.yml
vendored
@@ -1,48 +0,0 @@
|
||||
name: Deploy to Netlify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Push build files to Netlify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup and run pnpm install
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7
|
||||
run_install: true
|
||||
|
||||
- name: Setup Environment
|
||||
run: mv packages/hoppscotch-app/.env.example packages/hoppscotch-app/.env
|
||||
|
||||
- name: Build Site
|
||||
env:
|
||||
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_SENTRY_ENVIRONMENT: production
|
||||
VITE_SENTRY_RELEASE_TAG: ${{ github.sha }}
|
||||
run: pnpm run generate
|
||||
|
||||
# Deploy the production site with netlify-cli
|
||||
- name: Deploy to Netlify (production)
|
||||
run: npx netlify-cli deploy --dir=packages/hoppscotch-app/dist --prod
|
||||
env:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_PRODUCTION_SITE_ID }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
- name: Create Sentry Release
|
||||
uses: getsentry/action-release@v1
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
with:
|
||||
environment: production
|
||||
ignore_missing: true
|
||||
ignore_empty: true
|
||||
version: ${{ github.sha }}
|
||||
18
.github/workflows/deploy-prod.yml
vendored
18
.github/workflows/deploy-prod.yml
vendored
@@ -1,18 +0,0 @@
|
||||
name: Deploy to Live Channel
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy_live_website:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: FirebaseExtended/action-hosting-deploy@v0
|
||||
with:
|
||||
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_POSTWOMAN_API }}'
|
||||
channelId: live
|
||||
projectId: postwoman-api
|
||||
58
.github/workflows/deploy-staging-netlify.yml
vendored
58
.github/workflows/deploy-staging-netlify.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: Deploy to Staging Netlify
|
||||
|
||||
on:
|
||||
push:
|
||||
# TODO: Migrate to staging branch only
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Push build files to Netlify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup and run pnpm install
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7
|
||||
run_install: true
|
||||
|
||||
- name: Build Site
|
||||
env:
|
||||
VITE_GA_ID: ${{ secrets.STAGING_GA_ID }}
|
||||
VITE_GTM_ID: ${{ secrets.STAGING_GTM_ID }}
|
||||
VITE_API_KEY: ${{ secrets.STAGING_FB_API_KEY }}
|
||||
VITE_AUTH_DOMAIN: ${{ secrets.STAGING_FB_AUTH_DOMAIN }}
|
||||
VITE_DATABASE_URL: ${{ secrets.STAGING_FB_DATABASE_URL }}
|
||||
VITE_PROJECT_ID: ${{ secrets.STAGING_FB_PROJECT_ID }}
|
||||
VITE_STORAGE_BUCKET: ${{ secrets.STAGING_FB_STORAGE_BUCKET }}
|
||||
VITE_MESSAGING_SENDER_ID: ${{ secrets.STAGING_FB_MESSAGING_SENDER_ID }}
|
||||
VITE_APP_ID: ${{ secrets.STAGING_FB_APP_ID }}
|
||||
VITE_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
|
||||
VITE_BACKEND_WS_URL: ${{ secrets.STAGING_BACKEND_WS_URL }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE_TAG: ${{ github.sha }}
|
||||
VITE_SENTRY_ENVIRONMENT: staging
|
||||
run: pnpm run generate
|
||||
|
||||
# Deploy the staging site with netlify-cli
|
||||
- name: Deploy to Netlify (staging)
|
||||
run: npx netlify-cli deploy --dir=packages/hoppscotch-app/dist --prod
|
||||
env:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_STAGING_SITE_ID }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
- name: Create Sentry Release
|
||||
uses: getsentry/action-release@v1
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
with:
|
||||
environment: staging
|
||||
ignore_missing: true
|
||||
ignore_empty: true
|
||||
version: ${{ github.sha }}
|
||||
46
.github/workflows/publish-docker.yml
vendored
46
.github/workflows/publish-docker.yml
vendored
@@ -1,46 +0,0 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: hoppscotch/hoppscotch
|
||||
flavor: |
|
||||
latest=true
|
||||
prefix=
|
||||
suffix=
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
29
.github/workflows/tests.yml
vendored
29
.github/workflows/tests.yml
vendored
@@ -2,12 +2,13 @@ name: Node.js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, staging]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
@@ -15,17 +16,23 @@ jobs:
|
||||
node-version: ["lts/*"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- name: Setup and run pnpm install
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup environment
|
||||
run: mv .env.example .env
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: true
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -168,3 +168,9 @@ tests/*/videos
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
||||
# PNPM
|
||||
.pnpm-store
|
||||
|
||||
# GQL SDL generated for the frontends
|
||||
gql-gen/
|
||||
|
||||
1
.vscode/extensions.json
vendored
1
.vscode/extensions.json
vendored
@@ -6,6 +6,7 @@
|
||||
"dbaeumer.vscode-eslint",
|
||||
"editorconfig.editorconfig",
|
||||
"csstools.postcss",
|
||||
"folke.vscode-monorepo-workspace"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"octref.vetur"
|
||||
|
||||
30
CODEOWNERS
Normal file
30
CODEOWNERS
Normal file
@@ -0,0 +1,30 @@
|
||||
# CODEOWNERS is prioritized from bottom to top
|
||||
|
||||
# If none of the below matched
|
||||
* @AndrewBastin @liyasthomas
|
||||
|
||||
# Packages
|
||||
/packages/codemirror-lang-graphql/ @AndrewBastin
|
||||
/packages/hoppscotch-cli/ @AndrewBastin
|
||||
/packages/hoppscotch-common/ @amk-dev @AndrewBastin
|
||||
/packages/hoppscotch-data/ @AndrewBastin
|
||||
/packages/hoppscotch-js-sandbox/ @AndrewBastin
|
||||
/packages/hoppscotch-ui/ @anwarulislam
|
||||
/packages/hoppscotch-web/ @amk-dev
|
||||
/packages/hoppscotch-selfhost-web/ @amk-dev
|
||||
/packages/hoppscotch-sh-admin/ @JoelJacobStephen
|
||||
/packages/hoppscotch-backend/ @ankitsridhar16 @balub
|
||||
|
||||
# Sections within Hoppscotch Common
|
||||
/packages/hoppscotch-common/src/components @anwarulislam
|
||||
/packages/hoppscotch-common/src/components/collections @nivedin @amk-dev
|
||||
/packages/hoppscotch-common/src/components/environments @nivedin @amk-dev
|
||||
/packages/hoppscotch-common/src/composables @amk-dev
|
||||
/packages/hoppscotch-common/src/modules @AndrewBastin @amk-dev
|
||||
/packages/hoppscotch-common/src/pages @AndrewBastin @amk-dev
|
||||
/packages/hoppscotch-common/src/newstore @AndrewBastin @amk-dev
|
||||
|
||||
README.md @liyasthomas
|
||||
|
||||
# The lockfile has no owner
|
||||
pnpm-lock.yaml
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,29 +0,0 @@
|
||||
FROM node:lts-alpine
|
||||
|
||||
LABEL maintainer="Hoppscotch (support@hoppscotch.io)"
|
||||
|
||||
# Add git as the prebuild target requires it to parse version information
|
||||
RUN apk add --no-cache --virtual .gyp \
|
||||
python3 \
|
||||
make \
|
||||
g++
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
ADD . /app/
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
RUN pnpm i --unsafe-perm=true
|
||||
|
||||
ENV HOST 0.0.0.0
|
||||
EXPOSE 3000
|
||||
|
||||
RUN mv packages/hoppscotch-app/.env.example packages/hoppscotch-app/.env
|
||||
|
||||
RUN pnpm run generate
|
||||
|
||||
CMD ["pnpm", "run", "start"]
|
||||
50
README.md
50
README.md
@@ -36,14 +36,14 @@
|
||||
<p>
|
||||
<a href="https://hoppscotch.io/#gh-light-mode-only" target="_blank">
|
||||
<img
|
||||
src="./packages/hoppscotch-app/public/images/banner-light.png"
|
||||
src="./packages/hoppscotch-common/public/images/banner-light.png"
|
||||
alt="Hoppscotch"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://hoppscotch.io/#gh-dark-mode-only" target="_blank">
|
||||
<img
|
||||
src="./packages/hoppscotch-app/public/images/banner-dark.png"
|
||||
src="./packages/hoppscotch-common/public/images/banner-dark.png"
|
||||
alt="Hoppscotch"
|
||||
width="100%"
|
||||
/>
|
||||
@@ -161,7 +161,7 @@ _Collections are synced with cloud / local session storage_
|
||||
- Access APIs served in non-HTTPS (`http://`) endpoints
|
||||
- Use your Proxy URL
|
||||
|
||||
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/privacy)**_
|
||||
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/support/privacy)**_
|
||||
|
||||
📜 **Pre-Request Scripts β:** Snippets of code associated with a request that is executed before the request is sent.
|
||||
|
||||
@@ -178,7 +178,7 @@ _Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/h
|
||||
|
||||
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
|
||||
|
||||
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/features/shortcuts)**
|
||||
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
|
||||
|
||||
🌎 **i18n:** Experience the app in your language.
|
||||
|
||||
@@ -275,49 +275,11 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
|
||||
- [JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript)
|
||||
- [TypeScript](https://www.typescriptlang.org)
|
||||
- [Vue](https://vuejs.org)
|
||||
- [Nuxt](https://nuxtjs.org)
|
||||
- [Vite](https://vitejs.dev)
|
||||
|
||||
## **Developing**
|
||||
|
||||
0. Update [`.env.example`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-app/.env.example) file found in `packages/hoppscotch-app` with your own keys and rename it to `.env`.
|
||||
|
||||
_Sample keys only work with the [production build](https://hoppscotch.io)._
|
||||
|
||||
### Browser-based development environment
|
||||
|
||||
- [GitHub codespace](https://docs.github.com/en/codespaces/developing-in-codespaces/creating-a-codespace)
|
||||
- [Gitpod](https://gitpod.io/#https://github.com/hoppscotch/hoppscotch)
|
||||
|
||||
### Local development environment
|
||||
|
||||
1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git.
|
||||
2. Install pnpm using npm by running `npm install -g pnpm`.
|
||||
3. Install dependencies by running `pnpm install` within the directory that you cloned (probably `hoppscotch`).
|
||||
4. Start the development server with `pnpm run dev`.
|
||||
5. Open the development site by going to [`http://localhost:3000`](http://localhost:3000) in your browser.
|
||||
|
||||
### Docker compose
|
||||
|
||||
1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git.
|
||||
2. Run `docker-compose up` within the directory that you cloned (probably `hoppscotch`).
|
||||
3. Open the development site by going to [`http://localhost:3000`](http://localhost:3000) in your browser.
|
||||
|
||||
## **Docker**
|
||||
|
||||
**Official container** [](https://hub.docker.com/r/hoppscotch/hoppscotch)
|
||||
|
||||
```bash
|
||||
docker run --rm --name hoppscotch -p 3000:3000 hoppscotch/hoppscotch:latest
|
||||
```
|
||||
|
||||
## **Releasing**
|
||||
|
||||
1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git.
|
||||
2. Install pnpm using npm by running `npm install -g pnpm`.
|
||||
3. Install dependencies by running `pnpm install` within the directory that you cloned (probably `hoppscotch`).
|
||||
4. Update [`.env.example`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-app/.env.example) file found in `packages/hoppscotch-app` with your own keys and rename it to `.env`.
|
||||
5. Build the release files with `pnpm run generate`.
|
||||
6. Find the built project in `packages/hoppscotch-app/dist`. Host these files on any [static hosting servers](https://www.pluralsight.com/blog/software-development/where-to-host-your-jamstack-site).
|
||||
Follow our [self-hosting guide](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment.
|
||||
|
||||
## **Contributing**
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ if there is no existing translation, you can create a new one by following these
|
||||
1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).**
|
||||
2. **Checkout the `i18n` branch for latest translations.**
|
||||
3. **Create a new branch for your translation with base branch `i18n`.**
|
||||
4. **Create target language file in the [`locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-app/locales) directory.**
|
||||
5. **Copy the contents of the source file [`locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-app/locales/en.json) to the target language file.**
|
||||
4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.**
|
||||
5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.**
|
||||
6. **Translate the strings in the target language file.**
|
||||
7. **Add your language entry to [`languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-app/languages.json).**
|
||||
7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).**
|
||||
8. **Save & commit changes.**
|
||||
9. **Send a pull request.**
|
||||
|
||||
|
||||
@@ -1,23 +1,71 @@
|
||||
# To make it easier to self-host, we have a preset docker compose config that also
|
||||
# has a container with a Postgres instance running.
|
||||
# You can tweak around this file to match your instances
|
||||
version: "3.7"
|
||||
|
||||
services:
|
||||
web:
|
||||
# This service runs the backend app in the port 3170
|
||||
hoppscotch-backend:
|
||||
container_name: hoppscotch-backend
|
||||
build:
|
||||
dockerfile: packages/hoppscotch-backend/Dockerfile
|
||||
context: .
|
||||
volumes:
|
||||
- "./.hoppscotch:/app/.hoppscotch"
|
||||
- "./assets:/app/assets"
|
||||
- "./directives:/app/directives"
|
||||
- "./layouts:/app/layouts"
|
||||
- "./middleware:/app/middleware"
|
||||
- "./pages:/app/pages"
|
||||
- "./plugins:/app/plugins"
|
||||
- "./static:/app/static"
|
||||
- "./store:/app/store"
|
||||
- "./components:/app/components"
|
||||
- "./helpers:/app/helpers"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
target: prod
|
||||
env_file:
|
||||
- ./.env
|
||||
restart: always
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
command: "pnpm run dev"
|
||||
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
|
||||
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
||||
- PORT=3000
|
||||
volumes:
|
||||
- ./packages/hoppscotch-backend/:/usr/src/app
|
||||
- /usr/src/app/node_modules/
|
||||
depends_on:
|
||||
- hoppscotch-db
|
||||
ports:
|
||||
- "3170:3000"
|
||||
|
||||
# The main hoppscotch app. This will be hosted at port 3000
|
||||
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
||||
# the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile
|
||||
hoppscotch-app:
|
||||
container_name: hoppscotch-app
|
||||
build:
|
||||
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
|
||||
context: .
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
- hoppscotch-backend
|
||||
ports:
|
||||
- "3000:8080"
|
||||
|
||||
# The Self Host dashboard for managing the app. This will be hosted at port 3100
|
||||
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
||||
# the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile
|
||||
hoppscotch-sh-admin:
|
||||
container_name: hoppscotch-sh-admin
|
||||
build:
|
||||
dockerfile: packages/hoppscotch-sh-admin/Dockerfile
|
||||
context: .
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
- hoppscotch-backend
|
||||
ports:
|
||||
- "3100:8080"
|
||||
|
||||
# The preset DB service, you can delete/comment the below lines if
|
||||
# you are using an external postgres instance
|
||||
# This will be exposed at port 5432
|
||||
hoppscotch-db:
|
||||
image: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
# NOTE: Please UPDATE THIS PASSWORD!
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: hoppscotch
|
||||
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
},
|
||||
"hosting": {
|
||||
"predeploy": [
|
||||
"cd packages/hoppscotch-app && mv .env.example .env && cd ../.. && npm install -g pnpm && pnpm i && pnpm run generate"
|
||||
"mv .env.example .env && npm install -g pnpm && pnpm i && pnpm run generate"
|
||||
],
|
||||
"public": "packages/hoppscotch-app/dist",
|
||||
"public": "packages/hoppscotch-web/dist",
|
||||
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
|
||||
"rewrites": [
|
||||
{
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
[build]
|
||||
base = "/"
|
||||
publish = "packages/hoppscotch-app/dist"
|
||||
publish = "packages/hoppscotch-web/dist"
|
||||
command = "npx pnpm i --store=node_modules/.pnpm-store && npx pnpm run generate"
|
||||
|
||||
[[headers]]
|
||||
for = "/*"
|
||||
[headers.values]
|
||||
X-Frame-Options = "DENY"
|
||||
X-Frame-Options = "SAMEORIGIN"
|
||||
X-XSS-Protection = "1; mode=block"
|
||||
|
||||
[[redirects]]
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"prepare": "husky install",
|
||||
"dev": "pnpm -r do-dev",
|
||||
"gen-gql": "cross-env GQL_SCHEMA_EMIT_LOCATION='../../../gql-gen/backend-schema.gql' pnpm -r generate-gql-sdl",
|
||||
"generate": "pnpm -r do-build-prod",
|
||||
"start": "http-server packages/hoppscotch-app/dist -p 3000",
|
||||
"start": "http-server packages/hoppscotch-selfhost-web/dist -p 3000",
|
||||
"lint": "pnpm -r do-lint",
|
||||
"typecheck": "pnpm -r do-typecheck",
|
||||
"lintfix": "pnpm -r do-lintfix",
|
||||
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
|
||||
"test": "pnpm -r do-test"
|
||||
"test": "pnpm -r do-test",
|
||||
"generate-ui": "pnpm -r do-build-ui"
|
||||
},
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
@@ -28,6 +30,7 @@
|
||||
"@commitlint/cli": "^16.2.3",
|
||||
"@commitlint/config-conventional": "^16.2.1",
|
||||
"@types/node": "^17.0.24",
|
||||
"cross-env": "^7.0.3",
|
||||
"http-server": "^14.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Google Analytics ID
|
||||
VITE_GA_ID=UA-61422507-4
|
||||
|
||||
# Google Tag Manager ID
|
||||
VITE_GTM_ID=GTM-NMKVBMV
|
||||
|
||||
# Firebase config
|
||||
VITE_API_KEY=AIzaSyCMsFreESs58-hRxTtiqQrIcimh4i1wbsM
|
||||
VITE_AUTH_DOMAIN=postwoman-api.firebaseapp.com
|
||||
VITE_DATABASE_URL=https://postwoman-api.firebaseio.com
|
||||
VITE_PROJECT_ID=postwoman-api
|
||||
VITE_STORAGE_BUCKET=postwoman-api.appspot.com
|
||||
VITE_MESSAGING_SENDER_ID=421993993223
|
||||
VITE_APP_ID=1:421993993223:web:ec0baa8ee8c02ffa1fc6a2
|
||||
VITE_MEASUREMENT_ID=G-BBJ3R80PJT
|
||||
|
||||
# Base URL
|
||||
VITE_BASE_URL=https://hoppscotch.io
|
||||
|
||||
# Backend URLs
|
||||
VITE_BACKEND_GQL_URL=https://api.hoppscotch.io/graphql
|
||||
VITE_BACKEND_WS_URL=wss://api.hoppscotch.io/graphql
|
||||
|
||||
# Sentry (Optional)
|
||||
# VITE_SENTRY_DSN: <Sentry DSN here>
|
||||
# VITE_SENTRY_ENVIRONMENT: <Sentry environment value here>
|
||||
# VITE_SENTRY_RELEASE_TAG: <Sentry release tag here (for release monitoring)>
|
||||
@@ -1,16 +0,0 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)
|
||||
|
||||
## Type Support For `.vue` Imports in TS
|
||||
|
||||
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type by default. In most cases this is fine if you don't really care about component prop types outside of templates. However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using manual `h(...)` calls), you can enable Volar's Take Over mode by following these steps:
|
||||
|
||||
1. Run `Extensions: Show Built-in Extensions` from VS Code's command palette, look for `TypeScript and JavaScript Language Features`, then right click and select `Disable (Workspace)`. By default, Take Over mode will enable itself if the default TypeScript extension is disabled.
|
||||
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
|
||||
|
||||
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).
|
||||
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 504 B |
@@ -1,672 +0,0 @@
|
||||
{
|
||||
"action": {
|
||||
"autoscroll": "Autoscroll",
|
||||
"cancel": "Cancel",
|
||||
"choose_file": "Choose a file",
|
||||
"clear": "Clear",
|
||||
"clear_all": "Clear all",
|
||||
"close": "Close",
|
||||
"connect": "Connect",
|
||||
"copy": "Copy",
|
||||
"delete": "Delete",
|
||||
"disconnect": "Disconnect",
|
||||
"dismiss": "Dismiss",
|
||||
"dont_save": "Don't save",
|
||||
"download_file": "Download file",
|
||||
"drag_to_reorder": "Drag to reorder",
|
||||
"duplicate": "Duplicate",
|
||||
"edit": "Edit",
|
||||
"filter_response": "Filter response",
|
||||
"go_back": "Go back",
|
||||
"label": "Label",
|
||||
"learn_more": "Learn more",
|
||||
"less": "Less",
|
||||
"more": "More",
|
||||
"new": "New",
|
||||
"no": "No",
|
||||
"open_workspace": "Open workspace",
|
||||
"paste": "Paste",
|
||||
"prettify": "Prettify",
|
||||
"remove": "Remove",
|
||||
"restore": "Restore",
|
||||
"save": "Save",
|
||||
"scroll_to_bottom": "Scroll to bottom",
|
||||
"scroll_to_top": "Scroll to top",
|
||||
"search": "Search",
|
||||
"send": "Send",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"to_close": "to close",
|
||||
"to_navigate": "to navigate",
|
||||
"to_select": "to select",
|
||||
"turn_off": "Turn off",
|
||||
"turn_on": "Turn on",
|
||||
"undo": "Undo",
|
||||
"yes": "Yes"
|
||||
},
|
||||
"add": {
|
||||
"new": "Add new",
|
||||
"star": "Add star"
|
||||
},
|
||||
"app": {
|
||||
"chat_with_us": "Chat with us",
|
||||
"contact_us": "Contact us",
|
||||
"copy": "Copy",
|
||||
"copy_user_id": "Copy User Auth Token",
|
||||
"developer_option": "Developer options",
|
||||
"developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.",
|
||||
"discord": "Discord",
|
||||
"documentation": "Documentation",
|
||||
"github": "GitHub",
|
||||
"help": "Help & feedback",
|
||||
"home": "Home",
|
||||
"invite": "Invite",
|
||||
"invite_description": "Hoppscotch is an open source API development ecosystem. We designed a simple and intuitive interface for creating and managing your APIs. Hoppscotch is a tool that helps you build, test, document and share your APIs.",
|
||||
"invite_your_friends": "Invite your friends",
|
||||
"join_discord_community": "Join our Discord community",
|
||||
"keyboard_shortcuts": "Keyboard shortcuts",
|
||||
"name": "Hoppscotch",
|
||||
"new_version_found": "New version found. Refresh to update.",
|
||||
"options": "Options",
|
||||
"proxy_privacy_policy": "Proxy privacy policy",
|
||||
"reload": "Reload",
|
||||
"search": "Search",
|
||||
"share": "Share",
|
||||
"shortcuts": "Shortcuts",
|
||||
"spotlight": "Spotlight",
|
||||
"status": "Status",
|
||||
"status_description": "Check the status of the website",
|
||||
"terms_and_privacy": "Terms and privacy",
|
||||
"twitter": "Twitter",
|
||||
"type_a_command_search": "Type a command or search…",
|
||||
"we_use_cookies": "We use cookies",
|
||||
"whats_new": "What's new?",
|
||||
"wiki": "Wiki"
|
||||
},
|
||||
"auth": {
|
||||
"account_exists": "Account exists with different credential - Login to link both accounts",
|
||||
"all_sign_in_options": "All sign in options",
|
||||
"continue_with_email": "Continue with Email",
|
||||
"continue_with_github": "Continue with GitHub",
|
||||
"continue_with_google": "Continue with Google",
|
||||
"continue_with_microsoft": "Continue with Microsoft",
|
||||
"email": "Email",
|
||||
"logged_out": "Logged out",
|
||||
"login": "Login",
|
||||
"login_success": "Successfully logged in",
|
||||
"login_to_hoppscotch": "Login to Hoppscotch",
|
||||
"logout": "Logout",
|
||||
"re_enter_email": "Re-enter email",
|
||||
"send_magic_link": "Send a magic link",
|
||||
"sync": "Sync",
|
||||
"we_sent_magic_link": "We sent you a magic link!",
|
||||
"we_sent_magic_link_description": "Check your inbox - we sent an email to {email}. It contains a magic link that will log you in."
|
||||
},
|
||||
"authorization": {
|
||||
"generate_token": "Generate Token",
|
||||
"include_in_url": "Include in URL",
|
||||
"learn": "Learn how",
|
||||
"pass_key_by": "Pass by",
|
||||
"password": "Password",
|
||||
"token": "Token",
|
||||
"type": "Authorization Type",
|
||||
"username": "Username"
|
||||
},
|
||||
"collection": {
|
||||
"created": "Collection created",
|
||||
"edit": "Edit Collection",
|
||||
"invalid_name": "Please provide a name for the collection",
|
||||
"my_collections": "My Collections",
|
||||
"name": "My New Collection",
|
||||
"name_length_insufficient": "Collection name should be at least 3 characters long",
|
||||
"new": "New Collection",
|
||||
"renamed": "Collection renamed",
|
||||
"request_in_use": "Request in use",
|
||||
"save_as": "Save as",
|
||||
"select": "Select a Collection",
|
||||
"select_location": "Select location",
|
||||
"select_team": "Select a team",
|
||||
"team_collections": "Team Collections"
|
||||
},
|
||||
"confirm": {
|
||||
"exit_team": "Are you sure you want to leave this team?",
|
||||
"logout": "Are you sure you want to logout?",
|
||||
"remove_collection": "Are you sure you want to permanently delete this collection?",
|
||||
"remove_environment": "Are you sure you want to permanently delete this environment?",
|
||||
"remove_folder": "Are you sure you want to permanently delete this folder?",
|
||||
"remove_history": "Are you sure you want to permanently delete all history?",
|
||||
"remove_request": "Are you sure you want to permanently delete this request?",
|
||||
"remove_team": "Are you sure you want to delete this team?",
|
||||
"remove_telemetry": "Are you sure you want to opt-out of Telemetry?",
|
||||
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
|
||||
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
|
||||
},
|
||||
"count": {
|
||||
"header": "Header {count}",
|
||||
"message": "Message {count}",
|
||||
"parameter": "Parameter {count}",
|
||||
"protocol": "Protocol {count}",
|
||||
"value": "Value {count}",
|
||||
"variable": "Variable {count}"
|
||||
},
|
||||
"documentation": {
|
||||
"generate": "Generate documentation",
|
||||
"generate_message": "Import any Hoppscotch collection to generate API documentation on-the-go."
|
||||
},
|
||||
"empty": {
|
||||
"authorization": "This request does not use any authorization",
|
||||
"body": "This request does not have a body",
|
||||
"collection": "Collection is empty",
|
||||
"collections": "Collections are empty",
|
||||
"documentation": "Connect to a GraphQL endpoint to view documentation",
|
||||
"endpoint": "Endpoint cannot be empty",
|
||||
"environments": "Environments are empty",
|
||||
"folder": "Folder is empty",
|
||||
"headers": "This request does not have any headers",
|
||||
"history": "History is empty",
|
||||
"invites": "Invite list is empty",
|
||||
"members": "Team is empty",
|
||||
"parameters": "This request does not have any parameters",
|
||||
"pending_invites": "There are no pending invites for this team",
|
||||
"profile": "Login in to view your profile",
|
||||
"protocols": "Protocols are empty",
|
||||
"schema": "Connect to a GraphQL endpoint to view schema",
|
||||
"shortcodes": "Shortcodes are empty",
|
||||
"team_name": "Team name empty",
|
||||
"teams": "You don't belong to any teams",
|
||||
"tests": "There are no tests for this request"
|
||||
},
|
||||
"environment": {
|
||||
"add_to_global": "Add to Global",
|
||||
"added": "Environment addition",
|
||||
"create_new": "Create new environment",
|
||||
"created": "Environment created",
|
||||
"deleted": "Environment deletion",
|
||||
"edit": "Edit Environment",
|
||||
"invalid_name": "Please provide a name for the environment",
|
||||
"nested_overflow": "nested environment variables are limited to 10 levels",
|
||||
"new": "New Environment",
|
||||
"no_environment": "No environment",
|
||||
"no_environment_description": "No environments were selected. Choose what to do with the following variables.",
|
||||
"select": "Select environment",
|
||||
"title": "Environments",
|
||||
"updated": "Environment updated",
|
||||
"variable_list": "Variable List"
|
||||
},
|
||||
"error": {
|
||||
"browser_support_sse": "This browser doesn't seems to have Server Sent Events support.",
|
||||
"check_console_details": "Check console log for details.",
|
||||
"curl_invalid_format": "cURL is not formatted properly",
|
||||
"empty_req_name": "Empty Request Name",
|
||||
"f12_details": "(F12 for details)",
|
||||
"gql_prettify_invalid_query": "Couldn't prettify an invalid query, solve query syntax errors and try again",
|
||||
"incomplete_config_urls": "Incomplete configuration URLs",
|
||||
"incorrect_email": "Incorrect email",
|
||||
"invalid_link": "Invalid link",
|
||||
"invalid_link_description": "The link you clicked is invalid or expired.",
|
||||
"json_parsing_failed": "Invalid JSON",
|
||||
"json_prettify_invalid_body": "Couldn't prettify an invalid body, solve json syntax errors and try again",
|
||||
"network_error": "There seems to be a network error. Please try again.",
|
||||
"network_fail": "Could not send request",
|
||||
"no_duration": "No duration",
|
||||
"no_results_found": "No matches found",
|
||||
"page_not_found": "This page could not be found",
|
||||
"script_fail": "Could not execute pre-request script",
|
||||
"something_went_wrong": "Something went wrong",
|
||||
"test_script_fail": "Could not execute post-request script"
|
||||
},
|
||||
"export": {
|
||||
"as_json": "Export as JSON",
|
||||
"create_secret_gist": "Create secret Gist",
|
||||
"gist_created": "Gist created",
|
||||
"require_github": "Login with GitHub to create secret gist",
|
||||
"title": "Export"
|
||||
},
|
||||
"folder": {
|
||||
"created": "Folder created",
|
||||
"edit": "Edit Folder",
|
||||
"invalid_name": "Please provide a name for the folder",
|
||||
"name_length_insufficient": "Folder name should be at least 3 characters long",
|
||||
"new": "New Folder",
|
||||
"renamed": "Folder renamed"
|
||||
},
|
||||
"graphql": {
|
||||
"mutations": "Mutations",
|
||||
"schema": "Schema",
|
||||
"subscriptions": "Subscriptions"
|
||||
},
|
||||
"header": {
|
||||
"install_pwa": "Install app",
|
||||
"login": "Login",
|
||||
"save_workspace": "Save My Workspace"
|
||||
},
|
||||
"helpers": {
|
||||
"authorization": "The authorization header will be automatically generated when you send the request.",
|
||||
"generate_documentation_first": "Generate documentation first",
|
||||
"network_fail": "Unable to reach the API endpoint. Check your network connection or select a different Interceptor and try again.",
|
||||
"offline": "You seem to be offline. Data in this workspace might not be up to date.",
|
||||
"offline_short": "You seem to be offline.",
|
||||
"post_request_tests": "Test scripts are written in JavaScript, and are run after the response is received.",
|
||||
"pre_request_script": "Pre-request scripts are written in JavaScript, and are run before the request is sent.",
|
||||
"script_fail": "It seems there is a glitch in the pre-request script. Check the error below and fix the script accordingly.",
|
||||
"test_script_fail": "There seems to be an error with test script. Please fix the errors and run tests again",
|
||||
"tests": "Write a test script to automate debugging."
|
||||
},
|
||||
"hide": {
|
||||
"collection": "Collapse Collection Panel",
|
||||
"more": "Hide more",
|
||||
"preview": "Hide Preview",
|
||||
"sidebar": "Collapse sidebar"
|
||||
},
|
||||
"import": {
|
||||
"collections": "Import collections",
|
||||
"curl": "Import cURL",
|
||||
"failed": "Error while importing: format not recognized",
|
||||
"from_gist": "Import from Gist",
|
||||
"from_gist_description": "Import from Gist URL",
|
||||
"from_insomnia": "Import from Insomnia",
|
||||
"from_insomnia_description": "Import from Insomnia collection",
|
||||
"from_json": "Import from Hoppscotch",
|
||||
"from_json_description": "Import from Hoppscotch collection file",
|
||||
"from_my_collections": "Import from My Collections",
|
||||
"from_my_collections_description": "Import from My Collections file",
|
||||
"from_openapi": "Import from OpenAPI",
|
||||
"from_openapi_description": "Import from OpenAPI specification file (YML/JSON)",
|
||||
"from_postman": "Import from Postman",
|
||||
"from_postman_description": "Import from Postman collection",
|
||||
"from_url": "Import from URL",
|
||||
"gist_url": "Enter Gist URL",
|
||||
"import_from_url_invalid_fetch": "Couldn't get data from the url",
|
||||
"import_from_url_invalid_file_format": "Error while importing collections",
|
||||
"import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'",
|
||||
"import_from_url_success": "Collections Imported",
|
||||
"json_description": "Import collections from a Hoppscotch Collections JSON file",
|
||||
"title": "Import"
|
||||
},
|
||||
"layout": {
|
||||
"collapse_collection": "Collapse or Expand Collections",
|
||||
"collapse_sidebar": "Collapse or Expand the sidebar",
|
||||
"column": "Vertical layout",
|
||||
"name": "Layout",
|
||||
"row": "Horizontal layout",
|
||||
"zen_mode": "Zen mode"
|
||||
},
|
||||
"modal": {
|
||||
"collections": "Collections",
|
||||
"confirm": "Confirm",
|
||||
"edit_request": "Edit Request",
|
||||
"import_export": "Import / Export"
|
||||
},
|
||||
"mqtt": {
|
||||
"communication": "Communication",
|
||||
"log": "Log",
|
||||
"message": "Message",
|
||||
"publish": "Publish",
|
||||
"subscribe": "Subscribe",
|
||||
"topic": "Topic",
|
||||
"topic_name": "Topic Name",
|
||||
"topic_title": "Publish / Subscribe topic",
|
||||
"unsubscribe": "Unsubscribe",
|
||||
"url": "URL"
|
||||
},
|
||||
"navigation": {
|
||||
"doc": "Docs",
|
||||
"graphql": "GraphQL",
|
||||
"profile": "Profile",
|
||||
"realtime": "Realtime",
|
||||
"rest": "REST",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"preRequest": {
|
||||
"javascript_code": "JavaScript Code",
|
||||
"learn": "Read documentation",
|
||||
"script": "Pre-Request Script",
|
||||
"snippets": "Snippets"
|
||||
},
|
||||
"profile": {
|
||||
"app_settings": "App Settings",
|
||||
"editor": "Editor",
|
||||
"editor_description": "Editors can add, edit, and delete requests.",
|
||||
"email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.",
|
||||
"no_permission": "You do not have permission to perform this action.",
|
||||
"owner": "Owner",
|
||||
"owner_description": "Owners can add, edit, and delete requests, collections and team members.",
|
||||
"roles": "Roles",
|
||||
"roles_description": "Roles are used to control access to the shared collections.",
|
||||
"updated": "Profile updated",
|
||||
"viewer": "Viewer",
|
||||
"viewer_description": "Viewers can only view and use requests."
|
||||
},
|
||||
"remove": {
|
||||
"star": "Remove star"
|
||||
},
|
||||
"request": {
|
||||
"added": "Request added",
|
||||
"authorization": "Authorization",
|
||||
"body": "Request Body",
|
||||
"choose_language": "Choose language",
|
||||
"content_type": "Content Type",
|
||||
"content_type_titles": {
|
||||
"others": "Others",
|
||||
"structured": "Structured",
|
||||
"text": "Text"
|
||||
},
|
||||
"copy_link": "Copy link",
|
||||
"duration": "Duration",
|
||||
"enter_curl": "Enter cURL",
|
||||
"generate_code": "Generate code",
|
||||
"generated_code": "Generated code",
|
||||
"header_list": "Header List",
|
||||
"invalid_name": "Please provide a name for the request",
|
||||
"method": "Method",
|
||||
"name": "Request name",
|
||||
"new": "New Request",
|
||||
"override": "Override",
|
||||
"override_help": "Set <kbd>Content-Type</kbd> in Headers",
|
||||
"overriden": "Overridden",
|
||||
"parameter_list": "Query Parameters",
|
||||
"parameters": "Parameters",
|
||||
"path": "Path",
|
||||
"payload": "Payload",
|
||||
"query": "Query",
|
||||
"raw_body": "Raw Request Body",
|
||||
"renamed": "Request renamed",
|
||||
"run": "Run",
|
||||
"save": "Save",
|
||||
"save_as": "Save as",
|
||||
"saved": "Request saved",
|
||||
"share": "Share",
|
||||
"share_description": "Share Hoppscotch with your friends",
|
||||
"title": "Request",
|
||||
"type": "Request type",
|
||||
"url": "URL",
|
||||
"variables": "Variables",
|
||||
"view_my_links": "View my links"
|
||||
},
|
||||
"response": {
|
||||
"body": "Response Body",
|
||||
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
|
||||
"headers": "Headers",
|
||||
"html": "HTML",
|
||||
"image": "Image",
|
||||
"json": "JSON",
|
||||
"pdf": "PDF",
|
||||
"preview_html": "Preview HTML",
|
||||
"raw": "Raw",
|
||||
"size": "Size",
|
||||
"status": "Status",
|
||||
"time": "Time",
|
||||
"title": "Response",
|
||||
"waiting_for_connection": "waiting for connection",
|
||||
"xml": "XML"
|
||||
},
|
||||
"settings": {
|
||||
"accent_color": "Accent color",
|
||||
"account": "アカウント",
|
||||
"account_description": "Customize your account settings.",
|
||||
"account_email_description": "Your primary email address.",
|
||||
"account_name_description": "This is your display name.",
|
||||
"background": "Background",
|
||||
"black_mode": "Black",
|
||||
"change_font_size": "Change font size",
|
||||
"choose_language": "Choose language",
|
||||
"dark_mode": "Dark",
|
||||
"expand_navigation": "Expand navigation",
|
||||
"experiments": "Experiments",
|
||||
"experiments_notice": "This is a collection of experiments we're working on that might turn out to be useful, fun, both, or neither. They're not final and may not be stable, so if something overly weird happens, don't panic. Just turn the dang thing off. Jokes aside, ",
|
||||
"extension_ver_not_reported": "Not Reported",
|
||||
"extension_version": "Extension Version",
|
||||
"extensions": "Browser extension",
|
||||
"extensions_use_toggle": "Use the browser extension to send requests (if present)",
|
||||
"follow": "Follow Us",
|
||||
"font_size": "Font size",
|
||||
"font_size_large": "Large",
|
||||
"font_size_medium": "Medium",
|
||||
"font_size_small": "Small",
|
||||
"interceptor": "Interceptor",
|
||||
"interceptor_description": "Middleware between application and APIs.",
|
||||
"language": "Language",
|
||||
"light_mode": "Light",
|
||||
"official_proxy_hosting": "Official Proxy is hosted by Hoppscotch.",
|
||||
"profile": "Profile",
|
||||
"profile_description": "Update your profile details",
|
||||
"profile_email": "Email address",
|
||||
"profile_name": "Profile name",
|
||||
"proxy": "Proxy",
|
||||
"proxy_url": "Proxy URL",
|
||||
"proxy_use_toggle": "Use the proxy middleware to send requests",
|
||||
"read_the": "Read the",
|
||||
"reset_default": "Reset to default",
|
||||
"short_codes": "Short codes",
|
||||
"short_codes_description": "Short codes which were created by you.",
|
||||
"sidebar_on_left": "Sidebar on left",
|
||||
"sync": "Synchronise",
|
||||
"sync_collections": "Collections",
|
||||
"sync_description": "These settings are synced to cloud.",
|
||||
"sync_environments": "Environments",
|
||||
"sync_history": "History",
|
||||
"system_mode": "System",
|
||||
"telemetry": "Telemetry",
|
||||
"telemetry_helps_us": "Telemetry helps us to personalize our operations and deliver the best experience to you.",
|
||||
"theme": "Theme",
|
||||
"theme_description": "Customize your application theme.",
|
||||
"use_experimental_url_bar": "Use experimental URL bar with environment highlighting",
|
||||
"user": "User",
|
||||
"verified_email": "Verified email",
|
||||
"verify_email": "Verify email"
|
||||
},
|
||||
"shortcodes": {
|
||||
"actions": "Actions",
|
||||
"created_on": "Created on",
|
||||
"deleted": "Shortcode deleted",
|
||||
"method": "Method",
|
||||
"not_found": "Shortcode not found",
|
||||
"short_code": "Short code",
|
||||
"url": "URL"
|
||||
},
|
||||
"shortcut": {
|
||||
"general": {
|
||||
"close_current_menu": "Close current menu",
|
||||
"command_menu": "Search & command menu",
|
||||
"help_menu": "Help menu",
|
||||
"show_all": "Keyboard shortcuts",
|
||||
"title": "General"
|
||||
},
|
||||
"miscellaneous": {
|
||||
"invite": "Invite people to Hoppscotch",
|
||||
"title": "Miscellaneous"
|
||||
},
|
||||
"navigation": {
|
||||
"back": "Go back to previous page",
|
||||
"documentation": "Go to Documentation page",
|
||||
"forward": "Go forward to next page",
|
||||
"graphql": "Go to GraphQL page",
|
||||
"profile": "Go to Profile page",
|
||||
"realtime": "Go to Realtime page",
|
||||
"rest": "Go to REST page",
|
||||
"settings": "Go to Settings page",
|
||||
"title": "Navigation"
|
||||
},
|
||||
"request": {
|
||||
"copy_request_link": "Copy Request Link",
|
||||
"delete_method": "Select DELETE method",
|
||||
"get_method": "Select GET method",
|
||||
"head_method": "Select HEAD method",
|
||||
"method": "Method",
|
||||
"next_method": "Select Next method",
|
||||
"post_method": "Select POST method",
|
||||
"previous_method": "Select Previous method",
|
||||
"put_method": "Select PUT method",
|
||||
"reset_request": "Reset Request",
|
||||
"save_to_collections": "Save to Collections",
|
||||
"send_request": "Send Request",
|
||||
"title": "Request"
|
||||
},
|
||||
"response": {
|
||||
"copy": "Copy response to clipboard",
|
||||
"download": "Download response as file",
|
||||
"title": "Response"
|
||||
},
|
||||
"theme": {
|
||||
"black": "Switch theme to black mode",
|
||||
"dark": "Switch theme to dark mode",
|
||||
"light": "Switch theme to light mode",
|
||||
"system": "Switch theme to system mode",
|
||||
"title": "Theme"
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"code": "Show code",
|
||||
"collection": "Expand Collection Panel",
|
||||
"more": "Show more",
|
||||
"sidebar": "Expand sidebar"
|
||||
},
|
||||
"socketio": {
|
||||
"communication": "Communication",
|
||||
"connection_not_authorized": "This SocketIO connection does not use any authentication.",
|
||||
"event_name": "Event Name",
|
||||
"events": "Events",
|
||||
"log": "Log",
|
||||
"url": "URL"
|
||||
},
|
||||
"sse": {
|
||||
"event_type": "Event type",
|
||||
"log": "Log",
|
||||
"url": "URL"
|
||||
},
|
||||
"state": {
|
||||
"bulk_mode": "Bulk edit",
|
||||
"bulk_mode_placeholder": "Entries are separated by newline\nKeys and values are separated by :\nPrepend # to any row you want to add but keep disabled",
|
||||
"cleared": "Cleared",
|
||||
"connected": "Connected",
|
||||
"connected_to": "Connected to {name}",
|
||||
"connecting_to": "Connecting to {name}...",
|
||||
"connection_error": "Failed to connect",
|
||||
"connection_failed": "Connection failed",
|
||||
"connection_lost": "Connection lost",
|
||||
"copied_to_clipboard": "Copied to clipboard",
|
||||
"deleted": "Deleted",
|
||||
"deprecated": "DEPRECATED",
|
||||
"disabled": "Disabled",
|
||||
"disconnected": "Disconnected",
|
||||
"disconnected_from": "Disconnected from {name}",
|
||||
"docs_generated": "Documentation generated",
|
||||
"download_started": "Download started",
|
||||
"enabled": "Enabled",
|
||||
"file_imported": "File imported",
|
||||
"finished_in": "Finished in {duration} ms",
|
||||
"history_deleted": "History deleted",
|
||||
"linewrap": "Wrap lines",
|
||||
"loading": "Loading...",
|
||||
"message_received": "Message: {message} arrived on topic: {topic}",
|
||||
"mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}",
|
||||
"none": "None",
|
||||
"nothing_found": "Nothing found for",
|
||||
"published_error": "Something went wrong while publishing msg: {topic} to topic: {message}",
|
||||
"published_message": "Published message: {message} to topic: {topic}",
|
||||
"reconnection_error": "Failed to reconnect",
|
||||
"subscribed_failed": "Failed to subscribe to topic: {topic}",
|
||||
"subscribed_success": "Successfully subscribed to topic: {topic}",
|
||||
"unsubscribed_failed": "Failed to unsubscribe from topic: {topic}",
|
||||
"unsubscribed_success": "Successfully unsubscribed from topic: {topic}",
|
||||
"waiting_send_request": "Waiting to send request"
|
||||
},
|
||||
"support": {
|
||||
"changelog": "Read more about latest releases",
|
||||
"chat": "Questions? Chat with us!",
|
||||
"community": "Ask questions and help others",
|
||||
"documentation": "Read more about Hoppscotch",
|
||||
"forum": "Ask questions and get answers",
|
||||
"github": "Follow us on Github",
|
||||
"shortcuts": "Browse app faster",
|
||||
"team": "Get in touch with the team",
|
||||
"title": "Support",
|
||||
"twitter": "Follow us on Twitter"
|
||||
},
|
||||
"tab": {
|
||||
"authorization": "Authorization",
|
||||
"body": "Body",
|
||||
"collections": "Collections",
|
||||
"documentation": "Documentation",
|
||||
"headers": "Headers",
|
||||
"history": "History",
|
||||
"mqtt": "MQTT",
|
||||
"parameters": "Parameters",
|
||||
"pre_request_script": "Pre-request Script",
|
||||
"queries": "Queries",
|
||||
"query": "Query",
|
||||
"schema": "Schema",
|
||||
"socketio": "Socket.IO",
|
||||
"sse": "SSE",
|
||||
"tests": "Tests",
|
||||
"types": "Types",
|
||||
"variables": "Variables",
|
||||
"websocket": "WebSocket"
|
||||
},
|
||||
"team": {
|
||||
"already_member": "You are already a member of this team. Contact your team owner.",
|
||||
"create_new": "Create new team",
|
||||
"deleted": "Team deleted",
|
||||
"edit": "Edit Team",
|
||||
"email": "E-mail",
|
||||
"email_do_not_match": "Email doesn't match with your account details. Contact your team owner.",
|
||||
"exit": "Exit Team",
|
||||
"exit_disabled": "Only owner cannot exit the team",
|
||||
"invalid_email_format": "Email format is invalid",
|
||||
"invalid_id": "Invalid team ID. Contact your team owner.",
|
||||
"invalid_invite_link": "Invalid invite link",
|
||||
"invalid_invite_link_description": "The link you followed is invalid. Contact your team owner.",
|
||||
"invalid_member_permission": "Please provide a valid permission to the team member",
|
||||
"invite": "Invite",
|
||||
"invite_more": "Invite more",
|
||||
"invite_tooltip": "Invite people to this workspace",
|
||||
"invited_to_team": "{owner} invited you to join {team}",
|
||||
"join": "Invitation accepted",
|
||||
"join_beta": "Join the beta program to access teams.",
|
||||
"join_team": "Join {team}",
|
||||
"joined_team": "You have joined {team}",
|
||||
"joined_team_description": "You are now a member of this team",
|
||||
"left": "You left the team",
|
||||
"login_to_continue": "Login to continue",
|
||||
"login_to_continue_description": "You need to be logged in to join a team.",
|
||||
"logout_and_try_again": "Logout and sign in with another account",
|
||||
"member_has_invite": "This email ID already has an invite. Contact your team owner.",
|
||||
"member_not_found": "Member not found. Contact your team owner.",
|
||||
"member_removed": "User removed",
|
||||
"member_role_updated": "User roles updated",
|
||||
"members": "Members",
|
||||
"name_length_insufficient": "Team name should be at least 6 characters long",
|
||||
"name_updated": "Team name updated",
|
||||
"new": "New Team",
|
||||
"new_created": "New team created",
|
||||
"new_name": "My New Team",
|
||||
"no_access": "You do not have edit access to these collections",
|
||||
"no_invite_found": "Invitation not found. Contact your team owner.",
|
||||
"not_found": "Team not found. Contact your team owner.",
|
||||
"not_valid_viewer": "You are not a valid viewer. Contact your team owner.",
|
||||
"pending_invites": "Pending invites",
|
||||
"permissions": "Permissions",
|
||||
"saved": "Team saved",
|
||||
"select_a_team": "Select a team",
|
||||
"title": "Teams",
|
||||
"we_sent_invite_link": "We sent an invite link to all invitees!",
|
||||
"we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the team."
|
||||
},
|
||||
"test": {
|
||||
"failed": "test failed",
|
||||
"javascript_code": "JavaScript Code",
|
||||
"learn": "Read documentation",
|
||||
"passed": "test passed",
|
||||
"report": "Test Report",
|
||||
"results": "Test Results",
|
||||
"script": "Script",
|
||||
"snippets": "Snippets"
|
||||
},
|
||||
"websocket": {
|
||||
"communication": "Communication",
|
||||
"log": "Log",
|
||||
"message": "Message",
|
||||
"protocols": "Protocols",
|
||||
"url": "URL"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 840 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 831 KiB |
272
packages/hoppscotch-app/shims-volar.d.ts
vendored
272
packages/hoppscotch-app/shims-volar.d.ts
vendored
@@ -1,272 +0,0 @@
|
||||
import AppAnnouncement from "./components/app/Announcement.vue";
|
||||
import AppDeveloperOptions from "./components/app/DeveloperOptions.vue";
|
||||
import AppFooter from "./components/app/Footer.vue";
|
||||
import AppFuse from "./components/app/Fuse.vue";
|
||||
import AppGitHubStarButton from "./components/app/GitHubStarButton.vue";
|
||||
import AppHeader from "./components/app/Header.vue";
|
||||
import AppInterceptor from "./components/app/Interceptor.vue";
|
||||
import AppLogo from "./components/app/Logo.vue";
|
||||
import AppOptions from "./components/app/Options.vue";
|
||||
import AppPaneLayout from "./components/app/PaneLayout.vue";
|
||||
import AppPowerSearch from "./components/app/PowerSearch.vue";
|
||||
import AppPowerSearchEntry from "./components/app/PowerSearchEntry.vue";
|
||||
import AppShare from "./components/app/Share.vue";
|
||||
import AppShortcuts from "./components/app/Shortcuts.vue";
|
||||
import AppShortcutsEntry from "./components/app/ShortcutsEntry.vue";
|
||||
import AppSidenav from "./components/app/Sidenav.vue";
|
||||
import AppSlideOver from "./components/app/SlideOver.vue";
|
||||
import AppSupport from "./components/app/Support.vue";
|
||||
import ButtonPrimary from "./components/button/Primary.vue";
|
||||
import ButtonSecondary from "./components/button/Secondary.vue";
|
||||
import CollectionsAdd from "./components/collections/Add.vue";
|
||||
import CollectionsAddFolder from "./components/collections/AddFolder.vue";
|
||||
import CollectionsAddRequest from "./components/collections/AddRequest.vue";
|
||||
import CollectionsChooseType from "./components/collections/ChooseType.vue";
|
||||
import CollectionsEdit from "./components/collections/Edit.vue";
|
||||
import CollectionsEditFolder from "./components/collections/EditFolder.vue";
|
||||
import CollectionsEditRequest from "./components/collections/EditRequest.vue";
|
||||
import CollectionsImportExport from "./components/collections/ImportExport.vue";
|
||||
import CollectionsSaveRequest from "./components/collections/SaveRequest.vue";
|
||||
import CollectionsGraphqlAdd from "./components/collections/graphql/Add.vue";
|
||||
import CollectionsGraphqlAddFolder from "./components/collections/graphql/AddFolder.vue";
|
||||
import CollectionsGraphqlAddRequest from "./components/collections/graphql/AddRequest.vue";
|
||||
import CollectionsGraphqlCollection from "./components/collections/graphql/Collection.vue";
|
||||
import CollectionsGraphqlEdit from "./components/collections/graphql/Edit.vue";
|
||||
import CollectionsGraphqlEditFolder from "./components/collections/graphql/EditFolder.vue";
|
||||
import CollectionsGraphqlEditRequest from "./components/collections/graphql/EditRequest.vue";
|
||||
import CollectionsGraphqlFolder from "./components/collections/graphql/Folder.vue";
|
||||
import CollectionsGraphqlImportExport from "./components/collections/graphql/ImportExport.vue";
|
||||
import CollectionsGraphqlRequest from "./components/collections/graphql/Request.vue";
|
||||
import CollectionsGraphql from "./components/collections/graphql/index.vue";
|
||||
import Collections from "./components/collections/index.vue";
|
||||
import CollectionsMyCollection from "./components/collections/my/Collection.vue";
|
||||
import CollectionsMyFolder from "./components/collections/my/Folder.vue";
|
||||
import CollectionsMyRequest from "./components/collections/my/Request.vue";
|
||||
import CollectionsTeamsCollection from "./components/collections/teams/Collection.vue";
|
||||
import CollectionsTeamsFolder from "./components/collections/teams/Folder.vue";
|
||||
import CollectionsTeamsRequest from "./components/collections/teams/Request.vue";
|
||||
import DocsCollection from "./components/docs/Collection.vue";
|
||||
import DocsFolder from "./components/docs/Folder.vue";
|
||||
import DocsRequest from "./components/docs/Request.vue";
|
||||
import EnvironmentsDetails from "./components/environments/Details.vue";
|
||||
import EnvironmentsEnvironment from "./components/environments/Environment.vue";
|
||||
import EnvironmentsImportExport from "./components/environments/ImportExport.vue";
|
||||
import Environments from "./components/environments/index.vue";
|
||||
import FirebaseLogin from "./components/firebase/Login.vue";
|
||||
import FirebaseLogout from "./components/firebase/Logout.vue";
|
||||
import GraphqlAuthorization from "./components/graphql/Authorization.vue";
|
||||
import GraphqlField from "./components/graphql/Field.vue";
|
||||
import GraphqlRequest from "./components/graphql/Request.vue";
|
||||
import GraphqlRequestOptions from "./components/graphql/RequestOptions.vue";
|
||||
import GraphqlResponse from "./components/graphql/Response.vue";
|
||||
import GraphqlSidebar from "./components/graphql/Sidebar.vue";
|
||||
import GraphqlType from "./components/graphql/Type.vue";
|
||||
import GraphqlTypeLink from "./components/graphql/TypeLink.vue";
|
||||
import HistoryGraphqlCard from "./components/history/graphql/Card.vue";
|
||||
import History from "./components/history/index.vue";
|
||||
import HistoryRestCard from "./components/history/rest/Card.vue";
|
||||
import HttpAuthorization from "./components/http/Authorization.vue";
|
||||
import HttpBody from "./components/http/Body.vue";
|
||||
import HttpBodyParameters from "./components/http/BodyParameters.vue";
|
||||
import HttpCodegenModal from "./components/http/CodegenModal.vue";
|
||||
import HttpHeaders from "./components/http/Headers.vue";
|
||||
import HttpImportCurl from "./components/http/ImportCurl.vue";
|
||||
import HttpOAuth2Authorization from "./components/http/OAuth2Authorization.vue";
|
||||
import HttpParameters from "./components/http/Parameters.vue";
|
||||
import HttpPreRequestScript from "./components/http/PreRequestScript.vue";
|
||||
import HttpRawBody from "./components/http/RawBody.vue";
|
||||
import HttpReqChangeConfirmModal from "./components/http/ReqChangeConfirmModal.vue";
|
||||
import HttpRequest from "./components/http/Request.vue";
|
||||
import HttpRequestOptions from "./components/http/RequestOptions.vue";
|
||||
import HttpResponse from "./components/http/Response.vue";
|
||||
import HttpResponseMeta from "./components/http/ResponseMeta.vue";
|
||||
import HttpSidebar from "./components/http/Sidebar.vue";
|
||||
import HttpTestResult from "./components/http/TestResult.vue";
|
||||
import HttpTestResultEntry from "./components/http/TestResultEntry.vue";
|
||||
import HttpTestResultEnv from "./components/http/TestResultEnv.vue";
|
||||
import HttpTestResultReport from "./components/http/TestResultReport.vue";
|
||||
import HttpTests from "./components/http/Tests.vue";
|
||||
import HttpURLEncodedParams from "./components/http/URLEncodedParams.vue";
|
||||
import LensesHeadersRenderer from "./components/lenses/HeadersRenderer.vue";
|
||||
import LensesHeadersRendererEntry from "./components/lenses/HeadersRendererEntry.vue";
|
||||
import LensesResponseBodyRenderer from "./components/lenses/ResponseBodyRenderer.vue";
|
||||
import LensesRenderersHTMLLensRenderer from "./components/lenses/renderers/HTMLLensRenderer.vue";
|
||||
import LensesRenderersImageLensRenderer from "./components/lenses/renderers/ImageLensRenderer.vue";
|
||||
import LensesRenderersJSONLensRenderer from "./components/lenses/renderers/JSONLensRenderer.vue";
|
||||
import LensesRenderersPDFLensRenderer from "./components/lenses/renderers/PDFLensRenderer.vue";
|
||||
import LensesRenderersRawLensRenderer from "./components/lenses/renderers/RawLensRenderer.vue";
|
||||
import LensesRenderersXMLLensRenderer from "./components/lenses/renderers/XMLLensRenderer.vue";
|
||||
import ProfilePicture from "./components/profile/Picture.vue";
|
||||
import ProfileShortcode from "./components/profile/Shortcode.vue";
|
||||
import RealtimeCommunication from "./components/realtime/Communication.vue";
|
||||
import RealtimeLog from "./components/realtime/Log.vue";
|
||||
import RealtimeLogEntry from "./components/realtime/LogEntry.vue";
|
||||
import SmartAccentModePicker from "./components/smart/AccentModePicker.vue";
|
||||
import SmartAnchor from "./components/smart/Anchor.vue";
|
||||
import SmartAutoComplete from "./components/smart/AutoComplete.vue";
|
||||
import SmartChangeLanguage from "./components/smart/ChangeLanguage.vue";
|
||||
import SmartCheckbox from "./components/smart/Checkbox.vue";
|
||||
import SmartColorModePicker from "./components/smart/ColorModePicker.vue";
|
||||
import SmartConfirmModal from "./components/smart/ConfirmModal.vue";
|
||||
import SmartEnvInput from "./components/smart/EnvInput.vue";
|
||||
import SmartExpand from "./components/smart/Expand.vue";
|
||||
import SmartFileChip from "./components/smart/FileChip.vue";
|
||||
import SmartFontSizePicker from "./components/smart/FontSizePicker.vue";
|
||||
import SmartIcon from "./components/smart/Icon.vue";
|
||||
import SmartIntersection from "./components/smart/Intersection.vue";
|
||||
import SmartItem from "./components/smart/Item.vue";
|
||||
import SmartLoadingIndicator from "./components/smart/LoadingIndicator.vue";
|
||||
import SmartModal from "./components/smart/Modal.vue";
|
||||
import SmartProgressRing from "./components/smart/ProgressRing.vue";
|
||||
import SmartRadio from "./components/smart/Radio.vue";
|
||||
import SmartRadioGroup from "./components/smart/RadioGroup.vue";
|
||||
import SmartSpinner from "./components/smart/Spinner.vue";
|
||||
import SmartTab from "./components/smart/Tab.vue";
|
||||
import SmartTabs from "./components/smart/Tabs.vue";
|
||||
import SmartToggle from "./components/smart/Toggle.vue";
|
||||
import TabPrimary from "./components/tab/Primary.vue";
|
||||
import TabSecondary from "./components/tab/Secondary.vue";
|
||||
import TeamsAdd from "./components/teams/Add.vue";
|
||||
import TeamsEdit from "./components/teams/Edit.vue";
|
||||
import TeamsInvite from "./components/teams/Invite.vue";
|
||||
import TeamsModal from "./components/teams/Modal.vue";
|
||||
import TeamsTeam from "./components/teams/Team.vue";
|
||||
import Teams from "./components/teams/index.vue";
|
||||
declare global {
|
||||
interface __VLS_GlobalComponents {
|
||||
AppAnnouncement: typeof AppAnnouncement;
|
||||
AppDeveloperOptions: typeof AppDeveloperOptions;
|
||||
AppFooter: typeof AppFooter;
|
||||
AppFuse: typeof AppFuse;
|
||||
AppGitHubStarButton: typeof AppGitHubStarButton;
|
||||
AppHeader: typeof AppHeader;
|
||||
AppInterceptor: typeof AppInterceptor;
|
||||
AppLogo: typeof AppLogo;
|
||||
AppOptions: typeof AppOptions;
|
||||
AppPaneLayout: typeof AppPaneLayout;
|
||||
AppPowerSearch: typeof AppPowerSearch;
|
||||
AppPowerSearchEntry: typeof AppPowerSearchEntry;
|
||||
AppShare: typeof AppShare;
|
||||
AppShortcuts: typeof AppShortcuts;
|
||||
AppShortcutsEntry: typeof AppShortcutsEntry;
|
||||
AppSidenav: typeof AppSidenav;
|
||||
AppSlideOver: typeof AppSlideOver;
|
||||
AppSupport: typeof AppSupport;
|
||||
ButtonPrimary: typeof ButtonPrimary;
|
||||
ButtonSecondary: typeof ButtonSecondary;
|
||||
CollectionsAdd: typeof CollectionsAdd;
|
||||
CollectionsAddFolder: typeof CollectionsAddFolder;
|
||||
CollectionsAddRequest: typeof CollectionsAddRequest;
|
||||
CollectionsChooseType: typeof CollectionsChooseType;
|
||||
CollectionsEdit: typeof CollectionsEdit;
|
||||
CollectionsEditFolder: typeof CollectionsEditFolder;
|
||||
CollectionsEditRequest: typeof CollectionsEditRequest;
|
||||
CollectionsImportExport: typeof CollectionsImportExport;
|
||||
CollectionsSaveRequest: typeof CollectionsSaveRequest;
|
||||
CollectionsGraphqlAdd: typeof CollectionsGraphqlAdd;
|
||||
CollectionsGraphqlAddFolder: typeof CollectionsGraphqlAddFolder;
|
||||
CollectionsGraphqlAddRequest: typeof CollectionsGraphqlAddRequest;
|
||||
CollectionsGraphqlCollection: typeof CollectionsGraphqlCollection;
|
||||
CollectionsGraphqlEdit: typeof CollectionsGraphqlEdit;
|
||||
CollectionsGraphqlEditFolder: typeof CollectionsGraphqlEditFolder;
|
||||
CollectionsGraphqlEditRequest: typeof CollectionsGraphqlEditRequest;
|
||||
CollectionsGraphqlFolder: typeof CollectionsGraphqlFolder;
|
||||
CollectionsGraphqlImportExport: typeof CollectionsGraphqlImportExport;
|
||||
CollectionsGraphqlRequest: typeof CollectionsGraphqlRequest;
|
||||
CollectionsGraphql: typeof CollectionsGraphql;
|
||||
Collections: typeof Collections;
|
||||
CollectionsMyCollection: typeof CollectionsMyCollection;
|
||||
CollectionsMyFolder: typeof CollectionsMyFolder;
|
||||
CollectionsMyRequest: typeof CollectionsMyRequest;
|
||||
CollectionsTeamsCollection: typeof CollectionsTeamsCollection;
|
||||
CollectionsTeamsFolder: typeof CollectionsTeamsFolder;
|
||||
CollectionsTeamsRequest: typeof CollectionsTeamsRequest;
|
||||
DocsCollection: typeof DocsCollection;
|
||||
DocsFolder: typeof DocsFolder;
|
||||
DocsRequest: typeof DocsRequest;
|
||||
EnvironmentsDetails: typeof EnvironmentsDetails;
|
||||
EnvironmentsEnvironment: typeof EnvironmentsEnvironment;
|
||||
EnvironmentsImportExport: typeof EnvironmentsImportExport;
|
||||
Environments: typeof Environments;
|
||||
FirebaseLogin: typeof FirebaseLogin;
|
||||
FirebaseLogout: typeof FirebaseLogout;
|
||||
GraphqlAuthorization: typeof GraphqlAuthorization;
|
||||
GraphqlField: typeof GraphqlField;
|
||||
GraphqlRequest: typeof GraphqlRequest;
|
||||
GraphqlRequestOptions: typeof GraphqlRequestOptions;
|
||||
GraphqlResponse: typeof GraphqlResponse;
|
||||
GraphqlSidebar: typeof GraphqlSidebar;
|
||||
GraphqlType: typeof GraphqlType;
|
||||
GraphqlTypeLink: typeof GraphqlTypeLink;
|
||||
HistoryGraphqlCard: typeof HistoryGraphqlCard;
|
||||
History: typeof History;
|
||||
HistoryRestCard: typeof HistoryRestCard;
|
||||
HttpAuthorization: typeof HttpAuthorization;
|
||||
HttpBody: typeof HttpBody;
|
||||
HttpBodyParameters: typeof HttpBodyParameters;
|
||||
HttpCodegenModal: typeof HttpCodegenModal;
|
||||
HttpHeaders: typeof HttpHeaders;
|
||||
HttpImportCurl: typeof HttpImportCurl;
|
||||
HttpOAuth2Authorization: typeof HttpOAuth2Authorization;
|
||||
HttpParameters: typeof HttpParameters;
|
||||
HttpPreRequestScript: typeof HttpPreRequestScript;
|
||||
HttpRawBody: typeof HttpRawBody;
|
||||
HttpReqChangeConfirmModal: typeof HttpReqChangeConfirmModal;
|
||||
HttpRequest: typeof HttpRequest;
|
||||
HttpRequestOptions: typeof HttpRequestOptions;
|
||||
HttpResponse: typeof HttpResponse;
|
||||
HttpResponseMeta: typeof HttpResponseMeta;
|
||||
HttpSidebar: typeof HttpSidebar;
|
||||
HttpTestResult: typeof HttpTestResult;
|
||||
HttpTestResultEntry: typeof HttpTestResultEntry;
|
||||
HttpTestResultEnv: typeof HttpTestResultEnv;
|
||||
HttpTestResultReport: typeof HttpTestResultReport;
|
||||
HttpTests: typeof HttpTests;
|
||||
HttpURLEncodedParams: typeof HttpURLEncodedParams;
|
||||
LensesHeadersRenderer: typeof LensesHeadersRenderer;
|
||||
LensesHeadersRendererEntry: typeof LensesHeadersRendererEntry;
|
||||
LensesResponseBodyRenderer: typeof LensesResponseBodyRenderer;
|
||||
LensesRenderersHTMLLensRenderer: typeof LensesRenderersHTMLLensRenderer;
|
||||
LensesRenderersImageLensRenderer: typeof LensesRenderersImageLensRenderer;
|
||||
LensesRenderersJSONLensRenderer: typeof LensesRenderersJSONLensRenderer;
|
||||
LensesRenderersPDFLensRenderer: typeof LensesRenderersPDFLensRenderer;
|
||||
LensesRenderersRawLensRenderer: typeof LensesRenderersRawLensRenderer;
|
||||
LensesRenderersXMLLensRenderer: typeof LensesRenderersXMLLensRenderer;
|
||||
ProfilePicture: typeof ProfilePicture;
|
||||
ProfileShortcode: typeof ProfileShortcode;
|
||||
RealtimeCommunication: typeof RealtimeCommunication;
|
||||
RealtimeLog: typeof RealtimeLog;
|
||||
RealtimeLogEntry: typeof RealtimeLogEntry;
|
||||
SmartAccentModePicker: typeof SmartAccentModePicker;
|
||||
SmartAnchor: typeof SmartAnchor;
|
||||
SmartAutoComplete: typeof SmartAutoComplete;
|
||||
SmartChangeLanguage: typeof SmartChangeLanguage;
|
||||
SmartCheckbox: typeof SmartCheckbox;
|
||||
SmartColorModePicker: typeof SmartColorModePicker;
|
||||
SmartConfirmModal: typeof SmartConfirmModal;
|
||||
SmartEnvInput: typeof SmartEnvInput;
|
||||
SmartExpand: typeof SmartExpand;
|
||||
SmartFileChip: typeof SmartFileChip;
|
||||
SmartFontSizePicker: typeof SmartFontSizePicker;
|
||||
SmartIcon: typeof SmartIcon;
|
||||
SmartIntersection: typeof SmartIntersection;
|
||||
SmartItem: typeof SmartItem;
|
||||
SmartLoadingIndicator: typeof SmartLoadingIndicator;
|
||||
SmartModal: typeof SmartModal;
|
||||
SmartProgressRing: typeof SmartProgressRing;
|
||||
SmartRadio: typeof SmartRadio;
|
||||
SmartRadioGroup: typeof SmartRadioGroup;
|
||||
SmartSpinner: typeof SmartSpinner;
|
||||
SmartTab: typeof SmartTab;
|
||||
SmartTabs: typeof SmartTabs;
|
||||
SmartToggle: typeof SmartToggle;
|
||||
TabPrimary: typeof TabPrimary;
|
||||
TabSecondary: typeof TabSecondary;
|
||||
TeamsAdd: typeof TeamsAdd;
|
||||
TeamsEdit: typeof TeamsEdit;
|
||||
TeamsInvite: typeof TeamsInvite;
|
||||
TeamsModal: typeof TeamsModal;
|
||||
TeamsTeam: typeof TeamsTeam;
|
||||
Teams: typeof Teams;
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<header
|
||||
class="flex items-center justify-between flex-1 px-2 py-2 overflow-x-auto overflow-y-hidden space-x-2"
|
||||
>
|
||||
<div class="inline-flex items-center space-x-2">
|
||||
<ButtonSecondary
|
||||
class="tracking-wide !font-bold !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark uppercase"
|
||||
:label="t('app.name')"
|
||||
to="/"
|
||||
/>
|
||||
<AppGitHubStarButton class="mt-1.5 transition <sm:hidden" />
|
||||
</div>
|
||||
<div class="inline-flex items-center space-x-2">
|
||||
<ButtonSecondary
|
||||
v-if="showInstallButton"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('header.install_pwa')"
|
||||
:icon="IconDownload"
|
||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
@click="installPWA()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t('app.search')} <kbd>/</kbd>`"
|
||||
:icon="IconSearch"
|
||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
@click="invokeAction('modals.search.toggle')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${
|
||||
mdAndLarger ? t('support.title') : t('app.options')
|
||||
} <kbd>?</kbd>`"
|
||||
:icon="IconLifeBuoy"
|
||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
@click="invokeAction('modals.support.toggle')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="currentUser === null"
|
||||
:icon="IconUploadCloud"
|
||||
:label="t('header.save_workspace')"
|
||||
filled
|
||||
class="hidden md:flex"
|
||||
@click="showLogin = true"
|
||||
/>
|
||||
<ButtonPrimary
|
||||
v-if="currentUser === null"
|
||||
:label="t('header.login')"
|
||||
@click="showLogin = true"
|
||||
/>
|
||||
<div v-else class="inline-flex items-center space-x-2">
|
||||
<ButtonPrimary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('team.invite_tooltip')"
|
||||
:label="t('team.invite')"
|
||||
:icon="IconUserPlus"
|
||||
class="!bg-green-500 !bg-opacity-15 !text-green-500 !hover:bg-opacity-10 !hover:bg-green-400 !hover:text-green-600"
|
||||
@click="showTeamsModal = true"
|
||||
/>
|
||||
<span class="px-2">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ProfilePicture
|
||||
v-if="currentUser.photoURL"
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
}"
|
||||
:url="currentUser.photoURL"
|
||||
:alt="currentUser.displayName"
|
||||
:title="currentUser.displayName"
|
||||
indicator
|
||||
:indicator-styles="
|
||||
network.isOnline ? 'bg-green-500' : 'bg-red-500'
|
||||
"
|
||||
/>
|
||||
<ProfilePicture
|
||||
v-else
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="currentUser.displayName"
|
||||
:initial="currentUser.displayName"
|
||||
indicator
|
||||
:indicator-styles="
|
||||
network.isOnline ? 'bg-green-500' : 'bg-red-500'
|
||||
"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div class="flex flex-col px-2 text-tiny">
|
||||
<span class="inline-flex font-semibold truncate">
|
||||
{{ currentUser.displayName }}
|
||||
</span>
|
||||
<span class="inline-flex truncate text-secondaryLight">
|
||||
{{ currentUser.email }}
|
||||
</span>
|
||||
</div>
|
||||
<hr />
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.enter="profile.$el.click()"
|
||||
@keyup.s="settings.$el.click()"
|
||||
@keyup.l="logout.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="profile"
|
||||
to="/profile"
|
||||
:icon="IconUser"
|
||||
:label="t('navigation.profile')"
|
||||
:shortcut="['↩']"
|
||||
@click="hide()"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="settings"
|
||||
to="/settings"
|
||||
:icon="IconSettings"
|
||||
:label="t('navigation.settings')"
|
||||
:shortcut="['S']"
|
||||
@click="hide()"
|
||||
/>
|
||||
<FirebaseLogout
|
||||
ref="logout"
|
||||
:shortcut="['L']"
|
||||
@confirm-logout="hide()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<AppAnnouncement v-if="!network.isOnline" />
|
||||
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
|
||||
<TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue"
|
||||
import IconUser from "~icons/lucide/user"
|
||||
import IconSettings from "~icons/lucide/settings"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconSearch from "~icons/lucide/search"
|
||||
import IconLifeBuoy from "~icons/lucide/life-buoy"
|
||||
import IconUploadCloud from "~icons/lucide/upload-cloud"
|
||||
import IconUserPlus from "~icons/lucide/user-plus"
|
||||
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
|
||||
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
|
||||
import { probableUser$ } from "@helpers/fb/auth"
|
||||
import { getLocalConfig, setLocalConfig } from "~/newstore/localpersistence"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { invokeAction } from "@helpers/actions"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
/**
|
||||
* Once the PWA code is initialized, this holds a method
|
||||
* that can be called to show the user the installation
|
||||
* prompt.
|
||||
*/
|
||||
|
||||
const showInstallButton = computed(() => !!pwaDefferedPrompt.value)
|
||||
|
||||
const showLogin = ref(false)
|
||||
const showTeamsModal = ref(false)
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const mdAndLarger = breakpoints.greater("md")
|
||||
|
||||
const network = reactive(useNetwork())
|
||||
|
||||
const currentUser = useReadonlyStream(probableUser$, null)
|
||||
|
||||
onMounted(() => {
|
||||
const cookiesAllowed = getLocalConfig("cookiesAllowed") === "yes"
|
||||
if (!cookiesAllowed) {
|
||||
toast.show(`${t("app.we_use_cookies")}`, {
|
||||
duration: 0,
|
||||
action: [
|
||||
{
|
||||
text: `${t("action.learn_more")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
setLocalConfig("cookiesAllowed", "yes")
|
||||
toastObject.goAway(0)
|
||||
window.open("https://docs.hoppscotch.io/privacy", "_blank")?.focus()
|
||||
},
|
||||
},
|
||||
{
|
||||
text: `${t("action.dismiss")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
setLocalConfig("cookiesAllowed", "yes")
|
||||
toastObject.goAway(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const profile = ref<any | null>(null)
|
||||
const settings = ref<any | null>(null)
|
||||
const logout = ref<any | null>(null)
|
||||
</script>
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('folder.new')"
|
||||
@close="$emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelAddFolder"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="addFolder"
|
||||
/>
|
||||
<label for="selectLabelAddFolder">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="t('action.save')"
|
||||
:loading="loadingState"
|
||||
outline
|
||||
@click="addFolder"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
folder: { type: Object, default: () => ({}) },
|
||||
folderPath: { type: String, default: null },
|
||||
collectionIndex: { type: Number, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
emits: ["hide-modal", "add-folder"],
|
||||
setup() {
|
||||
return {
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: null,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(isShowing: boolean) {
|
||||
if (!isShowing) this.name = null
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addFolder() {
|
||||
if (!this.name) {
|
||||
this.toast.error(this.t("folder.invalid_name"))
|
||||
return
|
||||
}
|
||||
this.$emit("add-folder", {
|
||||
name: this.name,
|
||||
folder: this.folder,
|
||||
path: this.folderPath || `${this.collectionIndex}`,
|
||||
})
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,151 +0,0 @@
|
||||
<template>
|
||||
<div v-show="show">
|
||||
<SmartTabs
|
||||
:id="'collections_tab'"
|
||||
v-model="selectedCollectionTab"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab
|
||||
:id="'my-collections'"
|
||||
:label="`${t('collection.my_collections')}`"
|
||||
/>
|
||||
<SmartTab
|
||||
v-if="currentUser"
|
||||
:id="'team-collections'"
|
||||
:label="`${t('collection.team_collections')}`"
|
||||
>
|
||||
<SmartIntersection @intersecting="onTeamSelectIntersect">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
placement="bottom"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${t('collection.select_team')}`"
|
||||
class="bg-transparent border-b border-dividerLight select-wrapper"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-if="collectionsType.selectedTeam"
|
||||
:icon="IconUsers"
|
||||
:label="collectionsType.selectedTeam.name"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:label="`${t('collection.select_team')}`"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="(team, index) in myTeams"
|
||||
:key="`team-${index}`"
|
||||
:label="team.name"
|
||||
:info-icon="
|
||||
team.id === collectionsType.selectedTeam?.id
|
||||
? IconDone
|
||||
: null
|
||||
"
|
||||
:active-info-icon="
|
||||
team.id === collectionsType.selectedTeam?.id
|
||||
"
|
||||
:icon="IconUsers"
|
||||
@click="
|
||||
() => {
|
||||
updateSelectedTeam(team)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</SmartIntersection>
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconUsers from "~icons/lucide/users"
|
||||
import IconDone from "~icons/lucide/check"
|
||||
import { ref, watch } from "vue"
|
||||
import { GetMyTeamsQuery, Team } from "~/helpers/backend/graphql"
|
||||
import { currentUserInfo$ } from "~/helpers/teams/BackendUserInfo"
|
||||
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { onLoggedIn } from "@composables/auth"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useLocalState } from "~/newstore/localstate"
|
||||
|
||||
type TeamData = GetMyTeamsQuery["myTeams"][number]
|
||||
|
||||
type CollectionTabs = "my-collections" | "team-collections"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const selectedCollectionTab = ref<CollectionTabs>("my-collections")
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
collectionsType: {
|
||||
type: "my-collections" | "team-collections"
|
||||
selectedTeam: Team | undefined
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update-collection-type", tabID: string): void
|
||||
(e: "update-selected-team", team: TeamData | undefined): void
|
||||
}>()
|
||||
|
||||
const currentUser = useReadonlyStream(currentUserInfo$, null)
|
||||
|
||||
const adapter = new TeamListAdapter(true)
|
||||
const myTeams = useReadonlyStream(adapter.teamList$, null)
|
||||
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
|
||||
let teamListFetched = false
|
||||
|
||||
watch(myTeams, (teams) => {
|
||||
if (teams && !teamListFetched) {
|
||||
teamListFetched = true
|
||||
if (REMEMBERED_TEAM_ID.value && currentUser) {
|
||||
const team = teams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
|
||||
if (team) updateSelectedTeam(team)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onLoggedIn(() => {
|
||||
adapter.initialize()
|
||||
})
|
||||
|
||||
const onTeamSelectIntersect = () => {
|
||||
// Load team data as soon as intersection
|
||||
adapter.fetchList()
|
||||
}
|
||||
|
||||
const updateCollectionsType = (tabID: string) => {
|
||||
emit("update-collection-type", tabID)
|
||||
}
|
||||
|
||||
const updateSelectedTeam = (team: TeamData | undefined) => {
|
||||
REMEMBERED_TEAM_ID.value = team?.id
|
||||
emit("update-selected-team", team)
|
||||
}
|
||||
|
||||
watch(selectedCollectionTab, (newValue: string) => {
|
||||
updateCollectionsType(newValue)
|
||||
})
|
||||
</script>
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('modal.edit_request')"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelEditReq"
|
||||
v-model="requestUpdateData.name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="saveRequest"
|
||||
/>
|
||||
<label for="selectLabelEditReq">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="t('action.save')"
|
||||
:loading="loadingState"
|
||||
outline
|
||||
@click="saveRequest"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
editingRequestName: { type: String, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
emits: ["submit", "hide-modal"],
|
||||
setup() {
|
||||
return {
|
||||
t: useI18n(),
|
||||
toast: useToast(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
requestUpdateData: {
|
||||
name: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
editingRequestName(val) {
|
||||
this.requestUpdateData.name = val
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
saveRequest() {
|
||||
if (!this.requestUpdateData.name) {
|
||||
this.toast.error(this.t("request.invalid_name"))
|
||||
return
|
||||
}
|
||||
this.$emit("submit", this.requestUpdateData)
|
||||
},
|
||||
hideModal() {
|
||||
this.requestUpdateData = { name: null }
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,400 +0,0 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('collection.save_as')}`"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<div class="relative flex">
|
||||
<input
|
||||
id="selectLabelSaveReq"
|
||||
v-model="requestName"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="saveRequestAs"
|
||||
/>
|
||||
<label for="selectLabelSaveReq">
|
||||
{{ t("request.name") }}
|
||||
</label>
|
||||
</div>
|
||||
<label class="p-4">
|
||||
{{ t("collection.select_location") }}
|
||||
</label>
|
||||
<CollectionsGraphql
|
||||
v-if="mode === 'graphql'"
|
||||
:show-coll-actions="false"
|
||||
:picked="picked"
|
||||
:saving-mode="true"
|
||||
@select="onSelect"
|
||||
/>
|
||||
<Collections
|
||||
v-else
|
||||
:picked="picked"
|
||||
:save-request="true"
|
||||
@select="onSelect"
|
||||
@update-collection="updateColl"
|
||||
@update-coll-type="onUpdateCollType"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="`${t('action.save')}`"
|
||||
outline
|
||||
@click="saveRequestAs"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="`${t('action.cancel')}`"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from "vue"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { HoppGQLRequest, isHoppRESTRequest } from "@hoppscotch/data"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import {
|
||||
editGraphqlRequest,
|
||||
editRESTRequest,
|
||||
saveGraphqlRequestAs,
|
||||
saveRESTRequestAs,
|
||||
} from "~/newstore/collections"
|
||||
import { getGQLSession, useGQLRequestName } from "~/newstore/GQLSession"
|
||||
import {
|
||||
getRESTRequest,
|
||||
setRESTSaveContext,
|
||||
useRESTRequestName,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
||||
import {
|
||||
CreateRequestInCollectionDocument,
|
||||
UpdateRequestDocument,
|
||||
} from "~/helpers/backend/graphql"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
type CollectionType =
|
||||
| {
|
||||
type: "my-collections"
|
||||
}
|
||||
| {
|
||||
type: "team-collections"
|
||||
// TODO: Figure this type out
|
||||
selectedTeam: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
type Picked =
|
||||
| {
|
||||
pickedType: "my-request"
|
||||
folderPath: string
|
||||
requestIndex: number
|
||||
}
|
||||
| {
|
||||
pickedType: "my-folder"
|
||||
folderPath: string
|
||||
}
|
||||
| {
|
||||
pickedType: "my-collection"
|
||||
collectionIndex: number
|
||||
}
|
||||
| {
|
||||
pickedType: "teams-request"
|
||||
requestID: string
|
||||
}
|
||||
| {
|
||||
pickedType: "teams-folder"
|
||||
folderID: string
|
||||
}
|
||||
| {
|
||||
pickedType: "teams-collection"
|
||||
collectionID: string
|
||||
}
|
||||
| {
|
||||
pickedType: "gql-my-request"
|
||||
folderPath: string
|
||||
requestIndex: number
|
||||
}
|
||||
| {
|
||||
pickedType: "gql-my-folder"
|
||||
folderPath: string
|
||||
}
|
||||
| {
|
||||
pickedType: "gql-my-collection"
|
||||
collectionIndex: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
mode: "rest" | "graphql"
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// TODO: Use a better implementation with computed ?
|
||||
// This implementation can't work across updates to mode prop (which won't happen tho)
|
||||
const requestName =
|
||||
props.mode === "rest" ? useRESTRequestName() : useGQLRequestName()
|
||||
|
||||
const requestData = reactive({
|
||||
name: requestName,
|
||||
collectionIndex: undefined as number | undefined,
|
||||
folderName: undefined as number | undefined,
|
||||
requestIndex: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const collectionsType = ref<CollectionType>({
|
||||
type: "my-collections",
|
||||
})
|
||||
|
||||
// TODO: Figure this type out
|
||||
const picked = ref<Picked | null>(null)
|
||||
|
||||
// Resets
|
||||
watch(
|
||||
() => requestData.collectionIndex,
|
||||
() => {
|
||||
requestData.folderName = undefined
|
||||
requestData.requestIndex = undefined
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => requestData.folderName,
|
||||
() => {
|
||||
requestData.requestIndex = undefined
|
||||
}
|
||||
)
|
||||
|
||||
// All the methods
|
||||
const onUpdateCollType = (newCollType: CollectionType) => {
|
||||
collectionsType.value = newCollType
|
||||
}
|
||||
|
||||
const onSelect = ({ picked: pickedVal }: { picked: Picked | null }) => {
|
||||
picked.value = pickedVal
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
picked.value = null
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
const saveRequestAs = async () => {
|
||||
if (!requestName.value) {
|
||||
toast.error(`${t("error.empty_req_name")}`)
|
||||
return
|
||||
}
|
||||
if (picked.value === null) {
|
||||
toast.error(`${t("collection.select")}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Clone Deep because objects are shared by reference so updating
|
||||
// just one bit will update other referenced shared instances
|
||||
const requestUpdated =
|
||||
props.mode === "rest"
|
||||
? cloneDeep(getRESTRequest())
|
||||
: cloneDeep(getGQLSession().request)
|
||||
|
||||
// // Filter out all REST file inputs
|
||||
// if (this.mode === "rest" && requestUpdated.bodyParams) {
|
||||
// requestUpdated.bodyParams = requestUpdated.bodyParams.map((param) =>
|
||||
// param?.value?.[0] instanceof File ? { ...param, value: "" } : param
|
||||
// )
|
||||
// }
|
||||
|
||||
if (picked.value.pickedType === "my-request") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
editRESTRequest(
|
||||
picked.value.folderPath,
|
||||
picked.value.requestIndex,
|
||||
requestUpdated
|
||||
)
|
||||
|
||||
setRESTSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: picked.value.folderPath,
|
||||
requestIndex: picked.value.requestIndex,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
} else if (picked.value.pickedType === "my-folder") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
const insertionIndex = saveRESTRequestAs(
|
||||
picked.value.folderPath,
|
||||
requestUpdated
|
||||
)
|
||||
|
||||
setRESTSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: picked.value.folderPath,
|
||||
requestIndex: insertionIndex,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
} else if (picked.value.pickedType === "my-collection") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
const insertionIndex = saveRESTRequestAs(
|
||||
`${picked.value.collectionIndex}`,
|
||||
requestUpdated
|
||||
)
|
||||
|
||||
setRESTSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: `${picked.value.collectionIndex}`,
|
||||
requestIndex: insertionIndex,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
} else if (picked.value.pickedType === "teams-request") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
if (collectionsType.value.type !== "team-collections")
|
||||
throw new Error("Collections Type mismatch")
|
||||
|
||||
runMutation(UpdateRequestDocument, {
|
||||
requestID: picked.value.requestID,
|
||||
data: {
|
||||
request: JSON.stringify(requestUpdated),
|
||||
title: requestUpdated.name,
|
||||
},
|
||||
})().then((result) => {
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
throw new Error(`${result.left}`)
|
||||
} else {
|
||||
requestSaved()
|
||||
}
|
||||
})
|
||||
|
||||
setRESTSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: picked.value.requestID,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
} else if (picked.value.pickedType === "teams-folder") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
if (collectionsType.value.type !== "team-collections")
|
||||
throw new Error("Collections Type mismatch")
|
||||
|
||||
const result = await runMutation(CreateRequestInCollectionDocument, {
|
||||
collectionID: picked.value.folderID,
|
||||
data: {
|
||||
request: JSON.stringify(requestUpdated),
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
title: requestUpdated.name,
|
||||
},
|
||||
})()
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
console.error(result.left)
|
||||
} else {
|
||||
setRESTSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: result.right.createRequestInCollection.id,
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
collectionID: picked.value.folderID,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
}
|
||||
} else if (picked.value.pickedType === "teams-collection") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
if (collectionsType.value.type !== "team-collections")
|
||||
throw new Error("Collections Type mismatch")
|
||||
|
||||
const result = await runMutation(CreateRequestInCollectionDocument, {
|
||||
collectionID: picked.value.collectionID,
|
||||
data: {
|
||||
title: requestUpdated.name,
|
||||
request: JSON.stringify(requestUpdated),
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
},
|
||||
})()
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
console.error(result.left)
|
||||
} else {
|
||||
setRESTSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: result.right.createRequestInCollection.id,
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
collectionID: picked.value.collectionID,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
}
|
||||
} else if (picked.value.pickedType === "gql-my-request") {
|
||||
// TODO: Check for GQL request ?
|
||||
editGraphqlRequest(
|
||||
picked.value.folderPath,
|
||||
picked.value.requestIndex,
|
||||
requestUpdated as HoppGQLRequest
|
||||
)
|
||||
|
||||
requestSaved()
|
||||
} else if (picked.value.pickedType === "gql-my-folder") {
|
||||
// TODO: Check for GQL request ?
|
||||
saveGraphqlRequestAs(
|
||||
picked.value.folderPath,
|
||||
requestUpdated as HoppGQLRequest
|
||||
)
|
||||
|
||||
requestSaved()
|
||||
} else if (picked.value.pickedType === "gql-my-collection") {
|
||||
// TODO: Check for GQL request ?
|
||||
saveGraphqlRequestAs(
|
||||
`${picked.value.collectionIndex}`,
|
||||
requestUpdated as HoppGQLRequest
|
||||
)
|
||||
|
||||
requestSaved()
|
||||
}
|
||||
}
|
||||
|
||||
const requestSaved = () => {
|
||||
toast.success(`${t("request.added")}`)
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const updateColl = (ev: CollectionType["type"]) => {
|
||||
collectionsType.value.type = ev
|
||||
}
|
||||
</script>
|
||||
@@ -1,961 +0,0 @@
|
||||
<template>
|
||||
<div :class="{ 'rounded border border-divider': saveRequest }">
|
||||
<div
|
||||
class="sticky z-10 flex flex-col border-b rounded-t bg-primary border-dividerLight"
|
||||
:style="
|
||||
saveRequest ? 'top: calc(-1.35 * var(--font-size-body))' : 'top: 0'
|
||||
"
|
||||
>
|
||||
<div class="flex flex-col border-b border-dividerLight">
|
||||
<input
|
||||
v-model="filterText"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
:placeholder="t('action.search')"
|
||||
class="py-2 pl-4 pr-2 bg-transparent"
|
||||
:disabled="collectionsType.type == 'team-collections'"
|
||||
/>
|
||||
</div>
|
||||
<CollectionsChooseType
|
||||
:collections-type="collectionsType"
|
||||
:show="showTeamCollections"
|
||||
@update-collection-type="updateCollectionType"
|
||||
@update-selected-team="updateSelectedTeam"
|
||||
/>
|
||||
<div class="flex justify-between flex-1">
|
||||
<ButtonSecondary
|
||||
v-if="
|
||||
collectionsType.type == 'team-collections' &&
|
||||
(collectionsType.selectedTeam == undefined ||
|
||||
collectionsType.selectedTeam.myRole == 'VIEWER')
|
||||
"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
disabled
|
||||
class="!rounded-none"
|
||||
:icon="IconPlus"
|
||||
:title="t('team.no_access')"
|
||||
:label="t('action.new')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:icon="IconPlus"
|
||||
:label="t('action.new')"
|
||||
class="!rounded-none"
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
<span class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/collections"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="!saveRequest"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:disabled="
|
||||
collectionsType.type == 'team-collections' &&
|
||||
collectionsType.selectedTeam == undefined
|
||||
"
|
||||
:icon="IconArchive"
|
||||
:title="t('modal.import_export')"
|
||||
@click="displayModalImportExport(true)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col flex-1">
|
||||
<component
|
||||
:is="
|
||||
collectionsType.type == 'my-collections'
|
||||
? 'CollectionsMyCollection'
|
||||
: 'CollectionsTeamsCollection'
|
||||
"
|
||||
v-for="(collection, index) in filteredCollections"
|
||||
:key="`collection-${index}`"
|
||||
:collection-index="parseInt(index)"
|
||||
:collection="collection"
|
||||
:is-filtered="filterText.length > 0"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:picked="picked"
|
||||
:loading-collection-i-ds="loadingCollectionIDs"
|
||||
@edit-collection="editCollection(collection, index)"
|
||||
@add-request="addRequest($event)"
|
||||
@add-folder="addFolder($event)"
|
||||
@edit-folder="editFolder($event)"
|
||||
@edit-request="editRequest($event)"
|
||||
@duplicate-request="duplicateRequest($event)"
|
||||
@update-team-collections="updateTeamCollections"
|
||||
@select-collection="$emit('use-collection', collection)"
|
||||
@unselect-collection="$emit('remove-collection', collection)"
|
||||
@select="$emit('select', $event)"
|
||||
@expand-collection="expandCollection"
|
||||
@remove-collection="removeCollection"
|
||||
@remove-request="removeRequest"
|
||||
@remove-folder="removeFolder"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="loadingCollectionIDs.includes('root')"
|
||||
class="flex flex-col items-center justify-center p-4"
|
||||
>
|
||||
<SmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredCollections.length === 0 && filterText.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="t('empty.collections')"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.collections") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
v-if="
|
||||
collectionsType.type == 'team-collections' &&
|
||||
(collectionsType.selectedTeam == undefined ||
|
||||
collectionsType.selectedTeam.myRole == 'VIEWER')
|
||||
"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('team.no_access')"
|
||||
:label="t('add.new')"
|
||||
class="mb-4"
|
||||
filled
|
||||
outline
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:label="t('add.new')"
|
||||
filled
|
||||
class="mb-4"
|
||||
outline
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="filterText.length !== 0 && filteredCollections.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ filterText }}"
|
||||
</span>
|
||||
</div>
|
||||
<CollectionsAdd
|
||||
:show="showModalAdd"
|
||||
:loading-state="modalLoadingState"
|
||||
@submit="addNewRootCollection"
|
||||
@hide-modal="displayModalAdd(false)"
|
||||
/>
|
||||
<CollectionsEdit
|
||||
:show="showModalEdit"
|
||||
:editing-collection-name="
|
||||
editingCollection
|
||||
? editingCollection.name || editingCollection.title
|
||||
: ''
|
||||
"
|
||||
:loading-state="modalLoadingState"
|
||||
@hide-modal="displayModalEdit(false)"
|
||||
@submit="updateEditingCollection"
|
||||
/>
|
||||
<CollectionsAddRequest
|
||||
:show="showModalAddRequest"
|
||||
:folder="editingFolder"
|
||||
:folder-path="editingFolderPath"
|
||||
:loading-state="modalLoadingState"
|
||||
@add-request="onAddRequest($event)"
|
||||
@hide-modal="displayModalAddRequest(false)"
|
||||
/>
|
||||
<CollectionsAddFolder
|
||||
:show="showModalAddFolder"
|
||||
:folder="editingFolder"
|
||||
:folder-path="editingFolderPath"
|
||||
:loading-state="modalLoadingState"
|
||||
@add-folder="onAddFolder($event)"
|
||||
@hide-modal="displayModalAddFolder(false)"
|
||||
/>
|
||||
<CollectionsEditFolder
|
||||
:show="showModalEditFolder"
|
||||
:editing-folder-name="
|
||||
editingFolder ? editingFolder.name || editingFolder.title : ''
|
||||
"
|
||||
:loading-state="modalLoadingState"
|
||||
@submit="updateEditingFolder"
|
||||
@hide-modal="displayModalEditFolder(false)"
|
||||
/>
|
||||
<CollectionsEditRequest
|
||||
:show="showModalEditRequest"
|
||||
:editing-request-name="editingRequest ? editingRequest.name : ''"
|
||||
:loading-state="modalLoadingState"
|
||||
@submit="updateEditingRequest"
|
||||
@hide-modal="displayModalEditRequest(false)"
|
||||
/>
|
||||
<CollectionsImportExport
|
||||
:show="showModalImportExport"
|
||||
:collections-type="collectionsType"
|
||||
@hide-modal="displayModalImportExport(false)"
|
||||
@update-team-collections="updateTeamCollections"
|
||||
/>
|
||||
<SmartConfirmModal
|
||||
:show="showConfirmModal"
|
||||
:title="confirmModalTitle"
|
||||
:loading-state="modalLoadingState"
|
||||
@hide-modal="showConfirmModal = false"
|
||||
@resolve="resolveConfirmModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import IconArchive from "~icons/lucide/archive"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { defineComponent, markRaw } from "vue"
|
||||
import { makeCollection } from "@hoppscotch/data"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import * as E from "fp-ts/Either"
|
||||
import CollectionsMyCollection from "./my/Collection.vue"
|
||||
import CollectionsTeamsCollection from "./teams/Collection.vue"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import TeamCollectionAdapter from "~/helpers/teams/TeamCollectionAdapter"
|
||||
import {
|
||||
restCollections$,
|
||||
addRESTCollection,
|
||||
editRESTCollection,
|
||||
addRESTFolder,
|
||||
removeRESTCollection,
|
||||
removeRESTFolder,
|
||||
editRESTFolder,
|
||||
removeRESTRequest,
|
||||
editRESTRequest,
|
||||
saveRESTRequestAs,
|
||||
} from "~/newstore/collections"
|
||||
import {
|
||||
setRESTRequest,
|
||||
getRESTRequest,
|
||||
getRESTSaveContext,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { useReadonlyStream, useStreamSubscriber } from "@composables/stream"
|
||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
||||
import {
|
||||
CreateChildCollectionDocument,
|
||||
CreateNewRootCollectionDocument,
|
||||
CreateRequestInCollectionDocument,
|
||||
DeleteCollectionDocument,
|
||||
DeleteRequestDocument,
|
||||
RenameCollectionDocument,
|
||||
UpdateRequestDocument,
|
||||
} from "~/helpers/backend/graphql"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
CollectionsMyCollection,
|
||||
CollectionsTeamsCollection,
|
||||
},
|
||||
props: {
|
||||
saveRequest: Boolean,
|
||||
picked: { type: Object, default: () => ({}) },
|
||||
},
|
||||
emits: [
|
||||
"update-collection",
|
||||
"update-coll-type",
|
||||
"update-team-collections",
|
||||
"select-request",
|
||||
"select",
|
||||
"use-collection",
|
||||
"remove-collection",
|
||||
],
|
||||
setup() {
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
return {
|
||||
subscribeTo: subscribeToStream,
|
||||
|
||||
collections: useReadonlyStream(restCollections$, [], "deep"),
|
||||
currentUser: useReadonlyStream(currentUser$, null),
|
||||
colorMode: useColorMode(),
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
IconArchive: markRaw(IconArchive),
|
||||
IconHelpCircle: markRaw(IconHelpCircle),
|
||||
IconPlus: markRaw(IconPlus),
|
||||
showModalAdd: false,
|
||||
showModalEdit: false,
|
||||
showModalImportExport: false,
|
||||
showModalAddRequest: false,
|
||||
showModalAddFolder: false,
|
||||
showModalEditFolder: false,
|
||||
showModalEditRequest: false,
|
||||
showConfirmModal: false,
|
||||
modalLoadingState: false,
|
||||
editingCollection: undefined,
|
||||
editingCollectionIndex: undefined,
|
||||
editingCollectionID: undefined,
|
||||
editingFolder: undefined,
|
||||
editingFolderName: undefined,
|
||||
editingFolderIndex: undefined,
|
||||
editingFolderPath: undefined,
|
||||
editingRequest: undefined,
|
||||
editingRequestIndex: undefined,
|
||||
confirmModalTitle: undefined,
|
||||
filterText: "",
|
||||
collectionsType: {
|
||||
type: "my-collections",
|
||||
selectedTeam: undefined,
|
||||
},
|
||||
teamCollectionAdapter: new TeamCollectionAdapter(null),
|
||||
teamCollectionsNew: [],
|
||||
loadingCollectionIDs: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showTeamCollections() {
|
||||
if (this.currentUser == null) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
filteredCollections() {
|
||||
const collections =
|
||||
this.collectionsType.type === "my-collections"
|
||||
? this.collections
|
||||
: this.teamCollectionsNew
|
||||
|
||||
if (!this.filterText) {
|
||||
return collections
|
||||
}
|
||||
|
||||
if (this.collectionsType.type === "team-collections") {
|
||||
return []
|
||||
}
|
||||
|
||||
const filterText = this.filterText.toLowerCase()
|
||||
const filteredCollections = []
|
||||
|
||||
for (const collection of collections) {
|
||||
const filteredRequests = []
|
||||
const filteredFolders = []
|
||||
for (const request of collection.requests) {
|
||||
if (request.name.toLowerCase().includes(filterText))
|
||||
filteredRequests.push(request)
|
||||
}
|
||||
for (const folder of this.collectionsType.type === "team-collections"
|
||||
? collection.children
|
||||
: collection.folders) {
|
||||
const filteredFolderRequests = []
|
||||
for (const request of folder.requests) {
|
||||
if (request.name.toLowerCase().includes(filterText))
|
||||
filteredFolderRequests.push(request)
|
||||
}
|
||||
if (filteredFolderRequests.length > 0) {
|
||||
const filteredFolder = Object.assign({}, folder)
|
||||
filteredFolder.requests = filteredFolderRequests
|
||||
filteredFolders.push(filteredFolder)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
filteredRequests.length + filteredFolders.length > 0 ||
|
||||
collection.name.toLowerCase().includes(filterText)
|
||||
) {
|
||||
const filteredCollection = Object.assign({}, collection)
|
||||
filteredCollection.requests = filteredRequests
|
||||
filteredCollection.folders = filteredFolders
|
||||
filteredCollections.push(filteredCollection)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredCollections
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"collectionsType.type": function emitstuff() {
|
||||
this.$emit("update-collection", this.$data.collectionsType.type)
|
||||
},
|
||||
"collectionsType.selectedTeam"(value) {
|
||||
if (value?.id) this.teamCollectionAdapter.changeTeamID(value.id)
|
||||
},
|
||||
currentUser(newValue) {
|
||||
if (!newValue) this.updateCollectionType("my-collections")
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.teamCollectionAdapter.unsubscribeSubscriptions()
|
||||
},
|
||||
mounted() {
|
||||
this.subscribeTo(this.teamCollectionAdapter.collections$, (colls) => {
|
||||
this.teamCollectionsNew = cloneDeep(colls)
|
||||
})
|
||||
this.subscribeTo(
|
||||
this.teamCollectionAdapter.loadingCollections$,
|
||||
(collectionsIDs) => {
|
||||
this.loadingCollectionIDs = collectionsIDs
|
||||
}
|
||||
)
|
||||
},
|
||||
methods: {
|
||||
updateTeamCollections() {
|
||||
// TODO: Remove this at some point
|
||||
},
|
||||
updateSelectedTeam(newSelectedTeam) {
|
||||
this.collectionsType.selectedTeam = newSelectedTeam
|
||||
this.$emit("update-coll-type", this.collectionsType)
|
||||
},
|
||||
updateCollectionType(newCollectionType) {
|
||||
this.collectionsType.type = newCollectionType
|
||||
this.$emit("update-coll-type", this.collectionsType)
|
||||
},
|
||||
// Intented to be called by the CollectionAdd modal submit event
|
||||
addNewRootCollection(name) {
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
addRESTCollection(
|
||||
makeCollection({
|
||||
name,
|
||||
folders: [],
|
||||
requests: [],
|
||||
})
|
||||
)
|
||||
|
||||
this.displayModalAdd(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
runMutation(CreateNewRootCollectionDocument, {
|
||||
title: name,
|
||||
teamID: this.collectionsType.selectedTeam.id,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
if (result.left.error === "team_coll/short_title")
|
||||
this.toast.error(this.t("collection.name_length_insufficient"))
|
||||
else this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("collection.created"))
|
||||
this.displayModalAdd(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
// Intented to be called by CollectionEdit modal submit event
|
||||
updateEditingCollection(newName) {
|
||||
if (!newName) {
|
||||
this.toast.error(this.t("collection.invalid_name"))
|
||||
return
|
||||
}
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
const collectionUpdated = {
|
||||
...this.editingCollection,
|
||||
name: newName,
|
||||
}
|
||||
|
||||
editRESTCollection(this.editingCollectionIndex, collectionUpdated)
|
||||
this.displayModalEdit(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
|
||||
runMutation(RenameCollectionDocument, {
|
||||
collectionID: this.editingCollection.id,
|
||||
newTitle: newName,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("collection.renamed"))
|
||||
this.displayModalEdit(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
// Intended to be called by CollectionEditFolder modal submit event
|
||||
updateEditingFolder(name) {
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
editRESTFolder(this.editingFolderPath, { ...this.editingFolder, name })
|
||||
this.displayModalEditFolder(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
|
||||
runMutation(RenameCollectionDocument, {
|
||||
collectionID: this.editingFolder.id,
|
||||
newTitle: name,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
if (result.left.error === "team_coll/short_title")
|
||||
this.toast.error(this.t("folder.name_length_insufficient"))
|
||||
else this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("folder.renamed"))
|
||||
this.displayModalEditFolder(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
// Intented to by called by CollectionsEditRequest modal submit event
|
||||
updateEditingRequest(requestUpdateData) {
|
||||
const saveCtx = getRESTSaveContext()
|
||||
|
||||
const requestUpdated = {
|
||||
...this.editingRequest,
|
||||
name: requestUpdateData.name || this.editingRequest.name,
|
||||
}
|
||||
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
// Update REST Session with the updated state
|
||||
if (
|
||||
saveCtx &&
|
||||
saveCtx.originLocation === "user-collection" &&
|
||||
saveCtx.requestIndex === this.editingRequestIndex &&
|
||||
saveCtx.folderPath === this.editingFolderPath
|
||||
) {
|
||||
setRESTRequest({
|
||||
...getRESTRequest(),
|
||||
name: requestUpdateData.name,
|
||||
})
|
||||
}
|
||||
|
||||
editRESTRequest(
|
||||
this.editingFolderPath,
|
||||
this.editingRequestIndex,
|
||||
requestUpdated
|
||||
)
|
||||
this.displayModalEditRequest(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
|
||||
const requestName = requestUpdateData.name || this.editingRequest.name
|
||||
|
||||
// Update REST Session with the updated state
|
||||
if (
|
||||
saveCtx &&
|
||||
saveCtx.originLocation === "team-collection" &&
|
||||
saveCtx.requestID === this.editingRequestIndex
|
||||
) {
|
||||
setRESTRequest({
|
||||
...getRESTRequest(),
|
||||
name: requestUpdateData.name,
|
||||
})
|
||||
}
|
||||
|
||||
runMutation(UpdateRequestDocument, {
|
||||
data: {
|
||||
request: JSON.stringify(requestUpdated),
|
||||
title: requestName,
|
||||
},
|
||||
requestID: this.editingRequestIndex,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("request.renamed"))
|
||||
this.$emit("update-team-collections")
|
||||
this.displayModalEditRequest(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
displayModalAdd(shouldDisplay) {
|
||||
this.showModalAdd = shouldDisplay
|
||||
},
|
||||
displayModalEdit(shouldDisplay) {
|
||||
this.showModalEdit = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalImportExport(shouldDisplay) {
|
||||
this.showModalImportExport = shouldDisplay
|
||||
},
|
||||
displayModalAddRequest(shouldDisplay) {
|
||||
this.showModalAddRequest = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalAddFolder(shouldDisplay) {
|
||||
this.showModalAddFolder = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalEditFolder(shouldDisplay) {
|
||||
this.showModalEditFolder = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalEditRequest(shouldDisplay) {
|
||||
this.showModalEditRequest = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayConfirmModal(shouldDisplay) {
|
||||
this.showConfirmModal = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
editCollection(collection, collectionIndex) {
|
||||
this.$data.editingCollection = collection
|
||||
this.$data.editingCollectionIndex = collectionIndex
|
||||
this.displayModalEdit(true)
|
||||
},
|
||||
onAddFolder({ name, folder, path }) {
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
addRESTFolder(name, path)
|
||||
this.displayModalAddFolder(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
runMutation(CreateChildCollectionDocument, {
|
||||
childTitle: name,
|
||||
collectionID: folder.id,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
if (result.left.error === "team_coll/short_title")
|
||||
this.toast.error(this.t("folder.name_length_insufficient"))
|
||||
else this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("folder.created"))
|
||||
this.displayModalAddFolder(false)
|
||||
this.$emit("update-team-collections")
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
addFolder(payload) {
|
||||
const { folder, path } = payload
|
||||
this.$data.editingFolder = folder
|
||||
this.$data.editingFolderPath = path
|
||||
this.displayModalAddFolder(true)
|
||||
},
|
||||
editFolder(payload) {
|
||||
const { collectionIndex, folder, folderIndex, folderPath } = payload
|
||||
this.$data.editingCollectionIndex = collectionIndex
|
||||
this.$data.editingFolder = folder
|
||||
this.$data.editingFolderIndex = folderIndex
|
||||
this.$data.editingFolderPath = folderPath
|
||||
this.$data.collectionsType = this.collectionsType
|
||||
this.displayModalEditFolder(true)
|
||||
},
|
||||
editRequest(payload) {
|
||||
const {
|
||||
collectionIndex,
|
||||
folderIndex,
|
||||
folderName,
|
||||
request,
|
||||
requestIndex,
|
||||
folderPath,
|
||||
} = payload
|
||||
this.$data.editingCollectionIndex = collectionIndex
|
||||
this.$data.editingFolderIndex = folderIndex
|
||||
this.$data.editingFolderName = folderName
|
||||
this.$data.editingRequest = request
|
||||
this.$data.editingRequestIndex = requestIndex
|
||||
this.editingFolderPath = folderPath
|
||||
this.$emit("select-request", requestIndex)
|
||||
this.displayModalEditRequest(true)
|
||||
},
|
||||
resetSelectedData() {
|
||||
this.$data.editingCollection = undefined
|
||||
this.$data.editingCollectionIndex = undefined
|
||||
this.$data.editingCollectionID = undefined
|
||||
this.$data.editingFolder = undefined
|
||||
this.$data.editingFolderPath = undefined
|
||||
this.$data.editingFolderIndex = undefined
|
||||
this.$data.editingRequest = undefined
|
||||
this.$data.editingRequestIndex = undefined
|
||||
|
||||
this.$data.confirmModalTitle = undefined
|
||||
},
|
||||
expandCollection(collectionID) {
|
||||
this.teamCollectionAdapter.expandCollection(collectionID)
|
||||
},
|
||||
removeCollection({ collectionIndex, collectionID }) {
|
||||
this.$data.editingCollectionIndex = collectionIndex
|
||||
this.$data.editingCollectionID = collectionID
|
||||
this.confirmModalTitle = `${this.t("confirm.remove_collection")}`
|
||||
|
||||
this.displayConfirmModal(true)
|
||||
},
|
||||
onRemoveCollection() {
|
||||
const collectionIndex = this.$data.editingCollectionIndex
|
||||
const collectionID = this.$data.editingCollectionID
|
||||
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
// Cancel pick if picked collection is deleted
|
||||
if (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "my-collection" &&
|
||||
this.picked.collectionIndex === collectionIndex
|
||||
) {
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
|
||||
removeRESTCollection(collectionIndex)
|
||||
|
||||
this.toast.success(this.t("state.deleted"))
|
||||
this.displayConfirmModal(false)
|
||||
} else if (this.collectionsType.type === "team-collections") {
|
||||
this.modalLoadingState = true
|
||||
|
||||
// Cancel pick if picked collection is deleted
|
||||
if (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "teams-collection" &&
|
||||
this.picked.collectionID === collectionID
|
||||
) {
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
|
||||
if (this.collectionsType.selectedTeam.myRole !== "VIEWER") {
|
||||
runMutation(DeleteCollectionDocument, {
|
||||
collectionID,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("state.deleted"))
|
||||
this.displayConfirmModal(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
removeFolder({ collectionID, folder, folderPath }) {
|
||||
this.$data.editingCollectionID = collectionID
|
||||
this.$data.editingFolder = folder
|
||||
this.$data.editingFolderPath = folderPath
|
||||
this.confirmModalTitle = `${this.t("confirm.remove_folder")}`
|
||||
|
||||
this.displayConfirmModal(true)
|
||||
},
|
||||
onRemoveFolder() {
|
||||
const folder = this.$data.editingFolder
|
||||
const folderPath = this.$data.editingFolderPath
|
||||
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
// Cancel pick if picked folder was deleted
|
||||
if (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "my-folder" &&
|
||||
this.picked.folderPath === folderPath
|
||||
) {
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
removeRESTFolder(folderPath)
|
||||
|
||||
this.toast.success(this.t("state.deleted"))
|
||||
this.displayConfirmModal(false)
|
||||
} else if (this.collectionsType.type === "team-collections") {
|
||||
this.modalLoadingState = true
|
||||
|
||||
// Cancel pick if picked collection folder was deleted
|
||||
if (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "teams-folder" &&
|
||||
this.picked.folderID === folder.id
|
||||
) {
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
|
||||
if (this.collectionsType.selectedTeam.myRole !== "VIEWER") {
|
||||
runMutation(DeleteCollectionDocument, {
|
||||
collectionID: folder.id,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(`${this.t("state.deleted")}`)
|
||||
this.displayConfirmModal(false)
|
||||
|
||||
this.updateTeamCollections()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
removeRequest({ requestIndex, folderPath }) {
|
||||
this.$data.editingRequestIndex = requestIndex
|
||||
this.$data.editingFolderPath = folderPath
|
||||
this.confirmModalTitle = `${this.t("confirm.remove_request")}`
|
||||
|
||||
this.displayConfirmModal(true)
|
||||
},
|
||||
onRemoveRequest() {
|
||||
const requestIndex = this.$data.editingRequestIndex
|
||||
const folderPath = this.$data.editingFolderPath
|
||||
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
// Cancel pick if the picked item is being deleted
|
||||
if (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "my-request" &&
|
||||
this.picked.folderPath === folderPath &&
|
||||
this.picked.requestIndex === requestIndex
|
||||
) {
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
removeRESTRequest(folderPath, requestIndex)
|
||||
|
||||
this.toast.success(this.t("state.deleted"))
|
||||
this.displayConfirmModal(false)
|
||||
} else if (this.collectionsType.type === "team-collections") {
|
||||
this.modalLoadingState = true
|
||||
// Cancel pick if the picked item is being deleted
|
||||
if (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "teams-request" &&
|
||||
this.picked.requestID === requestIndex
|
||||
) {
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
|
||||
runMutation(DeleteRequestDocument, {
|
||||
requestID: requestIndex,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("state.deleted"))
|
||||
this.displayConfirmModal(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
addRequest(payload) {
|
||||
// TODO: check if the request being worked on
|
||||
// is being overwritten (selected or not)
|
||||
const { folder, path } = payload
|
||||
this.$data.editingFolder = folder
|
||||
this.$data.editingFolderPath = path
|
||||
this.displayModalAddRequest(true)
|
||||
},
|
||||
onAddRequest({ name, folder, path }) {
|
||||
const newRequest = {
|
||||
...cloneDeep(getRESTRequest()),
|
||||
name,
|
||||
}
|
||||
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
const insertionIndex = saveRESTRequestAs(path, newRequest)
|
||||
// point to it
|
||||
setRESTRequest(newRequest, {
|
||||
originLocation: "user-collection",
|
||||
folderPath: path,
|
||||
requestIndex: insertionIndex,
|
||||
})
|
||||
|
||||
this.displayModalAddRequest(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
runMutation(CreateRequestInCollectionDocument, {
|
||||
collectionID: folder.id,
|
||||
data: {
|
||||
request: JSON.stringify(newRequest),
|
||||
teamID: this.collectionsType.selectedTeam.id,
|
||||
title: name,
|
||||
},
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
const { createRequestInCollection } = result.right
|
||||
// point to it
|
||||
setRESTRequest(newRequest, {
|
||||
originLocation: "team-collection",
|
||||
requestID: createRequestInCollection.id,
|
||||
collectionID: createRequestInCollection.collection.id,
|
||||
teamID: createRequestInCollection.collection.team.id,
|
||||
})
|
||||
this.displayModalAddRequest(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
duplicateRequest({ folderPath, request, collectionID }) {
|
||||
if (this.collectionsType.type === "team-collections") {
|
||||
const newReq = {
|
||||
...cloneDeep(request),
|
||||
name: `${request.name} - ${this.t("action.duplicate")}`,
|
||||
}
|
||||
|
||||
// Error handling ?
|
||||
runMutation(CreateRequestInCollectionDocument, {
|
||||
collectionID,
|
||||
data: {
|
||||
request: JSON.stringify(newReq),
|
||||
teamID: this.collectionsType.selectedTeam.id,
|
||||
title: `${request.name} - ${this.t("action.duplicate")}`,
|
||||
},
|
||||
})()
|
||||
} else if (this.collectionsType.type === "my-collections") {
|
||||
saveRESTRequestAs(folderPath, {
|
||||
...cloneDeep(request),
|
||||
name: `${request.name} - ${this.t("action.duplicate")}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
resolveConfirmModal(title) {
|
||||
if (title === `${this.t("confirm.remove_collection")}`)
|
||||
this.onRemoveCollection()
|
||||
else if (title === `${this.t("confirm.remove_request")}`)
|
||||
this.onRemoveRequest()
|
||||
else if (title === `${this.t("confirm.remove_folder")}`)
|
||||
this.onRemoveFolder()
|
||||
else {
|
||||
console.error(
|
||||
`Confirm modal title ${title} is not handled by the component`
|
||||
)
|
||||
this.toast.error(this.t("error.something_went_wrong"))
|
||||
this.displayConfirmModal(false)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
// request inside folder is not being deleted, you dumb fuck
|
||||
</script>
|
||||
@@ -1,354 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropEvent"
|
||||
@dragover="dragging = true"
|
||||
@drop="dragging = false"
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<component
|
||||
:is="getCollectionIcon"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ collection.name }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFilePlus"
|
||||
:title="t('request.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="
|
||||
$emit('add-request', {
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFolderPlus"
|
||||
:title="t('folder.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="
|
||||
$emit('add-folder', {
|
||||
folder: collection,
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.x="exportAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="requestAction"
|
||||
:icon="IconFilePlus"
|
||||
:label="t('request.new')"
|
||||
:shortcut="['R']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-request', {
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="folderAction"
|
||||
:icon="IconFolderPlus"
|
||||
:label="t('folder.new')"
|
||||
:shortcut="['N']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-folder', {
|
||||
folder: collection,
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('edit-collection')
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="exportAction"
|
||||
:icon="IconDownload"
|
||||
:label="t('export.title')"
|
||||
:shortcut="['X']"
|
||||
@click="
|
||||
() => {
|
||||
exportCollection()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
removeCollection()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showChildren || isFiltered" class="flex">
|
||||
<div
|
||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
||||
@click="toggleShowChildren()"
|
||||
></div>
|
||||
<div class="flex flex-col flex-1 truncate">
|
||||
<CollectionsMyFolder
|
||||
v-for="(folder, index) in collection.folders"
|
||||
:key="`folder-${index}`"
|
||||
:folder="folder"
|
||||
:folder-index="index"
|
||||
:folder-path="`${collectionIndex}/${index}`"
|
||||
:collection-index="collectionIndex"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:is-filtered="isFiltered"
|
||||
:picked="picked"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@remove-folder="$emit('remove-folder', $event)"
|
||||
/>
|
||||
<CollectionsMyRequest
|
||||
v-for="(request, index) in collection.requests"
|
||||
:key="`request-${index}`"
|
||||
:request="request"
|
||||
:collection-index="collectionIndex"
|
||||
:folder-index="-1"
|
||||
:folder-name="collection.name"
|
||||
:folder-path="`${collectionIndex}`"
|
||||
:request-index="index"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:picked="picked"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
(collection.folders == undefined ||
|
||||
collection.folders.length === 0) &&
|
||||
(collection.requests == undefined ||
|
||||
collection.requests.length === 0)
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||
:alt="`${t('empty.collection')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.collection") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconFilePlus from "~icons/lucide/file-plus"
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { defineComponent, ref, markRaw } from "vue"
|
||||
import { moveRESTRequest } from "~/newstore/collections"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
collectionIndex: { type: Number, default: null },
|
||||
collection: { type: Object, default: () => ({}) },
|
||||
isFiltered: Boolean,
|
||||
saveRequest: Boolean,
|
||||
collectionsType: { type: Object, default: () => ({}) },
|
||||
picked: { type: Object, default: () => ({}) },
|
||||
},
|
||||
emits: [
|
||||
"select",
|
||||
"expand-collection",
|
||||
"add-collection",
|
||||
"remove-collection",
|
||||
"add-folder",
|
||||
"add-request",
|
||||
"edit-folder",
|
||||
"edit-request",
|
||||
"duplicate-request",
|
||||
"remove-folder",
|
||||
"remove-request",
|
||||
"select-collection",
|
||||
"unselect-collection",
|
||||
"edit-collection",
|
||||
],
|
||||
setup() {
|
||||
return {
|
||||
colorMode: useColorMode(),
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
|
||||
// Template refs
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
requestAction: ref<any | null>(null),
|
||||
folderAction: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
exportAction: ref<any | null>(null),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
IconCircle: markRaw(IconCircle),
|
||||
IconCheckCircle: markRaw(IconCheckCircle),
|
||||
IconFilePlus: markRaw(IconFilePlus),
|
||||
IconFolderPlus: markRaw(IconFolderPlus),
|
||||
IconMoreVertical: markRaw(IconMoreVertical),
|
||||
IconEdit: markRaw(IconEdit),
|
||||
IconDownload: markRaw(IconDownload),
|
||||
IconTrash2: markRaw(IconTrash2),
|
||||
|
||||
showChildren: false,
|
||||
dragging: false,
|
||||
selectedFolder: {},
|
||||
prevCursor: "",
|
||||
cursor: "",
|
||||
pageNo: 0,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSelected(): boolean {
|
||||
return (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "my-collection" &&
|
||||
this.picked.collectionIndex === this.collectionIndex
|
||||
)
|
||||
},
|
||||
getCollectionIcon() {
|
||||
if (this.isSelected) return IconCheckCircle
|
||||
else if (!this.showChildren && !this.isFiltered) return IconFolder
|
||||
else if (this.showChildren || this.isFiltered) return IconFolderOpen
|
||||
else return IconFolder
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
exportCollection() {
|
||||
const collectionJSON = JSON.stringify(this.collection)
|
||||
|
||||
const file = new Blob([collectionJSON], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
|
||||
a.download = `${this.collection.name}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
this.toast.success(this.t("state.download_started").toString())
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
},
|
||||
toggleShowChildren() {
|
||||
if (this.$props.saveRequest)
|
||||
this.$emit("select", {
|
||||
picked: {
|
||||
pickedType: "my-collection",
|
||||
collectionIndex: this.collectionIndex,
|
||||
},
|
||||
})
|
||||
|
||||
this.$emit("expand-collection", this.collection.id)
|
||||
this.showChildren = !this.showChildren
|
||||
},
|
||||
removeCollection() {
|
||||
this.$emit("remove-collection", {
|
||||
collectionIndex: this.collectionIndex,
|
||||
collectionID: this.collection.id,
|
||||
})
|
||||
},
|
||||
dropEvent({ dataTransfer }: any) {
|
||||
this.dragging = !this.dragging
|
||||
const folderPath = dataTransfer.getData("folderPath")
|
||||
const requestIndex = dataTransfer.getData("requestIndex")
|
||||
moveRESTRequest(folderPath, requestIndex, `${this.collectionIndex}`)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,340 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropEvent"
|
||||
@dragover="dragging = true"
|
||||
@drop="dragging = false"
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<component
|
||||
:is="getCollectionIcon"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ folder.name ? folder.name : folder.title }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFilePlus"
|
||||
:title="t('request.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="$emit('add-request', { path: folderPath })"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFolderPlus"
|
||||
:title="t('folder.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="$emit('add-folder', { folder, path: folderPath })"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.x="exportAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="requestAction"
|
||||
:icon="IconFilePlus"
|
||||
:label="t('request.new')"
|
||||
:shortcut="['R']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-request', { path: folderPath })
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="folderAction"
|
||||
:icon="IconFolderPlus"
|
||||
:label="t('folder.new')"
|
||||
:shortcut="['N']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-folder', { folder, path: folderPath })
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('edit-folder', {
|
||||
folder,
|
||||
folderIndex,
|
||||
collectionIndex,
|
||||
folderPath,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="exportAction"
|
||||
:icon="IconDownload"
|
||||
:label="t('export.title')"
|
||||
:shortcut="['X']"
|
||||
@click="
|
||||
() => {
|
||||
exportFolder()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
removeFolder()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showChildren || isFiltered" class="flex">
|
||||
<div
|
||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
||||
@click="toggleShowChildren()"
|
||||
></div>
|
||||
<div class="flex flex-col flex-1 truncate">
|
||||
<!-- Referring to this component only (this is recursive) -->
|
||||
<Folder
|
||||
v-for="(subFolder, subFolderIndex) in folder.folders"
|
||||
:key="`subFolder-${subFolderIndex}`"
|
||||
:folder="subFolder"
|
||||
:folder-index="subFolderIndex"
|
||||
:collection-index="collectionIndex"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:folder-path="`${folderPath}/${subFolderIndex}`"
|
||||
:picked="picked"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@update-team-collections="$emit('update-team-collections')"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@remove-folder="$emit('remove-folder', $event)"
|
||||
/>
|
||||
<CollectionsMyRequest
|
||||
v-for="(request, index) in folder.requests"
|
||||
:key="`request-${index}`"
|
||||
:request="request"
|
||||
:collection-index="collectionIndex"
|
||||
:folder-index="folderIndex"
|
||||
:folder-name="folder.name"
|
||||
:folder-path="folderPath"
|
||||
:request-index="index"
|
||||
:picked="picked"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
folder.folders &&
|
||||
folder.folders.length === 0 &&
|
||||
folder.requests &&
|
||||
folder.requests.length === 0
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||
:alt="`${t('empty.folder')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.folder") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import IconFilePlus from "~icons/lucide/file-plus"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { defineComponent, ref } from "vue"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { moveRESTRequest } from "~/newstore/collections"
|
||||
|
||||
export default defineComponent({
|
||||
name: "Folder",
|
||||
props: {
|
||||
folder: { type: Object, default: () => ({}) },
|
||||
folderIndex: { type: Number, default: null },
|
||||
collectionIndex: { type: Number, default: null },
|
||||
folderPath: { type: String, default: null },
|
||||
saveRequest: Boolean,
|
||||
isFiltered: Boolean,
|
||||
collectionsType: { type: Object, default: () => ({}) },
|
||||
picked: { type: Object, default: () => ({}) },
|
||||
},
|
||||
emits: [
|
||||
"add-request",
|
||||
"add-folder",
|
||||
"edit-folder",
|
||||
"update-team",
|
||||
"remove-folder",
|
||||
"edit-request",
|
||||
"duplicate-request",
|
||||
"select",
|
||||
"remove-request",
|
||||
"update-team-collections",
|
||||
],
|
||||
setup() {
|
||||
const t = useI18n()
|
||||
|
||||
return {
|
||||
// Template refs
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
requestAction: ref<any | null>(null),
|
||||
folderAction: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
exportAction: ref<any | null>(null),
|
||||
t,
|
||||
toast: useToast(),
|
||||
colorMode: useColorMode(),
|
||||
IconFilePlus,
|
||||
IconFolderPlus,
|
||||
IconMoreVertical,
|
||||
IconEdit,
|
||||
IconDownload,
|
||||
IconTrash2,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showChildren: false,
|
||||
dragging: false,
|
||||
prevCursor: "",
|
||||
cursor: "",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSelected(): boolean {
|
||||
return (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "my-folder" &&
|
||||
this.picked.folderPath === this.folderPath
|
||||
)
|
||||
},
|
||||
getCollectionIcon() {
|
||||
if (this.isSelected) return IconCheckCircle
|
||||
else if (!this.showChildren && !this.isFiltered) return IconFolder
|
||||
else if (this.showChildren || this.isFiltered) return IconFolderOpen
|
||||
else return IconFolder
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
exportFolder() {
|
||||
const folderJSON = JSON.stringify(this.folder)
|
||||
|
||||
const file = new Blob([folderJSON], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
|
||||
a.download = `${this.folder.name}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
this.toast.success(this.t("state.download_started").toString())
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
},
|
||||
toggleShowChildren() {
|
||||
if (this.$props.saveRequest)
|
||||
this.$emit("select", {
|
||||
picked: {
|
||||
pickedType: "my-folder",
|
||||
collectionIndex: this.collectionIndex,
|
||||
folderName: this.folder.name,
|
||||
folderPath: this.folderPath,
|
||||
},
|
||||
})
|
||||
this.showChildren = !this.showChildren
|
||||
},
|
||||
removeFolder() {
|
||||
this.$emit("remove-folder", {
|
||||
folder: this.folder,
|
||||
folderPath: this.folderPath,
|
||||
})
|
||||
},
|
||||
dropEvent({ dataTransfer }) {
|
||||
this.dragging = !this.dragging
|
||||
const folderPath = dataTransfer.getData("folderPath")
|
||||
const requestIndex = dataTransfer.getData("requestIndex")
|
||||
moveRESTRequest(folderPath, requestIndex, this.folderPath)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,433 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
draggable="true"
|
||||
@dragstart="dragStart"
|
||||
@dragover.stop
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
|
||||
:class="getRequestLabelColor(request.method)"
|
||||
@click="selectRequest()"
|
||||
>
|
||||
<component
|
||||
:is="IconCheckCircle"
|
||||
v-if="isSelected"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
<span v-else class="font-semibold truncate text-tiny">
|
||||
{{ request.method }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="selectRequest()"
|
||||
>
|
||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ request.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isActive"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
|
||||
:title="`${t('collection.request_in_use')}`"
|
||||
>
|
||||
<span
|
||||
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="!saveRequest"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconRotateCCW"
|
||||
:title="t('action.restore')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="selectRequest()"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.d="duplicate.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
emit('edit-request', {
|
||||
collectionIndex,
|
||||
folderIndex,
|
||||
folderName,
|
||||
request,
|
||||
requestIndex,
|
||||
folderPath,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="duplicate"
|
||||
:icon="IconCopy"
|
||||
:label="t('action.duplicate')"
|
||||
:shortcut="['D']"
|
||||
@click="
|
||||
() => {
|
||||
emit('duplicate-request', {
|
||||
collectionIndex,
|
||||
folderIndex,
|
||||
folderName,
|
||||
request,
|
||||
requestIndex,
|
||||
folderPath,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
removeRequest()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<HttpReqChangeConfirmModal
|
||||
:show="confirmChange"
|
||||
@hide-modal="confirmChange = false"
|
||||
@save-change="saveRequestChange"
|
||||
@discard-change="discardRequestChange"
|
||||
/>
|
||||
<CollectionsSaveRequest
|
||||
mode="rest"
|
||||
:show="showSaveRequestModal"
|
||||
@hide-modal="showSaveRequestModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
||||
import { ref, computed } from "vue"
|
||||
import {
|
||||
HoppRESTRequest,
|
||||
safelyExtractRESTRequest,
|
||||
translateToNewRequest,
|
||||
isEqualHoppRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import {
|
||||
getDefaultRESTRequest,
|
||||
getRESTRequest,
|
||||
restSaveContext$,
|
||||
setRESTRequest,
|
||||
setRESTSaveContext,
|
||||
getRESTSaveContext,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { editRESTRequest } from "~/newstore/collections"
|
||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
||||
import { UpdateRequestDocument } from "~/helpers/backend/graphql"
|
||||
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
|
||||
|
||||
const props = defineProps<{
|
||||
request: HoppRESTRequest
|
||||
collectionIndex: number
|
||||
folderIndex: number
|
||||
folderName: string
|
||||
requestIndex: number
|
||||
saveRequest: boolean
|
||||
collectionsType: object
|
||||
folderPath: string
|
||||
picked?: {
|
||||
pickedType: string
|
||||
collectionIndex: number
|
||||
folderPath: string
|
||||
folderName: string
|
||||
requestIndex: number
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: "select",
|
||||
data:
|
||||
| {
|
||||
picked: {
|
||||
pickedType: string
|
||||
collectionIndex: number
|
||||
folderPath: string
|
||||
folderName: string
|
||||
requestIndex: number
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
): void
|
||||
|
||||
(
|
||||
e: "remove-request",
|
||||
data: {
|
||||
folderPath: string
|
||||
requestIndex: number
|
||||
}
|
||||
): void
|
||||
|
||||
(
|
||||
e: "duplicate-request",
|
||||
data: {
|
||||
collectionIndex: number
|
||||
folderIndex: number
|
||||
folderName: string
|
||||
request: HoppRESTRequest
|
||||
folderPath: string
|
||||
requestIndex: number
|
||||
}
|
||||
): void
|
||||
|
||||
(
|
||||
e: "edit-request",
|
||||
data: {
|
||||
collectionIndex: number
|
||||
folderIndex: number
|
||||
folderName: string
|
||||
request: HoppRESTRequest
|
||||
folderPath: string
|
||||
requestIndex: number
|
||||
}
|
||||
): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const dragging = ref(false)
|
||||
const requestMethodLabels = {
|
||||
get: "text-green-500",
|
||||
post: "text-yellow-500",
|
||||
put: "text-blue-500",
|
||||
delete: "text-red-500",
|
||||
default: "text-gray-500",
|
||||
}
|
||||
const confirmChange = ref(false)
|
||||
const showSaveRequestModal = ref(false)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const options = ref<any | null>(null)
|
||||
const edit = ref<any | null>(null)
|
||||
const duplicate = ref<any | null>(null)
|
||||
const deleteAction = ref<any | null>(null)
|
||||
|
||||
const active = useReadonlyStream(restSaveContext$, null)
|
||||
|
||||
const isSelected = computed(
|
||||
() =>
|
||||
props.picked &&
|
||||
props.picked.pickedType === "my-request" &&
|
||||
props.picked.folderPath === props.folderPath &&
|
||||
props.picked.requestIndex === props.requestIndex
|
||||
)
|
||||
|
||||
const isActive = computed(
|
||||
() =>
|
||||
active.value &&
|
||||
active.value.originLocation === "user-collection" &&
|
||||
active.value.folderPath === props.folderPath &&
|
||||
active.value.requestIndex === props.requestIndex
|
||||
)
|
||||
|
||||
const dragStart = ({ dataTransfer }: DragEvent) => {
|
||||
if (dataTransfer) {
|
||||
dragging.value = !dragging.value
|
||||
dataTransfer.setData("folderPath", props.folderPath)
|
||||
dataTransfer.setData("requestIndex", props.requestIndex.toString())
|
||||
}
|
||||
}
|
||||
|
||||
const removeRequest = () => {
|
||||
emit("remove-request", {
|
||||
folderPath: props.folderPath,
|
||||
requestIndex: props.requestIndex,
|
||||
})
|
||||
}
|
||||
|
||||
const getRequestLabelColor = (method: string) =>
|
||||
requestMethodLabels[
|
||||
method.toLowerCase() as keyof typeof requestMethodLabels
|
||||
] || requestMethodLabels.default
|
||||
|
||||
const setRestReq = (request: any) => {
|
||||
setRESTRequest(
|
||||
cloneDeep(
|
||||
safelyExtractRESTRequest(
|
||||
translateToNewRequest(request),
|
||||
getDefaultRESTRequest()
|
||||
)
|
||||
),
|
||||
{
|
||||
originLocation: "user-collection",
|
||||
folderPath: props.folderPath,
|
||||
requestIndex: props.requestIndex,
|
||||
req: cloneDeep(request),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Loads request from the save once, checks for unsaved changes, but ignores default values */
|
||||
const selectRequest = () => {
|
||||
// Check if this is a save as request popup, if so we don't need to prompt the confirm change popup.
|
||||
if (props.saveRequest) {
|
||||
emit("select", {
|
||||
picked: {
|
||||
pickedType: "my-request",
|
||||
collectionIndex: props.collectionIndex,
|
||||
folderPath: props.folderPath,
|
||||
folderName: props.folderName,
|
||||
requestIndex: props.requestIndex,
|
||||
},
|
||||
})
|
||||
} else if (isEqualHoppRESTRequest(props.request, getDefaultRESTRequest())) {
|
||||
confirmChange.value = false
|
||||
setRestReq(props.request)
|
||||
} else if (!active.value) {
|
||||
// If the current request is the same as the request to be loaded in, there is no data loss
|
||||
const currentReq = getRESTRequest()
|
||||
|
||||
if (isEqualHoppRESTRequest(currentReq, props.request)) {
|
||||
setRestReq(props.request)
|
||||
} else {
|
||||
confirmChange.value = true
|
||||
}
|
||||
} else {
|
||||
const currentReqWithNoChange = active.value.req
|
||||
const currentFullReq = getRESTRequest()
|
||||
|
||||
// Check if whether user clicked the same request or not
|
||||
if (!isActive.value && currentReqWithNoChange !== undefined) {
|
||||
// Check if there is any changes done on the current request
|
||||
if (isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)) {
|
||||
setRestReq(props.request)
|
||||
} else {
|
||||
confirmChange.value = true
|
||||
}
|
||||
} else {
|
||||
setRESTSaveContext(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Save current request to the collection */
|
||||
const saveRequestChange = () => {
|
||||
const saveCtx = getRESTSaveContext()
|
||||
saveCurrentRequest(saveCtx)
|
||||
confirmChange.value = false
|
||||
}
|
||||
|
||||
/** Discard changes and change the current request and context */
|
||||
const discardRequestChange = () => {
|
||||
setRestReq(props.request)
|
||||
if (!isActive.value) {
|
||||
setRESTSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: props.folderPath,
|
||||
requestIndex: props.requestIndex,
|
||||
req: cloneDeep(props.request),
|
||||
})
|
||||
}
|
||||
|
||||
confirmChange.value = false
|
||||
}
|
||||
|
||||
const saveCurrentRequest = (saveCtx: HoppRequestSaveContext | null) => {
|
||||
if (!saveCtx) {
|
||||
showSaveRequestModal.value = true
|
||||
return
|
||||
}
|
||||
if (saveCtx.originLocation === "user-collection") {
|
||||
try {
|
||||
editRESTRequest(
|
||||
saveCtx.folderPath,
|
||||
saveCtx.requestIndex,
|
||||
getRESTRequest()
|
||||
)
|
||||
setRestReq(props.request)
|
||||
toast.success(`${t("request.saved")}`)
|
||||
} catch (e) {
|
||||
setRESTSaveContext(null)
|
||||
saveCurrentRequest(saveCtx)
|
||||
}
|
||||
} else if (saveCtx.originLocation === "team-collection") {
|
||||
const req = getRESTRequest()
|
||||
try {
|
||||
runMutation(UpdateRequestDocument, {
|
||||
requestID: saveCtx.requestID,
|
||||
data: {
|
||||
title: req.name,
|
||||
request: JSON.stringify(req),
|
||||
},
|
||||
})().then((result) => {
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
} else {
|
||||
toast.success(`${t("request.saved")}`)
|
||||
}
|
||||
})
|
||||
setRestReq(props.request)
|
||||
} catch (error) {
|
||||
showSaveRequestModal.value = true
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,406 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropEvent"
|
||||
@dragover="dragging = true"
|
||||
@drop="dragging = false"
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<component
|
||||
:is="getCollectionIcon"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ collection.title }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFilePlus"
|
||||
:title="t('request.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="
|
||||
$emit('add-request', {
|
||||
folder: collection,
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFolderPlus"
|
||||
:title="t('folder.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="
|
||||
$emit('add-folder', {
|
||||
folder: collection,
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.x="exportAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="requestAction"
|
||||
:icon="IconFilePlus"
|
||||
:label="t('request.new')"
|
||||
:shortcut="['R']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-request', {
|
||||
folder: collection,
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="folderAction"
|
||||
:icon="IconFolderPlus"
|
||||
:label="t('folder.new')"
|
||||
:shortcut="['N']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-folder', {
|
||||
folder: collection,
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('edit-collection')
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="exportAction"
|
||||
:icon="IconDownload"
|
||||
:label="t('export.title')"
|
||||
:shortcut="['X']"
|
||||
:loading="exportLoading"
|
||||
@click="exportCollection"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
removeCollection()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showChildren || isFiltered" class="flex">
|
||||
<div
|
||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
||||
@click="toggleShowChildren()"
|
||||
></div>
|
||||
<div class="flex flex-col flex-1 truncate">
|
||||
<CollectionsTeamsFolder
|
||||
v-for="(folder, index) in collection.children"
|
||||
:key="`folder-${index}`"
|
||||
:folder="folder"
|
||||
:folder-index="index"
|
||||
:folder-path="`${collectionIndex}/${index}`"
|
||||
:collection-index="collectionIndex"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:is-filtered="isFiltered"
|
||||
:picked="picked"
|
||||
:loading-collection-i-ds="loadingCollectionIDs"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@expand-collection="expandCollection"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@remove-folder="$emit('remove-folder', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
/>
|
||||
<CollectionsTeamsRequest
|
||||
v-for="(request, index) in collection.requests"
|
||||
:key="`request-${index}`"
|
||||
:request="request.request"
|
||||
:collection-index="collectionIndex"
|
||||
:folder-index="-1"
|
||||
:folder-name="collection.name"
|
||||
:request-index="request.id"
|
||||
:save-request="saveRequest"
|
||||
:collection-i-d="collection.id"
|
||||
:collections-type="collectionsType"
|
||||
:picked="picked"
|
||||
@edit-request="editRequest($event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="loadingCollectionIDs.includes(collection.id)"
|
||||
class="flex flex-col items-center justify-center p-4"
|
||||
>
|
||||
<SmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
(collection.children == undefined ||
|
||||
collection.children.length === 0) &&
|
||||
(collection.requests == undefined ||
|
||||
collection.requests.length === 0)
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||
:alt="`${t('empty.collection')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.collection") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconFilePlus from "~icons/lucide/file-plus"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { defineComponent, ref } from "vue"
|
||||
import * as E from "fp-ts/Either"
|
||||
import {
|
||||
getCompleteCollectionTree,
|
||||
teamCollToHoppRESTColl,
|
||||
} from "~/helpers/backend/helpers"
|
||||
import { moveRESTTeamRequest } from "~/helpers/backend/mutations/TeamRequest"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
collectionIndex: { type: Number, default: null },
|
||||
collection: { type: Object, default: () => ({}) },
|
||||
isFiltered: Boolean,
|
||||
saveRequest: Boolean,
|
||||
collectionsType: { type: Object, default: () => ({}) },
|
||||
picked: { type: Object, default: () => ({}) },
|
||||
loadingCollectionIDs: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: [
|
||||
"edit-collection",
|
||||
"add-request",
|
||||
"add-folder",
|
||||
"edit-folder",
|
||||
"edit-request",
|
||||
"remove-folder",
|
||||
"select",
|
||||
"remove-request",
|
||||
"duplicate-request",
|
||||
"expand-collection",
|
||||
"remove-collection",
|
||||
],
|
||||
setup() {
|
||||
const t = useI18n()
|
||||
|
||||
return {
|
||||
// Template refs
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
requestAction: ref<any | null>(null),
|
||||
folderAction: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
exportAction: ref<any | null>(null),
|
||||
exportLoading: ref<boolean>(false),
|
||||
t,
|
||||
toast: useToast(),
|
||||
colorMode: useColorMode(),
|
||||
|
||||
IconCheckCircle,
|
||||
IconCircle,
|
||||
IconFilePlus,
|
||||
IconFolderPlus,
|
||||
IconEdit,
|
||||
IconDownload,
|
||||
IconTrash2,
|
||||
IconMoreVertical,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showChildren: false,
|
||||
dragging: false,
|
||||
selectedFolder: {},
|
||||
prevCursor: "",
|
||||
cursor: "",
|
||||
pageNo: 0,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSelected(): boolean {
|
||||
return (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "teams-collection" &&
|
||||
this.picked.collectionID === this.collection.id
|
||||
)
|
||||
},
|
||||
getCollectionIcon() {
|
||||
if (this.isSelected) return IconCheckCircle
|
||||
else if (!this.showChildren && !this.isFiltered) return IconFolder
|
||||
else if (this.showChildren || this.isFiltered) return IconFolderOpen
|
||||
else return IconFolder
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async exportCollection() {
|
||||
this.exportLoading = true
|
||||
|
||||
const result = await getCompleteCollectionTree(this.collection.id)()
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong").toString())
|
||||
console.log(result.left)
|
||||
this.exportLoading = false
|
||||
this.options.tippy().hide()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const hoppColl = teamCollToHoppRESTColl(result.right)
|
||||
|
||||
const collectionJSON = JSON.stringify(hoppColl)
|
||||
|
||||
const file = new Blob([collectionJSON], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
|
||||
a.download = `${hoppColl.name}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
this.toast.success(this.t("state.download_started").toString())
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
|
||||
this.exportLoading = false
|
||||
|
||||
this.options.tippy().hide()
|
||||
},
|
||||
editRequest(event: any) {
|
||||
this.$emit("edit-request", event)
|
||||
if (this.$props.saveRequest)
|
||||
this.$emit("select", {
|
||||
picked: {
|
||||
pickedType: "teams-collection",
|
||||
collectionID: this.collection.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
toggleShowChildren() {
|
||||
if (this.$props.saveRequest)
|
||||
this.$emit("select", {
|
||||
picked: {
|
||||
pickedType: "teams-collection",
|
||||
collectionID: this.collection.id,
|
||||
},
|
||||
})
|
||||
|
||||
this.$emit("expand-collection", this.collection.id)
|
||||
this.showChildren = !this.showChildren
|
||||
},
|
||||
removeCollection() {
|
||||
this.$emit("remove-collection", {
|
||||
collectionIndex: this.collectionIndex,
|
||||
collectionID: this.collection.id,
|
||||
})
|
||||
},
|
||||
expandCollection(collectionID: string) {
|
||||
this.$emit("expand-collection", collectionID)
|
||||
},
|
||||
async dropEvent({ dataTransfer }: any) {
|
||||
this.dragging = !this.dragging
|
||||
const requestIndex = dataTransfer.getData("requestIndex")
|
||||
const moveRequestResult = await moveRESTTeamRequest(
|
||||
requestIndex,
|
||||
this.collection.id
|
||||
)()
|
||||
if (E.isLeft(moveRequestResult))
|
||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,381 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropEvent"
|
||||
@dragover="dragging = true"
|
||||
@drop="dragging = false"
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<component
|
||||
:is="getCollectionIcon"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ folder.name ? folder.name : folder.title }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFilePlus"
|
||||
:title="t('request.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="$emit('add-request', { folder, path: folderPath })"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFolderPlus"
|
||||
:title="t('folder.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="$emit('add-folder', { folder, path: folderPath })"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.x="exportAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="requestAction"
|
||||
:icon="IconFilePlus"
|
||||
:label="t('request.new')"
|
||||
:shortcut="['R']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-request', { folder, path: folderPath })
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="folderAction"
|
||||
:icon="IconFolderPlus"
|
||||
:label="t('folder.new')"
|
||||
:shortcut="['N']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-folder', { folder, path: folderPath })
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('edit-folder', {
|
||||
folder,
|
||||
folderIndex,
|
||||
collectionIndex,
|
||||
folderPath: '',
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="exportAction"
|
||||
:icon="IconDownload"
|
||||
:label="t('export.title')"
|
||||
:shortcut="['X']"
|
||||
:loading="exportLoading"
|
||||
@click="exportFolder"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
removeFolder()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showChildren || isFiltered" class="flex">
|
||||
<div
|
||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
||||
@click="toggleShowChildren()"
|
||||
></div>
|
||||
<div class="flex flex-col flex-1 truncate">
|
||||
<!-- Referring to this component only (this is recursive) -->
|
||||
<Folder
|
||||
v-for="(subFolder, subFolderIndex) in folder.children"
|
||||
:key="`subFolder-${subFolderIndex}`"
|
||||
:folder="subFolder"
|
||||
:folder-index="subFolderIndex"
|
||||
:collection-index="collectionIndex"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:folder-path="`${folderPath}/${subFolderIndex}`"
|
||||
:picked="picked"
|
||||
:loading-collection-i-ds="loadingCollectionIDs"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@update-team-collections="$emit('update-team-collections')"
|
||||
@select="$emit('select', $event)"
|
||||
@expand-collection="expandCollection"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@remove-folder="$emit('remove-folder', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
/>
|
||||
<CollectionsTeamsRequest
|
||||
v-for="(request, index) in folder.requests"
|
||||
:key="`request-${index}`"
|
||||
:request="request.request"
|
||||
:collection-index="collectionIndex"
|
||||
:folder-index="folderIndex"
|
||||
:folder-name="folder.name"
|
||||
:request-index="request.id"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:picked="picked"
|
||||
:collection-i-d="folder.id"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="loadingCollectionIDs.includes(folder.id)"
|
||||
class="flex flex-col items-center justify-center p-4"
|
||||
>
|
||||
<SmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
(folder.children == undefined || folder.children.length === 0) &&
|
||||
(folder.requests == undefined || folder.requests.length === 0)
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||
:alt="`${t('empty.folder')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.folder") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconFilePlus from "~icons/lucide/file-plus"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { defineComponent, ref } from "vue"
|
||||
import * as E from "fp-ts/Either"
|
||||
import {
|
||||
getCompleteCollectionTree,
|
||||
teamCollToHoppRESTColl,
|
||||
} from "~/helpers/backend/helpers"
|
||||
import { moveRESTTeamRequest } from "~/helpers/backend/mutations/TeamRequest"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
|
||||
export default defineComponent({
|
||||
name: "Folder",
|
||||
props: {
|
||||
folder: { type: Object, default: () => ({}) },
|
||||
folderIndex: { type: Number, default: null },
|
||||
collectionIndex: { type: Number, default: null },
|
||||
folderPath: { type: String, default: null },
|
||||
saveRequest: Boolean,
|
||||
isFiltered: Boolean,
|
||||
collectionsType: { type: Object, default: () => ({}) },
|
||||
picked: { type: Object, default: () => ({}) },
|
||||
loadingCollectionIDs: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: [
|
||||
"add-request",
|
||||
"add-folder",
|
||||
"edit-folder",
|
||||
"update-team-collections",
|
||||
"edit-request",
|
||||
"remove-request",
|
||||
"duplicate-request",
|
||||
"select",
|
||||
"remove-folder",
|
||||
"expand-collection",
|
||||
],
|
||||
setup() {
|
||||
return {
|
||||
// Template refs
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
requestAction: ref<any | null>(null),
|
||||
folderAction: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
exportAction: ref<any | null>(null),
|
||||
exportLoading: ref<boolean>(false),
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
colorMode: useColorMode(),
|
||||
IconFilePlus,
|
||||
IconFolderPlus,
|
||||
IconCheckCircle,
|
||||
IconFolder,
|
||||
IconFolderOpen,
|
||||
IconMoreVertical,
|
||||
IconEdit,
|
||||
IconDownload,
|
||||
IconTrash2,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showChildren: false,
|
||||
dragging: false,
|
||||
prevCursor: "",
|
||||
cursor: "",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSelected(): boolean {
|
||||
return (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "teams-folder" &&
|
||||
this.picked.folderID === this.folder.id
|
||||
)
|
||||
},
|
||||
getCollectionIcon() {
|
||||
if (this.isSelected) return IconCheckCircle
|
||||
else if (!this.showChildren && !this.isFiltered) return IconFolder
|
||||
else if (this.showChildren || this.isFiltered) return IconFolderOpen
|
||||
else return IconFolder
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async exportFolder() {
|
||||
this.exportLoading = true
|
||||
|
||||
const result = await getCompleteCollectionTree(this.folder.id)()
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong").toString())
|
||||
console.log(result.left)
|
||||
this.exportLoading = false
|
||||
this.options.tippy().hide()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const hoppColl = teamCollToHoppRESTColl(result.right)
|
||||
|
||||
const collectionJSON = JSON.stringify(hoppColl)
|
||||
|
||||
const file = new Blob([collectionJSON], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
|
||||
a.download = `${hoppColl.name}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
this.toast.success(this.t("state.download_started").toString())
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
|
||||
this.exportLoading = false
|
||||
|
||||
this.options.tippy().hide()
|
||||
},
|
||||
toggleShowChildren() {
|
||||
if (this.$props.saveRequest)
|
||||
this.$emit("select", {
|
||||
picked: {
|
||||
pickedType: "teams-folder",
|
||||
folderID: this.folder.id,
|
||||
},
|
||||
})
|
||||
|
||||
this.$emit("expand-collection", this.$props.folder.id)
|
||||
this.showChildren = !this.showChildren
|
||||
},
|
||||
removeFolder() {
|
||||
this.$emit("remove-folder", {
|
||||
collectionsType: this.collectionsType,
|
||||
folder: this.folder,
|
||||
})
|
||||
},
|
||||
expandCollection(collectionID: number) {
|
||||
this.$emit("expand-collection", collectionID)
|
||||
},
|
||||
async dropEvent({ dataTransfer }: any) {
|
||||
this.dragging = !this.dragging
|
||||
const requestIndex = dataTransfer.getData("requestIndex")
|
||||
const moveRequestResult = await moveRESTTeamRequest(
|
||||
requestIndex,
|
||||
this.folder.id
|
||||
)()
|
||||
if (E.isLeft(moveRequestResult))
|
||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,405 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
draggable="true"
|
||||
@dragstart="dragStart"
|
||||
@dragover.stop
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
|
||||
:class="getRequestLabelColor(request.method)"
|
||||
@click="selectRequest()"
|
||||
>
|
||||
<component
|
||||
:is="IconCheckCircle"
|
||||
v-if="isSelected"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
<span v-else class="font-semibold truncate text-tiny">
|
||||
{{ request.method }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="selectRequest()"
|
||||
>
|
||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ request.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isActive"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
|
||||
:title="`${t('collection.request_in_use')}`"
|
||||
>
|
||||
<span
|
||||
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="!saveRequest"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconRotateCCW"
|
||||
:title="t('action.restore')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="selectRequest()"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.d="duplicate.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
emit('edit-request', {
|
||||
collectionIndex,
|
||||
folderIndex,
|
||||
folderName,
|
||||
request,
|
||||
requestIndex,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="duplicate"
|
||||
:icon="IconCopy"
|
||||
:label="t('action.duplicate')"
|
||||
:shortcut="['D']"
|
||||
@click="
|
||||
() => {
|
||||
emit('duplicate-request', {
|
||||
request,
|
||||
requestIndex,
|
||||
collectionID,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
removeRequest()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<HttpReqChangeConfirmModal
|
||||
:show="confirmChange"
|
||||
@hide-modal="confirmChange = false"
|
||||
@save-change="saveRequestChange"
|
||||
@discard-change="discardRequestChange"
|
||||
/>
|
||||
<CollectionsSaveRequest
|
||||
mode="rest"
|
||||
:show="showSaveRequestModal"
|
||||
@hide-modal="showSaveRequestModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import { ref, computed } from "vue"
|
||||
import {
|
||||
HoppRESTRequest,
|
||||
isEqualHoppRESTRequest,
|
||||
safelyExtractRESTRequest,
|
||||
translateToNewRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import {
|
||||
getDefaultRESTRequest,
|
||||
restSaveContext$,
|
||||
setRESTRequest,
|
||||
setRESTSaveContext,
|
||||
getRESTSaveContext,
|
||||
getRESTRequest,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { editRESTRequest } from "~/newstore/collections"
|
||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
||||
import { Team, UpdateRequestDocument } from "~/helpers/backend/graphql"
|
||||
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
|
||||
|
||||
const props = defineProps<{
|
||||
request: HoppRESTRequest
|
||||
collectionIndex: number
|
||||
folderIndex: number
|
||||
folderName?: string
|
||||
requestIndex: string
|
||||
saveRequest: boolean
|
||||
collectionsType: {
|
||||
type: "my-collections" | "team-collections"
|
||||
selectedTeam: Team | undefined
|
||||
}
|
||||
collectionID: string
|
||||
picked?: {
|
||||
pickedType: string
|
||||
requestID: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: "select",
|
||||
data:
|
||||
| {
|
||||
picked: {
|
||||
pickedType: string
|
||||
requestID: string
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
): void
|
||||
|
||||
(
|
||||
e: "remove-request",
|
||||
data: {
|
||||
folderPath: string | undefined
|
||||
requestIndex: string
|
||||
}
|
||||
): void
|
||||
|
||||
(
|
||||
e: "edit-request",
|
||||
data: {
|
||||
collectionIndex: number
|
||||
folderIndex: number
|
||||
folderName: string | undefined
|
||||
requestIndex: string
|
||||
request: HoppRESTRequest
|
||||
}
|
||||
): void
|
||||
|
||||
(
|
||||
e: "duplicate-request",
|
||||
data: {
|
||||
collectionID: number | string
|
||||
requestIndex: string
|
||||
request: HoppRESTRequest
|
||||
}
|
||||
): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const dragging = ref(false)
|
||||
const requestMethodLabels = {
|
||||
get: "text-green-500",
|
||||
post: "text-yellow-500",
|
||||
put: "text-blue-500",
|
||||
delete: "text-red-500",
|
||||
default: "text-gray-500",
|
||||
}
|
||||
const confirmChange = ref(false)
|
||||
const showSaveRequestModal = ref(false)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const options = ref<any | null>(null)
|
||||
const edit = ref<any | null>(null)
|
||||
const duplicate = ref<any | null>(null)
|
||||
const deleteAction = ref<any | null>(null)
|
||||
|
||||
const active = useReadonlyStream(restSaveContext$, null)
|
||||
|
||||
const isSelected = computed(
|
||||
() =>
|
||||
props.picked &&
|
||||
props.picked.pickedType === "teams-request" &&
|
||||
props.picked.requestID === props.requestIndex
|
||||
)
|
||||
|
||||
const isActive = computed(
|
||||
() =>
|
||||
active.value &&
|
||||
active.value.originLocation === "team-collection" &&
|
||||
active.value.requestID === props.requestIndex
|
||||
)
|
||||
|
||||
const dragStart = ({ dataTransfer }: DragEvent) => {
|
||||
if (dataTransfer) {
|
||||
dragging.value = !dragging.value
|
||||
dataTransfer.setData("requestIndex", props.requestIndex)
|
||||
}
|
||||
}
|
||||
|
||||
const removeRequest = () => {
|
||||
emit("remove-request", {
|
||||
folderPath: props.folderName,
|
||||
requestIndex: props.requestIndex,
|
||||
})
|
||||
}
|
||||
|
||||
const getRequestLabelColor = (method: string): string => {
|
||||
return (
|
||||
(requestMethodLabels as any)[method.toLowerCase()] ||
|
||||
requestMethodLabels.default
|
||||
)
|
||||
}
|
||||
|
||||
const setRestReq = (request: HoppRESTRequest) => {
|
||||
setRESTRequest(
|
||||
safelyExtractRESTRequest(
|
||||
translateToNewRequest(request),
|
||||
getDefaultRESTRequest()
|
||||
),
|
||||
{
|
||||
originLocation: "team-collection",
|
||||
requestID: props.requestIndex,
|
||||
req: request,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const selectRequest = () => {
|
||||
// Check if this is a save as request popup, if so we don't need to prompt the confirm change popup.
|
||||
if (props.saveRequest) {
|
||||
emit("select", {
|
||||
picked: {
|
||||
pickedType: "teams-request",
|
||||
requestID: props.requestIndex,
|
||||
},
|
||||
})
|
||||
} else if (isEqualHoppRESTRequest(props.request, getDefaultRESTRequest())) {
|
||||
confirmChange.value = false
|
||||
setRestReq(props.request)
|
||||
} else if (!active.value) {
|
||||
confirmChange.value = true
|
||||
} else {
|
||||
const currentReqWithNoChange = active.value.req
|
||||
const currentFullReq = getRESTRequest()
|
||||
|
||||
// Check if whether user clicked the same request or not
|
||||
if (!isActive.value && currentReqWithNoChange) {
|
||||
// Check if there is any changes done on the current request
|
||||
if (isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)) {
|
||||
setRestReq(props.request)
|
||||
} else {
|
||||
confirmChange.value = true
|
||||
}
|
||||
} else {
|
||||
setRESTSaveContext(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Save current request to the collection */
|
||||
const saveRequestChange = () => {
|
||||
const saveCtx = getRESTSaveContext()
|
||||
saveCurrentRequest(saveCtx)
|
||||
confirmChange.value = false
|
||||
}
|
||||
|
||||
/** Discard changes and change the current request and context */
|
||||
const discardRequestChange = () => {
|
||||
setRestReq(props.request)
|
||||
if (!isActive.value) {
|
||||
setRESTSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: props.requestIndex,
|
||||
req: props.request,
|
||||
})
|
||||
}
|
||||
confirmChange.value = false
|
||||
}
|
||||
|
||||
const saveCurrentRequest = (saveCtx: HoppRequestSaveContext | null) => {
|
||||
if (!saveCtx) {
|
||||
showSaveRequestModal.value = true
|
||||
return
|
||||
}
|
||||
if (saveCtx.originLocation === "team-collection") {
|
||||
const req = getRESTRequest()
|
||||
try {
|
||||
runMutation(UpdateRequestDocument, {
|
||||
requestID: saveCtx.requestID,
|
||||
data: {
|
||||
title: req.name,
|
||||
request: JSON.stringify(req),
|
||||
},
|
||||
})().then((result) => {
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
} else {
|
||||
toast.success(`${t("request.saved")}`)
|
||||
}
|
||||
})
|
||||
setRestReq(props.request)
|
||||
} catch (error) {
|
||||
showSaveRequestModal.value = true
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
console.error(error)
|
||||
}
|
||||
} else if (saveCtx.originLocation === "user-collection") {
|
||||
try {
|
||||
editRESTRequest(
|
||||
saveCtx.folderPath,
|
||||
saveCtx.requestIndex,
|
||||
getRESTRequest()
|
||||
)
|
||||
setRestReq(props.request)
|
||||
toast.success(`${t("request.saved")}`)
|
||||
} catch (e) {
|
||||
setRESTSaveContext(null)
|
||||
saveCurrentRequest(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,197 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-0 z-10 flex flex-col rounded-t bg-primary">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${t('environment.select')}`"
|
||||
class="flex-1 bg-transparent border-b border-dividerLight select-wrapper"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-if="selectedEnvironmentIndex !== -1"
|
||||
:label="environments[selectedEnvironmentIndex].name"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:label="`${t('environment.select')}`"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
:label="`${t('environment.no_environment')}`"
|
||||
:info-icon="selectedEnvironmentIndex === -1 ? IconDone : null"
|
||||
:active-info-icon="selectedEnvironmentIndex === -1"
|
||||
@click="
|
||||
() => {
|
||||
selectedEnvironmentIndex = -1
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<hr v-if="environments.length > 0" />
|
||||
<SmartItem
|
||||
v-for="(gen, index) in environments"
|
||||
:key="`gen-${index}`"
|
||||
:label="gen.name"
|
||||
:info-icon="index === selectedEnvironmentIndex ? IconDone : null"
|
||||
:active-info-icon="index === selectedEnvironmentIndex"
|
||||
@click="
|
||||
() => {
|
||||
selectedEnvironmentIndex = index
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
<div class="flex justify-between flex-1 border-b border-dividerLight">
|
||||
<ButtonSecondary
|
||||
:icon="IconPlus"
|
||||
:label="`${t('action.new')}`"
|
||||
class="!rounded-none"
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/environments"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconArchive"
|
||||
:title="t('modal.import_export')"
|
||||
@click="displayModalImportExport(true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<EnvironmentsEnvironment
|
||||
environment-index="Global"
|
||||
:environment="globalEnvironment"
|
||||
class="border-b border-dashed border-dividerLight"
|
||||
@edit-environment="editEnvironment('Global')"
|
||||
/>
|
||||
<EnvironmentsEnvironment
|
||||
v-for="(environment, index) in environments"
|
||||
:key="`environment-${index}`"
|
||||
:environment-index="index"
|
||||
:environment="environment"
|
||||
@edit-environment="editEnvironment(index)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="environments.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.environments')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.environments") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
class="mb-4"
|
||||
outline
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</div>
|
||||
<EnvironmentsDetails
|
||||
:show="showModalDetails"
|
||||
:action="action"
|
||||
:editing-environment-index="editingEnvironmentIndex"
|
||||
@hide-modal="displayModalEdit(false)"
|
||||
/>
|
||||
<EnvironmentsImportExport
|
||||
:show="showModalImportExport"
|
||||
@hide-modal="displayModalImportExport(false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconDone from "~icons/lucide/check"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconArchive from "~icons/lucide/archive"
|
||||
import { computed, ref } from "vue"
|
||||
import { useReadonlyStream, useStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import {
|
||||
environments$,
|
||||
setCurrentEnvironment,
|
||||
selectedEnvIndex$,
|
||||
globalEnv$,
|
||||
} from "~/newstore/environments"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const globalEnv = useReadonlyStream(globalEnv$, [])
|
||||
|
||||
const globalEnvironment = computed(() => ({
|
||||
name: "Global",
|
||||
variables: globalEnv.value,
|
||||
}))
|
||||
|
||||
const environments = useReadonlyStream(environments$, [])
|
||||
|
||||
const selectedEnvironmentIndex = useStream(
|
||||
selectedEnvIndex$,
|
||||
-1,
|
||||
setCurrentEnvironment
|
||||
)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const showModalImportExport = ref(false)
|
||||
const showModalDetails = ref(false)
|
||||
const action = ref<"new" | "edit">("edit")
|
||||
const editingEnvironmentIndex = ref<number | "Global" | null>(null)
|
||||
|
||||
const displayModalAdd = (shouldDisplay: boolean) => {
|
||||
action.value = "new"
|
||||
showModalDetails.value = shouldDisplay
|
||||
}
|
||||
const displayModalEdit = (shouldDisplay: boolean) => {
|
||||
action.value = "edit"
|
||||
showModalDetails.value = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) resetSelectedData()
|
||||
}
|
||||
const displayModalImportExport = (shouldDisplay: boolean) => {
|
||||
showModalImportExport.value = shouldDisplay
|
||||
}
|
||||
const editEnvironment = (environmentIndex: number | "Global") => {
|
||||
editingEnvironmentIndex.value = environmentIndex
|
||||
action.value = "edit"
|
||||
displayModalEdit(true)
|
||||
}
|
||||
const resetSelectedData = () => {
|
||||
editingEnvironmentIndex.value = null
|
||||
}
|
||||
</script>
|
||||
@@ -1,302 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1">
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperMobileSecondaryStickyFold sm:top-upperSecondaryStickyFold"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<label class="font-semibold text-secondaryLight">
|
||||
{{ t("authorization.type") }}
|
||||
</label>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary class="pr-8 ml-2 rounded-none" :label="authName" />
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
label="None"
|
||||
:icon="authName === 'None' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'None'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'none'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="Basic Auth"
|
||||
:icon="authName === 'Basic Auth' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'Basic Auth'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'basic'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="Bearer Token"
|
||||
:icon="authName === 'Bearer' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'Bearer'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'bearer'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="OAuth 2.0"
|
||||
:icon="authName === 'OAuth 2.0' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'OAuth 2.0'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'oauth-2'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="API key"
|
||||
:icon="authName === 'API key' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'API key'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'api-key'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<!-- <SmartCheckbox
|
||||
:on="!URLExcludes.auth"
|
||||
@change="setExclude('auth', !$event)"
|
||||
>
|
||||
{{ $t("authorization.include_in_url") }}
|
||||
</SmartCheckbox>-->
|
||||
<SmartCheckbox
|
||||
:on="authActive"
|
||||
class="px-2"
|
||||
@change="authActive = !authActive"
|
||||
>{{ t("state.enabled") }}</SmartCheckbox
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="authType === 'none'"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/login.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.authorization')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">{{ t("empty.authorization") }}</span>
|
||||
<ButtonSecondary
|
||||
outline
|
||||
:label="t('app.documentation')"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
:icon="IconExternalLink"
|
||||
reverse
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex flex-1 border-b border-dividerLight">
|
||||
<div class="w-2/3 border-r border-dividerLight">
|
||||
<div v-if="authType === 'basic'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="basicUsername"
|
||||
:placeholder="t('authorization.username')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="basicPassword"
|
||||
:placeholder="t('authorization.password')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="authType === 'bearer'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="bearerToken" placeholder="Token" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="authType === 'oauth-2'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="oauth2Token" placeholder="Token" />
|
||||
</div>
|
||||
<HttpOAuth2Authorization />
|
||||
</div>
|
||||
<div v-if="authType === 'api-key'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="apiKey" placeholder="Key" />
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="apiValue" placeholder="Value" />
|
||||
</div>
|
||||
<div class="flex items-center border-b border-dividerLight">
|
||||
<span class="flex items-center">
|
||||
<label class="ml-4 text-secondaryLight">
|
||||
{{ t("authorization.pass_key_by") }}
|
||||
</label>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => authTippyActions.focus()"
|
||||
>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary
|
||||
:label="addTo || t('state.none')"
|
||||
class="pr-8 ml-2 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="authTippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
:icon="addTo === 'Headers' ? IconCircleDot : IconCircle"
|
||||
:active="addTo === 'Headers'"
|
||||
:label="'Headers'"
|
||||
@click="
|
||||
() => {
|
||||
addTo = 'Headers'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="
|
||||
addTo === 'Query params' ? IconCircleDot : IconCircle
|
||||
"
|
||||
:active="addTo === 'Query params'"
|
||||
:label="'Query params'"
|
||||
@click="
|
||||
() => {
|
||||
addTo = 'Query params'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sticky h-full p-4 overflow-auto bg-primary top-upperTertiaryStickyFold min-w-46 max-w-1/3 z-9"
|
||||
>
|
||||
<div class="pb-2 text-secondaryLight">
|
||||
{{ t("helpers.authorization") }}
|
||||
</div>
|
||||
<SmartAnchor
|
||||
class="link"
|
||||
:label="t('authorization.learn')"
|
||||
:icon="IconExternalLink"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
reverse
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconExternalLink from "~icons/lucide/external-link"
|
||||
import IconCircleDot from "~icons/lucide/circle-dot"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import { computed, ref, Ref } from "vue"
|
||||
import {
|
||||
HoppRESTAuthBasic,
|
||||
HoppRESTAuthBearer,
|
||||
HoppRESTAuthOAuth2,
|
||||
HoppRESTAuthAPIKey,
|
||||
} from "@hoppscotch/data"
|
||||
import { pluckRef } from "@composables/ref"
|
||||
import { useStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { restAuth$, setRESTAuth } from "~/newstore/RESTSession"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const auth = useStream(
|
||||
restAuth$,
|
||||
{ authType: "none", authActive: true },
|
||||
setRESTAuth
|
||||
)
|
||||
const authType = pluckRef(auth, "authType")
|
||||
const authName = computed(() => {
|
||||
if (authType.value === "basic") return "Basic Auth"
|
||||
else if (authType.value === "bearer") return "Bearer"
|
||||
else if (authType.value === "oauth-2") return "OAuth 2.0"
|
||||
else if (authType.value === "api-key") return "API key"
|
||||
else return "None"
|
||||
})
|
||||
const authActive = pluckRef(auth, "authActive")
|
||||
const basicUsername = pluckRef(auth as Ref<HoppRESTAuthBasic>, "username")
|
||||
const basicPassword = pluckRef(auth as Ref<HoppRESTAuthBasic>, "password")
|
||||
const bearerToken = pluckRef(auth as Ref<HoppRESTAuthBearer>, "token")
|
||||
const oauth2Token = pluckRef(auth as Ref<HoppRESTAuthOAuth2>, "token")
|
||||
const apiKey = pluckRef(auth as Ref<HoppRESTAuthAPIKey>, "key")
|
||||
const apiValue = pluckRef(auth as Ref<HoppRESTAuthAPIKey>, "value")
|
||||
const addTo = pluckRef(auth as Ref<HoppRESTAuthAPIKey>, "addTo")
|
||||
if (typeof addTo.value === "undefined") {
|
||||
addTo.value = "Headers"
|
||||
apiKey.value = ""
|
||||
apiValue.value = ""
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
auth.value = {
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const authTippyActions = ref<any | null>(null)
|
||||
</script>
|
||||
@@ -1,124 +0,0 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('import.curl')}`"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="px-2 h-46">
|
||||
<div
|
||||
ref="curlEditor"
|
||||
class="h-full border rounded border-dividerLight"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
ref="importButton"
|
||||
:label="`${t('import.title')}`"
|
||||
outline
|
||||
@click="handleImport"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="`${t('action.cancel')}`"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
<span class="flex">
|
||||
<ButtonSecondary
|
||||
:icon="pasteIcon"
|
||||
:label="`${t('action.paste')}`"
|
||||
filled
|
||||
outline
|
||||
@click="handlePaste"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { setRESTRequest } from "~/newstore/RESTSession"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { parseCurlToHoppRESTReq } from "~/helpers/curl"
|
||||
|
||||
import IconClipboard from "~icons/lucide/clipboard"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const curl = ref("")
|
||||
|
||||
const curlEditor = ref<any | null>(null)
|
||||
|
||||
const props = defineProps<{ show: boolean; text: string }>()
|
||||
|
||||
useCodemirror(curlEditor, curl, {
|
||||
extendedEditorConfig: {
|
||||
mode: "application/x-sh",
|
||||
placeholder: `${t("request.enter_curl")}`,
|
||||
},
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: false,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
() => {
|
||||
if (props.show) {
|
||||
curl.value = props.text.toString()
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const hideModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
const text = curl.value
|
||||
try {
|
||||
const req = parseCurlToHoppRESTReq(text)
|
||||
|
||||
setRESTRequest(req)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(`${t("error.curl_invalid_format")}`)
|
||||
}
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const pasteIcon = refAutoReset<typeof IconClipboard | typeof IconCheck>(
|
||||
IconClipboard,
|
||||
1000
|
||||
)
|
||||
|
||||
const handlePaste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) {
|
||||
curl.value = text
|
||||
pasteIcon.value = IconCheck
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to copy: ", e)
|
||||
toast.error(t("profile.no_permission").toString())
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,119 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="oidcDiscoveryURL"
|
||||
placeholder="OpenID Connect Discovery URL"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="authURL" placeholder="Authorization URL" />
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="accessTokenURL" placeholder="Access Token URL" />
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="clientID" placeholder="Client ID" />
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="clientSecret" placeholder="Client Secret" />
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="scope" placeholder="Scope" />
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<ButtonSecondary
|
||||
filled
|
||||
:label="`${t('authorization.generate_token')}`"
|
||||
@click="handleAccessTokenRequest()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Ref, defineComponent } from "vue"
|
||||
import { HoppRESTAuthOAuth2, parseTemplateString } from "@hoppscotch/data"
|
||||
import { pluckRef } from "@composables/ref"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { restAuth$, setRESTAuth } from "~/newstore/RESTSession"
|
||||
import { tokenRequest } from "~/helpers/oauth"
|
||||
import { getCombinedEnvVariables } from "~/helpers/preRequest"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const auth = useStream(
|
||||
restAuth$,
|
||||
{ authType: "none", authActive: true },
|
||||
setRESTAuth
|
||||
)
|
||||
|
||||
const oidcDiscoveryURL = pluckRef(
|
||||
auth as Ref<HoppRESTAuthOAuth2>,
|
||||
"oidcDiscoveryURL"
|
||||
)
|
||||
|
||||
const authURL = pluckRef(auth as Ref<HoppRESTAuthOAuth2>, "authURL")
|
||||
|
||||
const accessTokenURL = pluckRef(
|
||||
auth as Ref<HoppRESTAuthOAuth2>,
|
||||
"accessTokenURL"
|
||||
)
|
||||
|
||||
const clientID = pluckRef(auth as Ref<HoppRESTAuthOAuth2>, "clientID")
|
||||
|
||||
const clientSecret = pluckRef(
|
||||
auth as Ref<HoppRESTAuthOAuth2>,
|
||||
"clientSecret"
|
||||
)
|
||||
|
||||
const scope = pluckRef(auth as Ref<HoppRESTAuthOAuth2>, "scope")
|
||||
|
||||
const handleAccessTokenRequest = async () => {
|
||||
if (
|
||||
oidcDiscoveryURL.value === "" &&
|
||||
(authURL.value === "" || accessTokenURL.value === "")
|
||||
) {
|
||||
toast.error(`${t("error.incomplete_config_urls")}`)
|
||||
return
|
||||
}
|
||||
const envs = getCombinedEnvVariables()
|
||||
const envVars = [...envs.selected, ...envs.global]
|
||||
|
||||
try {
|
||||
const tokenReqParams = {
|
||||
grantType: "code",
|
||||
oidcDiscoveryUrl: parseTemplateString(
|
||||
oidcDiscoveryURL.value,
|
||||
envVars
|
||||
),
|
||||
authUrl: parseTemplateString(authURL.value, envVars),
|
||||
accessTokenUrl: parseTemplateString(accessTokenURL.value, envVars),
|
||||
clientId: parseTemplateString(clientID.value, envVars),
|
||||
clientSecret: parseTemplateString(clientSecret.value, envVars),
|
||||
scope: parseTemplateString(scope.value, envVars),
|
||||
}
|
||||
await tokenRequest(tokenReqParams)
|
||||
} catch (e) {
|
||||
toast.error(`${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oidcDiscoveryURL,
|
||||
authURL,
|
||||
accessTokenURL,
|
||||
clientID,
|
||||
clientSecret,
|
||||
scope,
|
||||
handleAccessTokenRequest,
|
||||
t,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<SmartTabs
|
||||
v-model="selectedRealtimeTab"
|
||||
styles="sticky bg-primary top-upperMobilePrimaryStickyFold sm:top-upperPrimaryStickyFold z-10"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab
|
||||
:id="'params'"
|
||||
:label="`${t('tab.parameters')}`"
|
||||
:info="`${newActiveParamsCount$}`"
|
||||
>
|
||||
<HttpParameters />
|
||||
</SmartTab>
|
||||
<SmartTab :id="'bodyParams'" :label="`${t('tab.body')}`">
|
||||
<HttpBody @change-tab="changeTab" />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
:id="'headers'"
|
||||
:label="`${t('tab.headers')}`"
|
||||
:info="`${newActiveHeadersCount$}`"
|
||||
>
|
||||
<HttpHeaders @change-tab="changeTab" />
|
||||
</SmartTab>
|
||||
<SmartTab :id="'authorization'" :label="`${t('tab.authorization')}`">
|
||||
<HttpAuthorization />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
:id="'preRequestScript'"
|
||||
:label="`${t('tab.pre_request_script')}`"
|
||||
:indicator="
|
||||
preRequestScript && preRequestScript.length > 0 ? true : false
|
||||
"
|
||||
>
|
||||
<HttpPreRequestScript />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
:id="'tests'"
|
||||
:label="`${t('tab.tests')}`"
|
||||
:indicator="testScript && testScript.length > 0 ? true : false"
|
||||
>
|
||||
<HttpTests />
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { map } from "rxjs/operators"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import {
|
||||
restActiveHeadersCount$,
|
||||
restActiveParamsCount$,
|
||||
usePreRequestScript,
|
||||
useTestScript,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
export type RequestOptionTabs =
|
||||
| "params"
|
||||
| "bodyParams"
|
||||
| "headers"
|
||||
| "authorization"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const selectedRealtimeTab = ref<RequestOptionTabs>("params")
|
||||
|
||||
const changeTab = (e: RequestOptionTabs) => {
|
||||
selectedRealtimeTab.value = e
|
||||
}
|
||||
|
||||
const newActiveParamsCount$ = useReadonlyStream(
|
||||
restActiveParamsCount$.pipe(
|
||||
map((e) => {
|
||||
if (e === 0) return null
|
||||
return `${e}`
|
||||
})
|
||||
),
|
||||
null
|
||||
)
|
||||
|
||||
const newActiveHeadersCount$ = useReadonlyStream(
|
||||
restActiveHeadersCount$.pipe(
|
||||
map((e) => {
|
||||
if (e === 0) return null
|
||||
return `${e}`
|
||||
})
|
||||
),
|
||||
null
|
||||
)
|
||||
|
||||
const preRequestScript = usePreRequestScript()
|
||||
|
||||
const testScript = useTestScript()
|
||||
</script>
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1">
|
||||
<HttpResponseMeta :response="response" />
|
||||
<LensesResponseBodyRenderer
|
||||
v-if="!loading && hasResponse"
|
||||
:response="response"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, watch } from "vue"
|
||||
import { startPageProgress, completePageProgress } from "@modules/loadingbar"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { restResponse$ } from "~/newstore/RESTSession"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const response = useReadonlyStream(restResponse$, null)
|
||||
|
||||
const hasResponse = computed(
|
||||
() =>
|
||||
response.value?.type === "success" || response.value?.type === "fail"
|
||||
)
|
||||
|
||||
const loading = computed(
|
||||
() => response.value === null || response.value.type === "loading"
|
||||
)
|
||||
|
||||
watch(response, () => {
|
||||
if (response.value?.type === "loading") startPageProgress()
|
||||
else completePageProgress()
|
||||
})
|
||||
|
||||
return {
|
||||
hasResponse,
|
||||
response,
|
||||
loading,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<SmartTabs
|
||||
v-if="response"
|
||||
v-model="selectedLensTab"
|
||||
styles="sticky z-10 bg-primary top-lowerPrimaryStickyFold"
|
||||
>
|
||||
<SmartTab
|
||||
v-for="(lens, index) in validLenses"
|
||||
:id="lens.renderer"
|
||||
:key="`lens-${index}`"
|
||||
:label="t(lens.lensName)"
|
||||
class="flex flex-col flex-1 w-full h-full"
|
||||
>
|
||||
<component :is="lens.renderer" :response="response" />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
v-if="headerLength"
|
||||
id="headers"
|
||||
:label="t('response.headers')"
|
||||
:info="`${headerLength}`"
|
||||
class="flex flex-col flex-1"
|
||||
>
|
||||
<LensesHeadersRenderer :headers="response.headers" />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
id="results"
|
||||
:label="t('test.results')"
|
||||
:indicator="
|
||||
testResults &&
|
||||
(testResults.expectResults.length ||
|
||||
testResults.tests.length ||
|
||||
testResults.envDiff.selected.additions.length ||
|
||||
testResults.envDiff.selected.updations.length ||
|
||||
testResults.envDiff.global.updations.length)
|
||||
? true
|
||||
: false
|
||||
"
|
||||
class="flex flex-col flex-1"
|
||||
>
|
||||
<HttpTestResult />
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { getSuitableLenses, getLensRenderers } from "~/helpers/lenses/lenses"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { restTestResults$ } from "~/newstore/RESTSession"
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
// Lens Renderers
|
||||
...getLensRenderers(),
|
||||
},
|
||||
props: {
|
||||
response: { type: Object, default: () => ({}) },
|
||||
},
|
||||
setup() {
|
||||
const testResults = useReadonlyStream(restTestResults$, null)
|
||||
|
||||
return {
|
||||
testResults,
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedLensTab: "",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
headerLength() {
|
||||
if (!this.response || !this.response.headers) return 0
|
||||
|
||||
return Object.keys(this.response.headers).length
|
||||
},
|
||||
validLenses() {
|
||||
if (!this.response) return []
|
||||
|
||||
return getSuitableLenses(this.response)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
validLenses: {
|
||||
handler(newValue) {
|
||||
if (newValue.length === 0) return
|
||||
this.selectedLensTab = newValue[0].renderer
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,83 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
tabindex="0"
|
||||
class="relative flex items-center justify-center cursor-pointer focus:outline-none focus-visible:ring focus-visible:ring-primaryDark"
|
||||
:class="[`rounded-${rounded}`, `w-${size} h-${size}`]"
|
||||
>
|
||||
<img
|
||||
v-if="url"
|
||||
class="absolute object-cover object-center transition bg-primaryDark"
|
||||
:class="[`rounded-${rounded}`, `w-${size} h-${size}`]"
|
||||
:src="url"
|
||||
:alt="alt"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="absolute flex items-center justify-center object-cover object-center transition bg-primaryDark text-accentContrast"
|
||||
:class="[`rounded-${rounded}`, `w-${size} h-${size}`]"
|
||||
:style="`background-color: ${toHex(initial)}`"
|
||||
>
|
||||
{{ initial.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<span
|
||||
v-if="indicator"
|
||||
class="border-primary border-2 h-2.5 -top-0.5 -right-0.5 w-2.5 absolute"
|
||||
:class="[`rounded-${rounded}`, indicatorStyles]"
|
||||
></span>
|
||||
<!-- w-5 h-5 rounded-lg -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: "Profile picture",
|
||||
},
|
||||
indicator: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
indicatorStyles: {
|
||||
type: String,
|
||||
default: "bg-green-500",
|
||||
},
|
||||
rounded: {
|
||||
type: String,
|
||||
default: "full",
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "5",
|
||||
},
|
||||
initial: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toHex(initial: string) {
|
||||
let hash = 0
|
||||
if (initial.length === 0) return hash
|
||||
for (let i = 0; i < initial.length; i++) {
|
||||
hash = initial.charCodeAt(i) + ((hash << 5) - hash)
|
||||
hash = hash & hash
|
||||
}
|
||||
let color = "#"
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 255
|
||||
color += `00${value.toString(16)}`.slice(-2)
|
||||
}
|
||||
return color
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,245 +0,0 @@
|
||||
<template>
|
||||
<div class="autocomplete-wrapper">
|
||||
<input
|
||||
ref="acInput"
|
||||
:value="text"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholder"
|
||||
:spellcheck="spellcheck"
|
||||
:autocapitalize="autocapitalize"
|
||||
:class="styles"
|
||||
@input.stop="
|
||||
(e) => {
|
||||
$emit('input', e.target.value)
|
||||
updateSuggestions(e)
|
||||
}
|
||||
"
|
||||
@keyup="updateSuggestions"
|
||||
@click="updateSuggestions"
|
||||
@keydown="handleKeystroke"
|
||||
@change="$emit('change', $event)"
|
||||
/>
|
||||
<ul
|
||||
v-if="suggestions.length > 0 && suggestionsVisible"
|
||||
class="suggestions"
|
||||
:style="{ transform: `translate(${suggestionsOffsetLeft}px, 0)` }"
|
||||
>
|
||||
<li
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="`suggestion-${index}`"
|
||||
:class="{ active: currentSuggestionIndex === index }"
|
||||
@click.prevent="forceSuggestion(suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
spellcheck: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
required: false,
|
||||
},
|
||||
|
||||
autocapitalize: {
|
||||
type: String,
|
||||
default: "off",
|
||||
required: false,
|
||||
},
|
||||
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
|
||||
source: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
value: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
|
||||
styles: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["input", "change"],
|
||||
data() {
|
||||
return {
|
||||
text: this.value,
|
||||
selectionStart: 0,
|
||||
suggestionsOffsetLeft: 0,
|
||||
currentSuggestionIndex: -1,
|
||||
suggestionsVisible: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Gets the suggestions list to be displayed under the input box.
|
||||
*
|
||||
* @returns {default.props.source|{type, required}}
|
||||
*/
|
||||
suggestions() {
|
||||
const input = this.text.substring(0, this.selectionStart)
|
||||
|
||||
return (
|
||||
this.source
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.toLowerCase().startsWith(input.toLowerCase()) &&
|
||||
input.toLowerCase() !== entry.toLowerCase()
|
||||
)
|
||||
// Cut off the part that's already been typed.
|
||||
.map((entry) => entry.substring(this.selectionStart))
|
||||
// We only want the top 10 suggestions.
|
||||
.slice(0, 10)
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
value(newValue) {
|
||||
this.text = newValue
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.updateSuggestions({
|
||||
target: this.$refs.acInput,
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateSuggestions(event) {
|
||||
// Hide suggestions if ESC pressed.
|
||||
if (event.code && event.code === "Escape") {
|
||||
event.preventDefault()
|
||||
this.suggestionsVisible = false
|
||||
this.currentSuggestionIndex = -1
|
||||
return
|
||||
}
|
||||
|
||||
// As suggestions is a reactive property, this implicitly
|
||||
// causes suggestions to update.
|
||||
this.selectionStart = this.$refs.acInput.selectionStart
|
||||
this.suggestionsOffsetLeft = 12 * this.selectionStart
|
||||
this.suggestionsVisible = true
|
||||
},
|
||||
|
||||
forceSuggestion(text) {
|
||||
const input = this.text.substring(0, this.selectionStart)
|
||||
this.text = input + text
|
||||
|
||||
this.selectionStart = this.text.length
|
||||
this.suggestionsVisible = true
|
||||
this.currentSuggestionIndex = -1
|
||||
|
||||
this.$emit("input", this.text)
|
||||
},
|
||||
|
||||
handleKeystroke(event) {
|
||||
switch (event.code) {
|
||||
case "Enter":
|
||||
event.preventDefault()
|
||||
if (this.currentSuggestionIndex > -1)
|
||||
this.forceSuggestion(
|
||||
this.suggestions.find(
|
||||
(_item, index) => index === this.currentSuggestionIndex
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
case "ArrowUp":
|
||||
event.preventDefault()
|
||||
this.currentSuggestionIndex =
|
||||
this.currentSuggestionIndex - 1 >= 0
|
||||
? this.currentSuggestionIndex - 1
|
||||
: 0
|
||||
break
|
||||
|
||||
case "ArrowDown":
|
||||
event.preventDefault()
|
||||
this.currentSuggestionIndex =
|
||||
this.currentSuggestionIndex < this.suggestions.length - 1
|
||||
? this.currentSuggestionIndex + 1
|
||||
: this.suggestions.length - 1
|
||||
break
|
||||
|
||||
case "Tab": {
|
||||
const activeSuggestion =
|
||||
this.suggestions[
|
||||
this.currentSuggestionIndex >= 0 ? this.currentSuggestionIndex : 0
|
||||
]
|
||||
|
||||
if (!activeSuggestion) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const input = this.text.substring(0, this.selectionStart)
|
||||
this.text = input + activeSuggestion
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.autocomplete-wrapper {
|
||||
@apply relative;
|
||||
@apply contents;
|
||||
|
||||
input:focus + ul.suggestions,
|
||||
ul.suggestions:hover {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
ul.suggestions {
|
||||
@apply hidden;
|
||||
@apply bg-popover;
|
||||
@apply absolute;
|
||||
@apply mx-2;
|
||||
@apply left-0;
|
||||
@apply z-50;
|
||||
@apply shadow-lg;
|
||||
@apply max-h-46;
|
||||
@apply overflow-y-auto;
|
||||
top: calc(100% - 4px);
|
||||
border-radius: 0 0 8px 8px;
|
||||
|
||||
li {
|
||||
@apply w-full;
|
||||
@apply block;
|
||||
@apply py-2 px-4;
|
||||
@apply text-secondary;
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
@apply bg-accentDark;
|
||||
@apply text-accentContrast;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-col overflow-hidden space-y-2"
|
||||
:class="expand ? 'h-full' : 'max-h-32'"
|
||||
>
|
||||
<slot name="body"></slot>
|
||||
<div class="sticky inset-x-0 bottom-0 flex items-center justify-center">
|
||||
<ButtonSecondary
|
||||
:icon="expand ? IconChevronUp : IconChevronDown"
|
||||
:label="expand ? t('action.less') : t('action.more')"
|
||||
filled
|
||||
rounded
|
||||
@click="expand = !expand"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconChevronUp from "~icons/lucide/chevron-up"
|
||||
import IconChevronDown from "~icons/lucide/chevron-down"
|
||||
import { ref } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const expand = ref(false)
|
||||
</script>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<span class="chip">
|
||||
<component :is="IconFile" class="opacity-75 svg-icons" />
|
||||
<span class="px-2 truncate max-w-32"><slot></slot></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconFile from "~icons/lucide/file"
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chip {
|
||||
@apply inline-flex;
|
||||
@apply items-center;
|
||||
@apply justify-center;
|
||||
@apply rounded;
|
||||
@apply pl-2;
|
||||
@apply pr-0.5;
|
||||
@apply bg-primaryDark;
|
||||
}
|
||||
</style>
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<SmartLink
|
||||
:to="to"
|
||||
:exact="exact"
|
||||
:blank="blank"
|
||||
class="inline-flex items-center flex-shrink-0 px-4 py-2 rounded transition hover:bg-primaryDark hover:text-secondaryDark focus:outline-none focus-visible:bg-primaryDark focus-visible:text-secondaryDark"
|
||||
:class="[
|
||||
{ 'opacity-75 cursor-not-allowed': disabled },
|
||||
{ 'pointer-events-none': loading },
|
||||
{ 'flex-1': label },
|
||||
{ 'flex-row-reverse justify-end': reverse },
|
||||
{
|
||||
'border border-divider hover:border-dividerDark focus-visible:border-dividerDark':
|
||||
outline,
|
||||
},
|
||||
]"
|
||||
:disabled="disabled"
|
||||
:tabindex="loading ? '-1' : '0'"
|
||||
role="menuitem"
|
||||
>
|
||||
<span
|
||||
v-if="!loading"
|
||||
class="inline-flex items-center"
|
||||
:class="{ 'self-start': !!infoIcon }"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
v-if="icon"
|
||||
class="opacity-75 svg-icons"
|
||||
:class="[
|
||||
label ? (reverse ? 'ml-4' : 'mr-4') : '',
|
||||
{ 'text-accent': active },
|
||||
]"
|
||||
/>
|
||||
</span>
|
||||
<SmartSpinner v-else class="mr-4 text-secondaryDark" />
|
||||
<div
|
||||
class="inline-flex items-start flex-1 truncate"
|
||||
:class="{ 'flex-col': description }"
|
||||
>
|
||||
<div class="font-semibold truncate">
|
||||
{{ label }}
|
||||
</div>
|
||||
<p v-if="description" class="my-2 text-left text-secondaryLight">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<component
|
||||
:is="infoIcon"
|
||||
v-if="infoIcon"
|
||||
class="items-center self-center ml-4 svg-icons"
|
||||
:class="{ 'text-accent': activeInfoIcon }"
|
||||
/>
|
||||
<div v-if="shortcut.length" class="ml-4 <sm:hidden font-medium">
|
||||
<kbd
|
||||
v-for="(key, index) in shortcut"
|
||||
:key="`key-${index}`"
|
||||
class="-mr-2 shortcut-key"
|
||||
>
|
||||
{{ key }}
|
||||
</kbd>
|
||||
</div>
|
||||
</SmartLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
to: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
exact: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
blank: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
/**
|
||||
* This will be a component!
|
||||
*/
|
||||
icon: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
/**
|
||||
* This will be a component!
|
||||
*/
|
||||
svg: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
reverse: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
outline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shortcut: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
activeInfoIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* This will be a component!
|
||||
*/
|
||||
infoIcon: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<icon-lucide-loader class="animate-spin svg-icons" />
|
||||
</template>
|
||||
@@ -1,80 +0,0 @@
|
||||
/* An `action` is a unique verb that is associated with certain thing that can be done on Hoppscotch.
|
||||
* For example, sending a request.
|
||||
*/
|
||||
|
||||
import { onBeforeUnmount, onMounted } from "vue"
|
||||
import { BehaviorSubject } from "rxjs"
|
||||
|
||||
export type HoppAction =
|
||||
| "request.send-cancel" // Send/Cancel a Hoppscotch Request
|
||||
| "request.reset" // Clear request data
|
||||
| "request.copy-link" // Copy Request Link
|
||||
| "request.save" // Save to Collections
|
||||
| "request.save-as" // Save As
|
||||
| "request.method.next" // Select Next Method
|
||||
| "request.method.prev" // Select Previous Method
|
||||
| "request.method.get" // Select GET Method
|
||||
| "request.method.head" // Select HEAD Method
|
||||
| "request.method.post" // Select POST Method
|
||||
| "request.method.put" // Select PUT Method
|
||||
| "request.method.delete" // Select DELETE Method
|
||||
| "flyouts.keybinds.toggle" // Shows the keybinds flyout
|
||||
| "modals.search.toggle" // Shows the search modal
|
||||
| "modals.support.toggle" // Shows the support modal
|
||||
| "modals.share.toggle" // Shows the share modal
|
||||
| "navigation.jump.rest" // Jump to REST page
|
||||
| "navigation.jump.graphql" // Jump to GraphQL page
|
||||
| "navigation.jump.realtime" // Jump to realtime page
|
||||
| "navigation.jump.documentation" // Jump to documentation page
|
||||
| "navigation.jump.settings" // Jump to settings page
|
||||
| "navigation.jump.profile" // Jump to profile page
|
||||
| "settings.theme.system" // Use system theme
|
||||
| "settings.theme.light" // Use light theme
|
||||
| "settings.theme.dark" // Use dark theme
|
||||
| "settings.theme.black" // Use black theme
|
||||
| "response.preview.toggle" // Toggle response preview
|
||||
| "response.file.download" // Download response as file
|
||||
| "response.copy" // Copy response to clipboard
|
||||
|
||||
type BoundActionList = {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
[_ in HoppAction]?: Array<() => void>
|
||||
}
|
||||
|
||||
const boundActions: BoundActionList = {}
|
||||
|
||||
export const activeActions$ = new BehaviorSubject<HoppAction[]>([])
|
||||
|
||||
export function bindAction(action: HoppAction, handler: () => void) {
|
||||
if (boundActions[action]) {
|
||||
boundActions[action]?.push(handler)
|
||||
} else {
|
||||
boundActions[action] = [handler]
|
||||
}
|
||||
|
||||
activeActions$.next(Object.keys(boundActions) as HoppAction[])
|
||||
}
|
||||
|
||||
export function invokeAction(action: HoppAction) {
|
||||
boundActions[action]?.forEach((handler) => handler())
|
||||
}
|
||||
|
||||
export function unbindAction(action: HoppAction, handler: () => void) {
|
||||
boundActions[action] = boundActions[action]?.filter((x) => x !== handler)
|
||||
|
||||
if (boundActions[action]?.length === 0) {
|
||||
delete boundActions[action]
|
||||
}
|
||||
|
||||
activeActions$.next(Object.keys(boundActions) as HoppAction[])
|
||||
}
|
||||
|
||||
export function defineActionHandler(action: HoppAction, handler: () => void) {
|
||||
onMounted(() => {
|
||||
bindAction(action, handler)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unbindAction(action, handler)
|
||||
})
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation MoveRESTTeamRequest($requestID: ID!, $collectionID: ID!) {
|
||||
moveRequest(requestID: $requestID, destCollID: $collectionID) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { runMutation } from "../GQLClient"
|
||||
import {
|
||||
MoveRestTeamRequestDocument,
|
||||
MoveRestTeamRequestMutation,
|
||||
MoveRestTeamRequestMutationVariables,
|
||||
} from "../graphql"
|
||||
|
||||
type MoveRestTeamRequestErrors =
|
||||
| "team_req/not_found"
|
||||
| "team_req/invalid_target_id"
|
||||
|
||||
export const moveRESTTeamRequest = (requestID: string, collectionID: string) =>
|
||||
runMutation<
|
||||
MoveRestTeamRequestMutation,
|
||||
MoveRestTeamRequestMutationVariables,
|
||||
MoveRestTeamRequestErrors
|
||||
>(MoveRestTeamRequestDocument, {
|
||||
requestID,
|
||||
collectionID,
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import {
|
||||
Analytics,
|
||||
getAnalytics,
|
||||
logEvent,
|
||||
setAnalyticsCollectionEnabled,
|
||||
setUserId,
|
||||
setUserProperties,
|
||||
} from "firebase/analytics"
|
||||
import { authEvents$ } from "./auth"
|
||||
import {
|
||||
HoppAccentColor,
|
||||
HoppBgColor,
|
||||
settings$,
|
||||
settingsStore,
|
||||
} from "~/newstore/settings"
|
||||
|
||||
let analytics: Analytics | null = null
|
||||
|
||||
type SettingsCustomDimensions = {
|
||||
usesProxy: boolean
|
||||
usesExtension: boolean
|
||||
syncCollections: boolean
|
||||
syncEnvironments: boolean
|
||||
syncHistory: boolean
|
||||
usesBg: HoppBgColor
|
||||
usesAccent: HoppAccentColor
|
||||
usesTelemetry: boolean
|
||||
}
|
||||
|
||||
type HoppRequestEvent =
|
||||
| {
|
||||
platform: "rest" | "graphql-query" | "graphql-schema"
|
||||
strategy: "normal" | "proxy" | "extension"
|
||||
}
|
||||
| { platform: "wss" | "sse" | "socketio" | "mqtt" }
|
||||
|
||||
export function initAnalytics() {
|
||||
analytics = getAnalytics()
|
||||
|
||||
initLoginListeners()
|
||||
initSettingsListeners()
|
||||
}
|
||||
|
||||
function initLoginListeners() {
|
||||
authEvents$.subscribe((ev) => {
|
||||
if (ev.event === "login") {
|
||||
if (settingsStore.value.TELEMETRY_ENABLED && analytics) {
|
||||
setUserId(analytics, ev.user.uid)
|
||||
|
||||
logEvent(analytics, "login", {
|
||||
method: ev.user.providerData[0]?.providerId, // Assume the first provider is the login provider
|
||||
})
|
||||
}
|
||||
} else if (ev.event === "logout") {
|
||||
if (settingsStore.value.TELEMETRY_ENABLED && analytics) {
|
||||
logEvent(analytics, "logout")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function initSettingsListeners() {
|
||||
// Keep track of the telemetry status
|
||||
let telemetryStatus = settingsStore.value.TELEMETRY_ENABLED
|
||||
|
||||
settings$.subscribe((settings) => {
|
||||
const conf: SettingsCustomDimensions = {
|
||||
usesProxy: settings.PROXY_ENABLED,
|
||||
usesExtension: settings.EXTENSIONS_ENABLED,
|
||||
syncCollections: settings.syncCollections,
|
||||
syncEnvironments: settings.syncEnvironments,
|
||||
syncHistory: settings.syncHistory,
|
||||
usesAccent: settings.THEME_COLOR,
|
||||
usesBg: settings.BG_COLOR,
|
||||
usesTelemetry: settings.TELEMETRY_ENABLED,
|
||||
}
|
||||
|
||||
// User toggled telemetry mode to off or to on
|
||||
if (
|
||||
((telemetryStatus && !settings.TELEMETRY_ENABLED) ||
|
||||
settings.TELEMETRY_ENABLED) &&
|
||||
analytics
|
||||
) {
|
||||
setUserProperties(analytics, conf)
|
||||
}
|
||||
|
||||
telemetryStatus = settings.TELEMETRY_ENABLED
|
||||
|
||||
if (analytics) setAnalyticsCollectionEnabled(analytics, telemetryStatus)
|
||||
})
|
||||
|
||||
if (analytics) setAnalyticsCollectionEnabled(analytics, telemetryStatus)
|
||||
}
|
||||
|
||||
export function logHoppRequestRunToAnalytics(ev: HoppRequestEvent) {
|
||||
if (settingsStore.value.TELEMETRY_ENABLED && analytics) {
|
||||
logEvent(analytics, "hopp-request", ev)
|
||||
}
|
||||
}
|
||||
|
||||
export function logPageView(pagePath: string) {
|
||||
if (settingsStore.value.TELEMETRY_ENABLED && analytics) {
|
||||
logEvent(analytics, "page_view", {
|
||||
page_path: pagePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
import {
|
||||
User,
|
||||
getAuth,
|
||||
onAuthStateChanged,
|
||||
onIdTokenChanged,
|
||||
signInWithPopup,
|
||||
GoogleAuthProvider,
|
||||
GithubAuthProvider,
|
||||
OAuthProvider,
|
||||
signInWithEmailAndPassword as signInWithEmailAndPass,
|
||||
isSignInWithEmailLink as isSignInWithEmailLinkFB,
|
||||
fetchSignInMethodsForEmail,
|
||||
sendSignInLinkToEmail,
|
||||
signInWithEmailLink as signInWithEmailLinkFB,
|
||||
ActionCodeSettings,
|
||||
signOut,
|
||||
linkWithCredential,
|
||||
AuthCredential,
|
||||
AuthError,
|
||||
UserCredential,
|
||||
updateProfile,
|
||||
updateEmail,
|
||||
sendEmailVerification,
|
||||
reauthenticateWithCredential,
|
||||
} from "firebase/auth"
|
||||
import {
|
||||
onSnapshot,
|
||||
getFirestore,
|
||||
setDoc,
|
||||
doc,
|
||||
updateDoc,
|
||||
} from "firebase/firestore"
|
||||
import { BehaviorSubject, filter, Subject, Subscription } from "rxjs"
|
||||
import {
|
||||
setLocalConfig,
|
||||
getLocalConfig,
|
||||
removeLocalConfig,
|
||||
} from "~/newstore/localpersistence"
|
||||
|
||||
export type HoppUser = User & {
|
||||
provider?: string
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
export type AuthEvent =
|
||||
| { event: "probable_login"; user: HoppUser } // We have previous login state, but the app is waiting for authentication
|
||||
| { event: "login"; user: HoppUser } // We are authenticated
|
||||
| { event: "logout" } // No authentication and we have no previous state
|
||||
| { event: "authTokenUpdate"; user: HoppUser; newToken: string | null } // Token has been updated
|
||||
|
||||
/**
|
||||
* A BehaviorSubject emitting the currently logged in user (or null if not logged in)
|
||||
*/
|
||||
export const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
|
||||
/**
|
||||
* A BehaviorSubject emitting the current idToken
|
||||
*/
|
||||
export const authIdToken$ = new BehaviorSubject<string | null>(null)
|
||||
|
||||
/**
|
||||
* A subject that emits events related to authentication flows
|
||||
*/
|
||||
export const authEvents$ = new Subject<AuthEvent>()
|
||||
|
||||
/**
|
||||
* Like currentUser$ but also gives probable user value
|
||||
*/
|
||||
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
|
||||
|
||||
/**
|
||||
* Resolves when the probable login resolves into proper login
|
||||
*/
|
||||
export const waitProbableLoginToConfirm = () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (authIdToken$.value) resolve()
|
||||
|
||||
if (!probableUser$.value) reject(new Error("no_probable_user"))
|
||||
|
||||
let sub: Subscription | null = null
|
||||
|
||||
sub = authIdToken$.pipe(filter((token) => !!token)).subscribe(() => {
|
||||
sub?.unsubscribe()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Initializes the firebase authentication related subjects
|
||||
*/
|
||||
export function initAuth() {
|
||||
const auth = getAuth()
|
||||
const firestore = getFirestore()
|
||||
|
||||
let extraSnapshotStop: (() => void) | null = null
|
||||
|
||||
probableUser$.next(JSON.parse(getLocalConfig("login_state") ?? "null"))
|
||||
|
||||
onAuthStateChanged(auth, (user) => {
|
||||
/** Whether the user was logged in before */
|
||||
const wasLoggedIn = currentUser$.value !== null
|
||||
|
||||
if (user) {
|
||||
probableUser$.next(user)
|
||||
} else {
|
||||
probableUser$.next(null)
|
||||
removeLocalConfig("login_state")
|
||||
}
|
||||
|
||||
if (!user && extraSnapshotStop) {
|
||||
extraSnapshotStop()
|
||||
extraSnapshotStop = null
|
||||
} else if (user) {
|
||||
// Merge all the user info from all the authenticated providers
|
||||
user.providerData.forEach((profile) => {
|
||||
if (!profile) return
|
||||
|
||||
const us = {
|
||||
updatedOn: new Date(),
|
||||
provider: profile.providerId,
|
||||
name: profile.displayName,
|
||||
email: profile.email,
|
||||
photoUrl: profile.photoURL,
|
||||
uid: profile.uid,
|
||||
}
|
||||
|
||||
setDoc(doc(firestore, "users", user.uid), us, { merge: true }).catch(
|
||||
(e) => console.error("error updating", us, e)
|
||||
)
|
||||
})
|
||||
|
||||
extraSnapshotStop = onSnapshot(
|
||||
doc(firestore, "users", user.uid),
|
||||
(doc) => {
|
||||
const data = doc.data()
|
||||
|
||||
const userUpdate: HoppUser = user
|
||||
|
||||
if (data) {
|
||||
// Write extra provider data
|
||||
userUpdate.provider = data.provider
|
||||
userUpdate.accessToken = data.accessToken
|
||||
}
|
||||
|
||||
currentUser$.next(userUpdate)
|
||||
}
|
||||
)
|
||||
}
|
||||
currentUser$.next(user)
|
||||
|
||||
// User wasn't found before, but now is there (login happened)
|
||||
if (!wasLoggedIn && user) {
|
||||
authEvents$.next({
|
||||
event: "login",
|
||||
user: currentUser$.value!,
|
||||
})
|
||||
} else if (wasLoggedIn && !user) {
|
||||
// User was found before, but now is not there (logout happened)
|
||||
authEvents$.next({
|
||||
event: "logout",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onIdTokenChanged(auth, async (user) => {
|
||||
if (user) {
|
||||
authIdToken$.next(await user.getIdToken())
|
||||
|
||||
authEvents$.next({
|
||||
event: "authTokenUpdate",
|
||||
newToken: authIdToken$.value,
|
||||
user: currentUser$.value!, // Force not-null because user is defined
|
||||
})
|
||||
|
||||
setLocalConfig("login_state", JSON.stringify(user))
|
||||
} else {
|
||||
authIdToken$.next(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getAuthIDToken(): string | null {
|
||||
return authIdToken$.getValue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign user in with a popup using Google
|
||||
*/
|
||||
export async function signInUserWithGoogle() {
|
||||
return await signInWithPopup(getAuth(), new GoogleAuthProvider())
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign user in with a popup using Github
|
||||
*/
|
||||
export async function signInUserWithGithub() {
|
||||
return await signInWithPopup(
|
||||
getAuth(),
|
||||
new GithubAuthProvider().addScope("gist")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign user in with a popup using Microsoft
|
||||
*/
|
||||
export async function signInUserWithMicrosoft() {
|
||||
return await signInWithPopup(getAuth(), new OAuthProvider("microsoft.com"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign user in with email and password
|
||||
*/
|
||||
export async function signInWithEmailAndPassword(
|
||||
email: string,
|
||||
password: string
|
||||
) {
|
||||
return await signInWithEmailAndPass(getAuth(), email, password)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sign in methods for a given email address
|
||||
*
|
||||
* @param email - Email to get the methods of
|
||||
*
|
||||
* @returns Promise for string array of the auth provider methods accessible
|
||||
*/
|
||||
export async function getSignInMethodsForEmail(email: string) {
|
||||
return await fetchSignInMethodsForEmail(getAuth(), email)
|
||||
}
|
||||
|
||||
export async function linkWithFBCredential(
|
||||
user: User,
|
||||
credential: AuthCredential
|
||||
) {
|
||||
return await linkWithCredential(user, credential)
|
||||
}
|
||||
|
||||
/**
|
||||
* Links account with another account given in a auth/account-exists-with-different-credential error
|
||||
*
|
||||
* @param error - Error caught after trying to login
|
||||
*
|
||||
* @returns Promise of UserCredential
|
||||
*/
|
||||
export async function linkWithFBCredentialFromAuthError(error: unknown) {
|
||||
// credential is not null since this function is called after an auth/account-exists-with-different-credential error, ie credentials actually exist
|
||||
const credentials = OAuthProvider.credentialFromError(error as AuthError)!
|
||||
|
||||
const otherLinkedProviders = (
|
||||
await getSignInMethodsForEmail((error as AuthError).customData.email!)
|
||||
).filter((providerId) => credentials.providerId !== providerId)
|
||||
|
||||
let user: User | null = null
|
||||
|
||||
if (otherLinkedProviders.indexOf("google.com") >= -1) {
|
||||
user = (await signInUserWithGoogle()).user
|
||||
} else if (otherLinkedProviders.indexOf("github.com") >= -1) {
|
||||
user = (await signInUserWithGithub()).user
|
||||
} else if (otherLinkedProviders.indexOf("microsoft.com") >= -1) {
|
||||
user = (await signInUserWithMicrosoft()).user
|
||||
}
|
||||
|
||||
// user is not null since going through each provider will return a user
|
||||
return await linkWithCredential(user!, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an email with the signin link to the user
|
||||
*
|
||||
* @param email - Email to send the email to
|
||||
* @param actionCodeSettings - The settings to apply to the link
|
||||
*/
|
||||
export async function signInWithEmail(
|
||||
email: string,
|
||||
actionCodeSettings: ActionCodeSettings
|
||||
) {
|
||||
return await sendSignInLinkToEmail(getAuth(), email, actionCodeSettings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks and returns whether the sign in link is an email link
|
||||
*
|
||||
* @param url - The URL to look in
|
||||
*/
|
||||
export function isSignInWithEmailLink(url: string) {
|
||||
return isSignInWithEmailLinkFB(getAuth(), url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an email with sign in with email link
|
||||
*
|
||||
* @param email - Email to log in to
|
||||
* @param url - The action URL which is used to validate login
|
||||
*/
|
||||
export async function signInWithEmailLink(email: string, url: string) {
|
||||
return await signInWithEmailLinkFB(getAuth(), email, url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs out the user
|
||||
*/
|
||||
export async function signOutUser() {
|
||||
if (!currentUser$.value) throw new Error("No user has logged in")
|
||||
|
||||
await signOut(getAuth())
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the provider id and relevant provider auth token
|
||||
* as user metadata
|
||||
*
|
||||
* @param id - The provider ID
|
||||
* @param token - The relevant auth token for the given provider
|
||||
*/
|
||||
export async function setProviderInfo(id: string, token: string) {
|
||||
if (!currentUser$.value) throw new Error("No user has logged in")
|
||||
|
||||
const us = {
|
||||
updatedOn: new Date(),
|
||||
provider: id,
|
||||
accessToken: token,
|
||||
}
|
||||
|
||||
try {
|
||||
await updateDoc(
|
||||
doc(getFirestore(), "users", currentUser$.value.uid),
|
||||
us
|
||||
).catch((e) => console.error("error updating", us, e))
|
||||
} catch (e) {
|
||||
console.error("error updating", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user's display name
|
||||
*
|
||||
* @param name - The new display name
|
||||
*/
|
||||
export async function setDisplayName(name: string) {
|
||||
if (!currentUser$.value) throw new Error("No user has logged in")
|
||||
|
||||
const us = {
|
||||
displayName: name,
|
||||
}
|
||||
|
||||
try {
|
||||
await updateProfile(currentUser$.value, us)
|
||||
} catch (e) {
|
||||
console.error("error updating", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send user's email address verification mail
|
||||
*/
|
||||
export async function verifyEmailAddress() {
|
||||
if (!currentUser$.value) throw new Error("No user has logged in")
|
||||
|
||||
try {
|
||||
await sendEmailVerification(currentUser$.value)
|
||||
} catch (e) {
|
||||
console.error("error updating", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user's email address
|
||||
*
|
||||
* @param email - The new email address
|
||||
*/
|
||||
export async function setEmailAddress(email: string) {
|
||||
if (!currentUser$.value) throw new Error("No user has logged in")
|
||||
|
||||
try {
|
||||
await updateEmail(currentUser$.value, email)
|
||||
} catch (e) {
|
||||
await reauthenticateUser()
|
||||
console.error("error updating", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reauthenticate the user with the given credential
|
||||
*/
|
||||
async function reauthenticateUser() {
|
||||
if (!currentUser$.value) throw new Error("No user has logged in")
|
||||
const currentAuthMethod = currentUser$.value.provider
|
||||
let credential
|
||||
if (currentAuthMethod === "google.com") {
|
||||
const result = await signInUserWithGithub()
|
||||
credential = GithubAuthProvider.credentialFromResult(result)
|
||||
} else if (currentAuthMethod === "github.com") {
|
||||
const result = await signInUserWithGoogle()
|
||||
credential = GoogleAuthProvider.credentialFromResult(result)
|
||||
} else if (currentAuthMethod === "microsoft.com") {
|
||||
const result = await signInUserWithMicrosoft()
|
||||
credential = OAuthProvider.credentialFromResult(result)
|
||||
} else if (currentAuthMethod === "password") {
|
||||
const email = prompt(
|
||||
"Reauthenticate your account using your current email:"
|
||||
)
|
||||
const actionCodeSettings = {
|
||||
url: `${process.env.BASE_URL}/enter`,
|
||||
handleCodeInApp: true,
|
||||
}
|
||||
await signInWithEmail(email as string, actionCodeSettings)
|
||||
.then(() =>
|
||||
alert(
|
||||
`Check your inbox - we sent an email to ${email}. It contains a magic link that will reauthenticate your account.`
|
||||
)
|
||||
)
|
||||
.catch((e) => {
|
||||
alert(`Error: ${e.message}`)
|
||||
console.error(e)
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
await reauthenticateWithCredential(
|
||||
currentUser$.value,
|
||||
credential as AuthCredential
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("error updating", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export function getGithubCredentialFromResult(result: UserCredential) {
|
||||
return GithubAuthProvider.credentialFromResult(result)
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import {
|
||||
collection,
|
||||
doc,
|
||||
getFirestore,
|
||||
onSnapshot,
|
||||
setDoc,
|
||||
} from "firebase/firestore"
|
||||
import {
|
||||
translateToNewRESTCollection,
|
||||
translateToNewGQLCollection,
|
||||
} from "@hoppscotch/data"
|
||||
import { currentUser$ } from "./auth"
|
||||
import {
|
||||
restCollections$,
|
||||
graphqlCollections$,
|
||||
setRESTCollections,
|
||||
setGraphqlCollections,
|
||||
} from "~/newstore/collections"
|
||||
import { getSettingSubject, settingsStore } from "~/newstore/settings"
|
||||
|
||||
type CollectionFlags = "collectionsGraphql" | "collections"
|
||||
|
||||
/**
|
||||
* Whether the collections are loaded. If this is set to true
|
||||
* Updates to the collections store are written into firebase.
|
||||
*
|
||||
* If you have want to update the store and not fire the store update
|
||||
* subscription, set this variable to false, do the update and then
|
||||
* set it to true
|
||||
*/
|
||||
let loadedRESTCollections = false
|
||||
|
||||
/**
|
||||
* Whether the collections are loaded. If this is set to true
|
||||
* Updates to the collections store are written into firebase.
|
||||
*
|
||||
* If you have want to update the store and not fire the store update
|
||||
* subscription, set this variable to false, do the update and then
|
||||
* set it to true
|
||||
*/
|
||||
let loadedGraphqlCollections = false
|
||||
|
||||
export async function writeCollections(
|
||||
collection: any[],
|
||||
flag: CollectionFlags
|
||||
) {
|
||||
if (currentUser$.value === null)
|
||||
throw new Error("User not logged in to write collections")
|
||||
|
||||
const cl = {
|
||||
updatedOn: new Date(),
|
||||
author: currentUser$.value.uid,
|
||||
author_name: currentUser$.value.displayName,
|
||||
author_image: currentUser$.value.photoURL,
|
||||
collection,
|
||||
}
|
||||
|
||||
try {
|
||||
await setDoc(
|
||||
doc(getFirestore(), "users", currentUser$.value.uid, flag, "sync"),
|
||||
cl
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("error updating", cl, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export function initCollections() {
|
||||
const restCollSub = restCollections$.subscribe((collections) => {
|
||||
if (
|
||||
loadedRESTCollections &&
|
||||
currentUser$.value &&
|
||||
settingsStore.value.syncCollections
|
||||
) {
|
||||
writeCollections(collections, "collections")
|
||||
}
|
||||
})
|
||||
|
||||
const gqlCollSub = graphqlCollections$.subscribe((collections) => {
|
||||
if (
|
||||
loadedGraphqlCollections &&
|
||||
currentUser$.value &&
|
||||
settingsStore.value.syncCollections
|
||||
) {
|
||||
writeCollections(collections, "collectionsGraphql")
|
||||
}
|
||||
})
|
||||
|
||||
let restSnapshotStop: (() => void) | null = null
|
||||
let graphqlSnapshotStop: (() => void) | null = null
|
||||
|
||||
const currentUserSub = currentUser$.subscribe((user) => {
|
||||
if (!user) {
|
||||
if (restSnapshotStop) {
|
||||
restSnapshotStop()
|
||||
restSnapshotStop = null
|
||||
}
|
||||
|
||||
if (graphqlSnapshotStop) {
|
||||
graphqlSnapshotStop()
|
||||
graphqlSnapshotStop = null
|
||||
}
|
||||
} else {
|
||||
restSnapshotStop = onSnapshot(
|
||||
collection(getFirestore(), "users", user.uid, "collections"),
|
||||
(collectionsRef) => {
|
||||
const collections: any[] = []
|
||||
collectionsRef.forEach((doc) => {
|
||||
const collection = doc.data()
|
||||
collection.id = doc.id
|
||||
collections.push(collection)
|
||||
})
|
||||
|
||||
// Prevent infinite ping-pong of updates
|
||||
loadedRESTCollections = false
|
||||
|
||||
// TODO: Wth is with collections[0]
|
||||
if (collections.length > 0 && settingsStore.value.syncCollections) {
|
||||
setRESTCollections(
|
||||
(collections[0].collection ?? []).map(
|
||||
translateToNewRESTCollection
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
loadedRESTCollections = true
|
||||
}
|
||||
)
|
||||
|
||||
graphqlSnapshotStop = onSnapshot(
|
||||
collection(getFirestore(), "users", user.uid, "collectionsGraphql"),
|
||||
(collectionsRef) => {
|
||||
const collections: any[] = []
|
||||
collectionsRef.forEach((doc) => {
|
||||
const collection = doc.data()
|
||||
collection.id = doc.id
|
||||
collections.push(collection)
|
||||
})
|
||||
|
||||
// Prevent infinite ping-pong of updates
|
||||
loadedGraphqlCollections = false
|
||||
|
||||
// TODO: Wth is with collections[0]
|
||||
if (collections.length > 0 && settingsStore.value.syncCollections) {
|
||||
setGraphqlCollections(
|
||||
(collections[0].collection ?? []).map(translateToNewGQLCollection)
|
||||
)
|
||||
}
|
||||
|
||||
loadedGraphqlCollections = true
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
let oldSyncStatus = settingsStore.value.syncCollections
|
||||
|
||||
const syncStop = getSettingSubject("syncCollections").subscribe(
|
||||
(newStatus) => {
|
||||
if (oldSyncStatus === true && newStatus === false) {
|
||||
restSnapshotStop?.()
|
||||
graphqlSnapshotStop?.()
|
||||
|
||||
oldSyncStatus = newStatus
|
||||
} else if (oldSyncStatus === false && newStatus === true) {
|
||||
syncStop.unsubscribe()
|
||||
restCollSub.unsubscribe()
|
||||
gqlCollSub.unsubscribe()
|
||||
currentUserSub.unsubscribe()
|
||||
|
||||
initCollections()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import {
|
||||
collection,
|
||||
doc,
|
||||
getFirestore,
|
||||
onSnapshot,
|
||||
setDoc,
|
||||
} from "firebase/firestore"
|
||||
import { currentUser$ } from "./auth"
|
||||
import {
|
||||
environments$,
|
||||
globalEnv$,
|
||||
replaceEnvironments,
|
||||
setGlobalEnvVariables,
|
||||
} from "~/newstore/environments"
|
||||
import { getSettingSubject, settingsStore } from "~/newstore/settings"
|
||||
|
||||
/**
|
||||
* Used locally to prevent infinite loop when environment sync update
|
||||
* is applied to the store which then fires the store sync listener.
|
||||
* When you want to update environments and not want to fire the update listener,
|
||||
* set this to true and then set it back to false once it is done
|
||||
*/
|
||||
let loadedEnvironments = false
|
||||
|
||||
/**
|
||||
* Used locally to prevent infinite loop when global env sync update
|
||||
* is applied to the store which then fires the store sync listener.
|
||||
* When you want to update global env and not want to fire the update listener,
|
||||
* set this to true and then set it back to false once it is done
|
||||
*/
|
||||
let loadedGlobals = true
|
||||
|
||||
async function writeEnvironments(environment: Environment[]) {
|
||||
if (currentUser$.value == null)
|
||||
throw new Error("Cannot write environments when signed out")
|
||||
|
||||
const ev = {
|
||||
updatedOn: new Date(),
|
||||
author: currentUser$.value.uid,
|
||||
author_name: currentUser$.value.displayName,
|
||||
author_image: currentUser$.value.photoURL,
|
||||
environment,
|
||||
}
|
||||
|
||||
try {
|
||||
await setDoc(
|
||||
doc(
|
||||
getFirestore(),
|
||||
"users",
|
||||
currentUser$.value.uid,
|
||||
"environments",
|
||||
"sync"
|
||||
),
|
||||
ev
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("error updating", ev, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function writeGlobalEnvironment(variables: Environment["variables"]) {
|
||||
if (currentUser$.value == null)
|
||||
throw new Error("Cannot write global environment when signed out")
|
||||
|
||||
const ev = {
|
||||
updatedOn: new Date(),
|
||||
author: currentUser$.value.uid,
|
||||
author_name: currentUser$.value.displayName,
|
||||
author_image: currentUser$.value.photoURL,
|
||||
variables,
|
||||
}
|
||||
|
||||
try {
|
||||
await setDoc(
|
||||
doc(getFirestore(), "users", currentUser$.value.uid, "globalEnv", "sync"),
|
||||
ev
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("error updating", ev, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export function initEnvironments() {
|
||||
const envListenSub = environments$.subscribe((envs) => {
|
||||
if (
|
||||
currentUser$.value &&
|
||||
settingsStore.value.syncEnvironments &&
|
||||
loadedEnvironments
|
||||
) {
|
||||
writeEnvironments(envs)
|
||||
}
|
||||
})
|
||||
|
||||
const globalListenSub = globalEnv$.subscribe((vars) => {
|
||||
if (
|
||||
currentUser$.value &&
|
||||
settingsStore.value.syncEnvironments &&
|
||||
loadedGlobals
|
||||
) {
|
||||
writeGlobalEnvironment(vars)
|
||||
}
|
||||
})
|
||||
|
||||
let envSnapshotStop: (() => void) | null = null
|
||||
let globalsSnapshotStop: (() => void) | null = null
|
||||
|
||||
const currentUserSub = currentUser$.subscribe((user) => {
|
||||
if (!user) {
|
||||
// User logged out, clean up snapshot listener
|
||||
if (envSnapshotStop) {
|
||||
envSnapshotStop()
|
||||
envSnapshotStop = null
|
||||
}
|
||||
|
||||
if (globalsSnapshotStop) {
|
||||
globalsSnapshotStop()
|
||||
globalsSnapshotStop = null
|
||||
}
|
||||
} else if (user) {
|
||||
envSnapshotStop = onSnapshot(
|
||||
collection(getFirestore(), "users", user.uid, "environments"),
|
||||
(environmentsRef) => {
|
||||
const environments: any[] = []
|
||||
|
||||
environmentsRef.forEach((doc) => {
|
||||
const environment = doc.data()
|
||||
environment.id = doc.id
|
||||
environments.push(environment)
|
||||
})
|
||||
|
||||
loadedEnvironments = false
|
||||
if (environments.length > 0 && settingsStore.value.syncEnvironments) {
|
||||
replaceEnvironments(environments[0].environment)
|
||||
}
|
||||
loadedEnvironments = true
|
||||
}
|
||||
)
|
||||
globalsSnapshotStop = onSnapshot(
|
||||
collection(getFirestore(), "users", user.uid, "globalEnv"),
|
||||
(globalsRef) => {
|
||||
if (globalsRef.docs.length === 0) {
|
||||
loadedGlobals = true
|
||||
return
|
||||
}
|
||||
|
||||
const doc = globalsRef.docs[0].data()
|
||||
loadedGlobals = false
|
||||
if (settingsStore.value.syncEnvironments)
|
||||
setGlobalEnvVariables(doc.variables)
|
||||
loadedGlobals = true
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
let oldSyncStatus = settingsStore.value.syncEnvironments
|
||||
|
||||
const syncStop = getSettingSubject("syncEnvironments").subscribe(
|
||||
(newStatus) => {
|
||||
if (oldSyncStatus === true && newStatus === false) {
|
||||
envSnapshotStop?.()
|
||||
globalsSnapshotStop?.()
|
||||
|
||||
oldSyncStatus = newStatus
|
||||
} else if (oldSyncStatus === false && newStatus === true) {
|
||||
syncStop.unsubscribe()
|
||||
envListenSub.unsubscribe()
|
||||
globalListenSub.unsubscribe()
|
||||
currentUserSub.unsubscribe()
|
||||
|
||||
initEnvironments()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import {
|
||||
addDoc,
|
||||
collection,
|
||||
deleteDoc,
|
||||
doc,
|
||||
getDocs,
|
||||
getFirestore,
|
||||
limit,
|
||||
onSnapshot,
|
||||
orderBy,
|
||||
query,
|
||||
updateDoc,
|
||||
} from "firebase/firestore"
|
||||
import { FormDataKeyValue } from "@hoppscotch/data"
|
||||
import { currentUser$ } from "./auth"
|
||||
import { getSettingSubject, settingsStore } from "~/newstore/settings"
|
||||
import {
|
||||
GQLHistoryEntry,
|
||||
graphqlHistoryStore,
|
||||
HISTORY_LIMIT,
|
||||
RESTHistoryEntry,
|
||||
restHistoryStore,
|
||||
setGraphqlHistoryEntries,
|
||||
setRESTHistoryEntries,
|
||||
translateToNewGQLHistory,
|
||||
translateToNewRESTHistory,
|
||||
} from "~/newstore/history"
|
||||
|
||||
type HistoryFBCollections = "history" | "graphqlHistory"
|
||||
|
||||
/**
|
||||
* Whether the history are loaded. If this is set to true
|
||||
* Updates to the history store are written into firebase.
|
||||
*
|
||||
* If you have want to update the store and not fire the store update
|
||||
* subscription, set this variable to false, do the update and then
|
||||
* set it to true
|
||||
*/
|
||||
let loadedRESTHistory = false
|
||||
|
||||
/**
|
||||
* Whether the history are loaded. If this is set to true
|
||||
* Updates to the history store are written into firebase.
|
||||
*
|
||||
* If you have want to update the store and not fire the store update
|
||||
* subscription, set this variable to false, do the update and then
|
||||
* set it to true
|
||||
*/
|
||||
let loadedGraphqlHistory = false
|
||||
|
||||
const purgeFormDataFromRequest = (req: RESTHistoryEntry): RESTHistoryEntry => {
|
||||
if (req.request.body.contentType !== "multipart/form-data") return req
|
||||
|
||||
req.request.body.body = req.request.body.body.map<FormDataKeyValue>(
|
||||
(formData) => {
|
||||
if (!formData.isFile) return formData
|
||||
|
||||
return {
|
||||
active: formData.active,
|
||||
isFile: false, // Something we can do to keep the status ?
|
||||
key: formData.key,
|
||||
value: "",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
async function writeHistory(
|
||||
entry: RESTHistoryEntry | GQLHistoryEntry,
|
||||
col: HistoryFBCollections
|
||||
) {
|
||||
const processedEntry =
|
||||
col === "history"
|
||||
? purgeFormDataFromRequest(entry as RESTHistoryEntry)
|
||||
: entry
|
||||
|
||||
if (currentUser$.value == null)
|
||||
throw new Error("User not logged in to sync history")
|
||||
|
||||
const hs = {
|
||||
...processedEntry,
|
||||
updatedOn: new Date(),
|
||||
}
|
||||
|
||||
try {
|
||||
await addDoc(
|
||||
collection(getFirestore(), "users", currentUser$.value.uid, col),
|
||||
hs
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("error writing to history", hs, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHistory(
|
||||
entry: (RESTHistoryEntry | GQLHistoryEntry) & { id: string },
|
||||
col: HistoryFBCollections
|
||||
) {
|
||||
if (currentUser$.value == null)
|
||||
throw new Error("User not logged in to delete history")
|
||||
|
||||
try {
|
||||
await deleteDoc(
|
||||
doc(getFirestore(), "users", currentUser$.value.uid, col, entry.id)
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("error deleting history", entry, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function clearHistory(col: HistoryFBCollections) {
|
||||
if (currentUser$.value == null)
|
||||
throw new Error("User not logged in to clear history")
|
||||
|
||||
const { docs } = await getDocs(
|
||||
collection(getFirestore(), "users", currentUser$.value.uid, col)
|
||||
)
|
||||
|
||||
await Promise.all(docs.map((e) => deleteHistory(e as any, col)))
|
||||
}
|
||||
|
||||
async function toggleStar(
|
||||
entry: (RESTHistoryEntry | GQLHistoryEntry) & { id: string },
|
||||
col: HistoryFBCollections
|
||||
) {
|
||||
if (currentUser$.value == null)
|
||||
throw new Error("User not logged in to toggle star")
|
||||
|
||||
try {
|
||||
await updateDoc(
|
||||
doc(getFirestore(), "users", currentUser$.value.uid, col, entry.id),
|
||||
{ star: !entry.star }
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("error toggling star", entry, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export function initHistory() {
|
||||
const restHistorySub = restHistoryStore.dispatches$.subscribe((dispatch) => {
|
||||
if (
|
||||
loadedRESTHistory &&
|
||||
currentUser$.value &&
|
||||
settingsStore.value.syncHistory
|
||||
) {
|
||||
if (dispatch.dispatcher === "addEntry") {
|
||||
writeHistory(dispatch.payload.entry, "history")
|
||||
} else if (dispatch.dispatcher === "deleteEntry") {
|
||||
deleteHistory(dispatch.payload.entry, "history")
|
||||
} else if (dispatch.dispatcher === "clearHistory") {
|
||||
clearHistory("history")
|
||||
} else if (dispatch.dispatcher === "toggleStar") {
|
||||
toggleStar(dispatch.payload.entry, "history")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const gqlHistorySub = graphqlHistoryStore.dispatches$.subscribe(
|
||||
(dispatch) => {
|
||||
if (
|
||||
loadedGraphqlHistory &&
|
||||
currentUser$.value &&
|
||||
settingsStore.value.syncHistory
|
||||
) {
|
||||
if (dispatch.dispatcher === "addEntry") {
|
||||
writeHistory(dispatch.payload.entry, "graphqlHistory")
|
||||
} else if (dispatch.dispatcher === "deleteEntry") {
|
||||
deleteHistory(dispatch.payload.entry, "graphqlHistory")
|
||||
} else if (dispatch.dispatcher === "clearHistory") {
|
||||
clearHistory("graphqlHistory")
|
||||
} else if (dispatch.dispatcher === "toggleStar") {
|
||||
toggleStar(dispatch.payload.entry, "graphqlHistory")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
let restSnapshotStop: (() => void) | null = null
|
||||
let graphqlSnapshotStop: (() => void) | null = null
|
||||
|
||||
const currentUserSub = currentUser$.subscribe((user) => {
|
||||
if (!user) {
|
||||
// Clear the snapshot listeners when the user logs out
|
||||
if (restSnapshotStop) {
|
||||
restSnapshotStop()
|
||||
restSnapshotStop = null
|
||||
}
|
||||
|
||||
if (graphqlSnapshotStop) {
|
||||
graphqlSnapshotStop()
|
||||
graphqlSnapshotStop = null
|
||||
}
|
||||
} else {
|
||||
restSnapshotStop = onSnapshot(
|
||||
query(
|
||||
collection(getFirestore(), "users", user.uid, "history"),
|
||||
orderBy("updatedOn", "desc"),
|
||||
limit(HISTORY_LIMIT)
|
||||
),
|
||||
(historyRef) => {
|
||||
const history: RESTHistoryEntry[] = []
|
||||
|
||||
historyRef.forEach((doc) => {
|
||||
const entry = doc.data()
|
||||
entry.id = doc.id
|
||||
entry.updatedOn = doc.data().updatedOn.toDate()
|
||||
history.push(translateToNewRESTHistory(entry))
|
||||
})
|
||||
|
||||
loadedRESTHistory = false
|
||||
setRESTHistoryEntries(history)
|
||||
loadedRESTHistory = true
|
||||
}
|
||||
)
|
||||
|
||||
graphqlSnapshotStop = onSnapshot(
|
||||
query(
|
||||
collection(getFirestore(), "users", user.uid, "graphqlHistory"),
|
||||
orderBy("updatedOn", "desc"),
|
||||
limit(HISTORY_LIMIT)
|
||||
),
|
||||
(historyRef) => {
|
||||
const history: GQLHistoryEntry[] = []
|
||||
|
||||
historyRef.forEach((doc) => {
|
||||
const entry = doc.data()
|
||||
entry.id = doc.id
|
||||
entry.updatedOn = doc.data().updatedOn.toDate()
|
||||
history.push(translateToNewGQLHistory(entry))
|
||||
})
|
||||
|
||||
loadedGraphqlHistory = false
|
||||
setGraphqlHistoryEntries(history)
|
||||
loadedGraphqlHistory = true
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
let oldSyncStatus = settingsStore.value.syncHistory
|
||||
|
||||
const syncStop = getSettingSubject("syncHistory").subscribe((newStatus) => {
|
||||
if (oldSyncStatus === true && newStatus === false) {
|
||||
restSnapshotStop?.()
|
||||
graphqlSnapshotStop?.()
|
||||
|
||||
oldSyncStatus = newStatus
|
||||
} else if (oldSyncStatus === false && newStatus === true) {
|
||||
syncStop.unsubscribe()
|
||||
restHistorySub.unsubscribe()
|
||||
gqlHistorySub.unsubscribe()
|
||||
currentUserSub.unsubscribe()
|
||||
|
||||
initHistory()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { initializeApp } from "firebase/app"
|
||||
import { initAnalytics } from "./analytics"
|
||||
import { initAuth } from "./auth"
|
||||
import { initCollections } from "./collections"
|
||||
import { initEnvironments } from "./environments"
|
||||
import { initHistory } from "./history"
|
||||
import { initSettings } from "./settings"
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: import.meta.env.VITE_API_KEY,
|
||||
authDomain: import.meta.env.VITE_AUTH_DOMAIN,
|
||||
databaseURL: import.meta.env.VITE_DATABASE_URL,
|
||||
projectId: import.meta.env.VITE_PROJECT_ID,
|
||||
storageBucket: import.meta.env.VITE_STORAGE_BUCKET,
|
||||
messagingSenderId: import.meta.env.VITE_MESSAGING_SENDER_ID,
|
||||
appId: import.meta.env.VITE_APP_TITLE,
|
||||
measurementId: import.meta.env.VITE_MEASUREMENT_ID,
|
||||
}
|
||||
|
||||
let initialized = false
|
||||
|
||||
export function initializeFirebase() {
|
||||
if (!initialized) {
|
||||
try {
|
||||
initializeApp(firebaseConfig)
|
||||
|
||||
initAuth()
|
||||
initSettings()
|
||||
initCollections()
|
||||
initHistory()
|
||||
initEnvironments()
|
||||
initAnalytics()
|
||||
|
||||
initialized = true
|
||||
} catch (e) {
|
||||
// initializeApp throws exception if we reinitialize
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import {
|
||||
audit,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
from,
|
||||
map,
|
||||
Subscription,
|
||||
} from "rxjs"
|
||||
import { doc, getDoc, getFirestore, setDoc } from "firebase/firestore"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { HoppRESTRequest, translateToNewRequest } from "@hoppscotch/data"
|
||||
import { currentUser$, HoppUser } from "./auth"
|
||||
import { restRequest$ } from "~/newstore/RESTSession"
|
||||
|
||||
/**
|
||||
* Writes a request to a user's firestore sync
|
||||
*
|
||||
* @param user The user to write to
|
||||
* @param request The request to write to the request sync
|
||||
*/
|
||||
function writeCurrentRequest(user: HoppUser, request: HoppRESTRequest) {
|
||||
const req = cloneDeep(request)
|
||||
|
||||
// Remove FormData entries because those can't be stored on Firestore
|
||||
if (req.body.contentType === "multipart/form-data") {
|
||||
req.body.body = req.body.body.map((formData) => {
|
||||
if (!formData.isFile) return formData
|
||||
|
||||
return {
|
||||
active: formData.active,
|
||||
isFile: false,
|
||||
key: formData.key,
|
||||
value: "",
|
||||
}
|
||||
})
|
||||
}
|
||||
return setDoc(doc(getFirestore(), "users", user.uid, "requests", "rest"), req)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the synced request from the firestore sync
|
||||
*
|
||||
* @returns Fetched request object if exists else null
|
||||
*/
|
||||
export async function loadRequestFromSync(): Promise<HoppRESTRequest | null> {
|
||||
const currentUser = currentUser$.value
|
||||
|
||||
if (!currentUser)
|
||||
throw new Error("Cannot load request from sync without login")
|
||||
|
||||
const fbDoc = await getDoc(
|
||||
doc(getFirestore(), "users", currentUser.uid, "requests", "rest")
|
||||
)
|
||||
|
||||
const data = fbDoc.data()
|
||||
|
||||
if (!data) return null
|
||||
else return translateToNewRequest(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs sync of the REST Request session with Firestore.
|
||||
*
|
||||
* @returns A subscription to the sync observable stream.
|
||||
* Unsubscribe to stop syncing.
|
||||
*/
|
||||
export function startRequestSync(): Subscription {
|
||||
const sub = combineLatest([
|
||||
currentUser$,
|
||||
restRequest$.pipe(distinctUntilChanged()),
|
||||
])
|
||||
.pipe(
|
||||
map(([user, request]) =>
|
||||
user ? from(writeCurrentRequest(user, request)) : EMPTY
|
||||
),
|
||||
audit((x) => x)
|
||||
)
|
||||
.subscribe(() => {
|
||||
// NOTE: This subscription should be kept
|
||||
})
|
||||
|
||||
return sub
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
collection,
|
||||
doc,
|
||||
getFirestore,
|
||||
onSnapshot,
|
||||
setDoc,
|
||||
} from "firebase/firestore"
|
||||
import { currentUser$ } from "./auth"
|
||||
import { applySetting, settingsStore, SettingsType } from "~/newstore/settings"
|
||||
|
||||
/**
|
||||
* Used locally to prevent infinite loop when settings sync update
|
||||
* is applied to the store which then fires the store sync listener.
|
||||
* When you want to update settings and not want to fire the update listener,
|
||||
* set this to true and then set it back to false once it is done
|
||||
*/
|
||||
let loadedSettings = false
|
||||
|
||||
/**
|
||||
* Write Transform
|
||||
*/
|
||||
async function writeSettings(setting: string, value: any) {
|
||||
if (currentUser$.value === null)
|
||||
throw new Error("Cannot write setting, user not signed in")
|
||||
|
||||
const st = {
|
||||
updatedOn: new Date(),
|
||||
author: currentUser$.value.uid,
|
||||
author_name: currentUser$.value.displayName,
|
||||
author_image: currentUser$.value.photoURL,
|
||||
name: setting,
|
||||
value,
|
||||
}
|
||||
|
||||
try {
|
||||
await setDoc(
|
||||
doc(getFirestore(), "users", currentUser$.value.uid, "settings", setting),
|
||||
st
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("error updating", st, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export function initSettings() {
|
||||
settingsStore.dispatches$.subscribe((dispatch) => {
|
||||
if (currentUser$.value && loadedSettings) {
|
||||
if (dispatch.dispatcher === "bulkApplySettings") {
|
||||
Object.keys(dispatch.payload).forEach((key) => {
|
||||
writeSettings(key, dispatch.payload[key])
|
||||
})
|
||||
} else {
|
||||
writeSettings(
|
||||
dispatch.payload.settingKey,
|
||||
settingsStore.value[dispatch.payload.settingKey as keyof SettingsType]
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let snapshotStop: (() => void) | null = null
|
||||
|
||||
// Subscribe and unsubscribe event listeners
|
||||
currentUser$.subscribe((user) => {
|
||||
if (!user && snapshotStop) {
|
||||
// User logged out
|
||||
snapshotStop()
|
||||
snapshotStop = null
|
||||
} else if (user) {
|
||||
snapshotStop = onSnapshot(
|
||||
collection(getFirestore(), "users", user.uid, "settings"),
|
||||
(settingsRef) => {
|
||||
const settings: any[] = []
|
||||
|
||||
settingsRef.forEach((doc) => {
|
||||
const setting = doc.data()
|
||||
setting.id = doc.id
|
||||
settings.push(setting)
|
||||
})
|
||||
|
||||
loadedSettings = false
|
||||
settings.forEach((e) => {
|
||||
if (e && e.name && e.value != null) {
|
||||
applySetting(e.name, e.value)
|
||||
}
|
||||
})
|
||||
loadedSettings = true
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { BehaviorSubject } from "rxjs"
|
||||
import { authIdToken$ } from "../fb/auth"
|
||||
import { runGQLQuery } from "../backend/GQLClient"
|
||||
import { GetUserInfoDocument } from "../backend/graphql"
|
||||
|
||||
/*
|
||||
* This file deals with interfacing data provided by the
|
||||
* Hoppscotch Backend server
|
||||
*/
|
||||
|
||||
/**
|
||||
* Defines the information provided about a user
|
||||
*/
|
||||
export interface UserInfo {
|
||||
/**
|
||||
* UID of the user
|
||||
*/
|
||||
uid: string
|
||||
/**
|
||||
* Displayable name of the user (or null if none available)
|
||||
*/
|
||||
displayName: string | null
|
||||
/**
|
||||
* Email of the user (or null if none available)
|
||||
*/
|
||||
email: string | null
|
||||
/**
|
||||
* URL to the profile photo of the user (or null if none available)
|
||||
*/
|
||||
photoURL: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* An observable subject onto the currently logged in user info (is null if not logged in)
|
||||
*/
|
||||
export const currentUserInfo$ = new BehaviorSubject<UserInfo | null>(null)
|
||||
|
||||
/**
|
||||
* Initializes the currenUserInfo$ view and sets up its update mechanism
|
||||
*/
|
||||
export function initUserInfo() {
|
||||
authIdToken$.subscribe((token) => {
|
||||
if (token) {
|
||||
updateUserInfo()
|
||||
} else {
|
||||
currentUserInfo$.next(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the actual user info fetching
|
||||
*/
|
||||
async function updateUserInfo() {
|
||||
const result = await runGQLQuery({
|
||||
query: GetUserInfoDocument,
|
||||
})
|
||||
|
||||
currentUserInfo$.next(
|
||||
pipe(
|
||||
result,
|
||||
E.matchW(
|
||||
() => null,
|
||||
(x) => ({
|
||||
uid: x.me.uid,
|
||||
displayName: x.me.displayName ?? null,
|
||||
email: x.me.email ?? null,
|
||||
photoURL: x.me.photoURL ?? null,
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { createApp } from "vue"
|
||||
import { setupLocalPersistence } from "./newstore/localpersistence"
|
||||
import { performMigrations } from "./helpers/migrations"
|
||||
import { initializeFirebase } from "./helpers/fb"
|
||||
import { initUserInfo } from "./helpers/teams/BackendUserInfo"
|
||||
import { HOPP_MODULES } from "@modules/."
|
||||
|
||||
import "virtual:windi.css"
|
||||
import "../assets/scss/themes.scss"
|
||||
import "../assets/scss/styles.scss"
|
||||
import "nprogress/nprogress.css"
|
||||
|
||||
import App from "./App.vue"
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Some basic work that needs to be done before module inits even
|
||||
initializeFirebase()
|
||||
setupLocalPersistence()
|
||||
performMigrations()
|
||||
initUserInfo()
|
||||
|
||||
HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app))
|
||||
|
||||
app.mount("#app")
|
||||
|
||||
console.info(
|
||||
"%cWe ❤︎ open source!",
|
||||
"background-color:white;padding:8px 16px;border-radius:8px;font-size:32px;color:red;"
|
||||
)
|
||||
console.info(
|
||||
"%cContribute: https://github.com/hoppscotch/hoppscotch",
|
||||
"background-color:black;padding:4px 8px;border-radius:8px;font-size:16px;color:white;"
|
||||
)
|
||||
@@ -1,710 +0,0 @@
|
||||
import { pluck, distinctUntilChanged, map, filter } from "rxjs/operators"
|
||||
import { Ref } from "vue"
|
||||
import {
|
||||
FormDataKeyValue,
|
||||
HoppRESTHeader,
|
||||
HoppRESTParam,
|
||||
HoppRESTReqBody,
|
||||
HoppRESTRequest,
|
||||
RESTReqSchemaVersion,
|
||||
HoppRESTAuth,
|
||||
ValidContentTypes,
|
||||
} from "@hoppscotch/data"
|
||||
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
|
||||
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
|
||||
import { useStream } from "@composables/stream"
|
||||
import { HoppTestResult } from "~/helpers/types/HoppTestResult"
|
||||
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
|
||||
import { applyBodyTransition } from "~/helpers/rules/BodyTransition"
|
||||
|
||||
type RESTSession = {
|
||||
request: HoppRESTRequest
|
||||
response: HoppRESTResponse | null
|
||||
testResults: HoppTestResult | null
|
||||
saveContext: HoppRequestSaveContext | null
|
||||
}
|
||||
|
||||
export const getDefaultRESTRequest = (): HoppRESTRequest => ({
|
||||
v: RESTReqSchemaVersion,
|
||||
endpoint: "https://echo.hoppscotch.io",
|
||||
name: "Untitled request",
|
||||
params: [],
|
||||
headers: [],
|
||||
method: "GET",
|
||||
auth: {
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
},
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
body: {
|
||||
contentType: null,
|
||||
body: null,
|
||||
},
|
||||
})
|
||||
|
||||
const defaultRESTSession: RESTSession = {
|
||||
request: getDefaultRESTRequest(),
|
||||
response: null,
|
||||
testResults: null,
|
||||
saveContext: null,
|
||||
}
|
||||
|
||||
const dispatchers = defineDispatchers({
|
||||
setRequest(_: RESTSession, { req }: { req: HoppRESTRequest }) {
|
||||
return {
|
||||
request: req,
|
||||
}
|
||||
},
|
||||
setRequestName(curr: RESTSession, { newName }: { newName: string }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
name: newName,
|
||||
},
|
||||
}
|
||||
},
|
||||
setEndpoint(curr: RESTSession, { newEndpoint }: { newEndpoint: string }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
endpoint: newEndpoint,
|
||||
},
|
||||
}
|
||||
},
|
||||
setParams(curr: RESTSession, { entries }: { entries: HoppRESTParam[] }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
params: entries,
|
||||
},
|
||||
}
|
||||
},
|
||||
addParam(curr: RESTSession, { newParam }: { newParam: HoppRESTParam }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
params: [...curr.request.params, newParam],
|
||||
},
|
||||
}
|
||||
},
|
||||
updateParam(
|
||||
curr: RESTSession,
|
||||
{ index, updatedParam }: { index: number; updatedParam: HoppRESTParam }
|
||||
) {
|
||||
const newParams = curr.request.params.map((param, i) => {
|
||||
if (i === index) return updatedParam
|
||||
else return param
|
||||
})
|
||||
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
params: newParams,
|
||||
},
|
||||
}
|
||||
},
|
||||
deleteParam(curr: RESTSession, { index }: { index: number }) {
|
||||
const newParams = curr.request.params.filter((_x, i) => i !== index)
|
||||
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
params: newParams,
|
||||
},
|
||||
}
|
||||
},
|
||||
deleteAllParams(curr: RESTSession) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
params: [],
|
||||
},
|
||||
}
|
||||
},
|
||||
updateMethod(curr: RESTSession, { newMethod }: { newMethod: string }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
method: newMethod,
|
||||
},
|
||||
}
|
||||
},
|
||||
setHeaders(curr: RESTSession, { entries }: { entries: HoppRESTHeader[] }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
headers: entries,
|
||||
},
|
||||
}
|
||||
},
|
||||
addHeader(curr: RESTSession, { entry }: { entry: HoppRESTHeader }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
headers: [...curr.request.headers, entry],
|
||||
},
|
||||
}
|
||||
},
|
||||
updateHeader(
|
||||
curr: RESTSession,
|
||||
{ index, updatedEntry }: { index: number; updatedEntry: HoppRESTHeader }
|
||||
) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
headers: curr.request.headers.map((header, i) => {
|
||||
if (i === index) return updatedEntry
|
||||
else return header
|
||||
}),
|
||||
},
|
||||
}
|
||||
},
|
||||
deleteHeader(curr: RESTSession, { index }: { index: number }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
headers: curr.request.headers.filter((_, i) => i !== index),
|
||||
},
|
||||
}
|
||||
},
|
||||
deleteAllHeaders(curr: RESTSession) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
headers: [],
|
||||
},
|
||||
}
|
||||
},
|
||||
setAuth(curr: RESTSession, { newAuth }: { newAuth: HoppRESTAuth }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
auth: newAuth,
|
||||
},
|
||||
}
|
||||
},
|
||||
setPreRequestScript(curr: RESTSession, { newScript }: { newScript: string }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
preRequestScript: newScript,
|
||||
},
|
||||
}
|
||||
},
|
||||
setTestScript(curr: RESTSession, { newScript }: { newScript: string }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
testScript: newScript,
|
||||
},
|
||||
}
|
||||
},
|
||||
setContentType(
|
||||
curr: RESTSession,
|
||||
{ newContentType }: { newContentType: ValidContentTypes | null }
|
||||
) {
|
||||
// TODO: persist body evenafter switching content typees
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
body: applyBodyTransition(curr.request.body, newContentType),
|
||||
},
|
||||
}
|
||||
},
|
||||
addFormDataEntry(curr: RESTSession, { entry }: { entry: FormDataKeyValue }) {
|
||||
// Only perform update if the current content-type is formdata
|
||||
if (curr.request.body.contentType !== "multipart/form-data") return {}
|
||||
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
body: <HoppRESTReqBody>{
|
||||
contentType: "multipart/form-data",
|
||||
body: [...curr.request.body.body, entry],
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
deleteFormDataEntry(curr: RESTSession, { index }: { index: number }) {
|
||||
// Only perform update if the current content-type is formdata
|
||||
if (curr.request.body.contentType !== "multipart/form-data") return {}
|
||||
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
body: <HoppRESTReqBody>{
|
||||
contentType: "multipart/form-data",
|
||||
body: curr.request.body.body.filter((_, i) => i !== index),
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
updateFormDataEntry(
|
||||
curr: RESTSession,
|
||||
{ index, entry }: { index: number; entry: FormDataKeyValue }
|
||||
) {
|
||||
// Only perform update if the current content-type is formdata
|
||||
if (curr.request.body.contentType !== "multipart/form-data") return {}
|
||||
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
body: <HoppRESTReqBody>{
|
||||
contentType: "multipart/form-data",
|
||||
body: curr.request.body.body.map((x, i) => (i !== index ? x : entry)),
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
deleteAllFormDataEntries(curr: RESTSession) {
|
||||
// Only perform update if the current content-type is formdata
|
||||
if (curr.request.body.contentType !== "multipart/form-data") return {}
|
||||
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
body: <HoppRESTReqBody>{
|
||||
contentType: "multipart/form-data",
|
||||
body: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
setRequestBody(curr: RESTSession, { newBody }: { newBody: HoppRESTReqBody }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
body: newBody,
|
||||
},
|
||||
}
|
||||
},
|
||||
updateResponse(
|
||||
_curr: RESTSession,
|
||||
{ updatedRes }: { updatedRes: HoppRESTResponse | null }
|
||||
) {
|
||||
return {
|
||||
response: updatedRes,
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
clearResponse(_curr: RESTSession) {
|
||||
return {
|
||||
response: null,
|
||||
}
|
||||
},
|
||||
setTestResults(
|
||||
_curr: RESTSession,
|
||||
{ newResults }: { newResults: HoppTestResult | null }
|
||||
) {
|
||||
return {
|
||||
testResults: newResults,
|
||||
}
|
||||
},
|
||||
setSaveContext(
|
||||
_,
|
||||
{ newContext }: { newContext: HoppRequestSaveContext | null }
|
||||
) {
|
||||
return {
|
||||
saveContext: newContext,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const restSessionStore = new DispatchingStore(defaultRESTSession, dispatchers)
|
||||
|
||||
export function getRESTRequest() {
|
||||
return restSessionStore.subject$.value.request
|
||||
}
|
||||
|
||||
export function setRESTRequest(
|
||||
req: HoppRESTRequest,
|
||||
saveContext?: HoppRequestSaveContext | null
|
||||
) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "setRequest",
|
||||
payload: {
|
||||
req,
|
||||
},
|
||||
})
|
||||
|
||||
if (saveContext) setRESTSaveContext(saveContext)
|
||||
}
|
||||
|
||||
export function setRESTSaveContext(saveContext: HoppRequestSaveContext | null) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "setSaveContext",
|
||||
payload: {
|
||||
newContext: saveContext,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getRESTSaveContext() {
|
||||
return restSessionStore.value.saveContext
|
||||
}
|
||||
|
||||
export function resetRESTRequest() {
|
||||
setRESTRequest(getDefaultRESTRequest())
|
||||
}
|
||||
|
||||
export function setRESTEndpoint(newEndpoint: string) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "setEndpoint",
|
||||
payload: {
|
||||
newEndpoint,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setRESTRequestName(newName: string) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "setRequestName",
|
||||
payload: {
|
||||
newName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setRESTParams(entries: HoppRESTParam[]) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "setParams",
|
||||
payload: {
|
||||
entries,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function addRESTParam(newParam: HoppRESTParam) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "addParam",
|
||||
payload: {
|
||||
newParam,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRESTParam(index: number, updatedParam: HoppRESTParam) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "updateParam",
|
||||
payload: {
|
||||
updatedParam,
|
||||
index,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteRESTParam(index: number) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "deleteParam",
|
||||
payload: {
|
||||
index,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAllRESTParams() {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "deleteAllParams",
|
||||
payload: {},
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRESTMethod(newMethod: string) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "updateMethod",
|
||||
payload: {
|
||||
newMethod,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setRESTHeaders(entries: HoppRESTHeader[]) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "setHeaders",
|
||||
payload: {
|
||||
entries,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function addRESTHeader(entry: HoppRESTHeader) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "addHeader",
|
||||
payload: {
|
||||
entry,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRESTHeader(index: number, updatedEntry: HoppRESTHeader) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "updateHeader",
|
||||
payload: {
|
||||
index,
|
||||
updatedEntry,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteRESTHeader(index: number) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "deleteHeader",
|
||||
payload: {
|
||||
index,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAllRESTHeaders() {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "deleteAllHeaders",
|
||||
payload: {},
|
||||
})
|
||||
}
|
||||
|
||||
export function setRESTAuth(newAuth: HoppRESTAuth) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "setAuth",
|
||||
payload: {
|
||||
newAuth,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setRESTPreRequestScript(newScript: string) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "setPreRequestScript",
|
||||
payload: {
|
||||
newScript,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setRESTTestScript(newScript: string) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "setTestScript",
|
||||
payload: {
|
||||
newScript,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setRESTReqBody(newBody: HoppRESTReqBody | null) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "setRequestBody",
|
||||
payload: {
|
||||
newBody,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRESTResponse(updatedRes: HoppRESTResponse | null) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "updateResponse",
|
||||
payload: {
|
||||
updatedRes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function clearRESTResponse() {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "clearResponse",
|
||||
payload: {},
|
||||
})
|
||||
}
|
||||
|
||||
export function setRESTTestResults(newResults: HoppTestResult | null) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "setTestResults",
|
||||
payload: {
|
||||
newResults,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function addFormDataEntry(entry: FormDataKeyValue) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "addFormDataEntry",
|
||||
payload: {
|
||||
entry,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteFormDataEntry(index: number) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "deleteFormDataEntry",
|
||||
payload: {
|
||||
index,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function updateFormDataEntry(index: number, entry: FormDataKeyValue) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "updateFormDataEntry",
|
||||
payload: {
|
||||
index,
|
||||
entry,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setRESTContentType(newContentType: ValidContentTypes | null) {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "setContentType",
|
||||
payload: {
|
||||
newContentType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAllFormDataEntries() {
|
||||
restSessionStore.dispatch({
|
||||
dispatcher: "deleteAllFormDataEntries",
|
||||
payload: {},
|
||||
})
|
||||
}
|
||||
|
||||
export const restSaveContext$ = restSessionStore.subject$.pipe(
|
||||
pluck("saveContext"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const restRequest$ = restSessionStore.subject$.pipe(
|
||||
pluck("request"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const restRequestName$ = restRequest$.pipe(
|
||||
pluck("name"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const restEndpoint$ = restSessionStore.subject$.pipe(
|
||||
pluck("request", "endpoint"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const restParams$ = restSessionStore.subject$.pipe(
|
||||
pluck("request", "params"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const restActiveParamsCount$ = restParams$.pipe(
|
||||
map(
|
||||
(params) =>
|
||||
params.filter((x) => x.active && (x.key !== "" || x.value !== "")).length
|
||||
)
|
||||
)
|
||||
|
||||
export const restMethod$ = restSessionStore.subject$.pipe(
|
||||
pluck("request", "method"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const restHeaders$ = restSessionStore.subject$.pipe(
|
||||
pluck("request", "headers"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const restActiveHeadersCount$ = restHeaders$.pipe(
|
||||
map(
|
||||
(params) =>
|
||||
params.filter((x) => x.active && (x.key !== "" || x.value !== "")).length
|
||||
)
|
||||
)
|
||||
|
||||
export const restAuth$ = restRequest$.pipe(pluck("auth"))
|
||||
|
||||
export const restPreRequestScript$ = restSessionStore.subject$.pipe(
|
||||
pluck("request", "preRequestScript"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const restContentType$ = restRequest$.pipe(
|
||||
pluck("body", "contentType"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const restTestScript$ = restSessionStore.subject$.pipe(
|
||||
pluck("request", "testScript"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const restReqBody$ = restSessionStore.subject$.pipe(
|
||||
pluck("request", "body"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const restResponse$ = restSessionStore.subject$.pipe(
|
||||
pluck("response"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const completedRESTResponse$ = restResponse$.pipe(
|
||||
filter(
|
||||
(res) =>
|
||||
res !== null &&
|
||||
res.type !== "loading" &&
|
||||
res.type !== "network_fail" &&
|
||||
res.type !== "script_fail"
|
||||
)
|
||||
)
|
||||
|
||||
export const restTestResults$ = restSessionStore.subject$.pipe(
|
||||
pluck("testResults"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
/**
|
||||
* A Vue 3 composable function that gives access to a ref
|
||||
* which is updated to the preRequestScript value in the store.
|
||||
* The ref value is kept in sync with the store and all writes
|
||||
* to the ref are dispatched to the store as `setPreRequestScript`
|
||||
* dispatches.
|
||||
*/
|
||||
export function usePreRequestScript(): Ref<string> {
|
||||
return useStream(
|
||||
restPreRequestScript$,
|
||||
restSessionStore.value.request.preRequestScript,
|
||||
(value) => {
|
||||
setRESTPreRequestScript(value)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Vue 3 composable function that gives access to a ref
|
||||
* which is updated to the testScript value in the store.
|
||||
* The ref value is kept in sync with the store and all writes
|
||||
* to the ref are dispatched to the store as `setTestScript`
|
||||
* dispatches.
|
||||
*/
|
||||
export function useTestScript(): Ref<string> {
|
||||
return useStream(
|
||||
restTestScript$,
|
||||
restSessionStore.value.request.testScript,
|
||||
(value) => {
|
||||
setRESTTestScript(value)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function useRESTRequestBody(): Ref<HoppRESTReqBody> {
|
||||
return useStream(
|
||||
restReqBody$,
|
||||
restSessionStore.value.request.body,
|
||||
setRESTReqBody
|
||||
)
|
||||
}
|
||||
|
||||
export function useRESTRequestName(): Ref<string> {
|
||||
return useStream(
|
||||
restRequestName$,
|
||||
restSessionStore.value.request.name,
|
||||
setRESTRequestName
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center min-h-screen">
|
||||
<div v-if="signingInWithEmail">
|
||||
<SmartSpinner />
|
||||
<div class="mt-2 text-sm text-secondaryLight">
|
||||
{{ t("state.loading") }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<AppLogo class="w-16 h-16 rounded" />
|
||||
</div>
|
||||
<pre v-if="error" class="mt-4 text-secondaryLight">{{ error }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { initializeFirebase } from "~/helpers/fb"
|
||||
import { isSignInWithEmailLink, signInWithEmailLink } from "~/helpers/fb/auth"
|
||||
import { getLocalConfig, removeLocalConfig } from "~/newstore/localpersistence"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
signingInWithEmail: false,
|
||||
error: null,
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
initializeFirebase()
|
||||
},
|
||||
async mounted() {
|
||||
if (isSignInWithEmailLink(window.location.href)) {
|
||||
this.signingInWithEmail = true
|
||||
|
||||
let email = getLocalConfig("emailForSignIn")
|
||||
|
||||
if (!email) {
|
||||
email = window.prompt(
|
||||
"Please provide your email for confirmation"
|
||||
) as string
|
||||
}
|
||||
|
||||
await signInWithEmailLink(email, window.location.href)
|
||||
.then(() => {
|
||||
removeLocalConfig("emailForSignIn")
|
||||
this.$router.push({ path: "/" })
|
||||
})
|
||||
.catch((e) => {
|
||||
this.signingInWithEmail = false
|
||||
this.error = e.message
|
||||
})
|
||||
.finally(() => {
|
||||
this.signingInWithEmail = false
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: empty
|
||||
</route>
|
||||
@@ -1,173 +0,0 @@
|
||||
<template>
|
||||
<AppPaneLayout layout-id="http">
|
||||
<template #primary>
|
||||
<HttpRequest />
|
||||
<HttpRequestOptions />
|
||||
</template>
|
||||
<template #secondary>
|
||||
<HttpResponse />
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<HttpSidebar />
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent,
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
Ref,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue"
|
||||
import type { Subscription } from "rxjs"
|
||||
import {
|
||||
HoppRESTRequest,
|
||||
HoppRESTAuthOAuth2,
|
||||
safelyExtractRESTRequest,
|
||||
isEqualHoppRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import {
|
||||
getRESTRequest,
|
||||
setRESTRequest,
|
||||
setRESTAuth,
|
||||
restAuth$,
|
||||
getDefaultRESTRequest,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
|
||||
import { pluckRef } from "@composables/ref"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { onLoggedIn } from "@composables/auth"
|
||||
import { loadRequestFromSync, startRequestSync } from "~/helpers/fb/request"
|
||||
import { oauthRedirect } from "~/helpers/oauth"
|
||||
import { useRoute } from "vue-router"
|
||||
|
||||
function bindRequestToURLParams() {
|
||||
const route = useRoute()
|
||||
// Get URL parameters and set that as the request
|
||||
onMounted(() => {
|
||||
const query = route.query
|
||||
// If query params are empty, or contains code or error param (these are from Oauth Redirect)
|
||||
// We skip URL params parsing
|
||||
if (Object.keys(query).length === 0 || query.code || query.error) return
|
||||
setRESTRequest(
|
||||
safelyExtractRESTRequest(
|
||||
translateExtURLParams(query),
|
||||
getDefaultRESTRequest()
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function oAuthURL() {
|
||||
const auth = useStream(
|
||||
restAuth$,
|
||||
{ authType: "none", authActive: true },
|
||||
setRESTAuth
|
||||
)
|
||||
|
||||
const oauth2Token = pluckRef(auth as Ref<HoppRESTAuthOAuth2>, "token")
|
||||
|
||||
onBeforeMount(async () => {
|
||||
try {
|
||||
const tokenInfo = await oauthRedirect()
|
||||
if (Object.prototype.hasOwnProperty.call(tokenInfo, "access_token")) {
|
||||
if (typeof tokenInfo === "object") {
|
||||
oauth2Token.value = tokenInfo.access_token
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (_) {}
|
||||
})
|
||||
}
|
||||
|
||||
function setupRequestSync(
|
||||
confirmSync: Ref<boolean>,
|
||||
requestForSync: Ref<HoppRESTRequest | null>
|
||||
) {
|
||||
const route = useRoute()
|
||||
|
||||
// Subscription to request sync
|
||||
let sub: Subscription | null = null
|
||||
|
||||
// Load request on login resolve and start sync
|
||||
onLoggedIn(async () => {
|
||||
if (
|
||||
Object.keys(route.query).length === 0 &&
|
||||
!(route.query.code || route.query.error)
|
||||
) {
|
||||
const request = await loadRequestFromSync()
|
||||
if (request) {
|
||||
if (!isEqualHoppRESTRequest(request, getRESTRequest())) {
|
||||
requestForSync.value = request
|
||||
confirmSync.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sub = startRequestSync()
|
||||
})
|
||||
|
||||
// Stop subscription to stop syncing
|
||||
onBeforeUnmount(() => {
|
||||
sub?.unsubscribe()
|
||||
})
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const requestForSync = ref<HoppRESTRequest | null>(null)
|
||||
|
||||
const confirmSync = ref(false)
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
watch(confirmSync, (newValue) => {
|
||||
if (newValue) {
|
||||
toast.show(`${t("confirm.sync")}`, {
|
||||
duration: 0,
|
||||
action: [
|
||||
{
|
||||
text: `${t("action.yes")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
syncRequest()
|
||||
toastObject.goAway(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: `${t("action.no")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
toastObject.goAway(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const syncRequest = () => {
|
||||
setRESTRequest(
|
||||
safelyExtractRESTRequest(requestForSync.value!, getDefaultRESTRequest())
|
||||
)
|
||||
}
|
||||
|
||||
setupRequestSync(confirmSync, requestForSync)
|
||||
bindRequestToURLParams()
|
||||
oAuthURL()
|
||||
|
||||
return {
|
||||
confirmSync,
|
||||
syncRequest,
|
||||
oAuthURL,
|
||||
requestForSync,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,343 +0,0 @@
|
||||
<template>
|
||||
<AppPaneLayout layout-id="mqtt">
|
||||
<template #primary>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-shrink-0 p-4 overflow-x-auto space-x-2 bg-primary"
|
||||
>
|
||||
<div class="inline-flex flex-1 space-x-2">
|
||||
<input
|
||||
id="mqtt-url"
|
||||
v-model="url"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="w-full px-4 py-2 border rounded bg-primaryLight border-divider text-secondaryDark"
|
||||
:placeholder="t('mqtt.url')"
|
||||
:disabled="
|
||||
connectionState === 'CONNECTED' ||
|
||||
connectionState === 'CONNECTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleConnection() : null"
|
||||
/>
|
||||
<ButtonPrimary
|
||||
id="connect"
|
||||
:disabled="!isUrlValid"
|
||||
class="w-32"
|
||||
:label="
|
||||
connectionState === 'DISCONNECTED'
|
||||
? t('action.connect')
|
||||
: t('action.disconnect')
|
||||
"
|
||||
:loading="connectionState === 'CONNECTING'"
|
||||
@click="toggleConnection"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
id="mqtt-username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
class="input"
|
||||
:placeholder="t('authorization.username')"
|
||||
/>
|
||||
<input
|
||||
id="mqtt-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
spellcheck="false"
|
||||
class="input"
|
||||
:placeholder="t('authorization.password')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #secondary>
|
||||
<RealtimeLog
|
||||
:title="t('mqtt.log')"
|
||||
:log="log"
|
||||
@delete="clearLogEntries()"
|
||||
/>
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<label for="pubTopic" class="font-semibold text-secondaryLight">
|
||||
{{ t("mqtt.topic") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex px-4">
|
||||
<input
|
||||
id="pubTopic"
|
||||
v-model="pubTopic"
|
||||
class="input"
|
||||
:placeholder="t('mqtt.topic_name')"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<label for="mqtt-message" class="font-semibold text-secondaryLight">
|
||||
{{ t("mqtt.communication") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex px-4 space-x-2">
|
||||
<input
|
||||
id="mqtt-message"
|
||||
v-model="message"
|
||||
class="input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="t('mqtt.message')"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<ButtonPrimary
|
||||
id="publish"
|
||||
name="get"
|
||||
:disabled="!canPublish"
|
||||
:label="t('mqtt.publish')"
|
||||
@click="publish"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between p-4 mt-4 border-t border-dividerLight"
|
||||
>
|
||||
<label for="subTopic" class="font-semibold text-secondaryLight">
|
||||
{{ t("mqtt.topic") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex px-4 space-x-2">
|
||||
<input
|
||||
id="subTopic"
|
||||
v-model="subTopic"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="t('mqtt.topic_name')"
|
||||
spellcheck="false"
|
||||
class="input"
|
||||
/>
|
||||
<ButtonPrimary
|
||||
id="subscribe"
|
||||
name="get"
|
||||
:disabled="!canSubscribe"
|
||||
:label="
|
||||
subscriptionState ? t('mqtt.unsubscribe') : t('mqtt.subscribe')
|
||||
"
|
||||
reverse
|
||||
@click="toggleSubscription"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
|
||||
import { debounce } from "lodash-es"
|
||||
import { MQTTConnection, MQTTError } from "~/helpers/realtime/MQTTConnection"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import {
|
||||
useReadonlyStream,
|
||||
useStream,
|
||||
useStreamSubscriber,
|
||||
} from "@composables/stream"
|
||||
import {
|
||||
addMQTTLogLine,
|
||||
MQTTConn$,
|
||||
MQTTEndpoint$,
|
||||
MQTTLog$,
|
||||
setMQTTConn,
|
||||
setMQTTEndpoint,
|
||||
setMQTTLog,
|
||||
} from "~/newstore/MQTTSession"
|
||||
import RegexWorker from "@workers/regex?worker"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
const url = useStream(MQTTEndpoint$, "", setMQTTEndpoint)
|
||||
const log = useStream(MQTTLog$, [], setMQTTLog)
|
||||
const socket = useStream(MQTTConn$, new MQTTConnection(), setMQTTConn)
|
||||
const connectionState = useReadonlyStream(
|
||||
socket.value.connectionState$,
|
||||
"DISCONNECTED"
|
||||
)
|
||||
const subscriptionState = useReadonlyStream(
|
||||
socket.value.subscriptionState$,
|
||||
false
|
||||
)
|
||||
|
||||
const isUrlValid = ref(true)
|
||||
const pubTopic = ref("")
|
||||
const subTopic = ref("")
|
||||
const message = ref("")
|
||||
const username = ref("")
|
||||
const password = ref("")
|
||||
|
||||
let worker: Worker
|
||||
|
||||
const canPublish = computed(
|
||||
() =>
|
||||
pubTopic.value !== "" &&
|
||||
message.value !== "" &&
|
||||
connectionState.value === "CONNECTED"
|
||||
)
|
||||
const canSubscribe = computed(
|
||||
() => subTopic.value !== "" && connectionState.value === "CONNECTED"
|
||||
)
|
||||
|
||||
const workerResponseHandler = ({
|
||||
data,
|
||||
}: {
|
||||
data: { url: string; result: boolean }
|
||||
}) => {
|
||||
if (data.url === url.value) isUrlValid.value = data.result
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
worker = new RegexWorker()
|
||||
worker.addEventListener("message", workerResponseHandler)
|
||||
|
||||
subscribeToStream(socket.value.event$, (event) => {
|
||||
switch (event?.type) {
|
||||
case "CONNECTING":
|
||||
log.value = [
|
||||
{
|
||||
payload: `${t("state.connecting_to", { name: url.value })}`,
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: undefined,
|
||||
},
|
||||
]
|
||||
break
|
||||
|
||||
case "CONNECTED":
|
||||
log.value = [
|
||||
{
|
||||
payload: `${t("state.connected_to", { name: url.value })}`,
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: Date.now(),
|
||||
},
|
||||
]
|
||||
toast.success(`${t("state.connected")}`)
|
||||
break
|
||||
|
||||
case "MESSAGE_SENT":
|
||||
addMQTTLogLine({
|
||||
prefix: `${event.message.topic}`,
|
||||
payload: event.message.message,
|
||||
source: "client",
|
||||
ts: Date.now(),
|
||||
})
|
||||
break
|
||||
|
||||
case "MESSAGE_RECEIVED":
|
||||
addMQTTLogLine({
|
||||
prefix: `${event.message.topic}`,
|
||||
payload: event.message.message,
|
||||
source: "server",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "SUBSCRIBED":
|
||||
addMQTTLogLine({
|
||||
payload: subscriptionState.value
|
||||
? `${t("state.subscribed_success", { topic: subTopic.value })}`
|
||||
: `${t("state.unsubscribed_success", { topic: subTopic.value })}`,
|
||||
source: "server",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "SUBSCRIPTION_FAILED":
|
||||
addMQTTLogLine({
|
||||
payload: subscriptionState.value
|
||||
? `${t("state.subscribed_failed", { topic: subTopic.value })}`
|
||||
: `${t("state.unsubscribed_failed", { topic: subTopic.value })}`,
|
||||
source: "server",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "ERROR":
|
||||
addMQTTLogLine({
|
||||
payload: getI18nError(event.error),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "DISCONNECTED":
|
||||
addMQTTLogLine({
|
||||
payload: t("state.disconnected_from", { name: url.value }).toString(),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: event.time,
|
||||
})
|
||||
toast.error(`${t("state.disconnected")}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const debouncer = debounce(function () {
|
||||
worker.postMessage({ type: "ws", url: url.value })
|
||||
}, 1000)
|
||||
|
||||
watch(url, (newUrl) => {
|
||||
if (newUrl) debouncer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
worker.terminate()
|
||||
})
|
||||
|
||||
// METHODS
|
||||
const toggleConnection = () => {
|
||||
// If it is connecting:
|
||||
if (connectionState.value === "DISCONNECTED") {
|
||||
return socket.value.connect(url.value, username.value, password.value)
|
||||
}
|
||||
// Otherwise, it's disconnecting.
|
||||
socket.value.disconnect()
|
||||
}
|
||||
const publish = () => {
|
||||
socket.value?.publish(pubTopic.value, message.value)
|
||||
}
|
||||
const toggleSubscription = () => {
|
||||
if (subscriptionState.value) {
|
||||
socket.value.unsubscribe(subTopic.value)
|
||||
} else {
|
||||
socket.value.subscribe(subTopic.value)
|
||||
}
|
||||
}
|
||||
|
||||
const getI18nError = (error: MQTTError): string => {
|
||||
if (typeof error === "string") return error
|
||||
|
||||
switch (error.type) {
|
||||
case "CONNECTION_NOT_ESTABLISHED":
|
||||
return t("state.connection_lost").toString()
|
||||
case "SUBSCRIPTION_FAILED":
|
||||
return t("state.mqtt_subscription_failed", {
|
||||
topic: error.topic,
|
||||
}).toString()
|
||||
case "PUBLISH_ERROR":
|
||||
return t("state.publish_error", { topic: error.topic }).toString()
|
||||
case "CONNECTION_LOST":
|
||||
return t("state.connection_lost").toString()
|
||||
case "CONNECTION_FAILED":
|
||||
return t("state.connection_failed").toString()
|
||||
default:
|
||||
return t("state.disconnected_from", { name: url.value }).toString()
|
||||
}
|
||||
}
|
||||
const clearLogEntries = () => {
|
||||
log.value = []
|
||||
}
|
||||
</script>
|
||||
@@ -1,165 +0,0 @@
|
||||
import { defineConfig, loadEnv } from "vite"
|
||||
import { APP_INFO, META_TAGS } from "./meta"
|
||||
import generateSitemap from "vite-plugin-pages-sitemap"
|
||||
import HtmlConfig from "vite-plugin-html-config"
|
||||
import Vue from "@vitejs/plugin-vue"
|
||||
import VueI18n from "@intlify/vite-plugin-vue-i18n"
|
||||
import Components from "unplugin-vue-components/vite"
|
||||
import Icons from "unplugin-icons/vite"
|
||||
import Inspect from "vite-plugin-inspect"
|
||||
import WindiCSS from "vite-plugin-windicss"
|
||||
import Checker from "vite-plugin-checker"
|
||||
import { VitePWA } from "vite-plugin-pwa"
|
||||
import Pages from "vite-plugin-pages"
|
||||
import Layouts from "vite-plugin-vue-layouts"
|
||||
import IconResolver from "unplugin-icons/resolver"
|
||||
import { FileSystemIconLoader } from "unplugin-icons/loaders"
|
||||
import * as path from "path"
|
||||
import { VitePluginFonts } from "vite-plugin-fonts"
|
||||
|
||||
const ENV = loadEnv("development", process.cwd())
|
||||
|
||||
export default defineConfig({
|
||||
// TODO: Migrate @hoppscotch/data to full ESM
|
||||
define: {
|
||||
// For 'util' polyfill required by dep of '@apidevtools/swagger-parser'
|
||||
"process.env": {},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
emptyOutDir: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": path.resolve(__dirname, "./src"),
|
||||
"@composables": path.resolve(__dirname, "./src/composables"),
|
||||
"@modules": path.resolve(__dirname, "./src/modules"),
|
||||
"@components": path.resolve(__dirname, "./src/components"),
|
||||
"@helpers": path.resolve(__dirname, "./src/helpers"),
|
||||
"@functional": path.resolve(__dirname, "./src/helpers/functional"),
|
||||
"@workers": path.resolve(__dirname, "./src/workers"),
|
||||
|
||||
stream: "stream-browserify",
|
||||
util: "util",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
Inspect(), // go to url -> /__inspect
|
||||
Checker({
|
||||
eslint: {
|
||||
lintCommand: "eslint src --ext .ts,.js,.vue --ignore-path .gitignore .",
|
||||
},
|
||||
overlay: {
|
||||
initialIsOpen: true,
|
||||
position: "br",
|
||||
},
|
||||
}),
|
||||
HtmlConfig({
|
||||
metas: META_TAGS(ENV),
|
||||
}),
|
||||
Vue(),
|
||||
Pages({
|
||||
routeStyle: "nuxt",
|
||||
dirs: "src/pages",
|
||||
importMode: "async",
|
||||
onRoutesGenerated(routes) {
|
||||
return generateSitemap({
|
||||
routes,
|
||||
nuxtStyle: true,
|
||||
allowRobots: true,
|
||||
hostname: ENV.VITE_BASE_URL,
|
||||
})
|
||||
},
|
||||
}),
|
||||
Layouts({
|
||||
layoutsDirs: "./src/layouts",
|
||||
defaultLayout: "default",
|
||||
}),
|
||||
VueI18n({
|
||||
runtimeOnly: false,
|
||||
compositionOnly: true,
|
||||
include: [path.resolve(__dirname, "locales")],
|
||||
}),
|
||||
WindiCSS(),
|
||||
Components({
|
||||
dts: "./src/components.d.ts",
|
||||
directoryAsNamespace: true,
|
||||
resolvers: [
|
||||
IconResolver({
|
||||
prefix: "icon",
|
||||
customCollections: ["hopp", "auth", "brands"],
|
||||
}),
|
||||
],
|
||||
types: [
|
||||
{
|
||||
from: "vue-tippy",
|
||||
names: ["Tippy"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
Icons({
|
||||
compiler: "vue3",
|
||||
customCollections: {
|
||||
hopp: FileSystemIconLoader("./assets/icons"),
|
||||
auth: FileSystemIconLoader("./assets/icons/auth"),
|
||||
brands: FileSystemIconLoader("./assets/icons/brands"),
|
||||
},
|
||||
}),
|
||||
VitePWA({
|
||||
manifest: {
|
||||
name: APP_INFO.name,
|
||||
short_name: APP_INFO.name,
|
||||
description: APP_INFO.shortDescription,
|
||||
start_url: "?source=pwa",
|
||||
background_color: APP_INFO.app.background,
|
||||
theme_color: APP_INFO.app.background,
|
||||
icons: [
|
||||
{
|
||||
src: "/icon.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
{
|
||||
src: "/logo.svg",
|
||||
sizes: "48x48 72x72 96x96 128x128 256x256 512x512",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
registerType: "prompt",
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
maximumFileSizeToCacheInBytes: 4194304,
|
||||
navigateFallbackDenylist: [
|
||||
/robots.txt/,
|
||||
/sitemap.xml/,
|
||||
/discord/,
|
||||
/telegram/,
|
||||
/beta/,
|
||||
/careers/,
|
||||
/newsletter/,
|
||||
/twitter/,
|
||||
/github/,
|
||||
/announcements/,
|
||||
],
|
||||
},
|
||||
}),
|
||||
VitePluginFonts({
|
||||
google: {
|
||||
families: [
|
||||
"Inter:wght@400;500;600;700;800",
|
||||
"Roboto+Mono:wght@400;500",
|
||||
"Material+Icons",
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
1
packages/hoppscotch-backend/.dockerignore
Normal file
1
packages/hoppscotch-backend/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
./node_modules
|
||||
27
packages/hoppscotch-backend/.eslintrc.js
Normal file
27
packages/hoppscotch-backend/.eslintrc.js
Normal file
@@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
"no-empty-function": "off",
|
||||
"@typescript-eslint/no-empty-function": "error"
|
||||
},
|
||||
};
|
||||
43
packages/hoppscotch-backend/.gitignore
vendored
Normal file
43
packages/hoppscotch-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
.vscode
|
||||
|
||||
.env
|
||||
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Generated artifacts (GQL Schema SDL generation etc.)
|
||||
gen/
|
||||
4
packages/hoppscotch-backend/.prettierrc
Normal file
4
packages/hoppscotch-backend/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
38
packages/hoppscotch-backend/Dockerfile
Normal file
38
packages/hoppscotch-backend/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
FROM node:18.8.0 AS builder
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# # Install pnpm
|
||||
RUN npm i -g pnpm
|
||||
|
||||
COPY .env .
|
||||
COPY pnpm-lock.yaml .
|
||||
RUN pnpm fetch
|
||||
|
||||
ENV APP_PORT=${PORT}
|
||||
ENV DB_URL=${DATABASE_URL}
|
||||
|
||||
# # PNPM package install
|
||||
COPY ./packages/hoppscotch-backend .
|
||||
RUN pnpm i --filter hoppscotch-backend
|
||||
|
||||
# Prisma bits
|
||||
RUN pnpm exec prisma generate
|
||||
|
||||
FROM builder AS dev
|
||||
|
||||
ENV PRODUCTION="false"
|
||||
|
||||
CMD ["pnpm", "run", "start:dev"]
|
||||
|
||||
EXPOSE 3170
|
||||
|
||||
|
||||
FROM builder AS prod
|
||||
|
||||
ENV PRODUCTION="true"
|
||||
|
||||
CMD ["pnpm", "run", "start:prod"]
|
||||
|
||||
EXPOSE 3170
|
||||
|
||||
0
packages/hoppscotch-backend/cross-env
Normal file
0
packages/hoppscotch-backend/cross-env
Normal file
0
packages/hoppscotch-backend/eslint
Normal file
0
packages/hoppscotch-backend/eslint
Normal file
1
packages/hoppscotch-backend/global.d.ts
vendored
Normal file
1
packages/hoppscotch-backend/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import '@relmify/jest-fp-ts';
|
||||
1
packages/hoppscotch-backend/jest.setup.js
Normal file
1
packages/hoppscotch-backend/jest.setup.js
Normal file
@@ -0,0 +1 @@
|
||||
require('@relmify/jest-fp-ts');
|
||||
11
packages/hoppscotch-backend/nest-cli.json
Normal file
11
packages/hoppscotch-backend/nest-cli.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"assets": [
|
||||
"**/*.hbs"
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
121
packages/hoppscotch-backend/package.json
Normal file
121
packages/hoppscotch-backend/package.json
Normal file
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"name": "hoppscotch-backend",
|
||||
"version": "2023.4.5",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"generate-gql-sdl": "cross-env GQL_SCHEMA_EMIT_LOCATION='../../../gql-gen/backend-schema.gql' GENERATE_GQL_SCHEMA=true WHITELISTED_ORIGINS='' nest start",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"postinstall": "prisma generate && pnpm run generate-gql-sdl",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"do-test": "pnpm run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs-modules/mailer": "^1.8.1",
|
||||
"@nestjs/apollo": "^10.1.6",
|
||||
"@nestjs/common": "^9.2.1",
|
||||
"@nestjs/core": "^9.2.1",
|
||||
"@nestjs/graphql": "^10.1.6",
|
||||
"@nestjs/jwt": "^10.0.1",
|
||||
"@nestjs/passport": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.2.1",
|
||||
"@nestjs/throttler": "^4.0.0",
|
||||
"@prisma/client": "^4.7.1",
|
||||
"apollo-server-express": "^3.11.1",
|
||||
"apollo-server-plugin-base": "^3.7.1",
|
||||
"argon2": "^0.30.3",
|
||||
"bcrypt": "^5.1.0",
|
||||
"cookie": "^0.5.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.17.3",
|
||||
"fp-ts": "^2.13.1",
|
||||
"graphql": "^15.5.0",
|
||||
"graphql-query-complexity": "^0.12.0",
|
||||
"graphql-redis-subscriptions": "^2.5.0",
|
||||
"graphql-subscriptions": "^2.0.0",
|
||||
"handlebars": "^4.7.7",
|
||||
"io-ts": "^2.2.16",
|
||||
"luxon": "^3.2.1",
|
||||
"nodemailer": "^6.9.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-github2": "^0.1.12",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-microsoft": "^1.0.0",
|
||||
"prisma": "^4.7.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.1.5",
|
||||
"@nestjs/schematics": "^9.0.3",
|
||||
"@nestjs/testing": "^9.2.1",
|
||||
"@relmify/jest-fp-ts": "^2.0.2",
|
||||
"@types/argon2": "^0.15.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/express": "^4.17.14",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/luxon": "^3.2.0",
|
||||
"@types/node": "^18.11.10",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
"@types/passport-github2": "^1.2.5",
|
||||
"@types/passport-google-oauth20": "^2.0.11",
|
||||
"@types/passport-jwt": "^3.0.8",
|
||||
"@types/passport-microsoft": "^0.0.0",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.29.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"jest": "^29.4.1",
|
||||
"jest-mock-extended": "^3.0.1",
|
||||
"jwt": "link:@types/nestjs/jwt",
|
||||
"prettier": "^2.8.4",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.2",
|
||||
"ts-jest": "29.0.5",
|
||||
"ts-loader": "^9.4.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "4.1.1",
|
||||
"typescript": "^4.9.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"../jest.setup.js"
|
||||
],
|
||||
"preset": "ts-jest",
|
||||
"clearMocks": true,
|
||||
"collectCoverage": true,
|
||||
"coverageDirectory": "coverage",
|
||||
"coverageProvider": "v8",
|
||||
"rootDir": "src",
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ReqType" AS ENUM ('REST', 'GQL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TeamMemberRole" AS ENUM ('OWNER', 'VIEWER', 'EDITOR');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Team" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"role" "TeamMemberRole" NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"teamID" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamInvitation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"teamID" TEXT NOT NULL,
|
||||
"creatorUid" TEXT NOT NULL,
|
||||
"inviteeEmail" TEXT NOT NULL,
|
||||
"inviteeRole" "TeamMemberRole" NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamInvitation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamCollection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"parentID" TEXT,
|
||||
"teamID" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"orderIndex" INTEGER NOT NULL,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamCollection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"collectionID" TEXT NOT NULL,
|
||||
"teamID" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"request" JSONB NOT NULL,
|
||||
"orderIndex" INTEGER NOT NULL,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Shortcode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"request" JSONB NOT NULL,
|
||||
"creatorUid" TEXT,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Shortcode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamEnvironment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"teamID" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"variables" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamEnvironment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"uid" TEXT NOT NULL,
|
||||
"displayName" TEXT,
|
||||
"email" TEXT,
|
||||
"photoURL" TEXT,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"refreshToken" TEXT,
|
||||
"currentRESTSession" JSONB,
|
||||
"currentGQLSession" JSONB,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("uid")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerAccountId" TEXT NOT NULL,
|
||||
"providerRefreshToken" TEXT,
|
||||
"providerAccessToken" TEXT,
|
||||
"providerScope" TEXT,
|
||||
"loggedIn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VerificationToken" (
|
||||
"deviceIdentifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"expiresOn" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserSettings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"properties" JSONB NOT NULL,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserHistory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"reqType" "ReqType" NOT NULL,
|
||||
"request" JSONB NOT NULL,
|
||||
"responseMetadata" JSONB NOT NULL,
|
||||
"isStarred" BOOLEAN NOT NULL,
|
||||
"executedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UserHistory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserEnvironment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"variables" JSONB NOT NULL,
|
||||
"isGlobal" BOOLEAN NOT NULL,
|
||||
|
||||
CONSTRAINT "UserEnvironment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InvitedUsers" (
|
||||
"adminUid" TEXT NOT NULL,
|
||||
"adminEmail" TEXT NOT NULL,
|
||||
"inviteeEmail" TEXT NOT NULL,
|
||||
"invitedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"collectionID" TEXT NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"request" JSONB NOT NULL,
|
||||
"type" "ReqType" NOT NULL,
|
||||
"orderIndex" INTEGER NOT NULL,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UserRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserCollection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"parentID" TEXT,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"orderIndex" INTEGER NOT NULL,
|
||||
"type" "ReqType" NOT NULL,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UserCollection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamMember_teamID_userUid_key" ON "TeamMember"("teamID", "userUid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamInvitation_teamID_idx" ON "TeamInvitation"("teamID");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamInvitation_teamID_inviteeEmail_key" ON "TeamInvitation"("teamID", "inviteeEmail");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Shortcode_id_creatorUid_key" ON "Shortcode"("id", "creatorUid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_deviceIdentifier_token_key" ON "VerificationToken"("deviceIdentifier", "token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserSettings_userUid_key" ON "UserSettings"("userUid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvitedUsers_inviteeEmail_key" ON "InvitedUsers"("inviteeEmail");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamInvitation" ADD CONSTRAINT "TeamInvitation_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "TeamCollection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "TeamCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamEnvironment" ADD CONSTRAINT "TeamEnvironment_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserHistory" ADD CONSTRAINT "UserHistory_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserEnvironment" ADD CONSTRAINT "UserEnvironment_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvitedUsers" ADD CONSTRAINT "InvitedUsers_adminUid_fkey" FOREIGN KEY ("adminUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserRequest" ADD CONSTRAINT "UserRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "UserCollection"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserRequest" ADD CONSTRAINT "UserRequest_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserCollection" ADD CONSTRAINT "UserCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "UserCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserCollection" ADD CONSTRAINT "UserCollection_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
205
packages/hoppscotch-backend/prisma/schema.prisma
Normal file
205
packages/hoppscotch-backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,205 @@
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-1.1.x"]
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
members TeamMember[]
|
||||
TeamInvitation TeamInvitation[]
|
||||
TeamCollection TeamCollection[]
|
||||
TeamRequest TeamRequest[]
|
||||
TeamEnvironment TeamEnvironment[]
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(uuid()) // Membership ID
|
||||
role TeamMemberRole
|
||||
userUid String
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamID, userUid])
|
||||
}
|
||||
|
||||
model TeamInvitation {
|
||||
id String @id @default(cuid())
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
creatorUid String
|
||||
inviteeEmail String
|
||||
inviteeRole TeamMemberRole
|
||||
|
||||
@@unique([teamID, inviteeEmail])
|
||||
@@index([teamID])
|
||||
}
|
||||
|
||||
model TeamCollection {
|
||||
id String @id @default(cuid())
|
||||
parentID String?
|
||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||
requests TeamRequest[]
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model TeamRequest {
|
||||
id String @id @default(cuid())
|
||||
collectionID String
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
request Json
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model Shortcode {
|
||||
id String @id
|
||||
request Json
|
||||
creatorUid String?
|
||||
createdOn DateTime @default(now())
|
||||
|
||||
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
|
||||
}
|
||||
|
||||
model TeamEnvironment {
|
||||
id String @id @default(cuid())
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
variables Json
|
||||
}
|
||||
|
||||
model User {
|
||||
uid String @id @default(cuid())
|
||||
displayName String?
|
||||
email String? @unique
|
||||
photoURL String?
|
||||
isAdmin Boolean @default(false)
|
||||
refreshToken String?
|
||||
providerAccounts Account[]
|
||||
VerificationToken VerificationToken[]
|
||||
settings UserSettings?
|
||||
UserHistory UserHistory[]
|
||||
UserEnvironments UserEnvironment[]
|
||||
userCollections UserCollection[]
|
||||
userRequests UserRequest[]
|
||||
currentRESTSession Json?
|
||||
currentGQLSession Json?
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
invitedUsers InvitedUsers[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [uid], onDelete: Cascade)
|
||||
provider String
|
||||
providerAccountId String
|
||||
providerRefreshToken String?
|
||||
providerAccessToken String?
|
||||
providerScope String?
|
||||
loggedIn DateTime @default(now()) @db.Timestamp(3)
|
||||
|
||||
@@unique(fields: [provider, providerAccountId], name: "verifyProviderAccount")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
deviceIdentifier String
|
||||
token String @unique @default(cuid())
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
expiresOn DateTime @db.Timestamp(3)
|
||||
|
||||
@@unique(fields: [deviceIdentifier, token], name: "passwordless_deviceIdentifier_tokens")
|
||||
}
|
||||
|
||||
model UserSettings {
|
||||
id String @id @default(cuid())
|
||||
userUid String @unique
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
properties Json
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model UserHistory {
|
||||
id String @id @default(cuid())
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
reqType ReqType
|
||||
request Json
|
||||
responseMetadata Json
|
||||
isStarred Boolean
|
||||
executedOn DateTime @default(now()) @db.Timestamp(3)
|
||||
}
|
||||
|
||||
enum ReqType {
|
||||
REST
|
||||
GQL
|
||||
}
|
||||
|
||||
model UserEnvironment {
|
||||
id String @id @default(cuid())
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
name String?
|
||||
variables Json
|
||||
isGlobal Boolean
|
||||
}
|
||||
|
||||
model InvitedUsers {
|
||||
adminUid String
|
||||
user User @relation(fields: [adminUid], references: [uid], onDelete: Cascade)
|
||||
adminEmail String
|
||||
inviteeEmail String @unique
|
||||
invitedOn DateTime @default(now()) @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model UserRequest {
|
||||
id String @id @default(cuid())
|
||||
userCollection UserCollection @relation(fields: [collectionID], references: [id])
|
||||
collectionID String
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
title String
|
||||
request Json
|
||||
type ReqType
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model UserCollection {
|
||||
id String @id @default(cuid())
|
||||
parentID String?
|
||||
parent UserCollection? @relation("ParentUserCollection", fields: [parentID], references: [id], onDelete: Cascade)
|
||||
children UserCollection[] @relation("ParentUserCollection")
|
||||
requests UserRequest[]
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
title String
|
||||
orderIndex Int
|
||||
type ReqType
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
enum TeamMemberRole {
|
||||
OWNER
|
||||
VIEWER
|
||||
EDITOR
|
||||
}
|
||||
4
packages/hoppscotch-backend/src/admin/admin.model.ts
Normal file
4
packages/hoppscotch-backend/src/admin/admin.model.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class Admin {}
|
||||
29
packages/hoppscotch-backend/src/admin/admin.module.ts
Normal file
29
packages/hoppscotch-backend/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminResolver } from './admin.resolver';
|
||||
import { AdminService } from './admin.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { PubSubModule } from '../pubsub/pubsub.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { MailerModule } from '../mailer/mailer.module';
|
||||
import { TeamModule } from '../team/team.module';
|
||||
import { TeamInvitationModule } from '../team-invitation/team-invitation.module';
|
||||
import { TeamEnvironmentsModule } from '../team-environments/team-environments.module';
|
||||
import { TeamCollectionModule } from '../team-collection/team-collection.module';
|
||||
import { TeamRequestModule } from '../team-request/team-request.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
PubSubModule,
|
||||
UserModule,
|
||||
MailerModule,
|
||||
TeamModule,
|
||||
TeamInvitationModule,
|
||||
TeamEnvironmentsModule,
|
||||
TeamCollectionModule,
|
||||
TeamRequestModule,
|
||||
],
|
||||
providers: [AdminResolver, AdminService],
|
||||
exports: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
425
packages/hoppscotch-backend/src/admin/admin.resolver.ts
Normal file
425
packages/hoppscotch-backend/src/admin/admin.resolver.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
Args,
|
||||
ID,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
Subscription,
|
||||
} from '@nestjs/graphql';
|
||||
import { Admin } from './admin.model';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlAuthGuard } from '../guards/gql-auth.guard';
|
||||
import { GqlAdminGuard } from './guards/gql-admin.guard';
|
||||
import { GqlAdmin } from './decorators/gql-admin.decorator';
|
||||
import { AdminService } from './admin.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { throwErr } from '../utils';
|
||||
import { AuthUser } from '../types/AuthUser';
|
||||
import { InvitedUser } from './invited-user.model';
|
||||
import { GqlUser } from '../decorators/gql-user.decorator';
|
||||
import { PubSubService } from '../pubsub/pubsub.service';
|
||||
import { Team, TeamMember } from '../team/team.model';
|
||||
import { User } from '../user/user.model';
|
||||
import { TeamInvitation } from '../team-invitation/team-invitation.model';
|
||||
import { PaginationArgs } from '../types/input-types.args';
|
||||
import {
|
||||
AddUserToTeamArgs,
|
||||
ChangeUserRoleInTeamArgs,
|
||||
} from './input-types.args';
|
||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
|
||||
@UseGuards(GqlThrottlerGuard)
|
||||
@Resolver(() => Admin)
|
||||
export class AdminResolver {
|
||||
constructor(
|
||||
private adminService: AdminService,
|
||||
private readonly pubsub: PubSubService,
|
||||
) {}
|
||||
|
||||
/* Query */
|
||||
|
||||
@Query(() => Admin, {
|
||||
description: 'Gives details of the admin executing this query',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
admin(@GqlAdmin() admin: Admin) {
|
||||
return admin;
|
||||
}
|
||||
|
||||
@ResolveField(() => [User], {
|
||||
description: 'Returns a list of all admin users in infra',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async admins() {
|
||||
const admins = await this.adminService.fetchAdmins();
|
||||
return admins;
|
||||
}
|
||||
@ResolveField(() => User, {
|
||||
description: 'Returns a user info by UID',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async userInfo(
|
||||
@Args({
|
||||
name: 'userUid',
|
||||
type: () => ID,
|
||||
description: 'The user UID',
|
||||
})
|
||||
userUid: string,
|
||||
): Promise<AuthUser> {
|
||||
const user = await this.adminService.fetchUserInfo(userUid);
|
||||
if (E.isLeft(user)) throwErr(user.left);
|
||||
return user.right;
|
||||
}
|
||||
|
||||
@ResolveField(() => [User], {
|
||||
description: 'Returns a list of all the users in infra',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async allUsers(
|
||||
@Parent() admin: Admin,
|
||||
@Args() args: PaginationArgs,
|
||||
): Promise<AuthUser[]> {
|
||||
const users = await this.adminService.fetchUsers(args.cursor, args.take);
|
||||
return users;
|
||||
}
|
||||
|
||||
@ResolveField(() => [InvitedUser], {
|
||||
description: 'Returns a list of all the invited users',
|
||||
})
|
||||
async invitedUsers(@Parent() admin: Admin): Promise<InvitedUser[]> {
|
||||
const users = await this.adminService.fetchInvitedUsers();
|
||||
return users;
|
||||
}
|
||||
|
||||
@ResolveField(() => [Team], {
|
||||
description: 'Returns a list of all the teams in the infra',
|
||||
})
|
||||
async allTeams(
|
||||
@Parent() admin: Admin,
|
||||
@Args() args: PaginationArgs,
|
||||
): Promise<Team[]> {
|
||||
const teams = await this.adminService.fetchAllTeams(args.cursor, args.take);
|
||||
return teams;
|
||||
}
|
||||
@ResolveField(() => Team, {
|
||||
description: 'Returns a team info by ID when requested by Admin',
|
||||
})
|
||||
async teamInfo(
|
||||
@Parent() admin: Admin,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which info to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<Team> {
|
||||
const team = await this.adminService.getTeamInfo(teamID);
|
||||
if (E.isLeft(team)) throwErr(team.left);
|
||||
return team.right;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return count of all the members in a team',
|
||||
})
|
||||
async membersCountInTeam(
|
||||
@Parent() admin: Admin,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
nullable: false,
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<number> {
|
||||
const teamMembersCount = await this.adminService.membersCountInTeam(teamID);
|
||||
return teamMembersCount;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return count of all the stored collections in a team',
|
||||
})
|
||||
async collectionCountInTeam(
|
||||
@Parent() admin: Admin,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<number> {
|
||||
const teamCollCount = await this.adminService.collectionCountInTeam(teamID);
|
||||
return teamCollCount;
|
||||
}
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return count of all the stored requests in a team',
|
||||
})
|
||||
async requestCountInTeam(
|
||||
@Parent() admin: Admin,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<number> {
|
||||
const teamReqCount = await this.adminService.requestCountInTeam(teamID);
|
||||
return teamReqCount;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return count of all the stored environments in a team',
|
||||
})
|
||||
async environmentCountInTeam(
|
||||
@Parent() admin: Admin,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<number> {
|
||||
const envsCount = await this.adminService.environmentCountInTeam(teamID);
|
||||
return envsCount;
|
||||
}
|
||||
|
||||
@ResolveField(() => [TeamInvitation], {
|
||||
description: 'Return all the pending invitations in a team',
|
||||
})
|
||||
async pendingInvitationCountInTeam(
|
||||
@Parent() admin: Admin,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
) {
|
||||
const invitations = await this.adminService.pendingInvitationCountInTeam(
|
||||
teamID,
|
||||
);
|
||||
return invitations;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return total number of Users in organization',
|
||||
})
|
||||
async usersCount() {
|
||||
return this.adminService.getUsersCount();
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return total number of Teams in organization',
|
||||
})
|
||||
async teamsCount() {
|
||||
return this.adminService.getTeamsCount();
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return total number of Team Collections in organization',
|
||||
})
|
||||
async teamCollectionsCount() {
|
||||
return this.adminService.getTeamCollectionsCount();
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return total number of Team Requests in organization',
|
||||
})
|
||||
async teamRequestsCount() {
|
||||
return this.adminService.getTeamRequestsCount();
|
||||
}
|
||||
|
||||
/* Mutations */
|
||||
|
||||
@Mutation(() => InvitedUser, {
|
||||
description: 'Invite a user to the infra using email',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async inviteNewUser(
|
||||
@GqlUser() adminUser: AuthUser,
|
||||
@Args({
|
||||
name: 'inviteeEmail',
|
||||
description: 'invitee email',
|
||||
})
|
||||
inviteeEmail: string,
|
||||
): Promise<InvitedUser> {
|
||||
const invitedUser = await this.adminService.inviteUserToSignInViaEmail(
|
||||
adminUser.uid,
|
||||
adminUser.email,
|
||||
inviteeEmail,
|
||||
);
|
||||
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
|
||||
return invitedUser.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Delete an user account from infra',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async removeUserByAdmin(
|
||||
@Args({
|
||||
name: 'userUID',
|
||||
description: 'users UID',
|
||||
type: () => ID,
|
||||
})
|
||||
userUID: string,
|
||||
): Promise<boolean> {
|
||||
const invitedUser = await this.adminService.removeUserAccount(userUID);
|
||||
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
|
||||
return invitedUser.right;
|
||||
}
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Make user an admin',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async makeUserAdmin(
|
||||
@Args({
|
||||
name: 'userUID',
|
||||
description: 'users UID',
|
||||
type: () => ID,
|
||||
})
|
||||
userUID: string,
|
||||
): Promise<boolean> {
|
||||
const admin = await this.adminService.makeUserAdmin(userUID);
|
||||
if (E.isLeft(admin)) throwErr(admin.left);
|
||||
return admin.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Remove user as admin',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async removeUserAsAdmin(
|
||||
@Args({
|
||||
name: 'userUID',
|
||||
description: 'users UID',
|
||||
type: () => ID,
|
||||
})
|
||||
userUID: string,
|
||||
): Promise<boolean> {
|
||||
const admin = await this.adminService.removeUserAsAdmin(userUID);
|
||||
if (E.isLeft(admin)) throwErr(admin.left);
|
||||
return admin.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Team, {
|
||||
description:
|
||||
'Create a new team by providing the user uid to nominate as Team owner',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async createTeamByAdmin(
|
||||
@GqlAdmin() adminUser: Admin,
|
||||
@Args({
|
||||
name: 'userUid',
|
||||
description: 'users uid to make team owner',
|
||||
type: () => ID,
|
||||
})
|
||||
userUid: string,
|
||||
@Args({ name: 'name', description: 'Displayed name of the team' })
|
||||
name: string,
|
||||
): Promise<Team> {
|
||||
const createdTeam = await this.adminService.createATeam(userUid, name);
|
||||
if (E.isLeft(createdTeam)) throwErr(createdTeam.left);
|
||||
return createdTeam.right;
|
||||
}
|
||||
@Mutation(() => TeamMember, {
|
||||
description: 'Change the role of a user in a team',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async changeUserRoleInTeamByAdmin(
|
||||
@GqlAdmin() adminUser: Admin,
|
||||
@Args() args: ChangeUserRoleInTeamArgs,
|
||||
): Promise<TeamMember> {
|
||||
const updatedRole = await this.adminService.changeRoleOfUserTeam(
|
||||
args.userUID,
|
||||
args.teamID,
|
||||
args.newRole,
|
||||
);
|
||||
if (E.isLeft(updatedRole)) throwErr(updatedRole.left);
|
||||
return updatedRole.right;
|
||||
}
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Remove the user from a team',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async removeUserFromTeamByAdmin(
|
||||
@GqlAdmin() adminUser: Admin,
|
||||
@Args({
|
||||
name: 'userUid',
|
||||
description: 'users UID',
|
||||
type: () => ID,
|
||||
})
|
||||
userUid: string,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
description: 'team ID',
|
||||
type: () => ID,
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<boolean> {
|
||||
const removedUser = await this.adminService.removeUserFromTeam(
|
||||
userUid,
|
||||
teamID,
|
||||
);
|
||||
if (E.isLeft(removedUser)) throwErr(removedUser.left);
|
||||
return removedUser.right;
|
||||
}
|
||||
@Mutation(() => TeamMember, {
|
||||
description: 'Add a user to a team with email and team member role',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async addUserToTeamByAdmin(
|
||||
@GqlAdmin() adminUser: Admin,
|
||||
@Args() args: AddUserToTeamArgs,
|
||||
): Promise<TeamMember> {
|
||||
const addedUser = await this.adminService.addUserToTeam(
|
||||
args.teamID,
|
||||
args.userEmail,
|
||||
args.role,
|
||||
);
|
||||
if (E.isLeft(addedUser)) throwErr(addedUser.left);
|
||||
return addedUser.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Team, {
|
||||
description: 'Change a team name',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async renameTeamByAdmin(
|
||||
@GqlAdmin() adminUser: Admin,
|
||||
@Args({ name: 'teamID', description: 'ID of the team', type: () => ID })
|
||||
teamID: string,
|
||||
@Args({ name: 'newName', description: 'The updated name of the team' })
|
||||
newName: string,
|
||||
): Promise<Team> {
|
||||
const renamedTeam = await this.adminService.renameATeam(teamID, newName);
|
||||
if (E.isLeft(renamedTeam)) throwErr(renamedTeam.left);
|
||||
return renamedTeam.right;
|
||||
}
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Delete a team',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async deleteTeamByAdmin(
|
||||
@Args({ name: 'teamID', description: 'ID of the team', type: () => ID })
|
||||
teamID: string,
|
||||
): Promise<boolean> {
|
||||
const deletedTeam = await this.adminService.deleteATeam(teamID);
|
||||
if (E.isLeft(deletedTeam)) throwErr(deletedTeam.left);
|
||||
return deletedTeam.right;
|
||||
}
|
||||
|
||||
/* Subscriptions */
|
||||
|
||||
@Subscription(() => InvitedUser, {
|
||||
description: 'Listen for User Invitation',
|
||||
resolve: (value) => value,
|
||||
})
|
||||
@SkipThrottle()
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
userInvited(@GqlUser() admin: AuthUser) {
|
||||
return this.pubsub.asyncIterator(`admin/${admin.uid}/invited`);
|
||||
}
|
||||
}
|
||||
168
packages/hoppscotch-backend/src/admin/admin.service.spec.ts
Normal file
168
packages/hoppscotch-backend/src/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { AdminService } from './admin.service';
|
||||
import { PubSubService } from '../pubsub/pubsub.service';
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import { InvitedUsers } from '@prisma/client';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { TeamService } from '../team/team.service';
|
||||
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
||||
import { TeamRequestService } from '../team-request/team-request.service';
|
||||
import { TeamInvitationService } from '../team-invitation/team-invitation.service';
|
||||
import { TeamCollectionService } from '../team-collection/team-collection.service';
|
||||
import { MailerService } from '../mailer/mailer.service';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import {
|
||||
DUPLICATE_EMAIL,
|
||||
INVALID_EMAIL,
|
||||
USER_ALREADY_INVITED,
|
||||
} from '../errors';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
const mockUserService = mockDeep<UserService>();
|
||||
const mockTeamService = mockDeep<TeamService>();
|
||||
const mockTeamEnvironmentsService = mockDeep<TeamEnvironmentsService>();
|
||||
const mockTeamRequestService = mockDeep<TeamRequestService>();
|
||||
const mockTeamInvitationService = mockDeep<TeamInvitationService>();
|
||||
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
|
||||
const mockMailerService = mockDeep<MailerService>();
|
||||
|
||||
const adminService = new AdminService(
|
||||
mockUserService,
|
||||
mockTeamService,
|
||||
mockTeamCollectionService,
|
||||
mockTeamRequestService,
|
||||
mockTeamEnvironmentsService,
|
||||
mockTeamInvitationService,
|
||||
mockPubSub as any,
|
||||
mockPrisma as any,
|
||||
mockMailerService,
|
||||
);
|
||||
|
||||
const invitedUsers: InvitedUsers[] = [
|
||||
{
|
||||
adminUid: 'uid1',
|
||||
adminEmail: 'admin1@example.com',
|
||||
inviteeEmail: 'i@example.com',
|
||||
invitedOn: new Date(),
|
||||
},
|
||||
{
|
||||
adminUid: 'uid2',
|
||||
adminEmail: 'admin2@example.com',
|
||||
inviteeEmail: 'u@example.com',
|
||||
invitedOn: new Date(),
|
||||
},
|
||||
];
|
||||
describe('AdminService', () => {
|
||||
describe('fetchInvitedUsers', () => {
|
||||
test('should resolve right and return an array of invited users', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
|
||||
|
||||
const results = await adminService.fetchInvitedUsers();
|
||||
expect(results).toEqual(invitedUsers);
|
||||
});
|
||||
test('should resolve left and return an empty array if invited users not found', async () => {
|
||||
mockPrisma.invitedUsers.findMany.mockResolvedValue([]);
|
||||
|
||||
const results = await adminService.fetchInvitedUsers();
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inviteUserToSignInViaEmail', () => {
|
||||
test('should resolve right and create a invited user', async () => {
|
||||
mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(null);
|
||||
mockPrisma.invitedUsers.create.mockResolvedValueOnce(invitedUsers[0]);
|
||||
const result = await adminService.inviteUserToSignInViaEmail(
|
||||
invitedUsers[0].adminUid,
|
||||
invitedUsers[0].adminEmail,
|
||||
invitedUsers[0].inviteeEmail,
|
||||
);
|
||||
expect(mockPrisma.invitedUsers.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
adminUid: invitedUsers[0].adminUid,
|
||||
adminEmail: invitedUsers[0].adminEmail,
|
||||
inviteeEmail: invitedUsers[0].inviteeEmail,
|
||||
},
|
||||
});
|
||||
return expect(result).toEqualRight(invitedUsers[0]);
|
||||
});
|
||||
test('should resolve right, create a invited user and publish a subscription', async () => {
|
||||
mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(null);
|
||||
mockPrisma.invitedUsers.create.mockResolvedValueOnce(invitedUsers[0]);
|
||||
await adminService.inviteUserToSignInViaEmail(
|
||||
invitedUsers[0].adminUid,
|
||||
invitedUsers[0].adminEmail,
|
||||
invitedUsers[0].inviteeEmail,
|
||||
);
|
||||
return expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`admin/${invitedUsers[0].adminUid}/invited`,
|
||||
invitedUsers[0],
|
||||
);
|
||||
});
|
||||
test('should resolve left and return an error when invalid invitee email is passed', async () => {
|
||||
const result = await adminService.inviteUserToSignInViaEmail(
|
||||
invitedUsers[0].adminUid,
|
||||
invitedUsers[0].adminEmail,
|
||||
'invalidemail',
|
||||
);
|
||||
return expect(result).toEqualLeft(INVALID_EMAIL);
|
||||
});
|
||||
test('should resolve left and return an error when user already invited', async () => {
|
||||
mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(invitedUsers[0]);
|
||||
const result = await adminService.inviteUserToSignInViaEmail(
|
||||
invitedUsers[0].adminUid,
|
||||
invitedUsers[0].adminEmail,
|
||||
invitedUsers[0].inviteeEmail,
|
||||
);
|
||||
return expect(result).toEqualLeft(USER_ALREADY_INVITED);
|
||||
});
|
||||
test('should resolve left and return an error when invitee and admin email is same', async () => {
|
||||
const result = await adminService.inviteUserToSignInViaEmail(
|
||||
invitedUsers[0].adminUid,
|
||||
invitedUsers[0].inviteeEmail,
|
||||
invitedUsers[0].inviteeEmail,
|
||||
);
|
||||
return expect(result).toEqualLeft(DUPLICATE_EMAIL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsersCount', () => {
|
||||
test('should return count of all users in the organization', async () => {
|
||||
mockUserService.getUsersCount.mockResolvedValueOnce(10);
|
||||
|
||||
const result = await adminService.getUsersCount();
|
||||
expect(result).toEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamsCount', () => {
|
||||
test('should return count of all teams in the organization', async () => {
|
||||
mockTeamService.getTeamsCount.mockResolvedValueOnce(10);
|
||||
|
||||
const result = await adminService.getTeamsCount();
|
||||
expect(result).toEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamCollectionsCount', () => {
|
||||
test('should return count of all Team Collections in the organization', async () => {
|
||||
mockTeamCollectionService.getTeamCollectionsCount.mockResolvedValueOnce(
|
||||
10,
|
||||
);
|
||||
|
||||
const result = await adminService.getTeamCollectionsCount();
|
||||
expect(result).toEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamRequestsCount', () => {
|
||||
test('should return count of all Team Collections in the organization', async () => {
|
||||
mockTeamRequestService.getTeamRequestsCount.mockResolvedValueOnce(10);
|
||||
|
||||
const result = await adminService.getTeamRequestsCount();
|
||||
expect(result).toEqual(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
407
packages/hoppscotch-backend/src/admin/admin.service.ts
Normal file
407
packages/hoppscotch-backend/src/admin/admin.service.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { PubSubService } from '../pubsub/pubsub.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { validateEmail } from '../utils';
|
||||
import {
|
||||
DUPLICATE_EMAIL,
|
||||
EMAIL_FAILED,
|
||||
INVALID_EMAIL,
|
||||
ONLY_ONE_ADMIN_ACCOUNT,
|
||||
TEAM_INVITE_ALREADY_MEMBER,
|
||||
USER_ALREADY_INVITED,
|
||||
USER_IS_ADMIN,
|
||||
USER_NOT_FOUND,
|
||||
} from '../errors';
|
||||
import { MailerService } from '../mailer/mailer.service';
|
||||
import { InvitedUser } from './invited-user.model';
|
||||
import { TeamService } from '../team/team.service';
|
||||
import { TeamCollectionService } from '../team-collection/team-collection.service';
|
||||
import { TeamRequestService } from '../team-request/team-request.service';
|
||||
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
||||
import { TeamInvitationService } from '../team-invitation/team-invitation.service';
|
||||
import { TeamMemberRole } from '../team/team.model';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly teamService: TeamService,
|
||||
private readonly teamCollectionService: TeamCollectionService,
|
||||
private readonly teamRequestService: TeamRequestService,
|
||||
private readonly teamEnvironmentsService: TeamEnvironmentsService,
|
||||
private readonly teamInvitationService: TeamInvitationService,
|
||||
private readonly pubsub: PubSubService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailerService: MailerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetch all the users in the infra.
|
||||
* @param cursorID Users uid
|
||||
* @param take number of users to fetch
|
||||
* @returns an Either of array of user or error
|
||||
*/
|
||||
async fetchUsers(cursorID: string, take: number) {
|
||||
const allUsers = await this.userService.fetchAllUsers(cursorID, take);
|
||||
return allUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a user to join the infra.
|
||||
* @param adminUID Admin's UID
|
||||
* @param adminEmail Admin's email
|
||||
* @param inviteeEmail Invitee's email
|
||||
* @returns an Either of `InvitedUser` object or error
|
||||
*/
|
||||
async inviteUserToSignInViaEmail(
|
||||
adminUID: string,
|
||||
adminEmail: string,
|
||||
inviteeEmail: string,
|
||||
) {
|
||||
if (inviteeEmail == adminEmail) return E.left(DUPLICATE_EMAIL);
|
||||
if (!validateEmail(inviteeEmail)) return E.left(INVALID_EMAIL);
|
||||
|
||||
const alreadyInvitedUser = await this.prisma.invitedUsers.findFirst({
|
||||
where: {
|
||||
inviteeEmail: inviteeEmail,
|
||||
},
|
||||
});
|
||||
if (alreadyInvitedUser != null) return E.left(USER_ALREADY_INVITED);
|
||||
|
||||
try {
|
||||
await this.mailerService.sendUserInvitationEmail(inviteeEmail, {
|
||||
template: 'code-your-own',
|
||||
variables: {
|
||||
inviteeEmail: inviteeEmail,
|
||||
magicLink: `${process.env.VITE_BASE_URL}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return E.left(EMAIL_FAILED);
|
||||
}
|
||||
|
||||
// Add invitee email to the list of invited users by admin
|
||||
const dbInvitedUser = await this.prisma.invitedUsers.create({
|
||||
data: {
|
||||
adminUid: adminUID,
|
||||
adminEmail: adminEmail,
|
||||
inviteeEmail: inviteeEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const invitedUser = <InvitedUser>{
|
||||
adminEmail: dbInvitedUser.adminEmail,
|
||||
adminUid: dbInvitedUser.adminUid,
|
||||
inviteeEmail: dbInvitedUser.inviteeEmail,
|
||||
invitedOn: dbInvitedUser.invitedOn,
|
||||
};
|
||||
|
||||
// Publish invited user subscription
|
||||
await this.pubsub.publish(`admin/${adminUID}/invited`, invitedUser);
|
||||
|
||||
return E.right(invitedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of invited users by the admin.
|
||||
* @returns an Either of array of `InvitedUser` object or error
|
||||
*/
|
||||
async fetchInvitedUsers() {
|
||||
const invitedUsers = await this.prisma.invitedUsers.findMany();
|
||||
|
||||
const users: InvitedUser[] = invitedUsers.map(
|
||||
(user) => <InvitedUser>{ ...user },
|
||||
);
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the teams in the infra.
|
||||
* @param cursorID team id
|
||||
* @param take number of items to fetch
|
||||
* @returns an array of teams
|
||||
*/
|
||||
async fetchAllTeams(cursorID: string, take: number) {
|
||||
const allTeams = await this.teamService.fetchAllTeams(cursorID, take);
|
||||
return allTeams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the count of all the members in a team.
|
||||
* @param teamID team id
|
||||
* @returns a count of team members
|
||||
*/
|
||||
async membersCountInTeam(teamID: string) {
|
||||
const teamMembersCount = await this.teamService.getCountOfMembersInTeam(
|
||||
teamID,
|
||||
);
|
||||
return teamMembersCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch count of all the collections in a team.
|
||||
* @param teamID team id
|
||||
* @returns a of count of collections
|
||||
*/
|
||||
async collectionCountInTeam(teamID: string) {
|
||||
const teamCollectionsCount =
|
||||
await this.teamCollectionService.totalCollectionsInTeam(teamID);
|
||||
return teamCollectionsCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the count of all the requests in a team.
|
||||
* @param teamID team id
|
||||
* @returns a count of total requests in a team
|
||||
*/
|
||||
async requestCountInTeam(teamID: string) {
|
||||
const teamRequestsCount =
|
||||
await this.teamRequestService.totalRequestsInATeam(teamID);
|
||||
|
||||
return teamRequestsCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the count of all the environments in a team.
|
||||
* @param teamID team id
|
||||
* @returns a count of environments in a team
|
||||
*/
|
||||
async environmentCountInTeam(teamID: string) {
|
||||
const envCount = await this.teamEnvironmentsService.totalEnvsInTeam(teamID);
|
||||
return envCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the invitations for a given team.
|
||||
* @param teamID team id
|
||||
* @returns an array team invitations
|
||||
*/
|
||||
async pendingInvitationCountInTeam(teamID: string) {
|
||||
const invitations = await this.teamInvitationService.getAllTeamInvitations(
|
||||
teamID,
|
||||
);
|
||||
|
||||
return invitations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the role of a user in a team
|
||||
* @param userUid users uid
|
||||
* @param teamID team id
|
||||
* @returns an Either of updated `TeamMember` object or error
|
||||
*/
|
||||
async changeRoleOfUserTeam(
|
||||
userUid: string,
|
||||
teamID: string,
|
||||
newRole: TeamMemberRole,
|
||||
) {
|
||||
const updatedTeamMember = await this.teamService.updateTeamMemberRole(
|
||||
teamID,
|
||||
userUid,
|
||||
newRole,
|
||||
);
|
||||
|
||||
if (E.isLeft(updatedTeamMember)) return E.left(updatedTeamMember.left);
|
||||
|
||||
return E.right(updatedTeamMember.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the user from a team
|
||||
* @param userUid users uid
|
||||
* @param teamID team id
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async removeUserFromTeam(userUid: string, teamID: string) {
|
||||
const removedUser = await this.teamService.leaveTeam(teamID, userUid);
|
||||
if (E.isLeft(removedUser)) return E.left(removedUser.left);
|
||||
|
||||
return E.right(removedUser.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the user to a team
|
||||
* @param teamID team id
|
||||
* @param userEmail users email
|
||||
* @param role team member role for the user
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async addUserToTeam(teamID: string, userEmail: string, role: TeamMemberRole) {
|
||||
if (!validateEmail(userEmail)) return E.left(INVALID_EMAIL);
|
||||
|
||||
const user = await this.userService.findUserByEmail(userEmail);
|
||||
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
|
||||
|
||||
const isUserAlreadyMember = await this.teamService.getTeamMemberTE(
|
||||
teamID,
|
||||
user.value.uid,
|
||||
)();
|
||||
if (E.left(isUserAlreadyMember)) {
|
||||
const addedUser = await this.teamService.addMemberToTeamWithEmail(
|
||||
teamID,
|
||||
userEmail,
|
||||
role,
|
||||
);
|
||||
if (E.isLeft(addedUser)) return E.left(addedUser.left);
|
||||
|
||||
return E.right(addedUser.right);
|
||||
}
|
||||
|
||||
return E.left(TEAM_INVITE_ALREADY_MEMBER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new team
|
||||
* @param userUid user uid
|
||||
* @param name team name
|
||||
* @returns an Either of `Team` object or error
|
||||
*/
|
||||
async createATeam(userUid: string, name: string) {
|
||||
const validUser = await this.userService.findUserById(userUid);
|
||||
if (O.isNone(validUser)) return E.left(USER_NOT_FOUND);
|
||||
|
||||
const createdTeam = await this.teamService.createTeam(name, userUid);
|
||||
if (E.isLeft(createdTeam)) return E.left(createdTeam.left);
|
||||
|
||||
return E.right(createdTeam.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a team
|
||||
* @param teamID team ID
|
||||
* @param newName new team name
|
||||
* @returns an Either of `Team` object or error
|
||||
*/
|
||||
async renameATeam(teamID: string, newName: string) {
|
||||
const renamedTeam = await this.teamService.renameTeam(teamID, newName);
|
||||
if (E.isLeft(renamedTeam)) return E.left(renamedTeam.left);
|
||||
|
||||
return E.right(renamedTeam.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a team
|
||||
* @param teamID team ID
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async deleteATeam(teamID: string) {
|
||||
const deleteTeam = await this.teamService.deleteTeam(teamID);
|
||||
if (E.isLeft(deleteTeam)) return E.left(deleteTeam.left);
|
||||
|
||||
return E.right(deleteTeam.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all admin accounts
|
||||
* @returns an array of admin users
|
||||
*/
|
||||
async fetchAdmins() {
|
||||
const admins = this.userService.fetchAdminUsers();
|
||||
return admins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a user by UID
|
||||
* @param userUid User UID
|
||||
* @returns an Either of `User` obj or error
|
||||
*/
|
||||
async fetchUserInfo(userUid: string) {
|
||||
const user = await this.userService.findUserById(userUid);
|
||||
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
|
||||
|
||||
return E.right(user.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user account by UID
|
||||
* @param userUid User UID
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async removeUserAccount(userUid: string) {
|
||||
const user = await this.userService.findUserById(userUid);
|
||||
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
|
||||
|
||||
if (user.value.isAdmin) return E.left(USER_IS_ADMIN);
|
||||
|
||||
const delUser = await this.userService.deleteUserByUID(user.value)();
|
||||
if (E.isLeft(delUser)) return E.left(delUser.left);
|
||||
return E.right(delUser.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a user an admin
|
||||
* @param userUid User UID
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async makeUserAdmin(userUID: string) {
|
||||
const admin = await this.userService.makeAdmin(userUID);
|
||||
if (E.isLeft(admin)) return E.left(admin.left);
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user as admin
|
||||
* @param userUid User UID
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async removeUserAsAdmin(userUID: string) {
|
||||
const adminUsers = await this.userService.fetchAdminUsers();
|
||||
if (adminUsers.length === 1) return E.left(ONLY_ONE_ADMIN_ACCOUNT);
|
||||
|
||||
const admin = await this.userService.removeUserAsAdmin(userUID);
|
||||
if (E.isLeft(admin)) return E.left(admin.left);
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of all the Users in org
|
||||
* @returns number of users in the org
|
||||
*/
|
||||
async getUsersCount() {
|
||||
const usersCount = this.userService.getUsersCount();
|
||||
return usersCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of all the Teams in org
|
||||
* @returns number of users in the org
|
||||
*/
|
||||
async getTeamsCount() {
|
||||
const teamsCount = this.teamService.getTeamsCount();
|
||||
return teamsCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of all the Team Collections in org
|
||||
* @returns number of users in the org
|
||||
*/
|
||||
async getTeamCollectionsCount() {
|
||||
const teamCollectionCount =
|
||||
this.teamCollectionService.getTeamCollectionsCount();
|
||||
return teamCollectionCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of all the Team Requests in org
|
||||
* @returns number of users in the org
|
||||
*/
|
||||
async getTeamRequestsCount() {
|
||||
const teamRequestCount = this.teamRequestService.getTeamRequestsCount();
|
||||
return teamRequestCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team info by ID
|
||||
* @param teamID Team ID
|
||||
* @returns an Either of `Team` or error
|
||||
*/
|
||||
async getTeamInfo(teamID: string) {
|
||||
const team = await this.teamService.getTeamWithIDTE(teamID)();
|
||||
if (E.isLeft(team)) return E.left(team.left);
|
||||
return E.right(team.right);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
export const GqlAdmin = createParamDecorator(
|
||||
(data: unknown, context: ExecutionContext) => {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
return ctx.getContext().req.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
@Injectable()
|
||||
export class GqlAdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
const { req, headers } = ctx.getContext();
|
||||
const request = headers ? headers : req;
|
||||
const user = request.user;
|
||||
if (user.isAdmin) return true;
|
||||
else return false;
|
||||
}
|
||||
}
|
||||
43
packages/hoppscotch-backend/src/admin/input-types.args.ts
Normal file
43
packages/hoppscotch-backend/src/admin/input-types.args.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Field, ID, ArgsType } from '@nestjs/graphql';
|
||||
import { TeamMemberRole } from '../team/team.model';
|
||||
|
||||
@ArgsType()
|
||||
export class ChangeUserRoleInTeamArgs {
|
||||
@Field(() => ID, {
|
||||
name: 'userUID',
|
||||
description: 'users UID',
|
||||
})
|
||||
userUID: string;
|
||||
@Field(() => ID, {
|
||||
name: 'teamID',
|
||||
description: 'team ID',
|
||||
})
|
||||
teamID: string;
|
||||
|
||||
@Field(() => TeamMemberRole, {
|
||||
name: 'newRole',
|
||||
description: 'updated team role',
|
||||
})
|
||||
newRole: TeamMemberRole;
|
||||
}
|
||||
|
||||
@ArgsType()
|
||||
export class AddUserToTeamArgs {
|
||||
@Field(() => ID, {
|
||||
name: 'teamID',
|
||||
description: 'team ID',
|
||||
})
|
||||
teamID: string;
|
||||
|
||||
@Field(() => TeamMemberRole, {
|
||||
name: 'role',
|
||||
description: 'The role of the user to add in the team',
|
||||
})
|
||||
role: TeamMemberRole;
|
||||
|
||||
@Field({
|
||||
name: 'userEmail',
|
||||
description: 'Email of the user to add to team',
|
||||
})
|
||||
userEmail: string;
|
||||
}
|
||||
24
packages/hoppscotch-backend/src/admin/invited-user.model.ts
Normal file
24
packages/hoppscotch-backend/src/admin/invited-user.model.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ObjectType, ID, Field } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class InvitedUser {
|
||||
@Field(() => ID, {
|
||||
description: 'Admin UID',
|
||||
})
|
||||
adminUid: string;
|
||||
|
||||
@Field({
|
||||
description: 'Admin email',
|
||||
})
|
||||
adminEmail: string;
|
||||
|
||||
@Field({
|
||||
description: 'Invitee email',
|
||||
})
|
||||
inviteeEmail: string;
|
||||
|
||||
@Field({
|
||||
description: 'Date when the user invitation was sent',
|
||||
})
|
||||
invitedOn: Date;
|
||||
}
|
||||
85
packages/hoppscotch-backend/src/app.module.ts
Normal file
85
packages/hoppscotch-backend/src/app.module.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ForbiddenException, HttpException, Module } from '@nestjs/common';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { GQLComplexityPlugin } from './plugins/GQLComplexityPlugin';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UserSettingsModule } from './user-settings/user-settings.module';
|
||||
import { UserEnvironmentsModule } from './user-environment/user-environments.module';
|
||||
import { UserRequestModule } from './user-request/user-request.module';
|
||||
import { UserHistoryModule } from './user-history/user-history.module';
|
||||
import { subscriptionContextCookieParser } from './auth/helper';
|
||||
import { TeamModule } from './team/team.module';
|
||||
import { TeamEnvironmentsModule } from './team-environments/team-environments.module';
|
||||
import { TeamCollectionModule } from './team-collection/team-collection.module';
|
||||
import { TeamRequestModule } from './team-request/team-request.module';
|
||||
import { TeamInvitationModule } from './team-invitation/team-invitation.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { UserCollectionModule } from './user-collection/user-collection.module';
|
||||
import { ShortcodeModule } from './shortcode/shortcode.module';
|
||||
import { COOKIES_NOT_FOUND } from './errors';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||
buildSchemaOptions: {
|
||||
numberScalarMode: 'integer',
|
||||
},
|
||||
cors: {
|
||||
origin: process.env.WHITELISTED_ORIGINS.split(','),
|
||||
credentials: true,
|
||||
},
|
||||
playground: process.env.PRODUCTION !== 'true',
|
||||
debug: process.env.PRODUCTION !== 'true',
|
||||
autoSchemaFile: true,
|
||||
installSubscriptionHandlers: true,
|
||||
subscriptions: {
|
||||
'subscriptions-transport-ws': {
|
||||
path: '/graphql',
|
||||
onConnect: (_, websocket) => {
|
||||
try {
|
||||
const cookies = subscriptionContextCookieParser(
|
||||
websocket.upgradeReq.headers.cookie,
|
||||
);
|
||||
|
||||
return {
|
||||
headers: { ...websocket?.upgradeReq?.headers, cookies },
|
||||
};
|
||||
} catch (error) {
|
||||
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
||||
cause: new Error(COOKIES_NOT_FOUND),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
context: ({ req, res, connection }) => ({
|
||||
req,
|
||||
res,
|
||||
connection,
|
||||
}),
|
||||
driver: ApolloDriver,
|
||||
}),
|
||||
ThrottlerModule.forRoot({
|
||||
ttl: +process.env.RATE_LIMIT_TTL,
|
||||
limit: +process.env.RATE_LIMIT_MAX,
|
||||
}),
|
||||
UserModule,
|
||||
AuthModule,
|
||||
AdminModule,
|
||||
UserSettingsModule,
|
||||
UserEnvironmentsModule,
|
||||
UserHistoryModule,
|
||||
UserRequestModule,
|
||||
TeamModule,
|
||||
TeamEnvironmentsModule,
|
||||
TeamCollectionModule,
|
||||
TeamRequestModule,
|
||||
TeamInvitationModule,
|
||||
UserCollectionModule,
|
||||
ShortcodeModule,
|
||||
],
|
||||
providers: [GQLComplexityPlugin],
|
||||
})
|
||||
export class AppModule {}
|
||||
171
packages/hoppscotch-backend/src/auth/auth.controller.ts
Normal file
171
packages/hoppscotch-backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Request,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SignInMagicDto } from './dto/signin-magic.dto';
|
||||
import { VerifyMagicDto } from './dto/verify-magic.dto';
|
||||
import { Response } from 'express';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { RTJwtAuthGuard } from './guards/rt-jwt-auth.guard';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
||||
import { authCookieHandler, throwHTTPErr } from './helper';
|
||||
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
||||
import { GithubSSOGuard } from './guards/github-sso.guard';
|
||||
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'auth', version: '1' })
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
/**
|
||||
** Route to initiate magic-link auth for a users email
|
||||
*/
|
||||
@Post('signin')
|
||||
async signInMagicLink(
|
||||
@Body() authData: SignInMagicDto,
|
||||
@Query('origin') origin: string,
|
||||
) {
|
||||
const deviceIdToken = await this.authService.signInMagicLink(
|
||||
authData.email,
|
||||
origin,
|
||||
);
|
||||
if (E.isLeft(deviceIdToken)) throwHTTPErr(deviceIdToken.left);
|
||||
return deviceIdToken.right;
|
||||
}
|
||||
|
||||
/**
|
||||
** Route to verify and sign in a valid user via magic-link
|
||||
*/
|
||||
@Post('verify')
|
||||
async verify(@Body() data: VerifyMagicDto, @Res() res: Response) {
|
||||
const authTokens = await this.authService.verifyMagicLinkTokens(data);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
authCookieHandler(res, authTokens.right, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
** Route to refresh auth tokens with Refresh Token Rotation
|
||||
* @see https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation
|
||||
*/
|
||||
@Get('refresh')
|
||||
@UseGuards(RTJwtAuthGuard)
|
||||
async refresh(
|
||||
@GqlUser() user: AuthUser,
|
||||
@RTCookie() refresh_token: string,
|
||||
@Res() res,
|
||||
) {
|
||||
const newTokenPair = await this.authService.refreshAuthTokens(
|
||||
refresh_token,
|
||||
user,
|
||||
);
|
||||
if (E.isLeft(newTokenPair)) throwHTTPErr(newTokenPair.left);
|
||||
authCookieHandler(res, newTokenPair.right, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
** Route to initiate SSO auth via Google
|
||||
*/
|
||||
@Get('google')
|
||||
@UseGuards(GoogleSSOGuard)
|
||||
async googleAuth(@Request() req) {}
|
||||
|
||||
/**
|
||||
** Callback URL for Google SSO
|
||||
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
|
||||
*/
|
||||
@Get('google/callback')
|
||||
@SkipThrottle()
|
||||
@UseGuards(GoogleSSOGuard)
|
||||
async googleAuthRedirect(@Request() req, @Res() res) {
|
||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
authCookieHandler(
|
||||
res,
|
||||
authTokens.right,
|
||||
true,
|
||||
req.authInfo.state.redirect_uri,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
** Route to initiate SSO auth via Github
|
||||
*/
|
||||
@Get('github')
|
||||
@UseGuards(GithubSSOGuard)
|
||||
async githubAuth(@Request() req) {}
|
||||
|
||||
/**
|
||||
** Callback URL for Github SSO
|
||||
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
|
||||
*/
|
||||
@Get('github/callback')
|
||||
@SkipThrottle()
|
||||
@UseGuards(GithubSSOGuard)
|
||||
async githubAuthRedirect(@Request() req, @Res() res) {
|
||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
authCookieHandler(
|
||||
res,
|
||||
authTokens.right,
|
||||
true,
|
||||
req.authInfo.state.redirect_uri,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
** Route to initiate SSO auth via Microsoft
|
||||
*/
|
||||
@Get('microsoft')
|
||||
@UseGuards(MicrosoftSSOGuard)
|
||||
async microsoftAuth(@Request() req) {}
|
||||
|
||||
/**
|
||||
** Callback URL for Microsoft SSO
|
||||
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
|
||||
*/
|
||||
@Get('microsoft/callback')
|
||||
@SkipThrottle()
|
||||
@UseGuards(MicrosoftSSOGuard)
|
||||
async microsoftAuthRedirect(@Request() req, @Res() res) {
|
||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
authCookieHandler(
|
||||
res,
|
||||
authTokens.right,
|
||||
true,
|
||||
req.authInfo.state.redirect_uri,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
** Log user out by clearing cookies containing auth tokens
|
||||
*/
|
||||
@Get('logout')
|
||||
async logout(@Res() res: Response) {
|
||||
res.clearCookie('access_token');
|
||||
res.clearCookie('refresh_token');
|
||||
return res.status(200).send();
|
||||
}
|
||||
|
||||
@Get('verify/admin')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async verifyAdmin(@GqlUser() user: AuthUser) {
|
||||
const userInfo = await this.authService.verifyAdmin(user);
|
||||
if (E.isLeft(userInfo)) throwHTTPErr(userInfo.left);
|
||||
return userInfo.right;
|
||||
}
|
||||
}
|
||||
35
packages/hoppscotch-backend/src/auth/auth.module.ts
Normal file
35
packages/hoppscotch-backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { UserModule } from 'src/user/user.module';
|
||||
import { MailerModule } from 'src/mailer/mailer.module';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { RTJwtStrategy } from './strategies/rt-jwt.strategy';
|
||||
import { GoogleStrategy } from './strategies/google.strategy';
|
||||
import { GithubStrategy } from './strategies/github.strategy';
|
||||
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
UserModule,
|
||||
MailerModule,
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
RTJwtStrategy,
|
||||
GoogleStrategy,
|
||||
GithubStrategy,
|
||||
MicrosoftStrategy,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
412
packages/hoppscotch-backend/src/auth/auth.service.spec.ts
Normal file
412
packages/hoppscotch-backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Account, VerificationToken } from '@prisma/client';
|
||||
import { mockDeep, mockFn } from 'jest-mock-extended';
|
||||
import {
|
||||
INVALID_EMAIL,
|
||||
INVALID_MAGIC_LINK_DATA,
|
||||
INVALID_REFRESH_TOKEN,
|
||||
MAGIC_LINK_EXPIRED,
|
||||
VERIFICATION_TOKEN_DATA_NOT_FOUND,
|
||||
USER_NOT_FOUND,
|
||||
USERS_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { MailerService } from 'src/mailer/mailer.service';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { VerifyMagicDto } from './dto/verify-magic.dto';
|
||||
import { DateTime } from 'luxon';
|
||||
import * as argon2 from 'argon2';
|
||||
import * as E from 'fp-ts/Either';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockUser = mockDeep<UserService>();
|
||||
const mockJWT = mockDeep<JwtService>();
|
||||
const mockMailer = mockDeep<MailerService>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const authService = new AuthService(mockUser, mockPrisma, mockJWT, mockMailer);
|
||||
|
||||
const currentTime = new Date();
|
||||
|
||||
const user: AuthUser = {
|
||||
uid: '123344',
|
||||
email: 'dwight@dundermifflin.com',
|
||||
displayName: 'Dwight Schrute',
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
};
|
||||
|
||||
const passwordlessData: VerificationToken = {
|
||||
deviceIdentifier: 'k23hb7u7gdcujhb',
|
||||
token: 'jhhj24sdjvl',
|
||||
userUid: user.uid,
|
||||
expiresOn: new Date(),
|
||||
};
|
||||
|
||||
const magicLinkVerify: VerifyMagicDto = {
|
||||
deviceIdentifier: 'Dscdc',
|
||||
token: 'SDcsdc',
|
||||
};
|
||||
|
||||
const accountDetails: Account = {
|
||||
id: '123dcdc',
|
||||
userId: user.uid,
|
||||
provider: 'email',
|
||||
providerAccountId: user.uid,
|
||||
providerRefreshToken: 'dscsdc',
|
||||
providerAccessToken: 'sdcsdcsdc',
|
||||
providerScope: 'user.email',
|
||||
loggedIn: currentTime,
|
||||
};
|
||||
|
||||
let nowPlus30 = new Date();
|
||||
nowPlus30.setMinutes(nowPlus30.getMinutes() + 30000);
|
||||
nowPlus30 = new Date(nowPlus30);
|
||||
|
||||
const encodedRefreshToken =
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$JTP8yZ8YXMHdafb5pB9Rfg$tdZrILUxMb9dQbu0uuyeReLgKxsgYnyUNbc5ZxQmy5I';
|
||||
|
||||
describe('signInMagicLink', () => {
|
||||
test('Should throw error if email is not in valid format', async () => {
|
||||
const result = await authService.signInMagicLink('bbbgmail.com', 'admin');
|
||||
expect(result).toEqualLeft({
|
||||
message: INVALID_EMAIL,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should successfully create a new user account and return the passwordless details', async () => {
|
||||
// check to see if user exists, return none
|
||||
mockUser.findUserByEmail.mockResolvedValue(O.none);
|
||||
// create new user
|
||||
mockUser.createUserViaMagicLink.mockResolvedValue(user);
|
||||
// create new entry in VerificationToken table
|
||||
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
|
||||
|
||||
const result = await authService.signInMagicLink(
|
||||
'dwight@dundermifflin.com',
|
||||
'admin',
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
deviceIdentifier: passwordlessData.deviceIdentifier,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should successfully return the passwordless details for a pre-existing user account', async () => {
|
||||
// check to see if user exists, return error
|
||||
mockUser.findUserByEmail.mockResolvedValueOnce(O.some(user));
|
||||
// create new entry in VerificationToken table
|
||||
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
|
||||
|
||||
const result = await authService.signInMagicLink(
|
||||
'dwight@dundermifflin.com',
|
||||
'admin',
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
deviceIdentifier: passwordlessData.deviceIdentifier,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyMagicLinkTokens', () => {
|
||||
test('Should throw INVALID_MAGIC_LINK_DATA if data is invalid', async () => {
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockRejectedValueOnce(
|
||||
'NotFoundError',
|
||||
);
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualLeft({
|
||||
message: INVALID_MAGIC_LINK_DATA,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw USER_NOT_FOUND if user is invalid', async () => {
|
||||
// validatePasswordlessTokens
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce(
|
||||
passwordlessData,
|
||||
);
|
||||
// findUserById
|
||||
mockUser.findUserById.mockResolvedValue(O.none);
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualLeft({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should successfully return auth token pair with provider account existing', async () => {
|
||||
// validatePasswordlessTokens
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
...passwordlessData,
|
||||
expiresOn: nowPlus30,
|
||||
});
|
||||
// findUserById
|
||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
||||
// checkIfProviderAccountExists
|
||||
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
|
||||
// mockPrisma.account.findUnique.mockResolvedValueOnce(null);
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
// deletePasswordlessVerificationToken
|
||||
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualRight({
|
||||
access_token: user.refreshToken,
|
||||
refresh_token: user.refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should successfully return auth token pair with provider account not existing', async () => {
|
||||
// validatePasswordlessTokens
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
...passwordlessData,
|
||||
expiresOn: nowPlus30,
|
||||
});
|
||||
// findUserById
|
||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
||||
// checkIfProviderAccountExists
|
||||
mockPrisma.account.findUnique.mockResolvedValueOnce(null);
|
||||
mockUser.createUserSSO.mockResolvedValueOnce(user);
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
// deletePasswordlessVerificationToken
|
||||
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualRight({
|
||||
access_token: user.refreshToken,
|
||||
refresh_token: user.refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw MAGIC_LINK_EXPIRED if passwordless token is expired', async () => {
|
||||
// validatePasswordlessTokens
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce(
|
||||
passwordlessData,
|
||||
);
|
||||
// findUserById
|
||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
||||
// checkIfProviderAccountExists
|
||||
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualLeft({
|
||||
message: MAGIC_LINK_EXPIRED,
|
||||
statusCode: HttpStatus.UNAUTHORIZED,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
||||
// validatePasswordlessTokens
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
...passwordlessData,
|
||||
expiresOn: nowPlus30,
|
||||
});
|
||||
// findUserById
|
||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
||||
// checkIfProviderAccountExists
|
||||
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
|
||||
// mockPrisma.account.findUnique.mockResolvedValueOnce(null);
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualLeft({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw PASSWORDLESS_DATA_NOT_FOUND when deleting passwordlessVerification entry from DB', async () => {
|
||||
// validatePasswordlessTokens
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
...passwordlessData,
|
||||
expiresOn: nowPlus30,
|
||||
});
|
||||
// findUserById
|
||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
||||
// checkIfProviderAccountExists
|
||||
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
|
||||
// mockPrisma.account.findUnique.mockResolvedValueOnce(null);
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
// deletePasswordlessVerificationToken
|
||||
mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound');
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualLeft({
|
||||
message: VERIFICATION_TOKEN_DATA_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAuthTokens', () => {
|
||||
test('Should successfully generate tokens with valid inputs', async () => {
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
|
||||
const result = await authService.generateAuthTokens(user.uid);
|
||||
expect(result).toEqualRight({
|
||||
access_token: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
refresh_token: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
const result = await authService.generateAuthTokens(user.uid);
|
||||
expect(result).toEqualLeft({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
jest.mock('argon2', () => {
|
||||
return {
|
||||
verify: jest.fn((x, y) => {
|
||||
if (y === null) return false;
|
||||
return true;
|
||||
}),
|
||||
hash: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('refreshAuthTokens', () => {
|
||||
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
const result = await authService.refreshAuthTokens(
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$MvVOam2clCOLtJFGEE26ZA$czvA5ez9hz+A/LML8QRgqgaFuWa5JcbwkH6r+imTQbs',
|
||||
user,
|
||||
);
|
||||
expect(result).toEqualLeft({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw USER_NOT_FOUND when user is invalid', async () => {
|
||||
const result = await authService.refreshAuthTokens(
|
||||
'jshdcbjsdhcbshdbc',
|
||||
null,
|
||||
);
|
||||
expect(result).toEqualLeft({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should successfully refresh the tokens and generate a new auth token pair', async () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.right({
|
||||
...user,
|
||||
refreshToken: 'sdhjcbjsdhcbshjdcb',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await authService.refreshAuthTokens(
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$MvVOam2clCOLtJFGEE26ZA$czvA5ez9hz+A/LML8QRgqgaFuWa5JcbwkH6r+imTQbs',
|
||||
user,
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
access_token: 'sdhjcbjsdhcbshjdcb',
|
||||
refresh_token: 'sdhjcbjsdhcbshjdcb',
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw INVALID_REFRESH_TOKEN when the refresh token is invalid', async () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
|
||||
mockPrisma.user.update.mockResolvedValueOnce({
|
||||
...user,
|
||||
refreshToken: 'sdhjcbjsdhcbshjdcb',
|
||||
});
|
||||
|
||||
const result = await authService.refreshAuthTokens(null, user);
|
||||
expect(result).toEqualLeft({
|
||||
message: INVALID_REFRESH_TOKEN,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyAdmin', () => {
|
||||
test('should successfully elevate user to admin when userCount is 1 ', async () => {
|
||||
// getUsersCount
|
||||
mockUser.getUsersCount.mockResolvedValueOnce(1);
|
||||
// makeAdmin
|
||||
mockUser.makeAdmin.mockResolvedValueOnce(
|
||||
E.right({
|
||||
...user,
|
||||
isAdmin: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await authService.verifyAdmin(user);
|
||||
expect(result).toEqualRight({ isAdmin: true });
|
||||
});
|
||||
|
||||
test('should return true if user is already an admin', async () => {
|
||||
const result = await authService.verifyAdmin({ ...user, isAdmin: true });
|
||||
expect(result).toEqualRight({ isAdmin: true });
|
||||
});
|
||||
|
||||
test('should throw USERS_NOT_FOUND when userUid is invalid', async () => {
|
||||
// getUsersCount
|
||||
mockUser.getUsersCount.mockResolvedValueOnce(1);
|
||||
// makeAdmin
|
||||
mockUser.makeAdmin.mockResolvedValueOnce(E.left(USER_NOT_FOUND));
|
||||
|
||||
const result = await authService.verifyAdmin(user);
|
||||
expect(result).toEqualLeft({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('should return false when user is not an admin and userCount is greater than 1', async () => {
|
||||
// getUsersCount
|
||||
mockUser.getUsersCount.mockResolvedValueOnce(13);
|
||||
|
||||
const result = await authService.verifyAdmin(user);
|
||||
expect(result).toEqualRight({ isAdmin: false });
|
||||
});
|
||||
});
|
||||
380
packages/hoppscotch-backend/src/auth/auth.service.ts
Normal file
380
packages/hoppscotch-backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { MailerService } from 'src/mailer/mailer.service';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { VerifyMagicDto } from './dto/verify-magic.dto';
|
||||
import { DateTime } from 'luxon';
|
||||
import * as argon2 from 'argon2';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { DeviceIdentifierToken } from 'src/types/Passwordless';
|
||||
import {
|
||||
INVALID_EMAIL,
|
||||
INVALID_MAGIC_LINK_DATA,
|
||||
VERIFICATION_TOKEN_DATA_NOT_FOUND,
|
||||
MAGIC_LINK_EXPIRED,
|
||||
USER_NOT_FOUND,
|
||||
INVALID_REFRESH_TOKEN,
|
||||
} from 'src/errors';
|
||||
import { validateEmail } from 'src/utils';
|
||||
import {
|
||||
AccessTokenPayload,
|
||||
AuthTokens,
|
||||
RefreshTokenPayload,
|
||||
} from 'src/types/AuthTokens';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthError } from 'src/types/AuthError';
|
||||
import { AuthUser, IsAdmin } from 'src/types/AuthUser';
|
||||
import { VerificationToken } from '@prisma/client';
|
||||
import { Origin } from './helper';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private usersService: UserService,
|
||||
private prismaService: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private readonly mailerService: MailerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate Id and token for email Magic-Link auth
|
||||
*
|
||||
* @param user User Object
|
||||
* @returns Created VerificationToken token
|
||||
*/
|
||||
private async generateMagicLinkTokens(user: AuthUser) {
|
||||
const salt = await bcrypt.genSalt(
|
||||
parseInt(process.env.TOKEN_SALT_COMPLEXITY),
|
||||
);
|
||||
const expiresOn = DateTime.now()
|
||||
.plus({ hours: parseInt(process.env.MAGIC_LINK_TOKEN_VALIDITY) })
|
||||
.toISO()
|
||||
.toString();
|
||||
|
||||
const idToken = await this.prismaService.verificationToken.create({
|
||||
data: {
|
||||
deviceIdentifier: salt,
|
||||
userUid: user.uid,
|
||||
expiresOn: expiresOn,
|
||||
},
|
||||
});
|
||||
|
||||
return idToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if VerificationToken exist or not
|
||||
*
|
||||
* @param magicLinkTokens Object containing deviceIdentifier and token
|
||||
* @returns Option of VerificationToken token
|
||||
*/
|
||||
private async validatePasswordlessTokens(magicLinkTokens: VerifyMagicDto) {
|
||||
try {
|
||||
const tokens =
|
||||
await this.prismaService.verificationToken.findUniqueOrThrow({
|
||||
where: {
|
||||
passwordless_deviceIdentifier_tokens: {
|
||||
deviceIdentifier: magicLinkTokens.deviceIdentifier,
|
||||
token: magicLinkTokens.token,
|
||||
},
|
||||
},
|
||||
});
|
||||
return O.some(tokens);
|
||||
} catch (error) {
|
||||
return O.none;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new refresh token for user
|
||||
*
|
||||
* @param userUid User Id
|
||||
* @returns Generated refreshToken
|
||||
*/
|
||||
private async generateRefreshToken(userUid: string) {
|
||||
const refreshTokenPayload: RefreshTokenPayload = {
|
||||
iss: process.env.VITE_BASE_URL,
|
||||
sub: userUid,
|
||||
aud: [process.env.VITE_BASE_URL],
|
||||
};
|
||||
|
||||
const refreshToken = await this.jwtService.sign(refreshTokenPayload, {
|
||||
expiresIn: process.env.REFRESH_TOKEN_VALIDITY, //7 Days
|
||||
});
|
||||
|
||||
const refreshTokenHash = await argon2.hash(refreshToken);
|
||||
|
||||
const updatedUser = await this.usersService.UpdateUserRefreshToken(
|
||||
refreshTokenHash,
|
||||
userUid,
|
||||
);
|
||||
if (E.isLeft(updatedUser))
|
||||
return E.left(<AuthError>{
|
||||
message: updatedUser.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
return E.right(refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access and refresh token pair
|
||||
*
|
||||
* @param userUid User ID
|
||||
* @returns Either of generated AuthTokens
|
||||
*/
|
||||
async generateAuthTokens(userUid: string) {
|
||||
const accessTokenPayload: AccessTokenPayload = {
|
||||
iss: process.env.VITE_BASE_URL,
|
||||
sub: userUid,
|
||||
aud: [process.env.VITE_BASE_URL],
|
||||
};
|
||||
|
||||
const refreshToken = await this.generateRefreshToken(userUid);
|
||||
if (E.isLeft(refreshToken)) return E.left(refreshToken.left);
|
||||
|
||||
return E.right(<AuthTokens>{
|
||||
access_token: await this.jwtService.sign(accessTokenPayload, {
|
||||
expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day
|
||||
}),
|
||||
refresh_token: refreshToken.right,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleted used VerificationToken tokens
|
||||
*
|
||||
* @param passwordlessTokens VerificationToken entry to delete from DB
|
||||
* @returns Either of deleted VerificationToken token
|
||||
*/
|
||||
private async deleteMagicLinkVerificationTokens(
|
||||
passwordlessTokens: VerificationToken,
|
||||
) {
|
||||
try {
|
||||
const deletedPasswordlessToken =
|
||||
await this.prismaService.verificationToken.delete({
|
||||
where: {
|
||||
passwordless_deviceIdentifier_tokens: {
|
||||
deviceIdentifier: passwordlessTokens.deviceIdentifier,
|
||||
token: passwordlessTokens.token,
|
||||
},
|
||||
},
|
||||
});
|
||||
return E.right(deletedPasswordlessToken);
|
||||
} catch (error) {
|
||||
return E.left(VERIFICATION_TOKEN_DATA_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if Provider account exists for User
|
||||
*
|
||||
* @param user User Object
|
||||
* @param SSOUserData User data from SSO providers (Magic,Google,Github,Microsoft)
|
||||
* @returns Either of existing user provider Account
|
||||
*/
|
||||
async checkIfProviderAccountExists(user: AuthUser, SSOUserData) {
|
||||
const provider = await this.prismaService.account.findUnique({
|
||||
where: {
|
||||
verifyProviderAccount: {
|
||||
provider: SSOUserData.provider,
|
||||
providerAccountId: SSOUserData.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!provider) return O.none;
|
||||
|
||||
return O.some(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create User (if not already present) and send email to initiate Magic-Link auth
|
||||
*
|
||||
* @param email User's email
|
||||
* @returns Either containing DeviceIdentifierToken
|
||||
*/
|
||||
async signInMagicLink(email: string, origin: string) {
|
||||
if (!validateEmail(email))
|
||||
return E.left({
|
||||
message: INVALID_EMAIL,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
|
||||
let user: AuthUser;
|
||||
const queriedUser = await this.usersService.findUserByEmail(email);
|
||||
|
||||
if (O.isNone(queriedUser)) {
|
||||
user = await this.usersService.createUserViaMagicLink(email);
|
||||
} else {
|
||||
user = queriedUser.value;
|
||||
}
|
||||
|
||||
const generatedTokens = await this.generateMagicLinkTokens(user);
|
||||
|
||||
// check to see if origin is valid
|
||||
let url: string;
|
||||
switch (origin) {
|
||||
case Origin.ADMIN:
|
||||
url = process.env.VITE_ADMIN_URL;
|
||||
break;
|
||||
case Origin.APP:
|
||||
url = process.env.VITE_BASE_URL;
|
||||
break;
|
||||
default:
|
||||
// if origin is invalid by default set URL to Hoppscotch-App
|
||||
url = process.env.VITE_BASE_URL;
|
||||
}
|
||||
|
||||
await this.mailerService.sendAuthEmail(email, {
|
||||
template: 'code-your-own',
|
||||
variables: {
|
||||
inviteeEmail: email,
|
||||
magicLink: `${url}/enter?token=${generatedTokens.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
return E.right(<DeviceIdentifierToken>{
|
||||
deviceIdentifier: generatedTokens.deviceIdentifier,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and authenticate user from received data for Magic-Link
|
||||
*
|
||||
* @param magicLinkIDTokens magic-link verification tokens from client
|
||||
* @returns Either of generated AuthTokens
|
||||
*/
|
||||
async verifyMagicLinkTokens(
|
||||
magicLinkIDTokens: VerifyMagicDto,
|
||||
): Promise<E.Right<AuthTokens> | E.Left<AuthError>> {
|
||||
const passwordlessTokens = await this.validatePasswordlessTokens(
|
||||
magicLinkIDTokens,
|
||||
);
|
||||
if (O.isNone(passwordlessTokens))
|
||||
return E.left({
|
||||
message: INVALID_MAGIC_LINK_DATA,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
const user = await this.usersService.findUserById(
|
||||
passwordlessTokens.value.userUid,
|
||||
);
|
||||
if (O.isNone(user))
|
||||
return E.left({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
/**
|
||||
* * Check to see if entry for Magic-Link is present in the Account table for user
|
||||
* * If user was created with another provider findUserById may return true
|
||||
*/
|
||||
const profile = {
|
||||
provider: 'magic',
|
||||
id: user.value.email,
|
||||
};
|
||||
const providerAccountExists = await this.checkIfProviderAccountExists(
|
||||
user.value,
|
||||
profile,
|
||||
);
|
||||
|
||||
if (O.isNone(providerAccountExists)) {
|
||||
await this.usersService.createProviderAccount(
|
||||
user.value,
|
||||
null,
|
||||
null,
|
||||
profile,
|
||||
);
|
||||
}
|
||||
|
||||
const currentTime = DateTime.now().toISO();
|
||||
if (currentTime > passwordlessTokens.value.expiresOn.toISOString())
|
||||
return E.left({
|
||||
message: MAGIC_LINK_EXPIRED,
|
||||
statusCode: HttpStatus.UNAUTHORIZED,
|
||||
});
|
||||
|
||||
const tokens = await this.generateAuthTokens(
|
||||
passwordlessTokens.value.userUid,
|
||||
);
|
||||
if (E.isLeft(tokens))
|
||||
return E.left({
|
||||
message: tokens.left.message,
|
||||
statusCode: tokens.left.statusCode,
|
||||
});
|
||||
|
||||
const deletedPasswordlessToken =
|
||||
await this.deleteMagicLinkVerificationTokens(passwordlessTokens.value);
|
||||
if (E.isLeft(deletedPasswordlessToken))
|
||||
return E.left({
|
||||
message: deletedPasswordlessToken.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
return E.right(tokens.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh refresh and auth tokens
|
||||
*
|
||||
* @param hashedRefreshToken Hashed refresh token received from client
|
||||
* @param user User Object
|
||||
* @returns Either of generated AuthTokens
|
||||
*/
|
||||
async refreshAuthTokens(hashedRefreshToken: string, user: AuthUser) {
|
||||
// Check to see user is valid
|
||||
if (!user)
|
||||
return E.left({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
// Check to see if the hashed refresh_token received from the client is the same as the refresh_token saved in the DB
|
||||
const isTokenMatched = await argon2.verify(
|
||||
user.refreshToken,
|
||||
hashedRefreshToken,
|
||||
);
|
||||
if (!isTokenMatched)
|
||||
return E.left({
|
||||
message: INVALID_REFRESH_TOKEN,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
// if tokens match, generate new pair of auth tokens
|
||||
const generatedAuthTokens = await this.generateAuthTokens(user.uid);
|
||||
if (E.isLeft(generatedAuthTokens))
|
||||
return E.left({
|
||||
message: generatedAuthTokens.left.message,
|
||||
statusCode: generatedAuthTokens.left.statusCode,
|
||||
});
|
||||
|
||||
return E.right(generatedAuthTokens.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify is signed in User is an admin or not
|
||||
*
|
||||
* @param user User Object
|
||||
* @returns Either of boolean if user is admin or not
|
||||
*/
|
||||
async verifyAdmin(user: AuthUser) {
|
||||
if (user.isAdmin) return E.right(<IsAdmin>{ isAdmin: true });
|
||||
|
||||
const usersCount = await this.usersService.getUsersCount();
|
||||
if (usersCount === 1) {
|
||||
const elevatedUser = await this.usersService.makeAdmin(user.uid);
|
||||
if (E.isLeft(elevatedUser))
|
||||
return E.left(<AuthError>{
|
||||
message: elevatedUser.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
return E.right(<IsAdmin>{ isAdmin: true });
|
||||
}
|
||||
|
||||
return E.right(<IsAdmin>{ isAdmin: false });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Inputs to initiate Magic-Link auth flow
|
||||
export class SignInMagicDto {
|
||||
email: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user