Compare commits
583 Commits
feat/expor
...
release/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7beed30815 | ||
|
|
bb380f3751 | ||
|
|
33a7580e46 | ||
|
|
ffb2b5c30a | ||
|
|
7c238fa854 | ||
|
|
185b575e5b | ||
|
|
bcc1147f81 | ||
|
|
f5b130024e | ||
|
|
bb5c333bae | ||
|
|
3684d25848 | ||
|
|
8b0ba3a45e | ||
|
|
e847fb7b77 | ||
|
|
5c78ae4dee | ||
|
|
53ec605963 | ||
|
|
75193a7aa8 | ||
|
|
b269c239d9 | ||
|
|
72b4a1fc4e | ||
|
|
d2d1674d31 | ||
|
|
a6b57777e3 | ||
|
|
65ef4db86f | ||
|
|
7201147b55 | ||
|
|
dd143c95a9 | ||
|
|
005581ee7d | ||
|
|
1431ecc6d7 | ||
|
|
f34d896095 | ||
|
|
e95ebb9226 | ||
|
|
57365eeae0 | ||
|
|
b22bd97818 | ||
|
|
b953b32ff4 | ||
|
|
0eacd6763b | ||
|
|
8499ac7fec | ||
|
|
4adac4af38 | ||
|
|
fd162e242c | ||
|
|
3e83828722 | ||
|
|
f7dc36e3f1 | ||
|
|
a7566dfd86 | ||
|
|
d4d7a20fbd | ||
|
|
dfb281bcf7 | ||
|
|
c62482e81f | ||
|
|
886847ab7b | ||
|
|
a268cab11e | ||
|
|
e9509b9fa1 | ||
|
|
8db452089c | ||
|
|
a1764023f3 | ||
|
|
b08b63dc73 | ||
|
|
a9a4ebf595 | ||
|
|
a8e279db28 | ||
|
|
d09a3e9237 | ||
|
|
efa40cf6ea | ||
|
|
1a3d9f18ab | ||
|
|
653ccd3240 | ||
|
|
c0806cfd07 | ||
|
|
008eb6b77b | ||
|
|
ac60843183 | ||
|
|
3c3fb1e4a9 | ||
|
|
88212e8cfe | ||
|
|
191fa376d2 | ||
|
|
6efae3a395 | ||
|
|
cb8678f07f | ||
|
|
b32b0f9bcb | ||
|
|
5a91fb53b2 | ||
|
|
b0b6edc58e | ||
|
|
8c57d81718 | ||
|
|
10bb68a538 | ||
|
|
d4d1e27ba9 | ||
|
|
d5c887f311 | ||
|
|
ce7adf6da3 | ||
|
|
c626fb9241 | ||
|
|
f21ed30e10 | ||
|
|
b55970cc7a | ||
|
|
74ad2e43a4 | ||
|
|
2d6282cf8b | ||
|
|
e255c46455 | ||
|
|
15c2c7bb5b | ||
|
|
71bcd22444 | ||
|
|
2d104160f2 | ||
|
|
f7c1825de5 | ||
|
|
2c1fd5d711 | ||
|
|
085fbb2a9b | ||
|
|
05f2d8817b | ||
|
|
81fbb22c51 | ||
|
|
01cf59c663 | ||
|
|
5c8ebaff3e | ||
|
|
0e70c28324 | ||
|
|
88f6a4ae26 | ||
|
|
610538ca02 | ||
|
|
8970ff5c68 | ||
|
|
d1a564d5b8 | ||
|
|
8bb1d19c07 | ||
|
|
c1efa381f0 | ||
|
|
29171d1b6f | ||
|
|
e869d49e16 | ||
|
|
6496bea846 | ||
|
|
39842559b5 | ||
|
|
51efb35aa6 | ||
|
|
9402bb9285 | ||
|
|
5a516f7242 | ||
|
|
3b217d78e7 | ||
|
|
8e153b38dc | ||
|
|
6f38bfb148 | ||
|
|
82b6e08d68 | ||
|
|
31fd6567b7 | ||
|
|
25177bd635 | ||
|
|
6928eb7992 | ||
|
|
8300f9a0a2 | ||
|
|
525ba77739 | ||
|
|
6bc748a267 | ||
|
|
5230d2d3b8 | ||
|
|
c3531c9d8b | ||
|
|
b29c04c28d | ||
|
|
b2af353941 | ||
|
|
9dbce74f5e | ||
|
|
db1cf5cc08 | ||
|
|
09360abf81 | ||
|
|
355bd62b8d | ||
|
|
5650de1183 | ||
|
|
2ee8614b93 | ||
|
|
5632334c9a | ||
|
|
780dd8a713 | ||
|
|
7db3c6d290 | ||
|
|
c765270dfe | ||
|
|
03f667c21d | ||
|
|
f79f3078dc | ||
|
|
6e29a2f6d4 | ||
|
|
6304fd50c3 | ||
|
|
2ec29c47ad | ||
|
|
399a238bf4 | ||
|
|
b20ab72298 | ||
|
|
f723e6496a | ||
|
|
8c0aff8863 | ||
|
|
64c5077506 | ||
|
|
2afc87847d | ||
|
|
878ec833ce | ||
|
|
039de8015f | ||
|
|
f67b366b90 | ||
|
|
6f35574d68 | ||
|
|
77e8a36ab0 | ||
|
|
d7cc9f5dbc | ||
|
|
fc3e3aeaec | ||
|
|
4ba135f3b9 | ||
|
|
24894e05dc | ||
|
|
e2b668bee2 | ||
|
|
f112c46bb4 | ||
|
|
331d482b22 | ||
|
|
b07243f131 | ||
|
|
84b0c30d64 | ||
|
|
e3dd9e99a1 | ||
|
|
e3091cb6db | ||
|
|
270f796683 | ||
|
|
24c6bce02d | ||
|
|
2db567589f | ||
|
|
1fe83ebdc8 | ||
|
|
8320d4f222 | ||
|
|
e76c1bc64c | ||
|
|
1f3f8464ea | ||
|
|
81a7e23a12 | ||
|
|
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 |
106
.dockerignore
106
.dockerignore
@@ -1,104 +1,2 @@
|
|||||||
Dockerfile
|
node_modules
|
||||||
.vscode
|
**/*/node_modules
|
||||||
.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
|
|
||||||
|
|||||||
78
.env.example
78
.env.example
@@ -1,31 +1,61 @@
|
|||||||
# Google Analytics ID
|
#-----------------------Backend Config------------------------------#
|
||||||
VITE_GA_ID=UA-61422507-4
|
# Prisma Config
|
||||||
|
DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
|
||||||
|
|
||||||
# Google Tag Manager ID
|
# Auth Tokens Config
|
||||||
VITE_GTM_ID=GTM-NMKVBMV
|
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"
|
||||||
|
VITE_ALLOWED_AUTH_PROVIDERS = GOOGLE,GITHUB,MICROSOFT,EMAIL
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
MICROSOFT_TENANT="common"
|
||||||
|
|
||||||
|
# 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------------------------------#
|
||||||
|
|
||||||
# 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 URLs
|
# Base URLs
|
||||||
VITE_BASE_URL=https://hoppscotch.io
|
VITE_BASE_URL=http://localhost:3000
|
||||||
VITE_SHORTCODE_BASE_URL=https://hopp.sh
|
VITE_SHORTCODE_BASE_URL=http://localhost:3000
|
||||||
|
VITE_ADMIN_URL=http://localhost:3100
|
||||||
|
|
||||||
# Backend URLs
|
# Backend URLs
|
||||||
VITE_BACKEND_GQL_URL=https://api.hoppscotch.io/graphql
|
VITE_BACKEND_GQL_URL=http://localhost:3170/graphql
|
||||||
VITE_BACKEND_WS_URL=wss://api.hoppscotch.io/graphql
|
VITE_BACKEND_WS_URL=ws://localhost:3170/graphql
|
||||||
|
VITE_BACKEND_API_URL=http://localhost:3170/v1
|
||||||
|
|
||||||
# Sentry (Optional)
|
# Terms Of Service And Privacy Policy Links (Optional)
|
||||||
# VITE_SENTRY_DSN: <Sentry DSN here>
|
VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms
|
||||||
# VITE_SENTRY_ENVIRONMENT: <Sentry environment value here>
|
VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy
|
||||||
# VITE_SENTRY_RELEASE_TAG: <Sentry release tag here (for release monitoring)>
|
|
||||||
|
|
||||||
# Proxyscotch Access Token (Optional)
|
|
||||||
# VITE_PROXYSCOTCH_ACCESS_TOKEN: <Token Set In Proxyscotch Server>
|
|
||||||
|
|||||||
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
|
name: "CodeQL analysis"
|
||||||
# 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"
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
# The branches below must be a subset of the branches above
|
branches: [main]
|
||||||
branches: [ main ]
|
|
||||||
schedule:
|
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:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
|
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
# required for all workflows
|
||||||
contents: read
|
|
||||||
security-events: write
|
security-events: write
|
||||||
|
|
||||||
strategy:
|
# only required for workflows in private repositories
|
||||||
fail-fast: false
|
actions: read
|
||||||
matrix:
|
contents: read
|
||||||
language: [ 'javascript' ]
|
|
||||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
|
||||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v2
|
||||||
with:
|
with:
|
||||||
# Run extended queries including queries using machine learning
|
# Run extended queries including queries using machine learning
|
||||||
queries: security-extended
|
queries: security-extended
|
||||||
languages: ${{ matrix.language }}
|
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
|
|
||||||
|
|
||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# 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)
|
# If this step fails, then you should remove it and run the build manually (see below).
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v2
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 https://git.io/JvXDl
|
# 📚 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
|
# ✏️ If the Autobuild fails above, remove it and uncomment the following
|
||||||
# and modify them (or add more) to build your code if your project
|
# three lines and modify them (or add more) to build your code if your
|
||||||
# uses a compiled language
|
# project uses a compiled language
|
||||||
|
|
||||||
#- run: |
|
#- run: |
|
||||||
# make bootstrap
|
# make bootstrap
|
||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
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 Environment
|
|
||||||
run: mv .env.example .env
|
|
||||||
|
|
||||||
- name: Setup and run pnpm install
|
|
||||||
uses: pnpm/action-setup@v2.2.4
|
|
||||||
with:
|
|
||||||
version: 7
|
|
||||||
run_install: true
|
|
||||||
|
|
||||||
- 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-web/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 }}
|
|
||||||
60
.github/workflows/deploy-preview-netlify.yml
vendored
60
.github/workflows/deploy-preview-netlify.yml
vendored
@@ -1,60 +0,0 @@
|
|||||||
name: Deploy to Preview Netlify
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
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.4
|
|
||||||
env:
|
|
||||||
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
|
|
||||||
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 preview site with netlify-cli
|
|
||||||
- name: Deploy to Netlify (preview)
|
|
||||||
run: npx netlify-cli deploy --dir=packages/hoppscotch-web/dist --alias=preview
|
|
||||||
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: preview
|
|
||||||
ignore_missing: true
|
|
||||||
ignore_empty: true
|
|
||||||
version: ${{ github.sha }}
|
|
||||||
21
.github/workflows/deploy-prod.yml
vendored
21
.github/workflows/deploy-prod.yml
vendored
@@ -1,21 +0,0 @@
|
|||||||
name: Deploy to Live Channel
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy_live_website:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Repository
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Deploy to Firebase (production)
|
|
||||||
uses: FirebaseExtended/action-hosting-deploy@v0
|
|
||||||
with:
|
|
||||||
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
|
||||||
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_POSTWOMAN_API }}'
|
|
||||||
channelId: live
|
|
||||||
projectId: postwoman-api
|
|
||||||
60
.github/workflows/deploy-staging-netlify.yml
vendored
60
.github/workflows/deploy-staging-netlify.yml
vendored
@@ -1,60 +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.4
|
|
||||||
env:
|
|
||||||
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
|
|
||||||
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-web/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 }}
|
|
||||||
81
.github/workflows/release-push-docker.yml
vendored
Normal file
81
.github/workflows/release-push-docker.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
name: "Push containers to Docker Hub on release"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup environment
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Setup QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push the backend container
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./prod.Dockerfile
|
||||||
|
target: backend
|
||||||
|
push: true
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:latest
|
||||||
|
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:${{ github.ref_name }}
|
||||||
|
|
||||||
|
- name: Build and push the frontend container
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./prod.Dockerfile
|
||||||
|
target: app
|
||||||
|
push: true
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:latest
|
||||||
|
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:${{ github.ref_name }}
|
||||||
|
|
||||||
|
- name: Build and push the admin dashboard container
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./prod.Dockerfile
|
||||||
|
target: sh_admin
|
||||||
|
push: true
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:latest
|
||||||
|
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:${{ github.ref_name }}
|
||||||
|
|
||||||
|
- name: Build and push the AIO container
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./prod.Dockerfile
|
||||||
|
target: aio
|
||||||
|
push: true
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:latest
|
||||||
|
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:${{ github.ref_name }}
|
||||||
19
.github/workflows/tests.yml
vendored
19
.github/workflows/tests.yml
vendored
@@ -2,12 +2,13 @@ name: Node.js CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, staging, "release/**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, staging, "release/**"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test:
|
||||||
|
name: Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
@@ -15,22 +16,22 @@ jobs:
|
|||||||
node-version: ["lts/*"]
|
node-version: ["lts/*"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Setup Environment
|
- name: Setup environment
|
||||||
run: mv .env.example .env
|
run: mv .env.example .env
|
||||||
|
|
||||||
- name: Setup and run pnpm install
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v2.2.4
|
uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 7
|
version: 8
|
||||||
run_install: true
|
run_install: true
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Setup node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node }}
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
|||||||
42
.github/workflows/ui.yml
vendored
Normal file
42
.github/workflows/ui.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: Deploy to Netlify (ui)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# run this workflow only if an update is made to the ui package
|
||||||
|
paths:
|
||||||
|
- "packages/hoppscotch-ui/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup environment
|
||||||
|
run: mv .env.example .env
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v2.2.4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
run_install: true
|
||||||
|
|
||||||
|
- name: Setup node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Build site
|
||||||
|
run: pnpm run generate-ui
|
||||||
|
|
||||||
|
# Deploy the ui site with netlify-cli
|
||||||
|
- name: Deploy to Netlify (ui)
|
||||||
|
run: npx netlify-cli deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
|
||||||
|
env:
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -171,3 +171,6 @@ tests/*/videos
|
|||||||
|
|
||||||
# PNPM
|
# PNPM
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
|
||||||
|
# GQL SDL generated for the frontends
|
||||||
|
gql-gen/
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
semi: false
|
semi: false,
|
||||||
|
trailingComma: "es5",
|
||||||
|
singleQuote: false,
|
||||||
|
printWidth: 80,
|
||||||
|
useTabs: false,
|
||||||
|
tabWidth: 2
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
@@ -6,8 +6,8 @@ We as members, contributors, and leaders pledge to make participation in our
|
|||||||
community a harassment-free experience for everyone, regardless of age, body
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
identity and expression, level of experience, education, socio-economic status,
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
nationality, personal appearance, race, religion, or sexual identity
|
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||||
and orientation.
|
identity and orientation.
|
||||||
|
|
||||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
diverse, inclusive, and healthy community.
|
diverse, inclusive, and healthy community.
|
||||||
@@ -22,17 +22,17 @@ community include:
|
|||||||
* Giving and gracefully accepting constructive feedback
|
* Giving and gracefully accepting constructive feedback
|
||||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
and learning from the experience
|
and learning from the experience
|
||||||
* Focusing on what is best not just for us as individuals, but for the
|
* Focusing on what is best not just for us as individuals, but for the overall
|
||||||
overall community
|
community
|
||||||
|
|
||||||
Examples of unacceptable behavior include:
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
* The use of sexualized language or imagery, and sexual attention or
|
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||||
advances of any kind
|
any kind
|
||||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
* Public or private harassment
|
* Public or private harassment
|
||||||
* Publishing others' private information, such as a physical or email
|
* Publishing others' private information, such as a physical or email address,
|
||||||
address, without their explicit permission
|
without their explicit permission
|
||||||
* Other conduct which could reasonably be considered inappropriate in a
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
professional setting
|
professional setting
|
||||||
|
|
||||||
@@ -82,15 +82,15 @@ behavior was inappropriate. A public apology may be requested.
|
|||||||
|
|
||||||
### 2. Warning
|
### 2. Warning
|
||||||
|
|
||||||
**Community Impact**: A violation through a single incident or series
|
**Community Impact**: A violation through a single incident or series of
|
||||||
of actions.
|
actions.
|
||||||
|
|
||||||
**Consequence**: A warning with consequences for continued behavior. No
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
interaction with the people involved, including unsolicited interaction with
|
interaction with the people involved, including unsolicited interaction with
|
||||||
those enforcing the Code of Conduct, for a specified period of time. This
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
includes avoiding interactions in community spaces as well as external channels
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
like social media. Violating these terms may lead to a temporary or
|
like social media. Violating these terms may lead to a temporary or permanent
|
||||||
permanent ban.
|
ban.
|
||||||
|
|
||||||
### 3. Temporary Ban
|
### 3. Temporary Ban
|
||||||
|
|
||||||
@@ -106,23 +106,27 @@ Violating these terms may lead to a permanent ban.
|
|||||||
### 4. Permanent Ban
|
### 4. Permanent Ban
|
||||||
|
|
||||||
**Community Impact**: Demonstrating a pattern of violation of community
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
standards, including sustained inappropriate behavior, harassment of an
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
individual, or aggression toward or disparagement of classes of individuals.
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
**Consequence**: A permanent ban from any sort of public interaction within
|
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||||
the community.
|
community.
|
||||||
|
|
||||||
## Attribution
|
## Attribution
|
||||||
|
|
||||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
version 2.0, available at
|
version 2.1, available at
|
||||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
Community Impact Guidelines were inspired by
|
||||||
enforcement ladder](https://github.com/mozilla/diversity).
|
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
[homepage]: https://www.contributor-covenant.org
|
|
||||||
|
|
||||||
For answers to common questions about this code of conduct, see the FAQ at
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
https://www.contributor-covenant.org/faq. Translations are available at
|
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||||
https://www.contributor-covenant.org/translations.
|
[https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
|
|||||||
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 mv .env.example .env
|
|
||||||
|
|
||||||
RUN pnpm i --unsafe-perm=true
|
|
||||||
|
|
||||||
ENV HOST 0.0.0.0
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
RUN pnpm run generate
|
|
||||||
|
|
||||||
CMD ["pnpm", "run", "start"]
|
|
||||||
228
README.md
228
README.md
@@ -2,23 +2,18 @@
|
|||||||
<a href="https://hoppscotch.io">
|
<a href="https://hoppscotch.io">
|
||||||
<img
|
<img
|
||||||
src="https://avatars.githubusercontent.com/u/56705483"
|
src="https://avatars.githubusercontent.com/u/56705483"
|
||||||
alt="Hoppscotch Logo"
|
alt="Hoppscotch"
|
||||||
height="64"
|
height="64"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<h3>
|
||||||
<p>
|
|
||||||
<h3>
|
|
||||||
<b>
|
|
||||||
Hoppscotch
|
|
||||||
</b>
|
|
||||||
</h3>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>
|
<b>
|
||||||
Open source API development ecosystem
|
Hoppscotch
|
||||||
</b>
|
</b>
|
||||||
</p>
|
</h3>
|
||||||
|
<b>
|
||||||
|
Open Source API Development Ecosystem
|
||||||
|
</b>
|
||||||
<p>
|
<p>
|
||||||
|
|
||||||
[](CODE_OF_CONDUCT.md) [](https://hoppscotch.io) [](https://github.com/hoppscotch/hoppscotch/actions) [](https://twitter.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io)
|
[](CODE_OF_CONDUCT.md) [](https://hoppscotch.io) [](https://github.com/hoppscotch/hoppscotch/actions) [](https://twitter.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io)
|
||||||
@@ -34,23 +29,18 @@
|
|||||||
</p>
|
</p>
|
||||||
<br />
|
<br />
|
||||||
<p>
|
<p>
|
||||||
<a href="https://hoppscotch.io/#gh-light-mode-only" target="_blank">
|
<a href="https://hoppscotch.io">
|
||||||
<img
|
<picture>
|
||||||
src="./packages/hoppscotch-app/public/images/banner-light.png"
|
<source media="(prefers-color-scheme: dark)" srcset="./packages/hoppscotch-common/public/images/banner-dark.png">
|
||||||
alt="Hoppscotch"
|
<source media="(prefers-color-scheme: light)" srcset="./packages/hoppscotch-common/public/images/banner-light.png">
|
||||||
width="100%"
|
<img alt="Hoppscotch" src="./packages/hoppscotch-common/public/images/banner-dark.png">
|
||||||
/>
|
</picture>
|
||||||
</a>
|
|
||||||
<a href="https://hoppscotch.io/#gh-dark-mode-only" target="_blank">
|
|
||||||
<img
|
|
||||||
src="./packages/hoppscotch-app/public/images/banner-dark.png"
|
|
||||||
alt="Hoppscotch"
|
|
||||||
width="100%"
|
|
||||||
/>
|
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
_We highly recommend you take a look at the [**Hoppscotch Documentation**](https://docs.hoppscotch.io) to learn more about the app._
|
||||||
|
|
||||||
#### **Support**
|
#### **Support**
|
||||||
|
|
||||||
[](https://hoppscotch.io/discord) [](https://hoppscotch.io/telegram) [](https://github.com/hoppscotch/hoppscotch/discussions)
|
[](https://hoppscotch.io/discord) [](https://hoppscotch.io/telegram) [](https://github.com/hoppscotch/hoppscotch/discussions)
|
||||||
@@ -59,9 +49,9 @@
|
|||||||
|
|
||||||
❤️ **Lightweight:** Crafted with minimalistic UI design.
|
❤️ **Lightweight:** Crafted with minimalistic UI design.
|
||||||
|
|
||||||
⚡️ **Fast:** Send requests and get/copy responses in real-time.
|
⚡️ **Fast:** Send requests and get responses in real time.
|
||||||
|
|
||||||
**HTTP Methods**
|
🗄️ **HTTP Methods:** Request methods define the type of action you are requesting to be performed.
|
||||||
|
|
||||||
- `GET` - Requests retrieve resource information
|
- `GET` - Requests retrieve resource information
|
||||||
- `POST` - The server creates a new entry in a database
|
- `POST` - The server creates a new entry in a database
|
||||||
@@ -74,17 +64,15 @@
|
|||||||
- `TRACE` - Performs a message loop-back test along the path to the target resource
|
- `TRACE` - Performs a message loop-back test along the path to the target resource
|
||||||
- `<custom>` - Some APIs use custom request methods such as `LIST`. Type in your custom methods.
|
- `<custom>` - Some APIs use custom request methods such as `LIST`. Type in your custom methods.
|
||||||
|
|
||||||
🌈 **Make it yours:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings).
|
🌈 **Theming:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings).
|
||||||
|
|
||||||
**Theming**
|
- Choose a theme: System preference, Light, Dark, and Black
|
||||||
|
- Choose accent colors: Green, Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
|
||||||
- Choose a theme: System (default), Light, Dark, and Black
|
|
||||||
- Choose accent color: Green (default), Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
|
|
||||||
- Distraction-free Zen mode
|
- Distraction-free Zen mode
|
||||||
|
|
||||||
_Customized themes are synced with cloud / local session_
|
_Customized themes are synced with your cloud/local session._
|
||||||
|
|
||||||
🔥 **PWA:** Install as a [PWA](https://web.dev/what-are-pwas/) on your device.
|
🔥 **PWA:** Install as a [Progressive Web App](https://web.dev/progressive-web-apps) on your device.
|
||||||
|
|
||||||
- Instant loading with Service Workers
|
- Instant loading with Service Workers
|
||||||
- Offline support
|
- Offline support
|
||||||
@@ -107,7 +95,7 @@ _Customized themes are synced with cloud / local session_
|
|||||||
|
|
||||||
📡 **Server-Sent Events:** Receive a stream of updates from a server over an HTTP connection without resorting to polling.
|
📡 **Server-Sent Events:** Receive a stream of updates from a server over an HTTP connection without resorting to polling.
|
||||||
|
|
||||||
🌩 **Socket.IO:** Send and Receive data with SocketIO server.
|
🌩 **Socket.IO:** Send and Receive data with the SocketIO server.
|
||||||
|
|
||||||
🦟 **MQTT:** Subscribe and Publish to topics of an MQTT Broker.
|
🦟 **MQTT:** Subscribe and Publish to topics of an MQTT Broker.
|
||||||
|
|
||||||
@@ -127,7 +115,7 @@ _Customized themes are synced with cloud / local session_
|
|||||||
- OAuth 2.0
|
- OAuth 2.0
|
||||||
- OIDC Access Token/PKCE
|
- OIDC Access Token/PKCE
|
||||||
|
|
||||||
📢 **Headers:** Describes the format the body of your request is being sent as.
|
📢 **Headers:** Describes the format the body of your request is being sent in.
|
||||||
|
|
||||||
📫 **Parameters:** Use request parameters to set varying parts in simulated requests.
|
📫 **Parameters:** Use request parameters to set varying parts in simulated requests.
|
||||||
|
|
||||||
@@ -137,14 +125,14 @@ _Customized themes are synced with cloud / local session_
|
|||||||
- FormData, JSON, and many more
|
- FormData, JSON, and many more
|
||||||
- Toggle between key-value and RAW input parameter list
|
- Toggle between key-value and RAW input parameter list
|
||||||
|
|
||||||
👋 **Response:** Contains the status line, headers, and the message/response body.
|
📮 **Response:** Contains the status line, headers, and the message/response body.
|
||||||
|
|
||||||
- Copy response to clipboard
|
- Copy the response to the clipboard
|
||||||
- Download response as a file
|
- Download the response as a file
|
||||||
- View response headers
|
- View response headers
|
||||||
- View raw and preview of HTML, image, JSON, XML responses
|
- View raw and preview HTML, image, JSON, and XML responses
|
||||||
|
|
||||||
⏰ **History:** Request entries are synced with cloud / local session storage to restore with a single click.
|
⏰ **History:** Request entries are synced with your cloud/local session storage.
|
||||||
|
|
||||||
📁 **Collections:** Keep your API requests organized with collections and folders. Reuse them with a single click.
|
📁 **Collections:** Keep your API requests organized with collections and folders. Reuse them with a single click.
|
||||||
|
|
||||||
@@ -152,7 +140,32 @@ _Customized themes are synced with cloud / local session_
|
|||||||
- Nested folders
|
- Nested folders
|
||||||
- Export and import as a file or GitHub gist
|
- Export and import as a file or GitHub gist
|
||||||
|
|
||||||
_Collections are synced with cloud / local session storage_
|
_Collections are synced with your cloud/local session storage._
|
||||||
|
|
||||||
|
📜 **Pre-Request Scripts:** Snippets of code associated with a request that is executed before the request is sent.
|
||||||
|
|
||||||
|
- Set environment variables
|
||||||
|
- Include timestamp in the request headers
|
||||||
|
- Send a random alphanumeric string in the URL parameters
|
||||||
|
- Any JavaScript functions
|
||||||
|
|
||||||
|
👨👩👧👦 **Teams:** Helps you collaborate across your teams to design, develop, and test APIs faster.
|
||||||
|
|
||||||
|
- Create unlimited teams
|
||||||
|
- Create unlimited shared collections
|
||||||
|
- Create unlimited team members
|
||||||
|
- Role-based access control
|
||||||
|
- Cloud sync
|
||||||
|
- Multiple devices
|
||||||
|
|
||||||
|
👥 **Workspaces:** Organize your personal and team collections environments into workspaces. Easily switch between workspaces to manage multiple projects.
|
||||||
|
|
||||||
|
- Create unlimited workspaces
|
||||||
|
- Switch between personal and team workspaces
|
||||||
|
|
||||||
|
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
|
||||||
|
|
||||||
|
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
|
||||||
|
|
||||||
🌐 **Proxy:** Enable Proxy Mode from Settings to access blocked APIs.
|
🌐 **Proxy:** Enable Proxy Mode from Settings to access blocked APIs.
|
||||||
|
|
||||||
@@ -161,60 +174,31 @@ _Collections are synced with cloud / local session storage_
|
|||||||
- Access APIs served in non-HTTPS (`http://`) endpoints
|
- Access APIs served in non-HTTPS (`http://`) endpoints
|
||||||
- Use your Proxy URL
|
- Use your Proxy URL
|
||||||
|
|
||||||
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/privacy)**_
|
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/support/privacy)**._
|
||||||
|
|
||||||
📜 **Pre-Request Scripts β:** Snippets of code associated with a request that is executed before the request is sent.
|
|
||||||
|
|
||||||
- Set environment variables
|
|
||||||
- Include timestamp in the request headers
|
|
||||||
- Send a random alphanumeric string in the URL parameters
|
|
||||||
- Any JavaScript functions
|
|
||||||
|
|
||||||
📄 **API Documentation:** Create and share dynamic API documentation easily, quickly.
|
|
||||||
|
|
||||||
1. Add your requests to Collections and Folders
|
|
||||||
2. Export Collections and easily share your APIs with the rest of your team
|
|
||||||
3. Import Collections and Generate Documentation on-the-go
|
|
||||||
|
|
||||||
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
|
|
||||||
|
|
||||||
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/features/shortcuts)**
|
|
||||||
|
|
||||||
🌎 **i18n:** Experience the app in your language.
|
🌎 **i18n:** Experience the app in your language.
|
||||||
|
|
||||||
Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md), and the process for submitting pull requests to us.
|
Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md) and the process for submitting pull requests to us.
|
||||||
|
|
||||||
📦 **Add-ons:** Official add-ons for hoppscotch.
|
☁️ **Auth + Sync:** Sign in and sync your data in real-time across all your devices.
|
||||||
|
|
||||||
- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch
|
**Sign in with:**
|
||||||
- **[CLI β](https://github.com/hoppscotch/hopp-cli)** - A CLI solution for Hoppscotch
|
|
||||||
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that simplifies access to Hoppscotch
|
|
||||||
|
|
||||||
[ **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) | [ **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
|
|
||||||
|
|
||||||
> **Extensions fixes `CORS` issues.**
|
|
||||||
|
|
||||||
- **[Hopp-Doc-Gen](https://github.com/hoppscotch/hopp-doc-gen)** - An API doc generator CLI for Hoppscotch
|
|
||||||
|
|
||||||
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
|
|
||||||
|
|
||||||
☁️ **Auth + Sync:** Sign in and sync your data in real-time.
|
|
||||||
|
|
||||||
**Sign in with**
|
|
||||||
|
|
||||||
- GitHub
|
- GitHub
|
||||||
- Google
|
- Google
|
||||||
- Microsoft
|
- Microsoft
|
||||||
- Email
|
- Email
|
||||||
|
- SSO (Single Sign-On)[^EE]
|
||||||
|
|
||||||
**Synchronize your data**
|
**🔄 Synchronize your data:** Handoff to continue tasks on your other devices.
|
||||||
|
|
||||||
|
- Workspaces
|
||||||
- History
|
- History
|
||||||
- Collections
|
- Collections
|
||||||
- Environments
|
- Environments
|
||||||
- Settings
|
- Settings
|
||||||
|
|
||||||
✅ **Post-Request Tests β:** Write tests associated with a request that is executed after the request's response.
|
✅ **Post-Request Tests:** Write tests associated with a request that is executed after the request's response.
|
||||||
|
|
||||||
- Check the status code as an integer
|
- Check the status code as an integer
|
||||||
- Filter response headers
|
- Filter response headers
|
||||||
@@ -222,7 +206,7 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
|
|||||||
- Set environment variables
|
- Set environment variables
|
||||||
- Write JavaScript code
|
- Write JavaScript code
|
||||||
|
|
||||||
🌱 **Environments** : Environment variables allow you to store and reuse values in your requests and scripts.
|
🌱 **Environments:** Environment variables allow you to store and reuse values in your requests and scripts.
|
||||||
|
|
||||||
- Unlimited environments and variables
|
- Unlimited environments and variables
|
||||||
- Initialize through the pre-request script
|
- Initialize through the pre-request script
|
||||||
@@ -241,22 +225,31 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
👨👩👧👦 **Teams β:** Helps you collaborate across your team to design, develop, and test APIs faster.
|
|
||||||
|
|
||||||
- Unlimited teams
|
|
||||||
- Unlimited shared collections
|
|
||||||
- Unlimited team members
|
|
||||||
- Role-based access control
|
|
||||||
- Cloud sync
|
|
||||||
- Multiple devices
|
|
||||||
|
|
||||||
🚚 **Bulk Edit:** Edit key-value pairs in bulk.
|
🚚 **Bulk Edit:** Edit key-value pairs in bulk.
|
||||||
|
|
||||||
- Entries are separated by newline
|
- Entries are separated by newline
|
||||||
- Keys and values are separated by `:`
|
- Keys and values are separated by `:`
|
||||||
- Prepend `#` to any row you want to add but keep disabled
|
- Prepend `#` to any row you want to add but keep disabled
|
||||||
|
|
||||||
**For more features, please read our [documentation](https://docs.hoppscotch.io).**
|
🎛️ **Admin dashboard:** Manage your team and invite members.
|
||||||
|
|
||||||
|
- Insights
|
||||||
|
- Manage users
|
||||||
|
- Manage teams
|
||||||
|
|
||||||
|
📦 **Add-ons:** Official add-ons for hoppscotch.
|
||||||
|
|
||||||
|
- **[Hoppscotch CLI](https://github.com/hoppscotch/hopp-cli)** - Command-line interface for Hoppscotch.
|
||||||
|
- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch.
|
||||||
|
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that enhance your Hoppscotch experience.
|
||||||
|
|
||||||
|
[ **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) | [ **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
|
||||||
|
|
||||||
|
> **Extensions fix `CORS` issues.**
|
||||||
|
|
||||||
|
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
|
||||||
|
|
||||||
|
**For a complete list of features, please read our [documentation](https://docs.hoppscotch.io).**
|
||||||
|
|
||||||
## **Demo**
|
## **Demo**
|
||||||
|
|
||||||
@@ -268,56 +261,9 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
|
|||||||
2. Click "Send" to simulate the request
|
2. Click "Send" to simulate the request
|
||||||
3. View the response
|
3. View the response
|
||||||
|
|
||||||
## **Built with**
|
|
||||||
|
|
||||||
- [HTML](https://developer.mozilla.org/en-US/docs/Web/HTML)
|
|
||||||
- [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), [SCSS](https://sass-lang.com), [Windi CSS](https://windicss.org)
|
|
||||||
- [JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript)
|
|
||||||
- [TypeScript](https://www.typescriptlang.org)
|
|
||||||
- [Vue](https://vuejs.org)
|
|
||||||
- [Vite](https://vitejs.dev)
|
|
||||||
|
|
||||||
## **Developing**
|
## **Developing**
|
||||||
|
|
||||||
0. Update [`.env.example`](https://github.com/hoppscotch/hoppscotch/blob/main/.env.example) file found in the root of repository with your own keys and rename it to `.env`.
|
Follow our [self-hosting documentation](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment.
|
||||||
|
|
||||||
_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/.env.example) file found in the root of repository 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).
|
|
||||||
|
|
||||||
## **Contributing**
|
## **Contributing**
|
||||||
|
|
||||||
@@ -335,7 +281,7 @@ See the [`CHANGELOG`](CHANGELOG.md) file for details.
|
|||||||
|
|
||||||
## **Authors**
|
## **Authors**
|
||||||
|
|
||||||
This project exists thanks to all the people who contribute — [contribute](CONTRIBUTING.md).
|
This project owes its existence to the collective efforts of all those who contribute — [contribute now](CONTRIBUTING.md).
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors">
|
<a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors">
|
||||||
@@ -347,4 +293,6 @@ This project exists thanks to all the people who contribute — [contribute](CON
|
|||||||
|
|
||||||
## **License**
|
## **License**
|
||||||
|
|
||||||
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [`LICENSE`](LICENSE) file for details.
|
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) — see the [`LICENSE`](LICENSE) file for details.
|
||||||
|
|
||||||
|
[^EE]: Enterprise edition feature. [Learn more](https://docs.hoppscotch.io/documentation/self-host/getting-started).
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
This document outlines security procedures and general policies for the Hoppscotch project.
|
This document outlines security procedures and general policies for the Hoppscotch project.
|
||||||
|
|
||||||
1. [Reporting a security vulnerability](#reporting-a-security-vulnerability)
|
- [Security Policy](#security-policy)
|
||||||
3. [Incident response process](#incident-response-process)
|
- [Reporting a security vulnerability](#reporting-a-security-vulnerability)
|
||||||
|
- [Incident response process](#incident-response-process)
|
||||||
|
|
||||||
## Reporting a security vulnerability
|
## Reporting a security vulnerability
|
||||||
|
|
||||||
|
|||||||
@@ -9,26 +9,24 @@ Before you start working on a new language, please look through the [open pull r
|
|||||||
if there is no existing translation, you can create a new one by following these steps:
|
if there is no existing translation, you can create a new one by following these steps:
|
||||||
|
|
||||||
1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).**
|
1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).**
|
||||||
2. **Checkout the `i18n` branch for latest translations.**
|
2. **Checkout the `main` branch for latest translations.**
|
||||||
3. **Create a new branch for your translation with base branch `i18n`.**
|
3. **Create a new branch for your translation with base branch `main`.**
|
||||||
4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.**
|
4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.**
|
||||||
5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.**
|
5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.**
|
||||||
6. **Translate the strings in the target language file.**
|
6. **Translate the strings in the target language file.**
|
||||||
7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).**
|
7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).**
|
||||||
8. **Save & commit changes.**
|
8. **Save and commit changes.**
|
||||||
9. **Send a pull request.**
|
9. **Send a pull request.**
|
||||||
|
|
||||||
_You may send a pull request before all steps above are complete: e.g., you may want to ask for help with translations, or getting tests to pass. However, your pull request will not be merged until all steps above are complete._
|
_You may send a pull request before all steps above are complete: e.g., you may want to ask for help with translations, or getting tests to pass. However, your pull request will not be merged until all steps above are complete._
|
||||||
|
|
||||||
`i18n` branch will be merged into `main` branch once every week.
|
|
||||||
|
|
||||||
Completing an initial translation of the whole site is a fairly large task. One way to break that task up is to work with other translators through pull requests on your fork. You can also [add collaborators to your fork](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) if you'd like to invite other translators to commit directly to your fork and share responsibility for merging pull requests.
|
Completing an initial translation of the whole site is a fairly large task. One way to break that task up is to work with other translators through pull requests on your fork. You can also [add collaborators to your fork](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) if you'd like to invite other translators to commit directly to your fork and share responsibility for merging pull requests.
|
||||||
|
|
||||||
## Updating a translation
|
## Updating a translation
|
||||||
|
|
||||||
### Corrections
|
### Corrections
|
||||||
|
|
||||||
If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, open an issue.
|
If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, [open an issue](https://github.com/hoppscotch/hoppscotch/issues/new/choose).
|
||||||
|
|
||||||
### Broken links
|
### Broken links
|
||||||
|
|
||||||
|
|||||||
11
aio.Caddyfile
Normal file
11
aio.Caddyfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
:3000 {
|
||||||
|
try_files {path} /
|
||||||
|
root * /site/selfhost-web
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
:3100 {
|
||||||
|
try_files {path} /
|
||||||
|
root * /site/sh-admin
|
||||||
|
file_server
|
||||||
|
}
|
||||||
72
aio_run.mjs
Normal file
72
aio_run.mjs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/local/bin/node
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import { execSync, spawn } from "child_process"
|
||||||
|
import fs from "fs"
|
||||||
|
import process from "process"
|
||||||
|
|
||||||
|
function runChildProcessWithPrefix(command, args, prefix) {
|
||||||
|
const childProcess = spawn(command, args);
|
||||||
|
|
||||||
|
childProcess.stdout.on('data', (data) => {
|
||||||
|
const output = data.toString().trim().split('\n');
|
||||||
|
output.forEach((line) => {
|
||||||
|
console.log(`${prefix} | ${line}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.stderr.on('data', (data) => {
|
||||||
|
const error = data.toString().trim().split('\n');
|
||||||
|
error.forEach((line) => {
|
||||||
|
console.error(`${prefix} | ${line}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.on('close', (code) => {
|
||||||
|
console.log(`${prefix} Child process exited with code ${code}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.on('error', (stuff) => {
|
||||||
|
console.log("error")
|
||||||
|
console.log(stuff)
|
||||||
|
})
|
||||||
|
|
||||||
|
return childProcess
|
||||||
|
}
|
||||||
|
|
||||||
|
const envFileContent = Object.entries(process.env)
|
||||||
|
.filter(([env]) => env.startsWith("VITE_"))
|
||||||
|
.map(([env, val]) => `${env}=${
|
||||||
|
(val.startsWith("\"") && val.endsWith("\""))
|
||||||
|
? val
|
||||||
|
: `"${val}"`
|
||||||
|
}`)
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
fs.writeFileSync("build.env", envFileContent)
|
||||||
|
|
||||||
|
execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`)
|
||||||
|
|
||||||
|
fs.rmSync("build.env")
|
||||||
|
|
||||||
|
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
|
||||||
|
const backendProcess = runChildProcessWithPrefix("pnpm", ["run", "start:prod"], "Backend Server")
|
||||||
|
|
||||||
|
caddyProcess.on("exit", (code) => {
|
||||||
|
console.log(`Exiting process because Caddy Server exited with code ${code}`)
|
||||||
|
process.exit(code)
|
||||||
|
})
|
||||||
|
|
||||||
|
backendProcess.on("exit", (code) => {
|
||||||
|
console.log(`Exiting process because Backend Server exited with code ${code}`)
|
||||||
|
process.exit(code)
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log("SIGINT received, exiting...")
|
||||||
|
|
||||||
|
caddyProcess.kill("SIGINT")
|
||||||
|
backendProcess.kill("SIGINT")
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
@@ -1,23 +1,150 @@
|
|||||||
|
# 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"
|
version: "3.7"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
# This service runs the backend app in the port 3170
|
||||||
|
hoppscotch-backend:
|
||||||
|
container_name: hoppscotch-backend
|
||||||
build:
|
build:
|
||||||
|
dockerfile: prod.Dockerfile
|
||||||
context: .
|
context: .
|
||||||
|
target: backend
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
|
||||||
|
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
||||||
|
- PORT=3170
|
||||||
volumes:
|
volumes:
|
||||||
- "./.hoppscotch:/app/.hoppscotch"
|
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
|
||||||
- "./assets:/app/assets"
|
# - ./packages/hoppscotch-backend/:/usr/src/app
|
||||||
- "./directives:/app/directives"
|
- /usr/src/app/node_modules/
|
||||||
- "./layouts:/app/layouts"
|
depends_on:
|
||||||
- "./middleware:/app/middleware"
|
hoppscotch-db:
|
||||||
- "./pages:/app/pages"
|
condition: service_healthy
|
||||||
- "./plugins:/app/plugins"
|
ports:
|
||||||
- "./static:/app/static"
|
- "3170:3170"
|
||||||
- "./store:/app/store"
|
|
||||||
- "./components:/app/components"
|
# The main hoppscotch app. This will be hosted at port 3000
|
||||||
- "./helpers:/app/helpers"
|
# 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: prod.Dockerfile
|
||||||
|
context: .
|
||||||
|
target: app
|
||||||
|
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: prod.Dockerfile
|
||||||
|
context: .
|
||||||
|
target: sh_admin
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
depends_on:
|
||||||
|
- hoppscotch-backend
|
||||||
|
ports:
|
||||||
|
- "3100:8080"
|
||||||
|
|
||||||
|
# The service that spins up all 3 services at once in one container
|
||||||
|
hoppscotch-aio:
|
||||||
|
container_name: hoppscotch-aio
|
||||||
|
build:
|
||||||
|
dockerfile: prod.Dockerfile
|
||||||
|
context: .
|
||||||
|
target: aio
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
depends_on:
|
||||||
|
hoppscotch-db:
|
||||||
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
- "3100:3100"
|
||||||
|
- "3170:3170"
|
||||||
|
|
||||||
|
# The preset DB service, you can delete/comment the below lines if
|
||||||
|
# you are using an external postgres instance
|
||||||
|
# This will be exposed at port 5432
|
||||||
|
hoppscotch-db:
|
||||||
|
image: postgres:15
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
user: postgres
|
||||||
environment:
|
environment:
|
||||||
HOST: 0.0.0.0
|
# The default user defined by the docker image
|
||||||
command: "pnpm run dev"
|
POSTGRES_USER: postgres
|
||||||
|
# NOTE: Please UPDATE THIS PASSWORD!
|
||||||
|
POSTGRES_PASSWORD: testpass
|
||||||
|
POSTGRES_DB: hoppscotch
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# All the services listed below are deprececated
|
||||||
|
hoppscotch-old-backend:
|
||||||
|
container_name: hoppscotch-old-backend
|
||||||
|
build:
|
||||||
|
dockerfile: packages/hoppscotch-backend/Dockerfile
|
||||||
|
context: .
|
||||||
|
target: prod
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
|
||||||
|
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
||||||
|
- PORT=3000
|
||||||
|
volumes:
|
||||||
|
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
|
||||||
|
# - ./packages/hoppscotch-backend/:/usr/src/app
|
||||||
|
- /usr/src/app/node_modules/
|
||||||
|
depends_on:
|
||||||
|
hoppscotch-db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "3170:3000"
|
||||||
|
|
||||||
|
hoppscotch-old-app:
|
||||||
|
container_name: hoppscotch-old-app
|
||||||
|
build:
|
||||||
|
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
|
||||||
|
context: .
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
depends_on:
|
||||||
|
- hoppscotch-old-backend
|
||||||
|
ports:
|
||||||
|
- "3000:8080"
|
||||||
|
|
||||||
|
hoppscotch-old-sh-admin:
|
||||||
|
container_name: hoppscotch-old-sh-admin
|
||||||
|
build:
|
||||||
|
dockerfile: packages/hoppscotch-sh-admin/Dockerfile
|
||||||
|
context: .
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
depends_on:
|
||||||
|
- hoppscotch-old-backend
|
||||||
|
ports:
|
||||||
|
- "3100:8080"
|
||||||
|
|||||||
14
healthcheck.sh
Normal file
14
healthcheck.sh
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
curlCheck() {
|
||||||
|
if ! curl -s --head "$1" | head -n 1 | grep -q "HTTP/1.[01] [23].."; then
|
||||||
|
echo "URL request failed!"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "URL request succeeded!"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
curlCheck "http://localhost:3000"
|
||||||
|
curlCheck "http://localhost:3100"
|
||||||
|
curlCheck "http://localhost:3170/ping"
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
[[headers]]
|
[[headers]]
|
||||||
for = "/*"
|
for = "/*"
|
||||||
[headers.values]
|
[headers.values]
|
||||||
X-Frame-Options = "DENY"
|
X-Frame-Options = "SAMEORIGIN"
|
||||||
X-XSS-Protection = "1; mode=block"
|
X-XSS-Protection = "1; mode=block"
|
||||||
|
|
||||||
[[redirects]]
|
[[redirects]]
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -9,13 +9,15 @@
|
|||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"dev": "pnpm -r do-dev",
|
"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",
|
"generate": "pnpm -r do-build-prod",
|
||||||
"start": "http-server packages/hoppscotch-web/dist -p 3000",
|
"start": "http-server packages/hoppscotch-selfhost-web/dist -p 3000",
|
||||||
"lint": "pnpm -r do-lint",
|
"lint": "pnpm -r do-lint",
|
||||||
"typecheck": "pnpm -r do-typecheck",
|
"typecheck": "pnpm -r do-typecheck",
|
||||||
"lintfix": "pnpm -r do-lintfix",
|
"lintfix": "pnpm -r do-lintfix",
|
||||||
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
|
"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": [
|
"workspaces": [
|
||||||
"./packages/*"
|
"./packages/*"
|
||||||
@@ -28,6 +30,16 @@
|
|||||||
"@commitlint/cli": "^16.2.3",
|
"@commitlint/cli": "^16.2.3",
|
||||||
"@commitlint/config-conventional": "^16.2.1",
|
"@commitlint/config-conventional": "^16.2.1",
|
||||||
"@types/node": "^17.0.24",
|
"@types/node": "^17.0.24",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"http-server": "^14.1.1"
|
"http-server": "^14.1.1"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"packageExtensions": {
|
||||||
|
"httpsnippet@^3.0.1": {
|
||||||
|
"peerDependencies": {
|
||||||
|
"ajv": "6.12.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,12 @@
|
|||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.2.0",
|
"@codemirror/language": "^6.9.0",
|
||||||
"@lezer/highlight": "^1.0.0",
|
"@lezer/highlight": "^1.1.6",
|
||||||
"@lezer/lr": "^1.2.0"
|
"@lezer/lr": "^1.3.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lezer/generator": "^1.1.0",
|
"@lezer/generator": "^1.5.0",
|
||||||
"mocha": "^9.2.2",
|
"mocha": "^9.2.2",
|
||||||
"rollup": "^2.70.2",
|
"rollup": "^2.70.2",
|
||||||
"rollup-plugin-dts": "^4.2.1",
|
"rollup-plugin-dts": "^4.2.1",
|
||||||
|
|||||||
141
packages/dioc/README.md
Normal file
141
packages/dioc/README.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# dioc
|
||||||
|
|
||||||
|
A small and lightweight dependency injection / inversion of control system.
|
||||||
|
|
||||||
|
### About
|
||||||
|
|
||||||
|
`dioc` is a really simple **DI/IOC** system where you write services (which are singletons per container) that can depend on each other and emit events that can be listened upon.
|
||||||
|
|
||||||
|
### Demo
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Service, Container } from "dioc"
|
||||||
|
|
||||||
|
// Here is a simple service, which you can define by extending the Service class
|
||||||
|
// and providing an ID static field (of type string)
|
||||||
|
export class PersistenceService extends Service {
|
||||||
|
// This should be unique for each container
|
||||||
|
public static ID = "PERSISTENCE_SERVICE"
|
||||||
|
|
||||||
|
public read(key: string): string | undefined {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
public write(key: string, value: string) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodoServiceEvent =
|
||||||
|
| { type: "TODO_CREATED"; index: number }
|
||||||
|
| { type: "TODO_DELETED"; index: number }
|
||||||
|
|
||||||
|
// Services have a built in event system
|
||||||
|
// Define the generic argument to say what are the possible emitted values
|
||||||
|
export class TodoService extends Service<TodoServiceEvent> {
|
||||||
|
public static ID = "TODO_SERVICE"
|
||||||
|
|
||||||
|
// Inject persistence service into this service
|
||||||
|
private readonly persistence = this.bind(PersistenceService)
|
||||||
|
|
||||||
|
public todos = []
|
||||||
|
|
||||||
|
// Service constructors cannot have arguments
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.todos = JSON.parse(this.persistence.read("todos") ?? "[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
public addTodo(text: string) {
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// You can access services via the bound fields
|
||||||
|
this.persistence.write("todos", JSON.stringify(this.todos))
|
||||||
|
|
||||||
|
// This is how you emit an event
|
||||||
|
this.emit({
|
||||||
|
type: "TODO_CREATED",
|
||||||
|
index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeTodo(index: number) {
|
||||||
|
// ...
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "TODO_DELETED",
|
||||||
|
index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Services need a container to run in
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
// You can initialize and get services using Container#bind
|
||||||
|
// It will automatically initialize the service (and its dependencies)
|
||||||
|
const todoService = container.bind(TodoService) // Returns an instance of TodoService
|
||||||
|
```
|
||||||
|
|
||||||
|
### Demo (Unit Test)
|
||||||
|
|
||||||
|
`dioc/testing` contains `TestContainer` which lets you bind mocked services to the container.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { TestContainer } from "dioc/testing"
|
||||||
|
import { TodoService, PersistenceService } from "./demo.ts" // The above demo code snippet
|
||||||
|
import { describe, it, expect, vi } from "vitest"
|
||||||
|
|
||||||
|
describe("TodoService", () => {
|
||||||
|
it("addTodo writes to persistence", () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const writeFn = vi.fn()
|
||||||
|
|
||||||
|
// The first parameter is the service to mock and the second parameter
|
||||||
|
// is the mocked service fields and functions
|
||||||
|
container.bindMock(PersistenceService, {
|
||||||
|
read: () => undefined, // Not really important for this test
|
||||||
|
write: writeFn,
|
||||||
|
})
|
||||||
|
|
||||||
|
// the peristence service bind in TodoService will now use the
|
||||||
|
// above defined mocked implementation
|
||||||
|
const todoService = container.bind(TodoService)
|
||||||
|
|
||||||
|
todoService.addTodo("sup")
|
||||||
|
|
||||||
|
expect(writeFn).toHaveBeenCalledOnce()
|
||||||
|
expect(writeFn).toHaveBeenCalledWith("todos", JSON.stringify(["sup"]))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Demo (Vue)
|
||||||
|
|
||||||
|
`dioc/vue` contains a Vue Plugin and a `useService` composable that allows Vue components to use the defined services.
|
||||||
|
|
||||||
|
In the app entry point:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createApp } from "vue"
|
||||||
|
import { diocPlugin } from "dioc/vue"
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
|
||||||
|
app.use(diocPlugin, {
|
||||||
|
container: new Container(), // You can pass in the container you want to provide to the components here
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
In your Vue components:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
import { TodoService } from "./demo.ts" // The above demo
|
||||||
|
import { useService } from "dioc/vue"
|
||||||
|
|
||||||
|
const todoService = useService(TodoService) // Returns an instance of the TodoService class
|
||||||
|
</script>
|
||||||
|
```
|
||||||
2
packages/dioc/index.d.ts
vendored
Normal file
2
packages/dioc/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./dist/main.d.ts"
|
||||||
|
export * from "./dist/main.d.ts"
|
||||||
147
packages/dioc/lib/container.ts
Normal file
147
packages/dioc/lib/container.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { Service } from "./service"
|
||||||
|
import { Observable, Subject } from 'rxjs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the current container instance in the current operating context.
|
||||||
|
*
|
||||||
|
* NOTE: This should not be used outside of dioc library code
|
||||||
|
*/
|
||||||
|
export let currentContainer: Container | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events emitted by the container
|
||||||
|
*
|
||||||
|
* `SERVICE_BIND` - emitted when a service is bound to the container directly or as a dependency to another service
|
||||||
|
* `SERVICE_INIT` - emitted when a service is initialized
|
||||||
|
*/
|
||||||
|
export type ContainerEvent =
|
||||||
|
| {
|
||||||
|
type: 'SERVICE_BIND';
|
||||||
|
|
||||||
|
/** The Service ID of the service being bounded (the dependency) */
|
||||||
|
boundeeID: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Service ID of the bounder that is binding the boundee (the dependent)
|
||||||
|
*
|
||||||
|
* NOTE: This will be undefined if the service is bound directly to the container
|
||||||
|
*/
|
||||||
|
bounderID: string | undefined
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'SERVICE_INIT';
|
||||||
|
|
||||||
|
/** The Service ID of the service being initialized */
|
||||||
|
serviceID: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dependency injection container, allows for services to be initialized and maintains the dependency trees.
|
||||||
|
*/
|
||||||
|
export class Container {
|
||||||
|
/** Used during the `bind` operation to detect circular dependencies */
|
||||||
|
private bindStack: string[] = []
|
||||||
|
|
||||||
|
/** The map of bound services to their IDs */
|
||||||
|
protected boundMap = new Map<string, Service<unknown>>()
|
||||||
|
|
||||||
|
/** The RxJS observable representing the event stream */
|
||||||
|
protected event$ = new Subject<ContainerEvent>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether a container has the given service bound
|
||||||
|
* @param service The service to check for
|
||||||
|
*/
|
||||||
|
public hasBound<
|
||||||
|
T extends typeof Service<any> & { ID: string }
|
||||||
|
>(service: T): boolean {
|
||||||
|
return this.boundMap.has(service.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the service bound to the container with the given ID or if not found, undefined.
|
||||||
|
*
|
||||||
|
* NOTE: This is an advanced method and should not be used as much as possible.
|
||||||
|
*
|
||||||
|
* @param serviceID The ID of the service to get
|
||||||
|
*/
|
||||||
|
public getBoundServiceWithID(serviceID: string): Service<unknown> | undefined {
|
||||||
|
return this.boundMap.get(serviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds a service to the container. This is equivalent to marking a service as a dependency.
|
||||||
|
* @param service The class reference of a service to bind
|
||||||
|
* @param bounder The class reference of the service that is binding the service (if bound directly to the container, this should be undefined)
|
||||||
|
*/
|
||||||
|
public bind<T extends typeof Service<any> & { ID: string }>(
|
||||||
|
service: T,
|
||||||
|
bounder: ((typeof Service<T>) & { ID: string }) | undefined = undefined
|
||||||
|
): InstanceType<T> {
|
||||||
|
// We need to store the current container in a variable so that we can restore it after the bind operation
|
||||||
|
const oldCurrentContainer = currentContainer;
|
||||||
|
currentContainer = this;
|
||||||
|
|
||||||
|
// If the service is already bound, return the existing instance
|
||||||
|
if (this.hasBound(service)) {
|
||||||
|
this.event$.next({
|
||||||
|
type: 'SERVICE_BIND',
|
||||||
|
boundeeID: service.ID,
|
||||||
|
bounderID: bounder?.ID // Return the bounder ID if it is defined, else assume its the container
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.boundMap.get(service.ID) as InstanceType<T> // Casted as InstanceType<T> because service IDs and types are expected to match
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect circular dependency and throw error
|
||||||
|
if (this.bindStack.findIndex((serviceID) => serviceID === service.ID) !== -1) {
|
||||||
|
const circularServices = `${this.bindStack.join(' -> ')} -> ${service.ID}`
|
||||||
|
|
||||||
|
throw new Error(`Circular dependency detected.\nChain: ${circularServices}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the service ID onto the bind stack to detect circular dependencies
|
||||||
|
this.bindStack.push(service.ID)
|
||||||
|
|
||||||
|
// Initialize the service and emit events
|
||||||
|
|
||||||
|
// NOTE: We need to cast the service to any as TypeScript thinks that the service is abstract
|
||||||
|
const instance: Service<any> = new (service as any)()
|
||||||
|
|
||||||
|
this.boundMap.set(service.ID, instance)
|
||||||
|
|
||||||
|
this.bindStack.pop()
|
||||||
|
|
||||||
|
this.event$.next({
|
||||||
|
type: 'SERVICE_INIT',
|
||||||
|
serviceID: service.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.event$.next({
|
||||||
|
type: 'SERVICE_BIND',
|
||||||
|
boundeeID: service.ID,
|
||||||
|
bounderID: bounder?.ID
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// Restore the current container
|
||||||
|
currentContainer = oldCurrentContainer;
|
||||||
|
|
||||||
|
// We expect the return type to match the service definition
|
||||||
|
return instance as InstanceType<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an iterator of the currently bound service IDs and their instances
|
||||||
|
*/
|
||||||
|
public getBoundServices(): IterableIterator<[string, Service<any>]> {
|
||||||
|
return this.boundMap.entries()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the public container event stream
|
||||||
|
*/
|
||||||
|
public getEventStream(): Observable<ContainerEvent> {
|
||||||
|
return this.event$.asObservable()
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/dioc/lib/main.ts
Normal file
2
packages/dioc/lib/main.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./container"
|
||||||
|
export * from "./service"
|
||||||
65
packages/dioc/lib/service.ts
Normal file
65
packages/dioc/lib/service.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Observable, Subject } from 'rxjs'
|
||||||
|
import { Container, currentContainer } from './container'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Dioc service that can bound to a container and can bind dependency services.
|
||||||
|
*
|
||||||
|
* NOTE: Services cannot have a constructor that takes arguments.
|
||||||
|
*
|
||||||
|
* @template EventDef The type of events that can be emitted by the service. These will be accessible by event streams
|
||||||
|
*/
|
||||||
|
export abstract class Service<EventDef = {}> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The internal event stream of the service
|
||||||
|
*/
|
||||||
|
private event$ = new Subject<EventDef>()
|
||||||
|
|
||||||
|
/** The container the service is bound to */
|
||||||
|
#container: Container
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!currentContainer) {
|
||||||
|
throw new Error(
|
||||||
|
`Tried to initialize service with no container (ID: ${ (this.constructor as any).ID })`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#container = currentContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds a dependency service into this service.
|
||||||
|
* @param service The class reference of the service to bind
|
||||||
|
*/
|
||||||
|
protected bind<T extends typeof Service<any> & { ID: string }>(service: T): InstanceType<T> {
|
||||||
|
if (!currentContainer) {
|
||||||
|
throw new Error('No currentContainer defined.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentContainer.bind(service, this.constructor as typeof Service<any> & { ID: string })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the container the service is bound to
|
||||||
|
*/
|
||||||
|
protected getContainer(): Container {
|
||||||
|
return this.#container
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits an event on the service's event stream
|
||||||
|
* @param event The event to emit
|
||||||
|
*/
|
||||||
|
protected emit(event: EventDef) {
|
||||||
|
this.event$.next(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the event stream of the service
|
||||||
|
*/
|
||||||
|
public getEventStream(): Observable<EventDef> {
|
||||||
|
|
||||||
|
return this.event$.asObservable()
|
||||||
|
}
|
||||||
|
}
|
||||||
33
packages/dioc/lib/testing.ts
Normal file
33
packages/dioc/lib/testing.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Container, Service } from "./main";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A container that can be used for writing tests, contains additional methods
|
||||||
|
* for binding suitable for writing tests. (see `bindMock`).
|
||||||
|
*/
|
||||||
|
export class TestContainer extends Container {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds a mock service to the container.
|
||||||
|
*
|
||||||
|
* @param service
|
||||||
|
* @param mock
|
||||||
|
*/
|
||||||
|
public bindMock<
|
||||||
|
T extends typeof Service<any> & { ID: string },
|
||||||
|
U extends Partial<InstanceType<T>>
|
||||||
|
>(service: T, mock: U): U {
|
||||||
|
if (this.boundMap.has(service.ID)) {
|
||||||
|
throw new Error(`Service '${service.ID}' already bound to container. Did you already call bindMock on this ?`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.boundMap.set(service.ID, mock as any)
|
||||||
|
|
||||||
|
this.event$.next({
|
||||||
|
type: "SERVICE_BIND",
|
||||||
|
boundeeID: service.ID,
|
||||||
|
bounderID: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
}
|
||||||
34
packages/dioc/lib/vue.ts
Normal file
34
packages/dioc/lib/vue.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Plugin, inject } from "vue"
|
||||||
|
import { Container } from "./container"
|
||||||
|
import { Service } from "./service"
|
||||||
|
|
||||||
|
const VUE_CONTAINER_KEY = Symbol()
|
||||||
|
|
||||||
|
// TODO: Some Vue version issue with plugin generics is breaking type checking
|
||||||
|
/**
|
||||||
|
* The Vue Dioc Plugin, this allows the composables to work and access the container
|
||||||
|
*
|
||||||
|
* NOTE: Make sure you add `vue` as dependency to be able to use this plugin (duh)
|
||||||
|
*/
|
||||||
|
export const diocPlugin: Plugin = {
|
||||||
|
install(app, { container }) {
|
||||||
|
app.provide(VUE_CONTAINER_KEY, container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composable that binds a service to a Vue Component
|
||||||
|
*
|
||||||
|
* @param service The class reference of the service to bind
|
||||||
|
*/
|
||||||
|
export function useService<
|
||||||
|
T extends typeof Service<any> & { ID: string }
|
||||||
|
>(service: T): InstanceType<T> {
|
||||||
|
const container = inject(VUE_CONTAINER_KEY) as Container | undefined | null
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
throw new Error("Container not found, did you forget to install the dioc plugin?")
|
||||||
|
}
|
||||||
|
|
||||||
|
return container.bind(service)
|
||||||
|
}
|
||||||
54
packages/dioc/package.json
Normal file
54
packages/dioc/package.json
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "dioc",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"index.d.ts"
|
||||||
|
],
|
||||||
|
"main": "./dist/counter.umd.cjs",
|
||||||
|
"module": "./dist/counter.js",
|
||||||
|
"types": "./index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/main.d.ts",
|
||||||
|
"require": "./dist/index.cjs",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./vue": {
|
||||||
|
"types": "./dist/vue.d.ts",
|
||||||
|
"require": "./dist/vue.cjs",
|
||||||
|
"import": "./dist/vue.js"
|
||||||
|
},
|
||||||
|
"./testing": {
|
||||||
|
"types": "./dist/testing.d.ts",
|
||||||
|
"require": "./dist/testing.cjs",
|
||||||
|
"import": "./dist/testing.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build && tsc --emitDeclarationOnly",
|
||||||
|
"prepare": "pnpm run build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"do-test": "pnpm run test",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^4.9.4",
|
||||||
|
"vite": "^4.0.4",
|
||||||
|
"vitest": "^0.29.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.2.25"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"vue": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
262
packages/dioc/test/container.spec.ts
Normal file
262
packages/dioc/test/container.spec.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { it, expect, describe, vi } from "vitest"
|
||||||
|
import { Service } from "../lib/service"
|
||||||
|
import { Container, currentContainer, ContainerEvent } from "../lib/container"
|
||||||
|
|
||||||
|
class TestServiceA extends Service {
|
||||||
|
public static ID = "TestServiceA"
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestServiceB extends Service {
|
||||||
|
public static ID = "TestServiceB"
|
||||||
|
|
||||||
|
// Marked public to allow for testing
|
||||||
|
public readonly serviceA = this.bind(TestServiceA)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Container", () => {
|
||||||
|
describe("getBoundServiceWithID", () => {
|
||||||
|
it("returns the service instance if it is bound to the container", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const service = container.bind(TestServiceA)
|
||||||
|
|
||||||
|
expect(container.getBoundServiceWithID(TestServiceA.ID)).toBe(service)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined if the service is not bound to the container", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
expect(container.getBoundServiceWithID(TestServiceA.ID)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("bind", () => {
|
||||||
|
it("correctly binds the service to it", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const service = container.bind(TestServiceA)
|
||||||
|
|
||||||
|
// @ts-expect-error getContainer is defined as a protected property, but we are leveraging it here to check
|
||||||
|
expect(service.getContainer()).toBe(container)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("after bind, the current container is set back to its previous value", () => {
|
||||||
|
const originalValue = currentContainer
|
||||||
|
|
||||||
|
const container = new Container()
|
||||||
|
container.bind(TestServiceA)
|
||||||
|
|
||||||
|
expect(currentContainer).toBe(originalValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("dependent services are registered in the same container", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const serviceB = container.bind(TestServiceB)
|
||||||
|
|
||||||
|
// @ts-expect-error getContainer is defined as a protected property, but we are leveraging it here to check
|
||||||
|
expect(serviceB.serviceA.getContainer()).toBe(container)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("binding an already initialized service returns the initialized instance (services are singletons)", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const serviceA = container.bind(TestServiceA)
|
||||||
|
const serviceA2 = container.bind(TestServiceA)
|
||||||
|
|
||||||
|
expect(serviceA).toBe(serviceA2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("binding a service which is a dependency of another service returns the same instance created from the dependency resolution (services are singletons)", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const serviceB = container.bind(TestServiceB)
|
||||||
|
const serviceA = container.bind(TestServiceA)
|
||||||
|
|
||||||
|
expect(serviceB.serviceA).toBe(serviceA)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("binding an initialized service as a dependency returns the same instance", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const serviceA = container.bind(TestServiceA)
|
||||||
|
const serviceB = container.bind(TestServiceB)
|
||||||
|
|
||||||
|
expect(serviceB.serviceA).toBe(serviceA)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("container emits an init event when an uninitialized service is initialized via bind and event only called once", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const serviceFunc = vi.fn<
|
||||||
|
[ContainerEvent & { type: "SERVICE_INIT" }],
|
||||||
|
void
|
||||||
|
>()
|
||||||
|
|
||||||
|
container.getEventStream().subscribe((ev) => {
|
||||||
|
if (ev.type === "SERVICE_INIT") {
|
||||||
|
serviceFunc(ev)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const instance = container.bind(TestServiceA)
|
||||||
|
|
||||||
|
expect(serviceFunc).toHaveBeenCalledOnce()
|
||||||
|
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
|
||||||
|
type: "SERVICE_INIT",
|
||||||
|
serviceID: TestServiceA.ID,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("the bind event emitted has an undefined bounderID when the service is bound directly to the container", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const serviceFunc = vi.fn<
|
||||||
|
[ContainerEvent & { type: "SERVICE_BIND" }],
|
||||||
|
void
|
||||||
|
>()
|
||||||
|
|
||||||
|
container.getEventStream().subscribe((ev) => {
|
||||||
|
if (ev.type === "SERVICE_BIND") {
|
||||||
|
serviceFunc(ev)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
container.bind(TestServiceA)
|
||||||
|
|
||||||
|
expect(serviceFunc).toHaveBeenCalledOnce()
|
||||||
|
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
|
||||||
|
type: "SERVICE_BIND",
|
||||||
|
boundeeID: TestServiceA.ID,
|
||||||
|
bounderID: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("the bind event emitted has the correct bounderID when the service is bound to another service", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const serviceFunc = vi.fn<
|
||||||
|
[ContainerEvent & { type: "SERVICE_BIND" }],
|
||||||
|
void
|
||||||
|
>()
|
||||||
|
|
||||||
|
container.getEventStream().subscribe((ev) => {
|
||||||
|
// We only care about the bind event of TestServiceA
|
||||||
|
if (ev.type === "SERVICE_BIND" && ev.boundeeID === TestServiceA.ID) {
|
||||||
|
serviceFunc(ev)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
container.bind(TestServiceB)
|
||||||
|
|
||||||
|
expect(serviceFunc).toHaveBeenCalledOnce()
|
||||||
|
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
|
||||||
|
type: "SERVICE_BIND",
|
||||||
|
boundeeID: TestServiceA.ID,
|
||||||
|
bounderID: TestServiceB.ID,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("hasBound", () => {
|
||||||
|
it("returns true if the given service is bound to the container", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
container.bind(TestServiceA)
|
||||||
|
|
||||||
|
expect(container.hasBound(TestServiceA)).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false if the given service is not bound to the container", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
expect(container.hasBound(TestServiceA)).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true when the service is bound because it is a dependency of another service", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
container.bind(TestServiceB)
|
||||||
|
|
||||||
|
expect(container.hasBound(TestServiceA)).toEqual(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getEventStream", () => {
|
||||||
|
it("returns an observable which emits events correctly when services are initialized", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const serviceFunc = vi.fn<
|
||||||
|
[ContainerEvent & { type: "SERVICE_INIT" }],
|
||||||
|
void
|
||||||
|
>()
|
||||||
|
|
||||||
|
container.getEventStream().subscribe((ev) => {
|
||||||
|
if (ev.type === "SERVICE_INIT") {
|
||||||
|
serviceFunc(ev)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
container.bind(TestServiceB)
|
||||||
|
|
||||||
|
expect(serviceFunc).toHaveBeenCalledTimes(2)
|
||||||
|
expect(serviceFunc).toHaveBeenNthCalledWith(1, <ContainerEvent>{
|
||||||
|
type: "SERVICE_INIT",
|
||||||
|
serviceID: TestServiceA.ID,
|
||||||
|
})
|
||||||
|
expect(serviceFunc).toHaveBeenNthCalledWith(2, <ContainerEvent>{
|
||||||
|
type: "SERVICE_INIT",
|
||||||
|
serviceID: TestServiceB.ID,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns an observable which emits events correctly when services are bound", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const serviceFunc = vi.fn<
|
||||||
|
[ContainerEvent & { type: "SERVICE_BIND" }],
|
||||||
|
void
|
||||||
|
>()
|
||||||
|
|
||||||
|
container.getEventStream().subscribe((ev) => {
|
||||||
|
if (ev.type === "SERVICE_BIND") {
|
||||||
|
serviceFunc(ev)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
container.bind(TestServiceB)
|
||||||
|
|
||||||
|
expect(serviceFunc).toHaveBeenCalledTimes(2)
|
||||||
|
expect(serviceFunc).toHaveBeenNthCalledWith(1, <ContainerEvent>{
|
||||||
|
type: "SERVICE_BIND",
|
||||||
|
boundeeID: TestServiceA.ID,
|
||||||
|
bounderID: TestServiceB.ID,
|
||||||
|
})
|
||||||
|
expect(serviceFunc).toHaveBeenNthCalledWith(2, <ContainerEvent>{
|
||||||
|
type: "SERVICE_BIND",
|
||||||
|
boundeeID: TestServiceB.ID,
|
||||||
|
bounderID: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getBoundServices", () => {
|
||||||
|
it("returns an iterator over all services bound to the container in the format [service id, service instance]", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const instanceB = container.bind(TestServiceB)
|
||||||
|
const instanceA = instanceB.serviceA
|
||||||
|
|
||||||
|
expect(Array.from(container.getBoundServices())).toEqual([
|
||||||
|
[TestServiceA.ID, instanceA],
|
||||||
|
[TestServiceB.ID, instanceB],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns an empty iterator if no services are bound", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
expect(Array.from(container.getBoundServices())).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
66
packages/dioc/test/service.spec.ts
Normal file
66
packages/dioc/test/service.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest"
|
||||||
|
import { Service, Container } from "../lib/main"
|
||||||
|
|
||||||
|
class TestServiceA extends Service {
|
||||||
|
public static ID = "TestServiceA"
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestServiceB extends Service<"test"> {
|
||||||
|
public static ID = "TestServiceB"
|
||||||
|
|
||||||
|
// Marked public to allow for testing
|
||||||
|
public readonly serviceA = this.bind(TestServiceA)
|
||||||
|
|
||||||
|
public emitTestEvent() {
|
||||||
|
this.emit("test")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Service", () => {
|
||||||
|
describe("constructor", () => {
|
||||||
|
it("throws an error if the service is initialized without a container", () => {
|
||||||
|
expect(() => new TestServiceA()).toThrowError(
|
||||||
|
"Tried to initialize service with no container (ID: TestServiceA)"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("bind", () => {
|
||||||
|
it("correctly binds the dependency service using the container", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const serviceA = container.bind(TestServiceA)
|
||||||
|
|
||||||
|
const serviceB = container.bind(TestServiceB)
|
||||||
|
expect(serviceB.serviceA).toBe(serviceA)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getContainer", () => {
|
||||||
|
it("returns the container the service is bound to", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const serviceA = container.bind(TestServiceA)
|
||||||
|
|
||||||
|
// @ts-expect-error getContainer is a protected member, we are just using it to help with testing
|
||||||
|
expect(serviceA.getContainer()).toBe(container)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getEventStream", () => {
|
||||||
|
it("returns the valid event stream of the service", () => {
|
||||||
|
const container = new Container()
|
||||||
|
|
||||||
|
const serviceB = container.bind(TestServiceB)
|
||||||
|
|
||||||
|
const serviceFunc = vi.fn()
|
||||||
|
|
||||||
|
serviceB.getEventStream().subscribe(serviceFunc)
|
||||||
|
|
||||||
|
serviceB.emitTestEvent()
|
||||||
|
|
||||||
|
expect(serviceFunc).toHaveBeenCalledOnce()
|
||||||
|
expect(serviceFunc).toHaveBeenCalledWith("test")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
92
packages/dioc/test/test-container.spec.ts
Normal file
92
packages/dioc/test/test-container.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest"
|
||||||
|
import { TestContainer } from "../lib/testing"
|
||||||
|
import { Service } from "../lib/service"
|
||||||
|
import { ContainerEvent } from "../lib/container"
|
||||||
|
|
||||||
|
class TestServiceA extends Service {
|
||||||
|
public static ID = "TestServiceA"
|
||||||
|
|
||||||
|
public test() {
|
||||||
|
return "real"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestServiceB extends Service {
|
||||||
|
public static ID = "TestServiceB"
|
||||||
|
|
||||||
|
// declared public to help with testing
|
||||||
|
public readonly serviceA = this.bind(TestServiceA)
|
||||||
|
|
||||||
|
public test() {
|
||||||
|
return this.serviceA.test()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TestContainer", () => {
|
||||||
|
describe("bindMock", () => {
|
||||||
|
it("returns the fake service defined", () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const fakeService = {
|
||||||
|
test: () => "fake",
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = container.bindMock(TestServiceA, fakeService)
|
||||||
|
|
||||||
|
expect(result).toBe(fakeService)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("new services bound to the container get the mock service", () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const fakeServiceA = {
|
||||||
|
test: () => "fake",
|
||||||
|
}
|
||||||
|
|
||||||
|
container.bindMock(TestServiceA, fakeServiceA)
|
||||||
|
|
||||||
|
const serviceB = container.bind(TestServiceB)
|
||||||
|
|
||||||
|
expect(serviceB.serviceA).toBe(fakeServiceA)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("container emits SERVICE_BIND event", () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const fakeServiceA = {
|
||||||
|
test: () => "fake",
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceFunc = vi.fn<[ContainerEvent, void]>()
|
||||||
|
|
||||||
|
container.getEventStream().subscribe((ev) => {
|
||||||
|
serviceFunc(ev)
|
||||||
|
})
|
||||||
|
|
||||||
|
container.bindMock(TestServiceA, fakeServiceA)
|
||||||
|
|
||||||
|
expect(serviceFunc).toHaveBeenCalledOnce()
|
||||||
|
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
|
||||||
|
type: "SERVICE_BIND",
|
||||||
|
boundeeID: TestServiceA.ID,
|
||||||
|
bounderID: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws if service already bound", () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const fakeServiceA = {
|
||||||
|
test: () => "fake",
|
||||||
|
}
|
||||||
|
|
||||||
|
container.bindMock(TestServiceA, fakeServiceA)
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
container.bindMock(TestServiceA, fakeServiceA)
|
||||||
|
}).toThrowError(
|
||||||
|
"Service 'TestServiceA' already bound to container. Did you already call bindMock on this ?"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
2
packages/dioc/testing.d.ts
vendored
Normal file
2
packages/dioc/testing.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./dist/testing.d.ts"
|
||||||
|
export * from "./dist/testing.d.ts"
|
||||||
21
packages/dioc/tsconfig.json
Normal file
21
packages/dioc/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["lib"]
|
||||||
|
}
|
||||||
16
packages/dioc/vite.config.ts
Normal file
16
packages/dioc/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: {
|
||||||
|
index: './lib/main.ts',
|
||||||
|
vue: './lib/vue.ts',
|
||||||
|
testing: './lib/testing.ts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['vue'],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
7
packages/dioc/vitest.config.ts
Normal file
7
packages/dioc/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
2
packages/dioc/vue.d.ts
vendored
Normal file
2
packages/dioc/vue.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./dist/vue.d.ts"
|
||||||
|
export * from "./dist/vue.d.ts"
|
||||||
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.8.1",
|
||||||
|
"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.16.2",
|
||||||
|
"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.16.2",
|
||||||
|
"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", "debian-openssl-3.0.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 {}
|
||||||
442
packages/hoppscotch-backend/src/admin/admin.resolver.ts
Normal file
442
packages/hoppscotch-backend/src/admin/admin.resolver.ts
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, {
|
||||||
|
description: 'Revoke a team Invite by Invite ID',
|
||||||
|
})
|
||||||
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
|
async revokeTeamInviteByAdmin(
|
||||||
|
@Args({
|
||||||
|
name: 'inviteID',
|
||||||
|
description: 'Team Invite ID',
|
||||||
|
type: () => ID,
|
||||||
|
})
|
||||||
|
inviteID: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const invite = await this.adminService.revokeTeamInviteByID(inviteID);
|
||||||
|
if (E.isLeft(invite)) throwErr(invite.left);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subscriptions */
|
||||||
|
|
||||||
|
@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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
435
packages/hoppscotch-backend/src/admin/admin.service.ts
Normal file
435
packages/hoppscotch-backend/src/admin/admin.service.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
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,
|
||||||
|
TEAM_INVITE_NO_INVITE_FOUND,
|
||||||
|
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.getTeamInvitations(
|
||||||
|
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 teamMember = await this.teamService.getTeamMemberTE(
|
||||||
|
teamID,
|
||||||
|
user.value.uid,
|
||||||
|
)();
|
||||||
|
if (E.isLeft(teamMember)) {
|
||||||
|
const addedUser = await this.teamService.addMemberToTeamWithEmail(
|
||||||
|
teamID,
|
||||||
|
userEmail,
|
||||||
|
role,
|
||||||
|
);
|
||||||
|
if (E.isLeft(addedUser)) return E.left(addedUser.left);
|
||||||
|
|
||||||
|
const userInvitation =
|
||||||
|
await this.teamInvitationService.getTeamInviteByEmailAndTeamID(
|
||||||
|
userEmail,
|
||||||
|
teamID,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (E.isRight(userInvitation)) {
|
||||||
|
await this.teamInvitationService.revokeInvitation(
|
||||||
|
userInvitation.right.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return E.right(addedUser.right);
|
||||||
|
}
|
||||||
|
|
||||||
|
return E.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a team invite by ID
|
||||||
|
* @param inviteID Team Invite ID
|
||||||
|
* @returns an Either of boolean or error
|
||||||
|
*/
|
||||||
|
async revokeTeamInviteByID(inviteID: string) {
|
||||||
|
const teamInvite = await this.teamInvitationService.revokeInvitation(
|
||||||
|
inviteID,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (E.isLeft(teamInvite)) return E.left(teamInvite.left);
|
||||||
|
|
||||||
|
return E.right(teamInvite.right);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
9
packages/hoppscotch-backend/src/app.controller.ts
Normal file
9
packages/hoppscotch-backend/src/app.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('ping')
|
||||||
|
export class AppController {
|
||||||
|
@Get()
|
||||||
|
ping(): string {
|
||||||
|
return 'Success';
|
||||||
|
}
|
||||||
|
}
|
||||||
87
packages/hoppscotch-backend/src/app.module.ts
Normal file
87
packages/hoppscotch-backend/src/app.module.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
|
||||||
|
@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],
|
||||||
|
controllers: [AppController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
180
packages/hoppscotch-backend/src/auth/auth.controller.ts
Normal file
180
packages/hoppscotch-backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
InternalServerErrorException,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
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 {
|
||||||
|
AuthProvider,
|
||||||
|
authCookieHandler,
|
||||||
|
authProviderCheck,
|
||||||
|
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';
|
||||||
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
|
|
||||||
|
@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,
|
||||||
|
) {
|
||||||
|
if (!authProviderCheck(AuthProvider.EMAIL))
|
||||||
|
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
packages/hoppscotch-backend/src/auth/auth.module.ts
Normal file
36
packages/hoppscotch-backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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';
|
||||||
|
import { AuthProvider, authProviderCheck } from './helper';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
UserModule,
|
||||||
|
MailerModule,
|
||||||
|
PassportModule,
|
||||||
|
JwtModule.register({
|
||||||
|
secret: process.env.JWT_SECRET,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
JwtStrategy,
|
||||||
|
RTJwtStrategy,
|
||||||
|
...(authProviderCheck(AuthProvider.GOOGLE) ? [GoogleStrategy] : []),
|
||||||
|
...(authProviderCheck(AuthProvider.GITHUB) ? [GithubStrategy] : []),
|
||||||
|
...(authProviderCheck(AuthProvider.MICROSOFT) ? [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.sendEmail(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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Inputs to verify and sign a user in via magic-link
|
||||||
|
export class VerifyMagicDto {
|
||||||
|
deviceIdentifier: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
|
||||||
|
canActivate(
|
||||||
|
context: ExecutionContext,
|
||||||
|
): boolean | Promise<boolean> | Observable<boolean> {
|
||||||
|
if (!authProviderCheck(AuthProvider.GITHUB))
|
||||||
|
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||||
|
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthenticateOptions(context: ExecutionContext) {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
redirect_uri: req.query.redirect_uri,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
|
||||||
|
canActivate(
|
||||||
|
context: ExecutionContext,
|
||||||
|
): boolean | Promise<boolean> | Observable<boolean> {
|
||||||
|
if (!authProviderCheck(AuthProvider.GOOGLE))
|
||||||
|
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||||
|
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthenticateOptions(context: ExecutionContext) {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
redirect_uri: req.query.redirect_uri,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MicrosoftSSOGuard
|
||||||
|
extends AuthGuard('microsoft')
|
||||||
|
implements CanActivate
|
||||||
|
{
|
||||||
|
canActivate(
|
||||||
|
context: ExecutionContext,
|
||||||
|
): boolean | Promise<boolean> | Observable<boolean> {
|
||||||
|
if (!authProviderCheck(AuthProvider.MICROSOFT))
|
||||||
|
throwHTTPErr({
|
||||||
|
message: AUTH_PROVIDER_NOT_SPECIFIED,
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthenticateOptions(context: ExecutionContext) {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
redirect_uri: req.query.redirect_uri,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RTJwtAuthGuard extends AuthGuard('jwt-refresh') {}
|
||||||
129
packages/hoppscotch-backend/src/auth/helper.ts
Normal file
129
packages/hoppscotch-backend/src/auth/helper.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { AuthError } from 'src/types/AuthError';
|
||||||
|
import { AuthTokens } from 'src/types/AuthTokens';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import * as cookie from 'cookie';
|
||||||
|
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
|
||||||
|
import { throwErr } from 'src/utils';
|
||||||
|
|
||||||
|
enum AuthTokenType {
|
||||||
|
ACCESS_TOKEN = 'access_token',
|
||||||
|
REFRESH_TOKEN = 'refresh_token',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Origin {
|
||||||
|
ADMIN = 'admin',
|
||||||
|
APP = 'app',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AuthProvider {
|
||||||
|
GOOGLE = 'GOOGLE',
|
||||||
|
GITHUB = 'GITHUB',
|
||||||
|
MICROSOFT = 'MICROSOFT',
|
||||||
|
EMAIL = 'EMAIL',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function allows throw to be used as an expression
|
||||||
|
* @param errMessage Message present in the error message
|
||||||
|
*/
|
||||||
|
export function throwHTTPErr(errorData: AuthError): never {
|
||||||
|
const { message, statusCode } = errorData;
|
||||||
|
throw new HttpException(message, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets and returns the cookies in the response object on successful authentication
|
||||||
|
* @param res Express Response Object
|
||||||
|
* @param authTokens Object containing the access and refresh tokens
|
||||||
|
* @param redirect if true will redirect to provided URL else just send a 200 status code
|
||||||
|
*/
|
||||||
|
export const authCookieHandler = (
|
||||||
|
res: Response,
|
||||||
|
authTokens: AuthTokens,
|
||||||
|
redirect: boolean,
|
||||||
|
redirectUrl: string | null,
|
||||||
|
) => {
|
||||||
|
const currentTime = DateTime.now();
|
||||||
|
const accessTokenValidity = currentTime
|
||||||
|
.plus({
|
||||||
|
milliseconds: parseInt(process.env.ACCESS_TOKEN_VALIDITY),
|
||||||
|
})
|
||||||
|
.toMillis();
|
||||||
|
const refreshTokenValidity = currentTime
|
||||||
|
.plus({
|
||||||
|
milliseconds: parseInt(process.env.REFRESH_TOKEN_VALIDITY),
|
||||||
|
})
|
||||||
|
.toMillis();
|
||||||
|
|
||||||
|
res.cookie(AuthTokenType.ACCESS_TOKEN, authTokens.access_token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: accessTokenValidity,
|
||||||
|
});
|
||||||
|
res.cookie(AuthTokenType.REFRESH_TOKEN, authTokens.refresh_token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: refreshTokenValidity,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!redirect) {
|
||||||
|
return res.status(HttpStatus.OK).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
// check to see if redirectUrl is a whitelisted url
|
||||||
|
const whitelistedOrigins = process.env.WHITELISTED_ORIGINS.split(',');
|
||||||
|
if (!whitelistedOrigins.includes(redirectUrl))
|
||||||
|
// if it is not redirect by default to REDIRECT_URL
|
||||||
|
redirectUrl = process.env.REDIRECT_URL;
|
||||||
|
|
||||||
|
return res.status(HttpStatus.OK).redirect(redirectUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the cookie header from incoming websocket connects and returns a auth token pair
|
||||||
|
* @param rawCookies cookies from the websocket connection
|
||||||
|
* @returns AuthTokens for JWT strategy to use
|
||||||
|
*/
|
||||||
|
export const subscriptionContextCookieParser = (rawCookies: string) => {
|
||||||
|
const cookies = cookie.parse(rawCookies);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!cookies[AuthTokenType.ACCESS_TOKEN] &&
|
||||||
|
!cookies[AuthTokenType.REFRESH_TOKEN]
|
||||||
|
) {
|
||||||
|
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
||||||
|
cause: new Error(COOKIES_NOT_FOUND),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthTokens>{
|
||||||
|
access_token: cookies[AuthTokenType.ACCESS_TOKEN],
|
||||||
|
refresh_token: cookies[AuthTokenType.REFRESH_TOKEN],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check to see if given auth provider is present in the VITE_ALLOWED_AUTH_PROVIDERS env variable
|
||||||
|
*
|
||||||
|
* @param provider Provider we want to check the presence of
|
||||||
|
* @returns Boolean if provider specified is present or not
|
||||||
|
*/
|
||||||
|
export function authProviderCheck(provider: string) {
|
||||||
|
if (!provider) {
|
||||||
|
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envVariables = process.env.VITE_ALLOWED_AUTH_PROVIDERS
|
||||||
|
? process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
|
||||||
|
provider.trim().toUpperCase(),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!envVariables.includes(provider.toUpperCase())) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Strategy } from 'passport-github2';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { AuthService } from '../auth.service';
|
||||||
|
import { UserService } from 'src/user/user.service';
|
||||||
|
import * as O from 'fp-ts/Option';
|
||||||
|
import * as E from 'fp-ts/Either';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GithubStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private usersService: UserService,
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
clientID: process.env.GITHUB_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||||
|
callbackURL: process.env.GITHUB_CALLBACK_URL,
|
||||||
|
scope: [process.env.GITHUB_SCOPE],
|
||||||
|
store: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(accessToken, refreshToken, profile, done) {
|
||||||
|
const user = await this.usersService.findUserByEmail(
|
||||||
|
profile.emails[0].value,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (O.isNone(user)) {
|
||||||
|
const createdUser = await this.usersService.createUserSSO(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
return createdUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * displayName and photoURL maybe null if user logged-in via magic-link before SSO
|
||||||
|
*/
|
||||||
|
if (!user.value.displayName || !user.value.photoURL) {
|
||||||
|
const updatedUser = await this.usersService.updateUserDetails(
|
||||||
|
user.value,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
if (E.isLeft(updatedUser)) {
|
||||||
|
throw new UnauthorizedException(updatedUser.left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * Check to see if entry for Github is present in the Account table for user
|
||||||
|
* * If user was created with another provider findUserByEmail may return true
|
||||||
|
*/
|
||||||
|
const providerAccountExists =
|
||||||
|
await this.authService.checkIfProviderAccountExists(user.value, profile);
|
||||||
|
|
||||||
|
if (O.isNone(providerAccountExists))
|
||||||
|
await this.usersService.createProviderAccount(
|
||||||
|
user.value,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
|
||||||
|
return user.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { UserService } from 'src/user/user.service';
|
||||||
|
import * as O from 'fp-ts/Option';
|
||||||
|
import { AuthService } from '../auth.service';
|
||||||
|
import * as E from 'fp-ts/Either';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(
|
||||||
|
private usersService: UserService,
|
||||||
|
private authService: AuthService,
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
||||||
|
scope: process.env.GOOGLE_SCOPE.split(','),
|
||||||
|
passReqToCallback: true,
|
||||||
|
store: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(
|
||||||
|
req: Request,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
profile,
|
||||||
|
done: VerifyCallback,
|
||||||
|
) {
|
||||||
|
const user = await this.usersService.findUserByEmail(
|
||||||
|
profile.emails[0].value,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (O.isNone(user)) {
|
||||||
|
const createdUser = await this.usersService.createUserSSO(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
return createdUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * displayName and photoURL maybe null if user logged-in via magic-link before SSO
|
||||||
|
*/
|
||||||
|
if (!user.value.displayName || !user.value.photoURL) {
|
||||||
|
const updatedUser = await this.usersService.updateUserDetails(
|
||||||
|
user.value,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
if (E.isLeft(updatedUser)) {
|
||||||
|
throw new UnauthorizedException(updatedUser.left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * Check to see if entry for Google is present in the Account table for user
|
||||||
|
* * If user was created with another provider findUserByEmail may return true
|
||||||
|
*/
|
||||||
|
const providerAccountExists =
|
||||||
|
await this.authService.checkIfProviderAccountExists(user.value, profile);
|
||||||
|
|
||||||
|
if (O.isNone(providerAccountExists))
|
||||||
|
await this.usersService.createProviderAccount(
|
||||||
|
user.value,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
|
||||||
|
return user.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
ForbiddenException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AccessTokenPayload } from 'src/types/AuthTokens';
|
||||||
|
import { UserService } from 'src/user/user.service';
|
||||||
|
import { AuthService } from '../auth.service';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import * as O from 'fp-ts/Option';
|
||||||
|
import {
|
||||||
|
COOKIES_NOT_FOUND,
|
||||||
|
INVALID_ACCESS_TOKEN,
|
||||||
|
USER_NOT_FOUND,
|
||||||
|
} from 'src/errors';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
|
constructor(private usersService: UserService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
|
(request: Request) => {
|
||||||
|
const ATCookie = request.cookies['access_token'];
|
||||||
|
if (!ATCookie) {
|
||||||
|
throw new ForbiddenException(COOKIES_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return ATCookie;
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
secretOrKey: process.env.JWT_SECRET,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: AccessTokenPayload) {
|
||||||
|
if (!payload) throw new ForbiddenException(INVALID_ACCESS_TOKEN);
|
||||||
|
|
||||||
|
const user = await this.usersService.findUserById(payload.sub);
|
||||||
|
if (O.isNone(user)) {
|
||||||
|
throw new UnauthorizedException(USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Strategy } from 'passport-microsoft';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { AuthService } from '../auth.service';
|
||||||
|
import { UserService } from 'src/user/user.service';
|
||||||
|
import * as O from 'fp-ts/Option';
|
||||||
|
import * as E from 'fp-ts/Either';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MicrosoftStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private usersService: UserService,
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
clientID: process.env.MICROSOFT_CLIENT_ID,
|
||||||
|
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
|
||||||
|
callbackURL: process.env.MICROSOFT_CALLBACK_URL,
|
||||||
|
scope: [process.env.MICROSOFT_SCOPE],
|
||||||
|
tenant: process.env.MICROSOFT_TENANT,
|
||||||
|
store: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(accessToken: string, refreshToken: string, profile, done) {
|
||||||
|
const user = await this.usersService.findUserByEmail(
|
||||||
|
profile.emails[0].value,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (O.isNone(user)) {
|
||||||
|
const createdUser = await this.usersService.createUserSSO(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
return createdUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * displayName and photoURL maybe null if user logged-in via magic-link before SSO
|
||||||
|
*/
|
||||||
|
if (!user.value.displayName || !user.value.photoURL) {
|
||||||
|
const updatedUser = await this.usersService.updateUserDetails(
|
||||||
|
user.value,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
if (E.isLeft(updatedUser)) {
|
||||||
|
throw new UnauthorizedException(updatedUser.left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * Check to see if entry for Microsoft is present in the Account table for user
|
||||||
|
* * If user was created with another provider findUserByEmail may return true
|
||||||
|
*/
|
||||||
|
const providerAccountExists =
|
||||||
|
await this.authService.checkIfProviderAccountExists(user.value, profile);
|
||||||
|
|
||||||
|
if (O.isNone(providerAccountExists))
|
||||||
|
await this.usersService.createProviderAccount(
|
||||||
|
user.value,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
|
||||||
|
return user.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
ForbiddenException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserService } from 'src/user/user.service';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { RefreshTokenPayload } from 'src/types/AuthTokens';
|
||||||
|
import {
|
||||||
|
COOKIES_NOT_FOUND,
|
||||||
|
INVALID_REFRESH_TOKEN,
|
||||||
|
USER_NOT_FOUND,
|
||||||
|
} from 'src/errors';
|
||||||
|
import * as O from 'fp-ts/Option';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
||||||
|
constructor(private usersService: UserService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
|
(request: Request) => {
|
||||||
|
const RTCookie = request.cookies['refresh_token'];
|
||||||
|
if (!RTCookie) {
|
||||||
|
throw new ForbiddenException(COOKIES_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return RTCookie;
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
secretOrKey: process.env.JWT_SECRET,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: RefreshTokenPayload) {
|
||||||
|
if (!payload) throw new ForbiddenException(INVALID_REFRESH_TOKEN);
|
||||||
|
|
||||||
|
const user = await this.usersService.findUserById(payload.sub);
|
||||||
|
if (O.isNone(user)) {
|
||||||
|
throw new UnauthorizedException(USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
export const GqlUser = createParamDecorator(
|
||||||
|
(data: unknown, context: ExecutionContext) => {
|
||||||
|
const ctx = GqlExecutionContext.create(context);
|
||||||
|
const { req, headers } = ctx.getContext();
|
||||||
|
return headers ? headers.user : req.user;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
/**
|
||||||
|
** Decorator to fetch refresh_token from cookie
|
||||||
|
*/
|
||||||
|
export const RTCookie = createParamDecorator(
|
||||||
|
(data: unknown, context: ExecutionContext) => {
|
||||||
|
const ctx = GqlExecutionContext.create(context);
|
||||||
|
return ctx.getContext().req.cookies['refresh_token'];
|
||||||
|
},
|
||||||
|
);
|
||||||
623
packages/hoppscotch-backend/src/errors.ts
Normal file
623
packages/hoppscotch-backend/src/errors.ts
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
export const INVALID_EMAIL = 'invalid/email' as const;
|
||||||
|
|
||||||
|
export const EMAIL_FAILED = 'email/failed' as const;
|
||||||
|
export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only one admin account found in infra
|
||||||
|
* (AdminService)
|
||||||
|
*/
|
||||||
|
export const ONLY_ONE_ADMIN_ACCOUNT =
|
||||||
|
'admin/only_one_admin_account_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token Authorization failed (Check 'Authorization' Header)
|
||||||
|
* (GqlAuthGuard)
|
||||||
|
*/
|
||||||
|
export const AUTH_FAIL = 'auth/fail';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid JSON
|
||||||
|
* (Utils)
|
||||||
|
*/
|
||||||
|
export const JSON_INVALID = 'json_invalid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth Provider not specified
|
||||||
|
* (Auth)
|
||||||
|
*/
|
||||||
|
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file
|
||||||
|
*/
|
||||||
|
export const ENV_NOT_FOUND_KEY_AUTH_PROVIDERS =
|
||||||
|
'"VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file
|
||||||
|
*/
|
||||||
|
export const ENV_EMPTY_AUTH_PROVIDERS =
|
||||||
|
'"VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" contains unsupported provider in .env file
|
||||||
|
*/
|
||||||
|
export const ENV_NOT_SUPPORT_AUTH_PROVIDERS =
|
||||||
|
'"VITE_ALLOWED_AUTH_PROVIDERS" contains an unsupported auth provider in .env file';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to delete a user data document from fb firestore but failed.
|
||||||
|
* (FirebaseService)
|
||||||
|
*/
|
||||||
|
export const USER_FB_DOCUMENT_DELETION_FAILED =
|
||||||
|
'fb/firebase_document_deletion_failed' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to do an action on a user where user is not found
|
||||||
|
*/
|
||||||
|
export const USER_NOT_FOUND = 'user/not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User is already invited by admin
|
||||||
|
*/
|
||||||
|
export const USER_ALREADY_INVITED = 'admin/user_already_invited' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User update failure
|
||||||
|
* (UserService)
|
||||||
|
*/
|
||||||
|
export const USER_UPDATE_FAILED = 'user/update_failed' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User deletion failure
|
||||||
|
* (UserService)
|
||||||
|
*/
|
||||||
|
export const USER_DELETION_FAILED = 'user/deletion_failed' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Users not found
|
||||||
|
* (UserService)
|
||||||
|
*/
|
||||||
|
export const USERS_NOT_FOUND = 'user/users_not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User deletion failure error due to user being a team owner
|
||||||
|
* (UserService)
|
||||||
|
*/
|
||||||
|
export const USER_IS_OWNER = 'user/is_owner' as const;
|
||||||
|
/**
|
||||||
|
* User deletion failure error due to user being an admin
|
||||||
|
* (UserService)
|
||||||
|
*/
|
||||||
|
export const USER_IS_ADMIN = 'user/is_admin' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teams not found
|
||||||
|
* (TeamsService)
|
||||||
|
*/
|
||||||
|
export const TEAMS_NOT_FOUND = 'user/teams_not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to find user collection but failed
|
||||||
|
* (UserRequestService)
|
||||||
|
*/
|
||||||
|
export const USER_COLLECTION_NOT_FOUND = 'user_collection/not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to reorder user request but failed
|
||||||
|
* (UserRequestService)
|
||||||
|
*/
|
||||||
|
export const USER_REQUEST_CREATION_FAILED =
|
||||||
|
'user_request/creation_failed' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to do an action on a user request but user request is not matched with user collection
|
||||||
|
* (UserRequestService)
|
||||||
|
*/
|
||||||
|
export const USER_REQUEST_INVALID_TYPE = 'user_request/type_mismatch' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to do an action on a user request where user request is not found
|
||||||
|
* (UserRequestService)
|
||||||
|
*/
|
||||||
|
export const USER_REQUEST_NOT_FOUND = 'user_request/not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to reorder user request but failed
|
||||||
|
* (UserRequestService)
|
||||||
|
*/
|
||||||
|
export const USER_REQUEST_REORDERING_FAILED =
|
||||||
|
'user_request/reordering_failed' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to perform action on a team which they are not a member of
|
||||||
|
* (GqlTeamMemberGuard)
|
||||||
|
*/
|
||||||
|
export const TEAM_MEMBER_NOT_FOUND = 'team/member_not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to perform action on a team that doesn't accept their member role level
|
||||||
|
* (GqlTeamMemberGuard)
|
||||||
|
*/
|
||||||
|
export const TEAM_NOT_REQUIRED_ROLE = 'team/not_required_role' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team name validation failure
|
||||||
|
* (TeamService)
|
||||||
|
*/
|
||||||
|
export const TEAM_NAME_INVALID = 'team/name_invalid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couldn't find the sync data from the user
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_USER_NO_FB_SYNCDATA = 'team/user_no_fb_syncdata';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There was a problem resolving the firebase collection path
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_FB_COLL_PATH_RESOLVE_FAIL = 'team/fb_coll_path_resolve_fail';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Could not find the team in the database
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_COLL_NOT_FOUND = 'team_coll/collection_not_found';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cannot make parent collection a child of a collection that a child of itself
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_COLL_IS_PARENT_COLL = 'team_coll/collection_is_parent_coll';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target and Parent collections are not from the same team
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_COLL_NOT_SAME_TEAM = 'team_coll/collections_not_same_team';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target and Parent collections are the same
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_COLL_DEST_SAME =
|
||||||
|
'team_coll/target_and_destination_collection_are_same';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection is already a root collection
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_COL_ALREADY_ROOT =
|
||||||
|
'team_coll/target_collection_is_already_root_collection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collections have different parents
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_COL_NOT_SAME_PARENT =
|
||||||
|
'team_coll/team_collections_have_different_parents';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection and next Collection are the same
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_COL_SAME_NEXT_COLL =
|
||||||
|
'team_coll/collection_and_next_collection_are_same';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team Collection Re-Ordering Failed
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_COL_REORDERING_FAILED = 'team_coll/reordering_failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to update the team to a state it doesn't have any owners
|
||||||
|
* (TeamService)
|
||||||
|
*/
|
||||||
|
export const TEAM_ONLY_ONE_OWNER = 'team/only_one_owner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid or non-existent Team ID
|
||||||
|
* (TeamService)
|
||||||
|
*/
|
||||||
|
export const TEAM_INVALID_ID = 'team/invalid_id' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid or non-existent collection id
|
||||||
|
* (GqlCollectionTeamMemberGuard)
|
||||||
|
*/
|
||||||
|
export const TEAM_INVALID_COLL_ID = 'team/invalid_coll_id' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid team id or user id
|
||||||
|
* (TeamService)
|
||||||
|
*/
|
||||||
|
export const TEAM_INVALID_ID_OR_USER = 'team/invalid_id_or_user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provided title for the team collection is short (less than 3 characters)
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_COLL_SHORT_TITLE = 'team_coll/short_title';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JSON used is not valid
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_COLL_INVALID_JSON = 'team_coll/invalid_json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Team Collection does not belong to the team
|
||||||
|
* (TeamCollectionService)
|
||||||
|
*/
|
||||||
|
export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to perform an action on a request that doesn't accept their member role level
|
||||||
|
* (GqlRequestTeamMemberGuard)
|
||||||
|
*/
|
||||||
|
export const TEAM_REQ_NOT_REQUIRED_ROLE = 'team_req/not_required_role';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to operate on a request which does not exist
|
||||||
|
* (TeamRequestService)
|
||||||
|
*/
|
||||||
|
export const TEAM_REQ_NOT_FOUND = 'team_req/not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid or non-existent collection id
|
||||||
|
* (TeamRequestService)
|
||||||
|
*/
|
||||||
|
export const TEAM_REQ_INVALID_TARGET_COLL_ID =
|
||||||
|
'team_req/invalid_target_id' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to reorder team request but failed
|
||||||
|
* (TeamRequestService)
|
||||||
|
*/
|
||||||
|
export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No Postmark Sender Email defined
|
||||||
|
* (AuthService)
|
||||||
|
*/
|
||||||
|
export const SENDER_EMAIL_INVALID = 'mailer/sender_email_invalid' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tried to perform an action on a request when the user is not even a member of the team
|
||||||
|
* (GqlRequestTeamMemberGuard, GqlCollectionTeamMemberGuard)
|
||||||
|
*/
|
||||||
|
export const TEAM_REQ_NOT_MEMBER = 'team_req/not_member';
|
||||||
|
|
||||||
|
export const TEAM_INVITE_MEMBER_HAS_INVITE =
|
||||||
|
'team_invite/member_has_invite' as const;
|
||||||
|
|
||||||
|
export const TEAM_INVITE_NO_INVITE_FOUND =
|
||||||
|
'team_invite/no_invite_found' as const;
|
||||||
|
|
||||||
|
export const TEAM_INVITE_ALREADY_MEMBER = 'team_invite/already_member' as const;
|
||||||
|
|
||||||
|
export const TEAM_INVITE_EMAIL_DO_NOT_MATCH =
|
||||||
|
'team_invite/email_do_not_match' as const;
|
||||||
|
|
||||||
|
export const TEAM_INVITE_NOT_VALID_VIEWER =
|
||||||
|
'team_invite/not_valid_viewer' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No team invitations found
|
||||||
|
* (TeamInvitationService)
|
||||||
|
*/
|
||||||
|
export const TEAM_INVITATION_NOT_FOUND =
|
||||||
|
'team_invite/invitations_not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShortCode not found in DB
|
||||||
|
* (ShortcodeService)
|
||||||
|
*/
|
||||||
|
export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid ShortCode format
|
||||||
|
* (ShortcodeService)
|
||||||
|
*/
|
||||||
|
export const SHORTCODE_INVALID_JSON = 'shortcode/invalid_json' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShortCode already exists in DB
|
||||||
|
* (ShortcodeService)
|
||||||
|
*/
|
||||||
|
export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid or non-existent TEAM ENVIRONMENT ID
|
||||||
|
* (TeamEnvironmentsService)
|
||||||
|
*/
|
||||||
|
export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid TEAM ENVIRONMENT name
|
||||||
|
* (TeamEnvironmentsService)
|
||||||
|
*/
|
||||||
|
export const TEAM_ENVIRONMENT_SHORT_NAME =
|
||||||
|
'team_environment/short_name' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user is not a member of the team of the given environment
|
||||||
|
* (GqlTeamEnvTeamGuard)
|
||||||
|
*/
|
||||||
|
export const TEAM_ENVIRONMENT_NOT_TEAM_MEMBER =
|
||||||
|
'team_environment/not_team_member' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User setting not found for a user
|
||||||
|
* (UserSettingsService)
|
||||||
|
*/
|
||||||
|
export const USER_SETTINGS_NOT_FOUND = 'user_settings/not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User setting already exists for a user
|
||||||
|
* (UserSettingsService)
|
||||||
|
*/
|
||||||
|
export const USER_SETTINGS_ALREADY_EXISTS =
|
||||||
|
'user_settings/settings_already_exists' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User setting invalid (null) settings
|
||||||
|
* (UserSettingsService)
|
||||||
|
*/
|
||||||
|
export const USER_SETTINGS_NULL_SETTINGS =
|
||||||
|
'user_settings/null_settings' as const;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Global environment doesn't exist for the user
|
||||||
|
* (UserEnvironmentsService)
|
||||||
|
*/
|
||||||
|
export const USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS =
|
||||||
|
'user_environment/global_env_does_not_exists' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global environment already exists for the user
|
||||||
|
* (UserEnvironmentsService)
|
||||||
|
*/
|
||||||
|
export const USER_ENVIRONMENT_GLOBAL_ENV_EXISTS =
|
||||||
|
'user_environment/global_env_already_exists' as const;
|
||||||
|
/*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User environment doesn't exist for the user
|
||||||
|
* (UserEnvironmentsService)
|
||||||
|
*/
|
||||||
|
export const USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS =
|
||||||
|
'user_environment/user_env_does_not_exists' as const;
|
||||||
|
/*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cannot delete the global user environment
|
||||||
|
* (UserEnvironmentsService)
|
||||||
|
*/
|
||||||
|
export const USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED =
|
||||||
|
'user_environment/user_env_global_env_deletion_failed' as const;
|
||||||
|
/*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User environment is not a global environment
|
||||||
|
* (UserEnvironmentsService)
|
||||||
|
*/
|
||||||
|
export const USER_ENVIRONMENT_IS_NOT_GLOBAL =
|
||||||
|
'user_environment/user_env_is_not_global' as const;
|
||||||
|
/*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User environment update failed
|
||||||
|
* (UserEnvironmentsService)
|
||||||
|
*/
|
||||||
|
export const USER_ENVIRONMENT_UPDATE_FAILED =
|
||||||
|
'user_environment/user_env_update_failed' as const;
|
||||||
|
/*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User environment invalid environment name
|
||||||
|
* (UserEnvironmentsService)
|
||||||
|
*/
|
||||||
|
export const USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME =
|
||||||
|
'user_environment/user_env_invalid_env_name' as const;
|
||||||
|
/*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User history not found
|
||||||
|
* (UserHistoryService)
|
||||||
|
*/
|
||||||
|
export const USER_HISTORY_NOT_FOUND = 'user_history/history_not_found' as const;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid Request Type in History
|
||||||
|
* (UserHistoryService)
|
||||||
|
*/
|
||||||
|
export const USER_HISTORY_INVALID_REQ_TYPE =
|
||||||
|
'user_history/req_type_invalid' as const;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
|------------------------------------|
|
||||||
|
|Server errors that are actually bugs|
|
||||||
|
|------------------------------------|
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couldn't find user data from the GraphQL context (Check if GqlAuthGuard is applied)
|
||||||
|
* (GqlTeamMemberGuard, GqlCollectionTeamMemberGuard)
|
||||||
|
*/
|
||||||
|
export const BUG_AUTH_NO_USER_CTX = 'bug/auth/auth_no_user_ctx' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couldn't find teamID parameter in the attached GraphQL operation. (Check if teamID is present)
|
||||||
|
* (GqlTeamMemberGuard, GQLEAAdminGuard, GqlCollectionTeamMemberGuard)
|
||||||
|
*/
|
||||||
|
export const BUG_TEAM_NO_TEAM_ID = 'bug/team/no_team_id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couldn't find RequireTeamRole decorator. (Check if it is applied)
|
||||||
|
* (GqlTeamMemberGuard)
|
||||||
|
*/
|
||||||
|
export const BUG_TEAM_NO_REQUIRE_TEAM_ROLE = 'bug/team/no_require_team_role';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couldn't find 'collectionID' param to the attached GQL operation. (Check if exists)
|
||||||
|
* (GqlCollectionTeamMemberGuard)
|
||||||
|
*/
|
||||||
|
export const BUG_TEAM_COLL_NO_COLL_ID = 'bug/team_coll/no_coll_id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couldn't find 'requestID' param to the attached GQL operation. (Check if exists)
|
||||||
|
* (GqlRequestTeamMemberGuard)
|
||||||
|
*/
|
||||||
|
export const BUG_TEAM_REQ_NO_REQ_ID = 'bug/team_req/no_req_id';
|
||||||
|
|
||||||
|
export const BUG_TEAM_INVITE_NO_INVITE_ID =
|
||||||
|
'bug/team_invite/no_invite_id' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couldn't find RequireTeamRole decorator. (Check if it is applied)
|
||||||
|
* (GqlTeamEnvTeamGuard)
|
||||||
|
*/
|
||||||
|
export const BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES =
|
||||||
|
'bug/team_env/guard_no_require_roles' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couldn't find 'id' param to the operation. (Check if it is applied)
|
||||||
|
* (GqlTeamEnvTeamGuard)
|
||||||
|
*/
|
||||||
|
export const BUG_TEAM_ENV_GUARD_NO_ENV_ID =
|
||||||
|
'bug/team_env/guard_no_env_id' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data sent to the verify route are invalid
|
||||||
|
* (AuthService)
|
||||||
|
*/
|
||||||
|
export const INVALID_MAGIC_LINK_DATA = 'auth/magic_link_invalid_data' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Could not find VerificationToken entry in the db
|
||||||
|
* (AuthService)
|
||||||
|
*/
|
||||||
|
export const VERIFICATION_TOKEN_DATA_NOT_FOUND =
|
||||||
|
'auth/verification_token_data_not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth Tokens expired
|
||||||
|
* (AuthService)
|
||||||
|
*/
|
||||||
|
export const TOKEN_EXPIRED = 'auth/token_expired' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VerificationToken Tokens expired i.e. magic-link expired
|
||||||
|
* (AuthService)
|
||||||
|
*/
|
||||||
|
export const MAGIC_LINK_EXPIRED = 'auth/magic_link_expired' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No cookies were found in the auth request
|
||||||
|
* (AuthService)
|
||||||
|
*/
|
||||||
|
export const COOKIES_NOT_FOUND = 'auth/cookies_not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access Token is malformed or invalid
|
||||||
|
* (AuthService)
|
||||||
|
*/
|
||||||
|
export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh Token is malformed or invalid
|
||||||
|
* (AuthService)
|
||||||
|
*/
|
||||||
|
export const INVALID_REFRESH_TOKEN = 'auth/invalid_refresh_token' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provided title for the user collection is short (less than 3 characters)
|
||||||
|
* (UserCollectionService)
|
||||||
|
*/
|
||||||
|
export const USER_COLL_SHORT_TITLE = 'user_coll/short_title' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Collection could not be found
|
||||||
|
* (UserCollectionService)
|
||||||
|
*/
|
||||||
|
export const USER_COLL_NOT_FOUND = 'user_coll/not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserCollection is already a root collection
|
||||||
|
* (UserCollectionService)
|
||||||
|
*/
|
||||||
|
export const USER_COLL_ALREADY_ROOT =
|
||||||
|
'user_coll/target_user_collection_is_already_root_user_collection' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target and Parent user collections are the same
|
||||||
|
* (UserCollectionService)
|
||||||
|
*/
|
||||||
|
export const USER_COLL_DEST_SAME =
|
||||||
|
'user_coll/target_and_destination_user_collection_are_same' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target and Parent user collections are not from the same user
|
||||||
|
* (UserCollectionService)
|
||||||
|
*/
|
||||||
|
export const USER_COLL_NOT_SAME_USER = 'user_coll/not_same_user' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target and Parent user collections are not from the same type
|
||||||
|
* (UserCollectionService)
|
||||||
|
*/
|
||||||
|
export const USER_COLL_NOT_SAME_TYPE = 'user_coll/type_mismatch' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cannot make a parent user collection a child of itself
|
||||||
|
* (UserCollectionService)
|
||||||
|
*/
|
||||||
|
export const USER_COLL_IS_PARENT_COLL =
|
||||||
|
'user_coll/user_collection_is_parent_coll' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Collection Re-Ordering Failed
|
||||||
|
* (UserCollectionService)
|
||||||
|
*/
|
||||||
|
export const USER_COLL_REORDERING_FAILED =
|
||||||
|
'user_coll/reordering_failed' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Collection and Next User Collection are the same
|
||||||
|
* (UserCollectionService)
|
||||||
|
*/
|
||||||
|
export const USER_COLL_SAME_NEXT_COLL =
|
||||||
|
'user_coll/user_collection_and_next_user_collection_are_same' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The User Collection does not belong to the logged-in user
|
||||||
|
* (UserCollectionService)
|
||||||
|
*/
|
||||||
|
export const USER_NOT_OWNER = 'user_coll/user_not_owner' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JSON used is not valid
|
||||||
|
* (UserCollectionService)
|
||||||
|
*/
|
||||||
|
export const USER_COLL_INVALID_JSON = 'user_coll/invalid_json';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* MAILER_SMTP_URL environment variable is not defined
|
||||||
|
* (MailerModule)
|
||||||
|
*/
|
||||||
|
export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MAILER_ADDRESS_FROM environment variable is not defined
|
||||||
|
* (MailerModule)
|
||||||
|
*/
|
||||||
|
export const MAILER_FROM_ADDRESS_UNDEFINED =
|
||||||
|
'mailer/from_address_undefined' as const;
|
||||||
112
packages/hoppscotch-backend/src/gql-schema.ts
Normal file
112
packages/hoppscotch-backend/src/gql-schema.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import {
|
||||||
|
GraphQLSchemaBuilderModule,
|
||||||
|
GraphQLSchemaFactory,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
|
import { printSchema } from 'graphql/utilities';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { ShortcodeResolver } from './shortcode/shortcode.resolver';
|
||||||
|
import { TeamCollectionResolver } from './team-collection/team-collection.resolver';
|
||||||
|
import { TeamEnvironmentsResolver } from './team-environments/team-environments.resolver';
|
||||||
|
import { TeamInvitationResolver } from './team-invitation/team-invitation.resolver';
|
||||||
|
import { TeamRequestResolver } from './team-request/team-request.resolver';
|
||||||
|
import { TeamMemberResolver } from './team/team-member.resolver';
|
||||||
|
import { TeamResolver } from './team/team.resolver';
|
||||||
|
import { UserCollectionResolver } from './user-collection/user-collection.resolver';
|
||||||
|
import { UserEnvironmentsResolver } from './user-environment/user-environments.resolver';
|
||||||
|
import { UserHistoryResolver } from './user-history/user-history.resolver';
|
||||||
|
import { UserRequestResolver } from './user-request/resolvers/user-request.resolver';
|
||||||
|
import { UserSettingsResolver } from './user-settings/user-settings.resolver';
|
||||||
|
import { UserResolver } from './user/user.resolver';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { AdminResolver } from './admin/admin.resolver';
|
||||||
|
import { TeamEnvsTeamResolver } from './team-environments/team.resolver';
|
||||||
|
import { TeamTeamInviteExtResolver } from './team-invitation/team-teaminvite-ext.resolver';
|
||||||
|
import { UserRequestUserCollectionResolver } from './user-request/resolvers/user-collection.resolver';
|
||||||
|
import { UserEnvsUserResolver } from './user-environment/user.resolver';
|
||||||
|
import { UserHistoryUserResolver } from './user-history/user.resolver';
|
||||||
|
import { UserSettingsUserResolver } from './user-settings/user.resolver';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All the resolvers present in the application.
|
||||||
|
*
|
||||||
|
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
|
||||||
|
*/
|
||||||
|
const RESOLVERS = [
|
||||||
|
AdminResolver,
|
||||||
|
ShortcodeResolver,
|
||||||
|
TeamResolver,
|
||||||
|
TeamEnvsTeamResolver,
|
||||||
|
TeamMemberResolver,
|
||||||
|
TeamCollectionResolver,
|
||||||
|
TeamTeamInviteExtResolver,
|
||||||
|
TeamEnvironmentsResolver,
|
||||||
|
TeamEnvsTeamResolver,
|
||||||
|
TeamInvitationResolver,
|
||||||
|
TeamRequestResolver,
|
||||||
|
UserResolver,
|
||||||
|
UserCollectionResolver,
|
||||||
|
UserEnvironmentsResolver,
|
||||||
|
UserEnvsUserResolver,
|
||||||
|
UserHistoryUserResolver,
|
||||||
|
UserHistoryResolver,
|
||||||
|
UserCollectionResolver,
|
||||||
|
UserRequestResolver,
|
||||||
|
UserRequestUserCollectionResolver,
|
||||||
|
UserSettingsResolver,
|
||||||
|
UserSettingsUserResolver,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All the custom scalars present in the application.
|
||||||
|
*
|
||||||
|
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
|
||||||
|
*/
|
||||||
|
const SCALARS = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the GraphQL Schema SDL definition and writes it into the location
|
||||||
|
* specified by the `GQL_SCHEMA_EMIT_LOCATION` environment variable.
|
||||||
|
*/
|
||||||
|
export async function emitGQLSchemaFile() {
|
||||||
|
const logger = new Logger('emitGQLSchemaFile');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const destination = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
process.env.GQL_SCHEMA_EMIT_LOCATION ?? '../gen/schema.gql',
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.log(`GQL_SCHEMA_EMIT_LOCATION: ${destination}`);
|
||||||
|
|
||||||
|
const app = await NestFactory.create(GraphQLSchemaBuilderModule);
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const gqlSchemaFactory = app.get(GraphQLSchemaFactory);
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
`Generating Schema against ${RESOLVERS.length} resolvers and ${SCALARS.length} custom scalars`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const schema = await gqlSchemaFactory.create(RESOLVERS, SCALARS, {
|
||||||
|
numberScalarMode: 'integer',
|
||||||
|
});
|
||||||
|
|
||||||
|
const schemaString = printSchema(schema, {
|
||||||
|
commentDescriptions: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);
|
||||||
|
|
||||||
|
// Generating folders if required to emit to the given output
|
||||||
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||||
|
fs.writeFileSync(destination, schemaString);
|
||||||
|
|
||||||
|
logger.log(`Wrote schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`Failed writing schema to GQL_SCHEMA_EMIT_LOCATION. Reason: ${e}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/hoppscotch-backend/src/guards/gql-auth.guard.ts
Normal file
12
packages/hoppscotch-backend/src/guards/gql-auth.guard.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GqlAuthGuard extends AuthGuard('jwt') {
|
||||||
|
getRequest(context: ExecutionContext) {
|
||||||
|
const ctx = GqlExecutionContext.create(context);
|
||||||
|
const { req, headers } = ctx.getContext();
|
||||||
|
return headers ? headers : req;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||||
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GqlThrottlerGuard extends ThrottlerGuard {
|
||||||
|
getRequestResponse(context: ExecutionContext) {
|
||||||
|
const gqlCtx = GqlExecutionContext.create(context);
|
||||||
|
const ctx = gqlCtx.getContext();
|
||||||
|
return { req: ctx.req, res: ctx.res };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
||||||
|
protected getTracker(req: Record<string, any>): string {
|
||||||
|
return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs
|
||||||
|
// learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#directives
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/hoppscotch-backend/src/mailer/MailDescriptions.ts
Normal file
24
packages/hoppscotch-backend/src/mailer/MailDescriptions.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export type MailDescription = {
|
||||||
|
template: 'team-invitation';
|
||||||
|
variables: {
|
||||||
|
invitee: string;
|
||||||
|
invite_team_name: string;
|
||||||
|
action_url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserMagicLinkMailDescription = {
|
||||||
|
template: 'code-your-own';
|
||||||
|
variables: {
|
||||||
|
inviteeEmail: string;
|
||||||
|
magicLink: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminUserInvitationMailDescription = {
|
||||||
|
template: 'code-your-own';
|
||||||
|
variables: {
|
||||||
|
inviteeEmail: string;
|
||||||
|
magicLink: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
30
packages/hoppscotch-backend/src/mailer/mailer.module.ts
Normal file
30
packages/hoppscotch-backend/src/mailer/mailer.module.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
|
||||||
|
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
|
||||||
|
import { MailerService } from './mailer.service';
|
||||||
|
import { throwErr } from 'src/utils';
|
||||||
|
import {
|
||||||
|
MAILER_FROM_ADDRESS_UNDEFINED,
|
||||||
|
MAILER_SMTP_URL_UNDEFINED,
|
||||||
|
} from 'src/errors';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
NestMailerModule.forRoot({
|
||||||
|
transport:
|
||||||
|
process.env.MAILER_SMTP_URL ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
|
||||||
|
defaults: {
|
||||||
|
from:
|
||||||
|
process.env.MAILER_ADDRESS_FROM ??
|
||||||
|
throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
dir: __dirname + '/templates',
|
||||||
|
adapter: new HandlebarsAdapter(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [MailerService],
|
||||||
|
exports: [MailerService],
|
||||||
|
})
|
||||||
|
export class MailerModule {}
|
||||||
79
packages/hoppscotch-backend/src/mailer/mailer.service.ts
Normal file
79
packages/hoppscotch-backend/src/mailer/mailer.service.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AdminUserInvitationMailDescription,
|
||||||
|
MailDescription,
|
||||||
|
UserMagicLinkMailDescription,
|
||||||
|
} from './MailDescriptions';
|
||||||
|
import { throwErr } from 'src/utils';
|
||||||
|
import { EMAIL_FAILED } from 'src/errors';
|
||||||
|
import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MailerService {
|
||||||
|
constructor(private readonly nestMailerService: NestMailerService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes an input mail description and spits out the Email subject required for it
|
||||||
|
* @param mailDesc The mail description to get subject for
|
||||||
|
* @returns The subject of the email
|
||||||
|
*/
|
||||||
|
private resolveSubjectForMailDesc(
|
||||||
|
mailDesc:
|
||||||
|
| MailDescription
|
||||||
|
| UserMagicLinkMailDescription
|
||||||
|
| AdminUserInvitationMailDescription,
|
||||||
|
): string {
|
||||||
|
switch (mailDesc.template) {
|
||||||
|
case 'team-invitation':
|
||||||
|
return `${mailDesc.variables.invitee} invited you to join ${mailDesc.variables.invite_team_name} in Hoppscotch`;
|
||||||
|
|
||||||
|
case 'code-your-own':
|
||||||
|
return 'Sign in to Hoppscotch';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an email to the given email address given a mail description
|
||||||
|
* @param to Receiver's email id
|
||||||
|
* @param mailDesc Definition of what email to be sent
|
||||||
|
* @returns Response if email was send successfully or not
|
||||||
|
*/
|
||||||
|
async sendEmail(
|
||||||
|
to: string,
|
||||||
|
mailDesc: MailDescription | UserMagicLinkMailDescription,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.nestMailerService.sendMail({
|
||||||
|
to,
|
||||||
|
template: mailDesc.template,
|
||||||
|
subject: this.resolveSubjectForMailDesc(mailDesc),
|
||||||
|
context: mailDesc.variables,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return throwErr(EMAIL_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param to Receiver's email id
|
||||||
|
* @param mailDesc Details of email to be sent for user invitation
|
||||||
|
* @returns Response if email was send successfully or not
|
||||||
|
*/
|
||||||
|
async sendUserInvitationEmail(
|
||||||
|
to: string,
|
||||||
|
mailDesc: AdminUserInvitationMailDescription,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const res = await this.nestMailerService.sendMail({
|
||||||
|
to,
|
||||||
|
template: mailDesc.template,
|
||||||
|
subject: this.resolveSubjectForMailDesc(mailDesc),
|
||||||
|
context: mailDesc.variables,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
} catch (error) {
|
||||||
|
return throwErr(EMAIL_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="x-apple-disable-message-reformatting" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="supported-color-schemes" content="light dark" />
|
||||||
|
<title></title>
|
||||||
|
<!--
|
||||||
|
The style block is collapsed on page load to save you some scrolling.
|
||||||
|
Postmark automatically inlines all CSS properties for maximum email client
|
||||||
|
compatibility. You can just update styles here, and Postmark does the rest.
|
||||||
|
-->
|
||||||
|
<style type="text/css" rel="stylesheet" media="all">
|
||||||
|
/* Base ------------------------------ */
|
||||||
|
|
||||||
|
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
||||||
|
body {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #3869D4;
|
||||||
|
}
|
||||||
|
|
||||||
|
a img {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preheader {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden;
|
||||||
|
mso-hide: all;
|
||||||
|
font-size: 1px;
|
||||||
|
line-height: 1px;
|
||||||
|
max-height: 0;
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* Type ------------------------------ */
|
||||||
|
|
||||||
|
body,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote {
|
||||||
|
margin: .4em 0 1.1875em;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.625;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.sub {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
/* Utilities ------------------------------ */
|
||||||
|
|
||||||
|
.align-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/* Buttons ------------------------------ */
|
||||||
|
|
||||||
|
.button {
|
||||||
|
background-color: #3869D4;
|
||||||
|
border-top: 10px solid #3869D4;
|
||||||
|
border-right: 18px solid #3869D4;
|
||||||
|
border-bottom: 10px solid #3869D4;
|
||||||
|
border-left: 18px solid #3869D4;
|
||||||
|
display: inline-block;
|
||||||
|
color: #FFF;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--green {
|
||||||
|
background-color: #22BC66;
|
||||||
|
border-top: 10px solid #22BC66;
|
||||||
|
border-right: 18px solid #22BC66;
|
||||||
|
border-bottom: 10px solid #22BC66;
|
||||||
|
border-left: 18px solid #22BC66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--red {
|
||||||
|
background-color: #FF6136;
|
||||||
|
border-top: 10px solid #FF6136;
|
||||||
|
border-right: 18px solid #FF6136;
|
||||||
|
border-bottom: 10px solid #FF6136;
|
||||||
|
border-left: 18px solid #FF6136;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 500px) {
|
||||||
|
.button {
|
||||||
|
width: 100% !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Attribute list ------------------------------ */
|
||||||
|
|
||||||
|
.attributes {
|
||||||
|
margin: 0 0 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attributes_content {
|
||||||
|
background-color: #F4F4F7;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attributes_item {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* Related Items ------------------------------ */
|
||||||
|
|
||||||
|
.related {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 25px 0 0 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related_item {
|
||||||
|
padding: 10px 0;
|
||||||
|
color: #CBCCCF;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related_item-title {
|
||||||
|
display: block;
|
||||||
|
margin: .5em 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related_item-thumb {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related_heading {
|
||||||
|
border-top: 1px solid #CBCCCF;
|
||||||
|
text-align: center;
|
||||||
|
padding: 25px 0 10px;
|
||||||
|
}
|
||||||
|
/* Discount Code ------------------------------ */
|
||||||
|
|
||||||
|
.discount {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
background-color: #F4F4F7;
|
||||||
|
border: 2px dashed #CBCCCF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount_heading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount_body {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
/* Social Icons ------------------------------ */
|
||||||
|
|
||||||
|
.social {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social td {
|
||||||
|
padding: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social_icon {
|
||||||
|
height: 20px;
|
||||||
|
margin: 0 8px 10px 8px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* Data table ------------------------------ */
|
||||||
|
|
||||||
|
.purchase {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 35px 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_content {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 25px 0 0 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_item {
|
||||||
|
padding: 10px 0;
|
||||||
|
color: #51545E;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_heading {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_heading p {
|
||||||
|
margin: 0;
|
||||||
|
color: #85878E;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_footer {
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_total {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_total--label {
|
||||||
|
padding: 0 15px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
color: #51545E;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #51545E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-content {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
/* Masthead ----------------------- */
|
||||||
|
|
||||||
|
.email-masthead {
|
||||||
|
padding: 25px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-masthead_logo {
|
||||||
|
width: 94px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-masthead_name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #A8AAAF;
|
||||||
|
text-decoration: none;
|
||||||
|
text-shadow: 0 1px 0 white;
|
||||||
|
}
|
||||||
|
/* Body ------------------------------ */
|
||||||
|
|
||||||
|
.email-body {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-body_inner {
|
||||||
|
width: 570px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 570px;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-footer {
|
||||||
|
width: 570px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 570px;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-footer p {
|
||||||
|
color: #A8AAAF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-action {
|
||||||
|
width: 100%;
|
||||||
|
margin: 30px auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-sub {
|
||||||
|
margin-top: 25px;
|
||||||
|
padding-top: 25px;
|
||||||
|
border-top: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-cell {
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
/*Media Queries ------------------------------ */
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-body_inner,
|
||||||
|
.email-footer {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body,
|
||||||
|
.email-body,
|
||||||
|
.email-body_inner,
|
||||||
|
.email-content,
|
||||||
|
.email-wrapper,
|
||||||
|
.email-masthead,
|
||||||
|
.email-footer {
|
||||||
|
background-color: #333333 !important;
|
||||||
|
color: #FFF !important;
|
||||||
|
}
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
span,
|
||||||
|
.purchase_item {
|
||||||
|
color: #FFF !important;
|
||||||
|
}
|
||||||
|
.attributes_content,
|
||||||
|
.discount {
|
||||||
|
background-color: #222 !important;
|
||||||
|
}
|
||||||
|
.email-masthead_name {
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
supported-color-schemes: light dark;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type="text/css">
|
||||||
|
.f-fallback {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td class="email-masthead">
|
||||||
|
<a href="https://hoppscotch.io" class="f-fallback email-masthead_name">
|
||||||
|
Hoppscotch
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Email Body -->
|
||||||
|
<tr>
|
||||||
|
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
|
||||||
|
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<!-- Body content -->
|
||||||
|
<tr>
|
||||||
|
<td class="content-cell">
|
||||||
|
<div class="f-fallback">
|
||||||
|
|
||||||
|
<h1>Hello,</h1>
|
||||||
|
|
||||||
|
<p>We received a request to sign in to Hoppscotch using this email address. If you want to sign in with your {{inviteeEmail}} account, click this link:</p>
|
||||||
|
|
||||||
|
|
||||||
|
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{magicLink}}" class="button button--green" target="_blank">Sign in to Hoppscotch</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
|
||||||
|
|
||||||
|
<p>If you did not request this link, you can safely ignore this email. </p>
|
||||||
|
|
||||||
|
<p>Thanks,</p>
|
||||||
|
|
||||||
|
<p>Your Hoppscotch team</p>
|
||||||
|
|
||||||
|
<table class="body-sub">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
|
||||||
|
<p class="sub">{{magicLink}}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td class="content-cell" align="center">
|
||||||
|
<p class="f-fallback sub align-center">© 2021 Hoppscotch</p>
|
||||||
|
<p class="f-fallback sub align-center">12 New Fetter Lane, London, United Kingdom, EC4A 1JP.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="x-apple-disable-message-reformatting" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="supported-color-schemes" content="light dark" />
|
||||||
|
<title></title>
|
||||||
|
<!--
|
||||||
|
The style block is collapsed on page load to save you some scrolling.
|
||||||
|
Postmark automatically inlines all CSS properties for maximum email client
|
||||||
|
compatibility. You can just update styles here, and Postmark does the rest.
|
||||||
|
-->
|
||||||
|
<style type="text/css" rel="stylesheet" media="all">
|
||||||
|
/* Base ------------------------------ */
|
||||||
|
|
||||||
|
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
||||||
|
body {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #3869D4;
|
||||||
|
}
|
||||||
|
|
||||||
|
a img {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preheader {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden;
|
||||||
|
mso-hide: all;
|
||||||
|
font-size: 1px;
|
||||||
|
line-height: 1px;
|
||||||
|
max-height: 0;
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* Type ------------------------------ */
|
||||||
|
|
||||||
|
body,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote {
|
||||||
|
margin: .4em 0 1.1875em;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.625;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.sub {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
/* Utilities ------------------------------ */
|
||||||
|
|
||||||
|
.align-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/* Buttons ------------------------------ */
|
||||||
|
|
||||||
|
.button {
|
||||||
|
background-color: #3869D4;
|
||||||
|
border-top: 10px solid #3869D4;
|
||||||
|
border-right: 18px solid #3869D4;
|
||||||
|
border-bottom: 10px solid #3869D4;
|
||||||
|
border-left: 18px solid #3869D4;
|
||||||
|
display: inline-block;
|
||||||
|
color: #FFF;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--green {
|
||||||
|
background-color: #22BC66;
|
||||||
|
border-top: 10px solid #22BC66;
|
||||||
|
border-right: 18px solid #22BC66;
|
||||||
|
border-bottom: 10px solid #22BC66;
|
||||||
|
border-left: 18px solid #22BC66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--red {
|
||||||
|
background-color: #FF6136;
|
||||||
|
border-top: 10px solid #FF6136;
|
||||||
|
border-right: 18px solid #FF6136;
|
||||||
|
border-bottom: 10px solid #FF6136;
|
||||||
|
border-left: 18px solid #FF6136;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 500px) {
|
||||||
|
.button {
|
||||||
|
width: 100% !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Attribute list ------------------------------ */
|
||||||
|
|
||||||
|
.attributes {
|
||||||
|
margin: 0 0 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attributes_content {
|
||||||
|
background-color: #F4F4F7;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attributes_item {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* Related Items ------------------------------ */
|
||||||
|
|
||||||
|
.related {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 25px 0 0 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related_item {
|
||||||
|
padding: 10px 0;
|
||||||
|
color: #CBCCCF;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related_item-title {
|
||||||
|
display: block;
|
||||||
|
margin: .5em 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related_item-thumb {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related_heading {
|
||||||
|
border-top: 1px solid #CBCCCF;
|
||||||
|
text-align: center;
|
||||||
|
padding: 25px 0 10px;
|
||||||
|
}
|
||||||
|
/* Discount Code ------------------------------ */
|
||||||
|
|
||||||
|
.discount {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
background-color: #F4F4F7;
|
||||||
|
border: 2px dashed #CBCCCF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount_heading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount_body {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
/* Social Icons ------------------------------ */
|
||||||
|
|
||||||
|
.social {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social td {
|
||||||
|
padding: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social_icon {
|
||||||
|
height: 20px;
|
||||||
|
margin: 0 8px 10px 8px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* Data table ------------------------------ */
|
||||||
|
|
||||||
|
.purchase {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 35px 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_content {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 25px 0 0 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_item {
|
||||||
|
padding: 10px 0;
|
||||||
|
color: #51545E;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_heading {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_heading p {
|
||||||
|
margin: 0;
|
||||||
|
color: #85878E;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_footer {
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_total {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_total--label {
|
||||||
|
padding: 0 15px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
color: #51545E;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #51545E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-content {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
/* Masthead ----------------------- */
|
||||||
|
|
||||||
|
.email-masthead {
|
||||||
|
padding: 25px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-masthead_logo {
|
||||||
|
width: 94px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-masthead_name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #A8AAAF;
|
||||||
|
text-decoration: none;
|
||||||
|
text-shadow: 0 1px 0 white;
|
||||||
|
}
|
||||||
|
/* Body ------------------------------ */
|
||||||
|
|
||||||
|
.email-body {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-body_inner {
|
||||||
|
width: 570px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 570px;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-footer {
|
||||||
|
width: 570px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 570px;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-footer p {
|
||||||
|
color: #A8AAAF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-action {
|
||||||
|
width: 100%;
|
||||||
|
margin: 30px auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-sub {
|
||||||
|
margin-top: 25px;
|
||||||
|
padding-top: 25px;
|
||||||
|
border-top: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-cell {
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
/*Media Queries ------------------------------ */
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-body_inner,
|
||||||
|
.email-footer {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body,
|
||||||
|
.email-body,
|
||||||
|
.email-body_inner,
|
||||||
|
.email-content,
|
||||||
|
.email-wrapper,
|
||||||
|
.email-masthead,
|
||||||
|
.email-footer {
|
||||||
|
background-color: #333333 !important;
|
||||||
|
color: #FFF !important;
|
||||||
|
}
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
span,
|
||||||
|
.purchase_item {
|
||||||
|
color: #FFF !important;
|
||||||
|
}
|
||||||
|
.attributes_content,
|
||||||
|
.discount {
|
||||||
|
background-color: #222 !important;
|
||||||
|
}
|
||||||
|
.email-masthead_name {
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
supported-color-schemes: light dark;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type="text/css">
|
||||||
|
.f-fallback {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td class="email-masthead">
|
||||||
|
<a href="https://hoppscotch.io" class="f-fallback email-masthead_name">
|
||||||
|
Hoppscotch
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Email Body -->
|
||||||
|
<tr>
|
||||||
|
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
|
||||||
|
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<!-- Body content -->
|
||||||
|
<tr>
|
||||||
|
<td class="content-cell">
|
||||||
|
<div class="f-fallback">
|
||||||
|
<h1>Hi there,</h1>
|
||||||
|
<p>{{invitee}} with {{invite_team_name}} has invited you to use Hoppscotch to collaborate with them. Click the button below to set up your account and get started:</p>
|
||||||
|
<!-- Action -->
|
||||||
|
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{action_url}}" class="button button--green" target="_blank">Join {{invite_team_name}}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
Welcome aboard, <br />
|
||||||
|
Your friends at Hoppscotch
|
||||||
|
</p>
|
||||||
|
<p><strong>P.S.</strong> If you don't associate with {{invitee}} or {{invite_team_name}}, just ignore this email.</p>
|
||||||
|
<!-- Sub copy -->
|
||||||
|
<table class="body-sub">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
|
||||||
|
<p class="sub">{{action_url}}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td class="content-cell" align="center">
|
||||||
|
<p class="f-fallback sub align-center">© 2021 Hoppscotch</p>
|
||||||
|
<p class="f-fallback sub align-center">12 New Fetter Lane, London, United Kingdom, EC4A 1JP.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
57
packages/hoppscotch-backend/src/main.ts
Normal file
57
packages/hoppscotch-backend/src/main.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { json } from 'express';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import * as cookieParser from 'cookie-parser';
|
||||||
|
import { VersioningType } from '@nestjs/common';
|
||||||
|
import * as session from 'express-session';
|
||||||
|
import { emitGQLSchemaFile } from './gql-schema';
|
||||||
|
import { checkEnvironmentAuthProvider } from './utils';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
console.log(`Running in production: ${process.env.PRODUCTION}`);
|
||||||
|
console.log(`Port: ${process.env.PORT}`);
|
||||||
|
|
||||||
|
checkEnvironmentAuthProvider();
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
secret: process.env.SESSION_SECRET,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Increase fil upload limit to 50MB
|
||||||
|
app.use(
|
||||||
|
json({
|
||||||
|
limit: '100mb',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (process.env.PRODUCTION === 'false') {
|
||||||
|
console.log('Enabling CORS with development settings');
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env.WHITELISTED_ORIGINS.split(','),
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('Enabling CORS with production settings');
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env.WHITELISTED_ORIGINS.split(','),
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app.enableVersioning({
|
||||||
|
type: VersioningType.URI,
|
||||||
|
});
|
||||||
|
app.use(cookieParser());
|
||||||
|
await app.listen(process.env.PORT || 3170);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.GENERATE_GQL_SCHEMA) {
|
||||||
|
bootstrap();
|
||||||
|
} else {
|
||||||
|
emitGQLSchemaFile();
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { GraphQLSchemaHost } from '@nestjs/graphql';
|
||||||
|
import {
|
||||||
|
ApolloServerPlugin,
|
||||||
|
GraphQLRequestListener,
|
||||||
|
} from 'apollo-server-plugin-base';
|
||||||
|
import { Plugin } from '@nestjs/apollo';
|
||||||
|
import { GraphQLError } from 'graphql';
|
||||||
|
import {
|
||||||
|
fieldExtensionsEstimator,
|
||||||
|
getComplexity,
|
||||||
|
simpleEstimator,
|
||||||
|
} from 'graphql-query-complexity';
|
||||||
|
|
||||||
|
const COMPLEXITY_LIMIT = 50;
|
||||||
|
|
||||||
|
@Plugin()
|
||||||
|
export class GQLComplexityPlugin implements ApolloServerPlugin {
|
||||||
|
constructor(private gqlSchemaHost: GraphQLSchemaHost) {}
|
||||||
|
|
||||||
|
async requestDidStart(): Promise<GraphQLRequestListener> {
|
||||||
|
const { schema } = this.gqlSchemaHost;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async didResolveOperation({ request, document }) {
|
||||||
|
const complexity = getComplexity({
|
||||||
|
schema,
|
||||||
|
operationName: request.operationName,
|
||||||
|
query: document,
|
||||||
|
variables: request.variables,
|
||||||
|
estimators: [
|
||||||
|
fieldExtensionsEstimator(),
|
||||||
|
simpleEstimator({ defaultComplexity: 1 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (complexity > COMPLEXITY_LIMIT) {
|
||||||
|
throw new GraphQLError(
|
||||||
|
`Query is too complex: ${complexity}. Maximum allowed complexity: ${COMPLEXITY_LIMIT}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('Query Complexity:', complexity);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
8
packages/hoppscotch-backend/src/prisma/prisma.module.ts
Normal file
8
packages/hoppscotch-backend/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common/decorators';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user