mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
589
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ef82ac3a8 | ||
|
|
f86c826d1b | ||
|
|
3de7ef1a26 | ||
|
|
1713780528 | ||
|
|
6db39b829e | ||
|
|
174ff0886c | ||
|
|
d49d8f52c4 | ||
|
|
c9552e448a | ||
|
|
47d3059d78 | ||
|
|
c3bafef6d8 | ||
|
|
1fbbc935c1 | ||
|
|
ad0e6be10d | ||
|
|
5735d47889 | ||
|
|
294bdfa296 | ||
|
|
83eb012cb5 | ||
|
|
51d165fe84 | ||
|
|
3b7eb6c606 | ||
|
|
333146c590 | ||
|
|
6adb310f0c | ||
|
|
a64fe1c08c | ||
|
|
40cdb3d0ab | ||
|
|
7a97721abf | ||
|
|
c1b71e7772 | ||
|
|
56517226b6 | ||
|
|
337d270ba6 | ||
|
|
373ab315a3 | ||
|
|
685e1fc300 | ||
|
|
274aded8fd | ||
|
|
39b6b5e116 | ||
|
|
3f52d8c76e | ||
|
|
11f814777b | ||
|
|
dd66ecdb78 | ||
|
|
ec90a34262 | ||
|
|
5a2ce4968d | ||
|
|
03659b4a15 | ||
|
|
b032c96a7c | ||
|
|
d887d44bd4 | ||
|
|
7115ce8484 | ||
|
|
96cc9822d0 | ||
|
|
68fb74f441 | ||
|
|
06ed4f3517 | ||
|
|
3a4dd10b4c | ||
|
|
0316fdc0a3 | ||
|
|
72ecd7f054 | ||
|
|
539fec4dbc | ||
|
|
3fa0f9b76d | ||
|
|
4fadfcfaf2 | ||
|
|
af3df7f72a | ||
|
|
2cd5bda416 | ||
|
|
965b215224 | ||
|
|
27f6b746e0 | ||
|
|
7d463d7da8 | ||
|
|
332540488d | ||
|
|
ab52ef6c27 | ||
|
|
2d55618898 | ||
|
|
6b3df405eb | ||
|
|
e91ef4c142 | ||
|
|
9b504ccd68 | ||
|
|
dd3f4411cf | ||
|
|
0ab94b6221 | ||
|
|
8477889cea | ||
|
|
2eb4a7a0fe | ||
|
|
1f68adda90 | ||
|
|
ffba2c56a3 | ||
|
|
02be78a26e | ||
|
|
711d4613ed | ||
|
|
a5fe5225e1 | ||
|
|
b752d9437c | ||
|
|
18df92e048 | ||
|
|
4e66b8ed04 | ||
|
|
4f14d16452 | ||
|
|
592588ef76 | ||
|
|
3fec7c3392 | ||
|
|
2335caefd7 | ||
|
|
194eedeaa8 | ||
|
|
ecd09aedb9 | ||
|
|
b5350b5fe3 | ||
|
|
ceaafd5531 | ||
|
|
654accad4a | ||
|
|
173c7894b4 | ||
|
|
1ab51d15f9 | ||
|
|
ddce51d2d3 | ||
|
|
fc9fbf4357 | ||
|
|
5e8cd7e0e6 | ||
|
|
1eb5fbb983 | ||
|
|
d9ccb389f0 | ||
|
|
dc80d1d752 | ||
|
|
ff28a80d7c | ||
|
|
ab55ab349a | ||
|
|
a1ec7dbec2 | ||
|
|
e29846ae25 | ||
|
|
e38519aec2 | ||
|
|
a620d7a9f7 | ||
|
|
88ebd79681 | ||
|
|
e08efa12fa | ||
|
|
7a685c0518 | ||
|
|
4deaaa2c8e | ||
|
|
adaf0f89fb | ||
|
|
47de5373b1 | ||
|
|
db9d23b5ec | ||
|
|
47c74931d4 | ||
|
|
be01031501 | ||
|
|
2e656d9c41 | ||
|
|
9071193c4d | ||
|
|
27e1da6363 | ||
|
|
e02c5496ae | ||
|
|
3fee236fd0 | ||
|
|
78ba42239d | ||
|
|
8ffb672e26 | ||
|
|
83ddc934ba | ||
|
|
31cbd52703 | ||
|
|
dd85f7a25d | ||
|
|
203c25aabe | ||
|
|
41b1c89283 | ||
|
|
180ea21041 | ||
|
|
e1c0fc6917 | ||
|
|
057c192314 | ||
|
|
aff782aa28 | ||
|
|
2e729cb79c | ||
|
|
d8677d74a7 | ||
|
|
2f77714994 | ||
|
|
bc8050c4df | ||
|
|
a9c69b112d | ||
|
|
04785c323b | ||
|
|
1d209ceb28 | ||
|
|
941a9256f0 | ||
|
|
9098012426 | ||
|
|
8e12ff59a4 | ||
|
|
0f8a267861 | ||
|
|
da53a00fd8 | ||
|
|
47e3a93e42 | ||
|
|
0262a1c176 | ||
|
|
7aecd911c0 | ||
|
|
595ccadfb3 | ||
|
|
bded273f94 | ||
|
|
c095d393cf | ||
|
|
c3ae82fde7 | ||
|
|
a8e11ebef7 | ||
|
|
61b1a2e724 | ||
|
|
7a4af4e1e0 | ||
|
|
a9d6481193 | ||
|
|
fb97963cc3 | ||
|
|
0cc0ba989c | ||
|
|
26eff02943 | ||
|
|
67246efafc | ||
|
|
260b3ba1f9 | ||
|
|
90f8885e73 | ||
|
|
0bacd6a65c | ||
|
|
2846deb4d0 | ||
|
|
ae6dbcab25 | ||
|
|
d823ec2e82 | ||
|
|
b0ce5273ce | ||
|
|
b576862024 | ||
|
|
dc2496ee18 | ||
|
|
87db207833 | ||
|
|
b1ccf6ec5f | ||
|
|
ca3f63af81 | ||
|
|
29b0ca5bf5 | ||
|
|
da50193e8c | ||
|
|
1829c1fc1c | ||
|
|
79a17407bd | ||
|
|
dd1e3283d5 | ||
|
|
d1baedd092 | ||
|
|
9e504cefc1 | ||
|
|
3cc5ab70fa | ||
|
|
0441af78a1 | ||
|
|
3c615a4fd7 | ||
|
|
8681e16fde | ||
|
|
918c6c448c | ||
|
|
1e2b4f16b3 | ||
|
|
f0d02eb8b3 | ||
|
|
96274206c5 | ||
|
|
7ae7ea6f79 | ||
|
|
487e7ad83d | ||
|
|
c9dabd39fe | ||
|
|
30ab689c6e | ||
|
|
8143af18af | ||
|
|
471c65cc48 | ||
|
|
9162d5afcc | ||
|
|
9236c664c2 | ||
|
|
767ed69020 | ||
|
|
93c706719f | ||
|
|
ed1ac1926b | ||
|
|
3adb6d01c0 | ||
|
|
9c195c1f30 | ||
|
|
e27093404b | ||
|
|
9602427ca0 | ||
|
|
46812cb5ff | ||
|
|
6258d92464 | ||
|
|
c57d913c46 | ||
|
|
5d285b7ac6 | ||
|
|
c96219f851 | ||
|
|
c7d949b1d1 | ||
|
|
6593a8e324 | ||
|
|
0988fa0115 | ||
|
|
9f0b1a172c | ||
|
|
0126a829dd | ||
|
|
b04a2750a3 | ||
|
|
9e22160b16 | ||
|
|
e8e4b093d2 | ||
|
|
0cb27f2637 | ||
|
|
1497c7385b | ||
|
|
327b06d2a1 | ||
|
|
2c2cfc0b73 | ||
|
|
93327b0a6a | ||
|
|
11c74d4117 | ||
|
|
f93bb6e33e | ||
|
|
73699bbda9 | ||
|
|
5f24ec2d86 | ||
|
|
c08bb8f8e0 | ||
|
|
d01e705866 | ||
|
|
ecc80c3c01 | ||
|
|
784a907384 | ||
|
|
b68a5f32ca | ||
|
|
467f9e2ffd | ||
|
|
1d25acde61 | ||
|
|
cb6ac86bb0 | ||
|
|
cd2da4441d | ||
|
|
d7515b172c | ||
|
|
e4de903cc9 | ||
|
|
45166a3b9c | ||
|
|
687b59e397 | ||
|
|
9a3be8d5a5 | ||
|
|
0c23c24651 | ||
|
|
e20e7f016c | ||
|
|
a0c1a24ba9 | ||
|
|
0475b3d01a | ||
|
|
39cb9b99f6 | ||
|
|
efd123ddfe | ||
|
|
413318886c | ||
|
|
187f9d2386 | ||
|
|
5f61c7baa6 | ||
|
|
e3c5ec9a58 | ||
|
|
fde52415cb | ||
|
|
d92225d093 | ||
|
|
e2a0932229 | ||
|
|
6acdf73557 | ||
|
|
4405b61e03 | ||
|
|
fba714d679 | ||
|
|
8e15282405 | ||
|
|
72b5816451 | ||
|
|
4016191497 | ||
|
|
94b36c1622 | ||
|
|
aa736b8b4a | ||
|
|
94cd937c56 | ||
|
|
9ccf3b7242 | ||
|
|
01b7a58c75 | ||
|
|
2b6c661fea | ||
|
|
fbd61cdf2f | ||
|
|
e9534181da | ||
|
|
fdc613fe8e | ||
|
|
fca09a84cb | ||
|
|
6ec6fb1f11 | ||
|
|
3d51d3548f | ||
|
|
8479b6c822 | ||
|
|
21bb165103 | ||
|
|
c7ca01c0fb | ||
|
|
5d2172f2bf | ||
|
|
1decd9d7a5 | ||
|
|
d0091cfed3 | ||
|
|
0ebcf1729c | ||
|
|
51d101e8f0 | ||
|
|
2d9bf225a0 | ||
|
|
f85d12448c | ||
|
|
82c8ad98b4 | ||
|
|
2499c7c426 | ||
|
|
da530217ab | ||
|
|
4c70d8aac7 | ||
|
|
ba1dbcc9f3 | ||
|
|
0ad380cc81 | ||
|
|
b77a2e374e | ||
|
|
647108e8ee | ||
|
|
8e4e41ca07 | ||
|
|
a8eb3f5f1a | ||
|
|
eb5ac5c8ea | ||
|
|
29344e0c48 | ||
|
|
73f7ac057c | ||
|
|
d359d53fb7 | ||
|
|
ff83ee9c62 | ||
|
|
da3a8d7fbe | ||
|
|
ba2fc55188 | ||
|
|
a6896c9b50 | ||
|
|
f2563c5e8d | ||
|
|
9326291780 | ||
|
|
8dd74f310d | ||
|
|
44ed9e9ca6 | ||
|
|
ab22680625 | ||
|
|
a73e29efc9 | ||
|
|
26273b7240 | ||
|
|
c925407f05 | ||
|
|
622fc369d0 | ||
|
|
0c4b7573cc | ||
|
|
401ce5c290 | ||
|
|
9580ad3934 | ||
|
|
b33c0810d5 | ||
|
|
e3e84e0d18 | ||
|
|
55cb4a7144 | ||
|
|
8fbe639aa1 | ||
|
|
9bc042e2cd | ||
|
|
67302c6a17 | ||
|
|
d7e2ec3b08 | ||
|
|
fdaca0e8a7 | ||
|
|
3af5f89606 | ||
|
|
3ccccfc5e5 | ||
|
|
7959f903bb | ||
|
|
59eb91f28f | ||
|
|
9aecfb22a5 | ||
|
|
ba4476a3a7 | ||
|
|
64724d2a92 | ||
|
|
be58ebdb7d | ||
|
|
746a9f5eb6 | ||
|
|
91a6d672fa | ||
|
|
50809476fa | ||
|
|
483f9cf192 | ||
|
|
f1c7dcda45 | ||
|
|
282ffb543c | ||
|
|
c840196a14 | ||
|
|
7d76037a43 | ||
|
|
68fea480ae | ||
|
|
b063e5c276 | ||
|
|
f2d98505f1 | ||
|
|
ec64ea8229 | ||
|
|
59dc181625 | ||
|
|
79387b83f6 | ||
|
|
b13433e554 | ||
|
|
d9fd382cb2 | ||
|
|
baa5e300c8 | ||
|
|
51747773ba | ||
|
|
4d324d885d | ||
|
|
af1a437523 | ||
|
|
dd8663591e | ||
|
|
de4f31f255 | ||
|
|
53c1bc9e59 | ||
|
|
77901ae1bc | ||
|
|
1aeff542e5 | ||
|
|
c05469d704 | ||
|
|
1e6bb542f9 | ||
|
|
b81ad079ee | ||
|
|
044edcda07 | ||
|
|
7eb85d2a99 | ||
|
|
7d4d6cd9df | ||
|
|
3f7eb4528e | ||
|
|
0008f70552 | ||
|
|
ff48517b43 | ||
|
|
2695715180 | ||
|
|
b3be1a561c | ||
|
|
cab7a7f8bb | ||
|
|
f6da5961c3 | ||
|
|
d297f1a27b | ||
|
|
b513509b01 | ||
|
|
bfa3f7e04e | ||
|
|
539b516e45 | ||
|
|
25744efc66 | ||
|
|
21e37366fb | ||
|
|
6002ad44e1 | ||
|
|
a431270718 | ||
|
|
3f6a47c39d | ||
|
|
4dace0a32b | ||
|
|
efb5318fc5 | ||
|
|
a5e83f3133 | ||
|
|
7b6ca12744 | ||
|
|
da1b5a969c | ||
|
|
5d790231ea | ||
|
|
ad363ce38c | ||
|
|
8e5d6eec6c | ||
|
|
92f1400a35 | ||
|
|
47f1938136 | ||
|
|
13d3cfb290 | ||
|
|
c657bdfb63 | ||
|
|
f226218b3a | ||
|
|
8f7fa0dea1 | ||
|
|
292b3c805d | ||
|
|
ded26f92af | ||
|
|
8cce7bbaf4 | ||
|
|
228b8c3ec7 | ||
|
|
2e298ce757 | ||
|
|
81daa9f0b0 | ||
|
|
713c8d9177 | ||
|
|
f571162eed | ||
|
|
19d2872943 | ||
|
|
0752b8bf7f | ||
|
|
c01097196a | ||
|
|
5f1bee018d | ||
|
|
8655b81618 | ||
|
|
1702e43373 | ||
|
|
a7d411f6ff | ||
|
|
6a84da0f96 | ||
|
|
79425f4490 | ||
|
|
2fcddea0fa | ||
|
|
ac1c8552e1 | ||
|
|
6a18eefe34 | ||
|
|
a43eedaf9f | ||
|
|
77bc24990e | ||
|
|
edbc79c89e | ||
|
|
527a090f1b | ||
|
|
370b86337f | ||
|
|
1ce3acdde0 | ||
|
|
1a72be323b | ||
|
|
bf252f22f2 | ||
|
|
937d92f686 | ||
|
|
5572957731 | ||
|
|
d2db5722b7 | ||
|
|
5404185a6c | ||
|
|
800f2dfbbb | ||
|
|
ed0b5410f6 | ||
|
|
ae72e262aa | ||
|
|
c4c7050c3f | ||
|
|
81e5227270 | ||
|
|
39319d9162 | ||
|
|
12206fa856 | ||
|
|
9d8c6e26f1 | ||
|
|
eb624672e6 | ||
|
|
1b026b5f08 | ||
|
|
21b9b8b44d | ||
|
|
6ef7fcc0c2 | ||
|
|
fc4ce178cb | ||
|
|
98b72988c4 | ||
|
|
317473e528 | ||
|
|
e13c5ddf51 | ||
|
|
90be3ab731 | ||
|
|
85c0445a64 | ||
|
|
6a227ccd7c | ||
|
|
3d9a21f854 | ||
|
|
d4229a985d | ||
|
|
c6e0921dea | ||
|
|
346a8a4375 | ||
|
|
4799faa827 | ||
|
|
2c6f5c38ed | ||
|
|
4a884ab5cc | ||
|
|
e59beaf749 | ||
|
|
0d4e1229ab | ||
|
|
8810fd91c9 | ||
|
|
095444610d | ||
|
|
7cf43c5e7c | ||
|
|
77183af08d | ||
|
|
3a2c5be5dd | ||
|
|
11c764c2f3 | ||
|
|
d128234ae6 | ||
|
|
ee31ee225d | ||
|
|
e2195616d3 | ||
|
|
2ca818b996 | ||
|
|
131f2b4538 | ||
|
|
6731c3fea9 | ||
|
|
d3da5e8c13 | ||
|
|
3b0d4225ce | ||
|
|
66f0930164 | ||
|
|
3979b83a0a | ||
|
|
b65e432564 | ||
|
|
340e7c3e00 | ||
|
|
dcdce63e15 | ||
|
|
c2eaed757d | ||
|
|
920743bea7 | ||
|
|
0970f5baae | ||
|
|
291aa13e9a | ||
|
|
e644cde514 | ||
|
|
2350e68f4f | ||
|
|
cce3b681e3 | ||
|
|
755799f19d | ||
|
|
ec02dc8579 | ||
|
|
7028982ba1 | ||
|
|
f04503e38e | ||
|
|
f1edffed17 | ||
|
|
fac87acee7 | ||
|
|
c46dd91ede | ||
|
|
648753f8cf | ||
|
|
76b3bda313 | ||
|
|
8102dd1a8f | ||
|
|
aceb4e7f4c | ||
|
|
4224389e96 | ||
|
|
7c66c10b6d | ||
|
|
bf06a2e50c | ||
|
|
8c69fdef95 | ||
|
|
883c1d2f57 | ||
|
|
e7f1652b4e | ||
|
|
ea07224a52 | ||
|
|
b655154182 | ||
|
|
a173892163 | ||
|
|
14f48c4a35 | ||
|
|
88aa4daf74 | ||
|
|
25326dc9be | ||
|
|
030cd13f7c | ||
|
|
d557620c81 | ||
|
|
3273f8dd11 | ||
|
|
646719557c | ||
|
|
1640b326be | ||
|
|
cc19c9ec19 | ||
|
|
bb84ab9697 | ||
|
|
0f7664201a | ||
|
|
0b8a8213e6 | ||
|
|
476b3da66b | ||
|
|
41c632ccfa | ||
|
|
dbf5addf12 | ||
|
|
8d414080a4 | ||
|
|
c8d390726c | ||
|
|
31285dd0d6 | ||
|
|
b9ef58044e | ||
|
|
e320f74dfc | ||
|
|
b9deb3cdf0 | ||
|
|
5dbf293e1e | ||
|
|
47f2308acc | ||
|
|
496be692e7 | ||
|
|
5df6dd169a | ||
|
|
7232f17cb7 | ||
|
|
5b1c3d0606 | ||
|
|
8e6ca64fa2 | ||
|
|
9116b4d126 | ||
|
|
4ce38d9857 | ||
|
|
8811c48384 | ||
|
|
078d0d1ee2 | ||
|
|
56d755afa6 | ||
|
|
9e3abb366f | ||
|
|
4ebac1c46f | ||
|
|
1476df7a1f | ||
|
|
833872603a | ||
|
|
737834310b | ||
|
|
4474c8ee5e | ||
|
|
ebe5c63a7b | ||
|
|
afd98ae652 | ||
|
|
8f905c8128 | ||
|
|
f43f30c9b9 | ||
|
|
6b1bea9b17 | ||
|
|
cdaed9766e | ||
|
|
d31a9ab2d2 | ||
|
|
07d49749fb | ||
|
|
a1f1da8a07 | ||
|
|
1b791fd287 | ||
|
|
2a4af58a51 | ||
|
|
b12f8ca044 | ||
|
|
519b3abd1d | ||
|
|
e08a5bd6b6 | ||
|
|
f6c597b42d | ||
|
|
f3b9032174 | ||
|
|
44b4ee2953 | ||
|
|
b77f79f42d | ||
|
|
8017bf7c8a | ||
|
|
85f4cda752 | ||
|
|
1fd2f553ed | ||
|
|
d68170eda8 | ||
|
|
51f27226e7 | ||
|
|
d54c366323 | ||
|
|
0e7cb9fbab | ||
|
|
63e83032f1 | ||
|
|
b4da6fdd9f | ||
|
|
12535a59e8 | ||
|
|
b345eda7aa | ||
|
|
660c1dcadf | ||
|
|
be66856034 | ||
|
|
80e3027107 | ||
|
|
3bf731055f | ||
|
|
362ca53efb | ||
|
|
7ab213e5e4 | ||
|
|
f0eafb1162 | ||
|
|
ad5baa7d6f | ||
|
|
5840e30afb | ||
|
|
8f95606dd7 | ||
|
|
ca9bc7ae94 | ||
|
|
288a40e697 | ||
|
|
fa91e8c289 | ||
|
|
4ab5fe4c6f | ||
|
|
d87782d180 | ||
|
|
1c97c97fa1 | ||
|
|
2426166676 | ||
|
|
6f6c1c7c52 | ||
|
|
7b9cb2ecb9 | ||
|
|
fc029af654 | ||
|
|
110a69dc4b | ||
|
|
a61791fdb2 | ||
|
|
c01440f9d4 | ||
|
|
39584dd7a1 | ||
|
|
d9badeacba | ||
|
|
d4cde2ba9c | ||
|
|
824ef4528e | ||
|
|
feb06a85ae | ||
|
|
cfad645e33 | ||
|
|
675f8dec20 | ||
|
|
11e85b613c | ||
|
|
eb82833c7e | ||
|
|
399a3e09de | ||
|
|
1a735bda42 | ||
|
|
d24e534828 | ||
|
|
80bdca138e | ||
|
|
0a2ca8c0b6 | ||
|
|
921a355ae0 | ||
|
|
a49b6d6573 | ||
|
|
baffd2efaf | ||
|
|
292693a1ed | ||
|
|
db1a0235ed | ||
|
|
2cd6a7cae0 | ||
|
|
bd80fdc960 |
+56
-8
@@ -1,15 +1,29 @@
|
||||
# Environnement
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Base de données
|
||||
# Logging
|
||||
# Controls minimum log level. Options: debug | info | warn | error
|
||||
# Set to "warn" or "error" in production to suppress health-check ping noise.
|
||||
# Use "debug" to see all request logs including agent status pings.
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
||||
|
||||
# Projet
|
||||
# Project
|
||||
PROJECT_NAME="Portabase"
|
||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
PROJECT_URL=http://localhost:8887
|
||||
PROJECT_SECRET=
|
||||
|
||||
AUTH_DEFAULT_USER_NAME="Portabase Admin"
|
||||
AUTH_DEFAULT_USER=user@example.com
|
||||
# Password must contain at least 8 characters
|
||||
# Password must contain at least 1 number
|
||||
# Password must contain at least 1 lowercase letter
|
||||
# Password must contain at least 1 uppercase letter
|
||||
# Password must contain at least 1 special character
|
||||
AUTH_DEFAULT_PASSWORD=testPASS123456!
|
||||
|
||||
# SMTP (email)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
@@ -21,11 +35,45 @@ SMTP_FROM=
|
||||
#Port 465 -> secure: true
|
||||
#By default : true
|
||||
|
||||
# Google
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
AUTH_GOOGLE_METHOD=
|
||||
# OIDC SSO Authentification
|
||||
AUTH_OIDC_ID=""
|
||||
AUTH_OIDC_TITLE=""
|
||||
AUTH_OIDC_DESC=""
|
||||
AUTH_OIDC_ICON=""
|
||||
AUTH_OIDC_CLIENT=""
|
||||
AUTH_OIDC_SECRET=""
|
||||
AUTH_OIDC_ISSUER_URL=""
|
||||
AUTH_OIDC_HOST=""
|
||||
AUTH_OIDC_SCOPES=""
|
||||
AUTH_OIDC_DISCOVERY_ENDPOINT=""
|
||||
AUTH_OIDC_JWKS_ENDPOINT=""
|
||||
AUTH_OIDC_PKCE=true
|
||||
ALLOWED_GROUP="admin"
|
||||
AUTH_OIDC_ROLE_MAP=""
|
||||
|
||||
AUTH_ALLOW_LINKING=true
|
||||
AUTH_ALLOW_UNLINKING=true
|
||||
|
||||
# Social OAuth2 Authentification
|
||||
AUTH_SOCIAL_ID=""
|
||||
AUTH_SOCIAL_TITLE=""
|
||||
AUTH_SOCIAL_DESC=""
|
||||
AUTH_SOCIAL_ICON=""
|
||||
AUTH_SOCIAL_CLIENT=""
|
||||
AUTH_SOCIAL_SECRET=""
|
||||
AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER=""
|
||||
|
||||
# If true, the user's role will be updated from OIDC claims on every login.
|
||||
# If false, the role is only set when the user is first created.
|
||||
AUTH_SYNC_OIDC_ROLES_ON_LOGIN=true
|
||||
AUTH_EMAIL_PASSWORD_ENABLED=true
|
||||
AUTH_SIGNUP_ENABLED=true
|
||||
AUTH_PASSKEY_ENABLED=true
|
||||
|
||||
# Retention
|
||||
RETENTION_CRON="* * * * *"
|
||||
|
||||
TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:3056"
|
||||
|
||||
#OPENAPI_ENABLED=true
|
||||
#API_ENABLED=true
|
||||
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
contact@soluce-technologies.com.
|
||||
contact@portabase.io.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
@@ -72,7 +72,7 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
||||
|
||||
3. **Push your branch**
|
||||
```bash
|
||||
git push origin feature/<feature-name>
|
||||
git push origin feat/<feature-name>
|
||||
```
|
||||
|
||||
4. **Open a Pull Request (PR)**
|
||||
@@ -82,7 +82,7 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- Follow the [specific coding style guide] (e.g., Prettier, ESLint, PEP8).
|
||||
- Follow the [specific coding style guide] (e.g., Prettier, ESLint, PEP8,Biome).
|
||||
- Use meaningful variable names and include comments where necessary.
|
||||
- Tests before submitting your changes.
|
||||
|
||||
@@ -107,4 +107,4 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
||||
|
||||
Thank you for contributing! 🙌
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ We take security seriously and aim to support the following versions of the proj
|
||||
If you discover a security vulnerability in this project, we appreciate your help in disclosing it responsibly.
|
||||
|
||||
1. **Contact Us**
|
||||
Please report the vulnerability by emailing **[contact@soluce-technologies.com](mailto:contact@soluce-technologies.com)**. Include the following details:
|
||||
Please report the vulnerability by emailing **[contact@portabase.io](mailto:contact@portabase.io)**. Include the following details:
|
||||
- A detailed description of the issue.
|
||||
- Steps to reproduce the vulnerability (if applicable).
|
||||
- Any potential impacts or risks.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Discord Notification
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
required: true
|
||||
type: string
|
||||
discord_title:
|
||||
required: true
|
||||
type: string
|
||||
discord_color:
|
||||
required: true
|
||||
type: number
|
||||
discord_footer:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
DISCORD_WEBHOOK:
|
||||
required: true
|
||||
GH_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
notify-discord:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send Discord Notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
RELEASE_INFO=$(gh release view "${{ inputs.release_tag }}" -R ${{ github.repository }} --json name,url,body,author)
|
||||
|
||||
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
||||
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ inputs.release_tag }}"; fi
|
||||
|
||||
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
||||
|
||||
AUTHOR_NAME="Portabase"
|
||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "$RELEASE_TITLE" \
|
||||
--arg description "$RELEASE_BODY" \
|
||||
--arg url "$RELEASE_URL" \
|
||||
--arg author "$AUTHOR_NAME" \
|
||||
--arg icon "$AUTHOR_ICON" \
|
||||
--arg discord_title "${{ inputs.discord_title }}" \
|
||||
--arg discord_footer "${{ inputs.discord_footer }}" \
|
||||
--argjson discord_color ${{ inputs.discord_color }} \
|
||||
'{
|
||||
content: $discord_title,
|
||||
embeds: [{
|
||||
title: $title,
|
||||
url: $url,
|
||||
description: $description,
|
||||
color: $discord_color,
|
||||
author: {
|
||||
name: $author,
|
||||
icon_url: $icon
|
||||
},
|
||||
footer: {
|
||||
text: $discord_footer
|
||||
}
|
||||
}]
|
||||
}'
|
||||
)
|
||||
|
||||
curl -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$DISCORD_WEBHOOK"
|
||||
+61
-142
@@ -3,14 +3,16 @@ name: Docker Publish
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
image_name:
|
||||
required: false
|
||||
type: string
|
||||
default: 'solucetechnologies/portabase'
|
||||
image_name2:
|
||||
required: false
|
||||
type: string
|
||||
default: 'portabase/portabase'
|
||||
default: "portabase/portabase"
|
||||
add_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
@@ -18,26 +20,21 @@ on:
|
||||
target:
|
||||
required: false
|
||||
type: string
|
||||
default: 'prod'
|
||||
default: "prod"
|
||||
dockerfile:
|
||||
required: false
|
||||
type: string
|
||||
default: './docker/dockerfile/Dockerfile'
|
||||
default: "./docker/dockerfile/Dockerfile"
|
||||
secrets:
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
DOCKER_PASSWORD:
|
||||
required: true
|
||||
DOCKER_USERNAME_2:
|
||||
required: true
|
||||
DOCKER_PASSWORD_2:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
name: Build and push Docker images (Portabase)
|
||||
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
|
||||
name: Build architectures
|
||||
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -45,23 +42,26 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Set image tag
|
||||
id: set-tags
|
||||
- name: Prepare Image Tags
|
||||
id: prep
|
||||
run: |
|
||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||
if [ "${{ matrix.platform }}" = "linux/amd64" ]; then
|
||||
IMAGE="${{ inputs.image_name }}:$REF_NAME-amd64"
|
||||
else
|
||||
IMAGE="${{ inputs.image_name }}:$REF_NAME-arm64"
|
||||
fi
|
||||
echo "image=$IMAGE" >> $GITHUB_OUTPUT
|
||||
ARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
echo "image=${{ inputs.image_name }}:${{inputs.version}}-$ARCH" >> $GITHUB_OUTPUT
|
||||
echo "safe_platform=${{ matrix.platform == 'linux/amd64' && 'linux-amd64' || 'linux-arm64' }}" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -70,154 +70,73 @@ jobs:
|
||||
file: ${{ inputs.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.image }}
|
||||
tags: ${{ steps.prep.outputs.image }}
|
||||
target: ${{ inputs.target }}
|
||||
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
|
||||
|
||||
- name: Prepare artifact name
|
||||
id: artifact
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "safe_platform=${platform//\//-}" >> $GITHUB_OUTPUT
|
||||
echo "${{ steps.set-tags.outputs.image }}" > image.txt
|
||||
- name: Save image name for manifest
|
||||
run: echo "${{ steps.prep.outputs.image }}" > image.txt
|
||||
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: image-${{ steps.artifact.outputs.safe_platform }}
|
||||
name: image-${{ steps.prep.outputs.safe_platform }}
|
||||
path: image.txt
|
||||
if-no-files-found: warn
|
||||
compression-level: 6
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
retention-days: 1
|
||||
|
||||
create-manifest:
|
||||
name: Create multi-arch Docker manifest (Portabase)
|
||||
name: Create multi-arch manifest
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: image-linux-amd64
|
||||
path: /tmp/digests/amd64
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: image-linux-arm64
|
||||
path: /tmp/digests/arm64
|
||||
|
||||
- name: Login to Docker
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/digests
|
||||
- name: Generate semantic tags
|
||||
id: tags
|
||||
run: |
|
||||
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||
MANIFEST_IMAGE="${{ inputs.image_name }}:$REF_NAME"
|
||||
|
||||
docker buildx imagetools create $DOCKER_IMAGES -t $MANIFEST_IMAGE
|
||||
docker buildx imagetools inspect $MANIFEST_IMAGE
|
||||
|
||||
VERSION="${{ inputs.version }}"
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||
|
||||
echo "VERSION_TAG=$VERSION" >> $GITHUB_OUTPUT
|
||||
if [ -n "$MINOR" ]; then
|
||||
echo "MINOR_TAG=$MAJOR.$MINOR" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
if [ -n "$MAJOR" ]; then
|
||||
echo "MAJOR_TAG=$MAJOR" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
if [ "${{ inputs.add_latest }}" = "true" ]; then
|
||||
docker buildx imagetools create $DOCKER_IMAGES -t ${{ inputs.image_name }}:latest
|
||||
docker buildx imagetools inspect ${{ inputs.image_name }}:latest
|
||||
fi
|
||||
|
||||
|
||||
build-2:
|
||||
name: Build and push Docker images (Soluce Technologies)
|
||||
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ linux/amd64, linux/arm64 ]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Docker
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
- name: Set image tag
|
||||
id: set-tags
|
||||
run: |
|
||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||
if [ "${{ matrix.platform }}" = "linux/amd64" ]; then
|
||||
IMAGE="${{ inputs.image_name2 }}:$REF_NAME-amd64"
|
||||
else
|
||||
IMAGE="${{ inputs.image_name2 }}:$REF_NAME-arm64"
|
||||
echo "LATEST_TAG=latest" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "image=$IMAGE2" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ inputs.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.image }}
|
||||
target: ${{ inputs.target }}
|
||||
|
||||
- name: Prepare artifact name
|
||||
id: artifact
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "safe_platform=${platform//\//-}" >> $GITHUB_OUTPUT
|
||||
echo "${{ steps.set-tags.outputs.image }}" > image.txt
|
||||
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
with:
|
||||
name: image-${{ steps.artifact.outputs.safe_platform }}
|
||||
path: image.txt
|
||||
if-no-files-found: warn
|
||||
compression-level: 6
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
|
||||
create-manifest-2:
|
||||
name: Create multi-arch Docker manifest (Soluce Technologies)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-2
|
||||
steps:
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
with:
|
||||
name: image-linux-amd64
|
||||
path: /tmp/digests/amd64
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
with:
|
||||
name: image-linux-arm64
|
||||
path: /tmp/digests/arm64
|
||||
|
||||
- name: Login to Docker
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
images: ${{ inputs.image_name }}
|
||||
tags: |
|
||||
type=raw,value=${{ steps.tags.outputs.VERSION_TAG }}
|
||||
type=raw,value=${{ steps.tags.outputs.MINOR_TAG }}
|
||||
type=raw,value=${{ steps.tags.outputs.MAJOR_TAG }}
|
||||
type=raw,value=${{ steps.tags.outputs.LATEST_TAG }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||
MANIFEST_IMAGE="${{ inputs.image_name2 }}:$REF_NAME"
|
||||
|
||||
docker buildx imagetools create $DOCKER_IMAGES -t $MANIFEST_IMAGE
|
||||
docker buildx imagetools inspect $MANIFEST_IMAGE
|
||||
|
||||
if [ "${{ inputs.add_latest }}" = "true" ]; then
|
||||
docker buildx imagetools create $DOCKER_IMAGES -t ${{ inputs.image_name2 }}:latest
|
||||
docker buildx imagetools inspect ${{ inputs.image_name2 }}:latest
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||
echo $TAG_ARGS
|
||||
docker buildx imagetools create $TAG_ARGS $DOCKER_IMAGES
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
name: End-to-end testing
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/dockerfile/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: portabase/portabase:test
|
||||
|
||||
- name: Run app container
|
||||
run: |
|
||||
docker run -d --name myapp \
|
||||
-p 8887:80 \
|
||||
-e NODE_ENV=production \
|
||||
-e PROJECT_URL=http://localhost:8887 \
|
||||
-e PROJECT_SECRET=80af5e4c875dfded3a6ba511c6ed8b0160cad723f848ee0851403ed6141f6ba1 \
|
||||
portabase/portabase:test
|
||||
|
||||
- name: Wait for app port to be listening
|
||||
run: |
|
||||
for i in {1..40}; do
|
||||
|
||||
if curl -fsS http://localhost:8887/ >/dev/null; then
|
||||
echo "App is responding on port 8887"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for app readiness... ($i/40)"
|
||||
sleep 2
|
||||
done
|
||||
echo "Timeout: app never became ready"
|
||||
docker logs myapp
|
||||
exit 1
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm exec playwright test
|
||||
env:
|
||||
PROJECT_URL: http://localhost:8887
|
||||
# E2E Notification
|
||||
E2E_NOTIFICATION_SMTP_HOST: ${{ secrets.E2E_NOTIFICATION_SMTP_HOST }}
|
||||
E2E_NOTIFICATION_SMTP_PORT: ${{ secrets.E2E_NOTIFICATION_SMTP_PORT }}
|
||||
E2E_NOTIFICATION_SMTP_USER: ${{ secrets.E2E_NOTIFICATION_SMTP_USER }}
|
||||
E2E_NOTIFICATION_SMTP_PASSWORD: ${{ secrets.E2E_NOTIFICATION_SMTP_PASSWORD }}
|
||||
E2E_NOTIFICATION_SMTP_FROM: ${{ secrets.E2E_NOTIFICATION_SMTP_FROM }}
|
||||
E2E_NOTIFICATION_SMTP_TO: ${{ secrets.E2E_NOTIFICATION_SMTP_TO }}
|
||||
E2E_NOTIFICATION_SLACK_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_SLACK_WEBHOOK }}
|
||||
E2E_NOTIFICATION_DISCORD_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_DISCORD_WEBHOOK }}
|
||||
E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN }}
|
||||
E2E_NOTIFICATION_TELEGRAM_CHAT_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_CHAT_ID }}
|
||||
E2E_NOTIFICATION_TELEGRAM_TOPIC_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_TOPIC_ID }}
|
||||
E2E_NOTIFICATION_GOTIFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_GOTIFY_SERVER_URL }}
|
||||
E2E_NOTIFICATION_GOTIFY_APP_TOKEN: ${{ secrets.E2E_NOTIFICATION_GOTIFY_APP_TOKEN }}
|
||||
E2E_NOTIFICATION_NTFY_TOPIC: ${{ secrets.E2E_NOTIFICATION_NTFY_TOPIC }}
|
||||
E2E_NOTIFICATION_NTFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_NTFY_SERVER_URL }}
|
||||
E2E_NOTIFICATION_NTFY_TOKEN: ${{ secrets.E2E_NOTIFICATION_NTFY_TOKEN }}
|
||||
E2E_NOTIFICATION_NTFY_USERNAME: ${{ secrets.E2E_NOTIFICATION_NTFY_USERNAME }}
|
||||
E2E_NOTIFICATION_NTFY_PASSWORD: ${{ secrets.E2E_NOTIFICATION_NTFY_PASSWORD }}
|
||||
E2E_NOTIFICATION_WEBHOOK_URL: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_URL }}
|
||||
E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER }}
|
||||
E2E_NOTIFICATION_WEBHOOK_SECRET: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET }}
|
||||
|
||||
# E2E Storage
|
||||
E2E_STORAGE_AWS_S3_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_AWS_S3_ENDPOINT_URL }}
|
||||
E2E_STORAGE_AWS_S3_REGION: ${{ secrets.E2E_STORAGE_AWS_S3_REGION }}
|
||||
E2E_STORAGE_AWS_S3_ACCESS_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_ACCESS_KEY }}
|
||||
E2E_STORAGE_AWS_S3_SECRET_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_SECRET_KEY }}
|
||||
E2E_STORAGE_AWS_S3_BUCKET_NAME: ${{ secrets.E2E_STORAGE_AWS_S3_BUCKET_NAME }}
|
||||
E2E_STORAGE_AWS_S3_PORT: ${{ secrets.E2E_STORAGE_AWS_S3_PORT }}
|
||||
E2E_STORAGE_R2_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_R2_ENDPOINT_URL }}
|
||||
E2E_STORAGE_R2_REGION: ${{ secrets.E2E_STORAGE_R2_REGION }}
|
||||
E2E_STORAGE_R2_ACCESS_KEY: ${{ secrets.E2E_STORAGE_R2_ACCESS_KEY }}
|
||||
E2E_STORAGE_R2_SECRET_KEY: ${{ secrets.E2E_STORAGE_R2_SECRET_KEY }}
|
||||
E2E_STORAGE_R2_BUCKET_NAME: ${{ secrets.E2E_STORAGE_R2_BUCKET_NAME }}
|
||||
E2E_STORAGE_R2_PORT: ${{ secrets.E2E_STORAGE_R2_PORT }}
|
||||
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID }}
|
||||
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET }}
|
||||
E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID }}
|
||||
|
||||
- name: Upload report if failed
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: docker stop myapp && docker rm myapp || true
|
||||
@@ -1,128 +0,0 @@
|
||||
name: GitHub Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
prerelease:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
make_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
discord_title:
|
||||
required: true
|
||||
type: string
|
||||
discord_color:
|
||||
required: true
|
||||
type: number
|
||||
discord_footer:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
DISCORD_WEBHOOK:
|
||||
required: true
|
||||
GH_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v5
|
||||
with:
|
||||
mode: "COMMIT"
|
||||
configurationJson: |
|
||||
{
|
||||
"template": "#{{CHANGELOG}}",
|
||||
"categories": [
|
||||
{
|
||||
"title": "## Feature",
|
||||
"labels": ["feat", "feature"]
|
||||
},
|
||||
{
|
||||
"title": "## Fix",
|
||||
"labels": ["fix", "bug"]
|
||||
},
|
||||
{
|
||||
"title": "## Other",
|
||||
"labels": []
|
||||
}
|
||||
],
|
||||
"label_extractor": [
|
||||
{
|
||||
"pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)",
|
||||
"on_property": "title",
|
||||
"target": "$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: false
|
||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
make_latest: ${{ inputs.make_latest }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Send Discord Notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
|
||||
|
||||
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
||||
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
|
||||
|
||||
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
||||
|
||||
AUTHOR_NAME="Portabase"
|
||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "$RELEASE_TITLE" \
|
||||
--arg description "$RELEASE_BODY" \
|
||||
--arg url "$RELEASE_URL" \
|
||||
--arg author "$AUTHOR_NAME" \
|
||||
--arg icon "$AUTHOR_ICON" \
|
||||
--arg discord_title "${{ inputs.discord_title }}" \
|
||||
--arg discord_footer "${{ inputs.discord_footer }}" \
|
||||
--argjson discord_color ${{ inputs.discord_color }} \
|
||||
'{
|
||||
content: $discord_title,
|
||||
embeds: [{
|
||||
title: $title,
|
||||
url: $url,
|
||||
description: $description,
|
||||
color: $discord_color,
|
||||
author: {
|
||||
name: $author,
|
||||
icon_url: $icon
|
||||
},
|
||||
footer: {
|
||||
text: $discord_footer
|
||||
}
|
||||
}]
|
||||
}'
|
||||
)
|
||||
|
||||
curl -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$DISCORD_WEBHOOK"
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Publish Helm Chart
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
GH_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
publish-helm:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4
|
||||
|
||||
- name: Package Helm chart
|
||||
run: |
|
||||
mkdir -p ./helm-packages
|
||||
helm package helm \
|
||||
--version ${{ inputs.version }} \
|
||||
--app-version ${{ inputs.version }} \
|
||||
--destination ./helm-packages
|
||||
|
||||
- name: Authenticate to GitHub Packages
|
||||
run: |
|
||||
echo "${{ secrets.GH_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push Helm chart to GitHub Packages (OCI)
|
||||
run: |
|
||||
helm push ./helm-packages/portabase-${{ inputs.version }}.tgz oci://ghcr.io/portabase/charts
|
||||
@@ -3,31 +3,28 @@ name: Publish Docker image for release candidate
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*-rc*'
|
||||
- "*.*.*-rc*"
|
||||
- "!*-*-rc*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
docker_publish:
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
add_latest: false
|
||||
add_latest: true
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
github_release:
|
||||
discord_notification:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/github.yml
|
||||
uses: ./.github/workflows/discord.yml
|
||||
with:
|
||||
prerelease: true
|
||||
make_latest: false
|
||||
discord_title: "||@release-dashboard|| New release candidate published"
|
||||
discord_color: 16776960
|
||||
discord_title: "New Release Candidate: ${{ github.ref_name }}"
|
||||
discord_color: 2044800
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
|
||||
+125
-17
@@ -1,34 +1,142 @@
|
||||
name: Publish Docker image for release
|
||||
name: Auto Release & Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
- '!*-*'
|
||||
pull_request_target:
|
||||
types: [ closed ]
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
docker_publish:
|
||||
|
||||
check-skip:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
skip: ${{ steps.set-skip.outputs.skip }}
|
||||
steps:
|
||||
- name: Determine if release should be skipped
|
||||
id: set-skip
|
||||
run: |
|
||||
TITLE="${{ github.event.pull_request.title }}"
|
||||
echo "PR title: $TITLE"
|
||||
|
||||
if [[ "$TITLE" == *"[skip-release]"* ]]; then
|
||||
echo "PR title contains [skip-release], skipping release jobs."
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
create-release:
|
||||
needs: check-skip
|
||||
if: ${{ needs.check-skip.outputs.skip == 'false' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
draft_tag: ${{ steps.release_step.outputs.draft_tag }}
|
||||
version: ${{ steps.release_step.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ vars.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
ref: main
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: |
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
|
||||
|
||||
- name: Run release-it
|
||||
id: release_step
|
||||
run: |
|
||||
git pull origin main
|
||||
|
||||
VERSION=$(pnpm exec release-it --ci --release-version)
|
||||
echo $VERSION
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
OUTPUT=$(pnpm exec release-it --ci)
|
||||
echo "$OUTPUT"
|
||||
|
||||
DRAFT_TAG=$(echo "$OUTPUT" | grep -oE 'untagged-[a-z0-9]+')
|
||||
echo $DRAFT_TAG
|
||||
echo "draft_tag=$DRAFT_TAG" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
|
||||
publish-docker:
|
||||
needs: create-release
|
||||
if: ${{ needs.create-release.result == 'success' }}
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
version: ${{ needs.create-release.outputs.version }}
|
||||
ref: ${{ needs.create-release.outputs.version }}
|
||||
add_latest: true
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
github_release:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/github.yml
|
||||
publish-helm:
|
||||
needs: create-release
|
||||
if: ${{ needs.create-release.result == 'success' }}
|
||||
uses: ./.github/workflows/helm.yml
|
||||
with:
|
||||
prerelease: false
|
||||
make_latest: true
|
||||
discord_title: "||@release-dashboard|| New release published"
|
||||
discord_color: 5814783
|
||||
version: ${{ needs.create-release.outputs.version }}
|
||||
secrets:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
finalize-release:
|
||||
needs:
|
||||
- create-release
|
||||
- publish-docker
|
||||
- publish-helm
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_tag: ${{ steps.publish_release_step.outputs.release_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Publish GitHub Release
|
||||
id: publish_release_step
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
OUTPUT=$(gh release edit ${{ needs.create-release.outputs.draft_tag }} --draft=false)
|
||||
echo "$OUTPUT"
|
||||
RELEASE_TAG=$(echo "$OUTPUT" | sed -E 's|.*/releases/tag/||')
|
||||
echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
notify-discord:
|
||||
needs:
|
||||
- publish-docker
|
||||
- publish-helm
|
||||
- create-release
|
||||
- finalize-release
|
||||
uses: ./.github/workflows/discord.yml
|
||||
with:
|
||||
release_tag: ${{ needs.create-release.outputs.version }}
|
||||
discord_title: "New Release"
|
||||
discord_color: 3066993
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
|
||||
@@ -5,27 +5,27 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
sca-deps: # Dependency & container scan
|
||||
sca-deps:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: aquasecurity/trivy-action@0.20.0
|
||||
- uses: aquasecurity/trivy-action@v0.35.0
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
format: 'table'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
ignore-unfixed: true
|
||||
|
||||
|
||||
secrets-gitleaks: # Secrets exposure
|
||||
secrets-gitleaks:
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history for gitleaks
|
||||
fetch-depth: 0
|
||||
- uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||
with:
|
||||
config-path: .gitleaks.toml # Optional, if you have a custom config
|
||||
config-path: .gitleaks.toml
|
||||
+16
-1
@@ -40,7 +40,22 @@ public/uploads/*
|
||||
!public/uploads/
|
||||
|
||||
private/uploads/*
|
||||
private/keys/*
|
||||
|
||||
/.env
|
||||
|
||||
certificates
|
||||
seeds/pocket-id/*.db
|
||||
|
||||
certificates
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
/e2e/local-storage.json
|
||||
|
||||
.claude
|
||||
.codex
|
||||
/docs
|
||||
|
||||
+1
-3
@@ -2,9 +2,7 @@ title = "Custom gitleaks config"
|
||||
|
||||
[allowlist]
|
||||
# Global allowlist patterns (won’t be flagged)
|
||||
regexes = [
|
||||
"s3SecretAccessKey: env.S3_SECRET_KEY ?? null",
|
||||
]
|
||||
regexes = []
|
||||
paths = [
|
||||
"src/utils/init.ts"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"github": {
|
||||
"release": true,
|
||||
"draft": true,
|
||||
"tokenRef": "GITHUB_TOKEN"
|
||||
},
|
||||
"git": {
|
||||
"commit": true,
|
||||
"commitMessage": "chore: release ${version}",
|
||||
"requireCleanWorkingDir": true,
|
||||
"tag": true,
|
||||
"tagName": "${version}",
|
||||
"push": true
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"preset": {
|
||||
"name": "conventionalcommits",
|
||||
"types": [
|
||||
{
|
||||
"type": "feat",
|
||||
"section": "✨ Features"
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"section": "🐛 Bug Fixes"
|
||||
},
|
||||
{
|
||||
"type": "perf",
|
||||
"section": "⚡️ Performance Improvements"
|
||||
},
|
||||
{
|
||||
"type": "revert",
|
||||
"section": "⏪️ Reverts"
|
||||
},
|
||||
{
|
||||
"type": "docs",
|
||||
"section": "📝 Documentation",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"type": "chore",
|
||||
"section": "🔧 Chores",
|
||||
"hidden": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"@release-it/bumper": {
|
||||
"out": {
|
||||
"file": "CITATION.cff",
|
||||
"path": "version",
|
||||
"type": "text/yaml"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-4
@@ -1,6 +1,6 @@
|
||||
cff-version: 1.2.0
|
||||
title: Portabase
|
||||
message: "If you use this software, please cite it as below."
|
||||
message: If you use this software, please cite it as below.
|
||||
type: software
|
||||
authors:
|
||||
- family-names: Gauthereau
|
||||
@@ -9,13 +9,20 @@ authors:
|
||||
given-names: Killian
|
||||
repository-code: https://github.com/Portabase/portabase
|
||||
url: https://portabase.io
|
||||
abstract: "Portabase is a free, open-source, self-hosted solution for database administration, providing backup and restore capabilities, scheduling, retention policies, notifications, and support for multiple storage backends. Its headless agent architecture enables connection to multiple database instances securely and efficiently."
|
||||
abstract: >-
|
||||
Portabase is a free, open-source, self-hosted solution for database
|
||||
administration, providing backup and restore capabilities, scheduling,
|
||||
retention policies, notifications, and support for multiple storage backends.
|
||||
Its headless agent architecture enables connection to multiple database
|
||||
instances securely and efficiently.
|
||||
keywords:
|
||||
- docker
|
||||
- kubernetes
|
||||
- backups
|
||||
- postgresql
|
||||
- mysql
|
||||
- firebird
|
||||
- mssql
|
||||
- mariadb
|
||||
- devops
|
||||
- database
|
||||
@@ -26,5 +33,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.2.5-rc.10
|
||||
date-released: "2026-02-10"
|
||||
version: 1.16.1
|
||||
date-released: '2026-03-02'
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2024 Soluce Technologies
|
||||
Copyright 2024 Portabase
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,4 +1,62 @@
|
||||
CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
|
||||
|
||||
up:
|
||||
@bash $(CLUSTER_SCRIPT)
|
||||
@bash $(CLUSTER_SCRIPT)
|
||||
|
||||
seed-keycloak:
|
||||
@[ $$(ls -1 seeds/keycloak/*.json 2>/dev/null | wc -l) -gt 0 ] || (echo "No realm export found in seeds/keycloak. Add an export from Keycloak first."; exit 1)
|
||||
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1 || true
|
||||
@docker compose -f docker-compose.func.yml rm -f -s keycloak >/dev/null 2>&1 || true
|
||||
@docker volume rm portabase-dev-func_keycloak-data >/dev/null 2>&1 || true
|
||||
@docker compose -f docker-compose.func.yml up -d keycloak
|
||||
@echo "Keycloak seed import triggered from seeds/keycloak/*.json"
|
||||
|
||||
seed-pocket:
|
||||
@[ -f seeds/pocket-id/portabase.zip ] || (echo "No export found in seeds/pocket-id. Add an export from Pocket ID first."; exit 1)
|
||||
@docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true
|
||||
@docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true
|
||||
@docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true
|
||||
@docker compose -f docker-compose.func.yml run --rm -v $$(pwd)/seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
|
||||
@docker compose -f docker-compose.func.yml up -d pocket-id
|
||||
@sleep 2
|
||||
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
|
||||
@echo "Pocket ID data restored from seeds/pocket-id/portabase.zip"
|
||||
|
||||
seed-auth: seed-keycloak seed-pocket
|
||||
|
||||
pocket-token:
|
||||
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
|
||||
|
||||
export-pocket:
|
||||
@echo "Exporting Pocket ID data to seeds/pocket-id/portabase.zip..."
|
||||
@mkdir -p ./seeds/pocket-id
|
||||
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id export --path /tmp/portabase-export.zip
|
||||
@docker compose -f docker-compose.func.yml cp pocket-id:/tmp/portabase-export.zip ./seeds/pocket-id/portabase.zip
|
||||
@docker compose -f docker-compose.func.yml exec pocket-id rm /tmp/portabase-export.zip
|
||||
@echo "Pocket ID data exported to seeds/pocket-id/portabase.zip"
|
||||
|
||||
export-keycloak:
|
||||
@echo "Exporting Keycloak configuration and users to seeds/keycloak/*.json..."
|
||||
@mkdir -p ./seeds/keycloak
|
||||
@rm -f ./seeds/keycloak/*.json
|
||||
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1
|
||||
@docker rm -f kc-exporter >/dev/null 2>&1 || true
|
||||
@docker compose -f docker-compose.func.yml run --name kc-exporter keycloak export --dir /tmp/kc-export --users realm_file
|
||||
@docker cp kc-exporter:/tmp/kc-export/. ./seeds/keycloak/
|
||||
@docker rm -f kc-exporter >/dev/null 2>&1
|
||||
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
|
||||
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
|
||||
|
||||
end-to-end:
|
||||
@echo "Starting E2E testing..."
|
||||
@docker compose -f docker-compose.e2e.yml up -d
|
||||
@CI=true PROJECT_URL=http://localhost:8887 pnpm playwright test --project=chromium || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
|
||||
@docker compose -f docker-compose.e2e.yml down --volumes
|
||||
@echo "Finished E2E testing successfully."
|
||||
|
||||
e2e-manual:
|
||||
@echo "Starting E2E testing..."
|
||||
@docker compose -f docker-compose.e2e.yml up -d
|
||||
@pnpm playwright test --ui || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
|
||||
@docker compose -f docker-compose.e2e.yml down --volumes
|
||||
@echo "Finished E2E testing successfully."
|
||||
@@ -7,23 +7,29 @@
|
||||
<h3 align="center">Portabase</h3>
|
||||
|
||||
<p align="center" style="margin-top: 20px; font-style: italic;">
|
||||
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent">Portabase agents</a> for managing operations securely and efficiently.</i>
|
||||
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent-rust">Portabase agents</a> for managing operations securely and efficiently.</i>
|
||||
</p>
|
||||
|
||||
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
||||
[](https://hub.docker.com/r/portabase/portabase)
|
||||
[](https://github.com/Portabase/portabase/pkgs/container/charts%2Fportabase)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://www.buymeacoffee.com/portabase)
|
||||
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](https://sqlite.org/)
|
||||
[](https://redis.io/)
|
||||
[](https://www.mongodb.com/)
|
||||
[](https://valkey.io/)
|
||||
[](https://firebirdsql.org/)
|
||||
[](https://www.microsoft.com/en-us/sql-server)
|
||||
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
|
||||
|
||||
[![NextJS][NextJS]][NextJS-url]
|
||||
[![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||
[![Drizzle][Drizzle]][Drizzle-url]
|
||||
@@ -34,14 +40,14 @@
|
||||
<strong>
|
||||
<a href="https://portabase.io">Website</a> •
|
||||
<a href="https://portabase.io/docs">Documentation</a> •
|
||||
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
|
||||
<a href="https://www.youtube.com/watch?v=nSTzT27GgAg">Demo</a> •
|
||||
<a href="https://portabase.io/docs/dashboard/setup">Installation</a> •
|
||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
|
||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||
</strong>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
</div>
|
||||
@@ -51,13 +57,28 @@
|
||||
You have 4 ways to install Portabase:
|
||||
|
||||
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
|
||||
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker)
|
||||
- Kubernetes with Helm (soon)
|
||||
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
|
||||
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose)
|
||||
- Kubernetes with Helm [details](https://portabase.io/docs/dashboard/setup#helm)
|
||||
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
|
||||
|
||||
**Ensure Docker is installed on your machine before getting started.**
|
||||
|
||||
## Supported databases
|
||||
|
||||
| Engine | Support | Supported Versions | Restore |
|
||||
|:-------------------|:----------|:------------------------------|:--------|
|
||||
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
|
||||
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
|
||||
| **MariaDB** | ✅ Stable | 10 and 11 | Yes |
|
||||
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
|
||||
| **SQLite** | ✅ Stable | 3.x | Yes |
|
||||
| **Redis** | ✅ Stable | 2.8+ | No |
|
||||
| **Valkey** | ✅ Stable | 7.2+ | No |
|
||||
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
|
||||
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
|
||||
|
||||
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
|
||||
|
||||
## Contributors
|
||||
|
||||
@@ -89,4 +110,3 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||
|
||||
[Docker-url]: https://www.docker.com/
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {ForgotPasswordForm} from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ForgotPasswordForm } from "@/features/auth/forgot-password-form";
|
||||
import { env } from "@/env.mjs";
|
||||
import { CardAuth } from "@/features/layout/card-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
|
||||
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -12,12 +17,11 @@ export default async function RoutePage(props: { searchParams: Promise<{ callbac
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Reset password</h1>
|
||||
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a
|
||||
link to reset your password.</p>
|
||||
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ForgotPasswordForm/>
|
||||
<ForgotPasswordForm />
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {GuardForm} from "@/components/wrappers/auth/guard/guard-form";
|
||||
import {GuardForm} from "@/features/auth/guard-form";
|
||||
import {cookies} from "next/headers";
|
||||
import {redirect} from "next/navigation";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
@@ -16,7 +16,7 @@ export default async function GuardPage() {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth className="w-full max-w-md">
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Two-factor verification</h1>
|
||||
|
||||
+30
-20
@@ -1,25 +1,35 @@
|
||||
import React from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { AuthLogoSection } from "@/features/auth/auth-logo-section";
|
||||
import { Heart } from "lucide-react";
|
||||
|
||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||
export default async function Layout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const user = await currentUser();
|
||||
|
||||
const user = await currentUser();
|
||||
if (user && !user.banned && user.role !== "pending") {
|
||||
redirect("/dashboard/home");
|
||||
}
|
||||
|
||||
if (user && !user.banned && user.role !== "pending") {
|
||||
redirect("/dashboard/home");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<AuthLogoSection/>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
|
||||
Powered by <span className="font-medium">Soluce Technologies</span>
|
||||
</footer>
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<AuthLogoSection />
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
|
||||
<p className="flex items-center justify-center gap-1">
|
||||
Made with{" "}
|
||||
<Heart className="size-3 fill-red-500 text-red-500 animate-pulse" />{" "}
|
||||
by <span className="font-medium text-foreground">Portabase</span>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+49
-42
@@ -1,52 +1,59 @@
|
||||
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
|
||||
import {Metadata} from "next";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
||||
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import { LoginForm } from "@/features/auth/login-form";
|
||||
import { Metadata } from "next";
|
||||
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
|
||||
import { SocialAuthButtons } from "@/features/auth/social-buttons";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CardAuth } from "@/features/layout/card-auth";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login",
|
||||
title: "Login",
|
||||
};
|
||||
|
||||
export default async function SignInPage() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Login</h1>
|
||||
<p className="text-balance text-muted-foreground">
|
||||
Fill your login informations
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
|
||||
<LoginForm isPasskeyEnabled={env.AUTH_PASSKEY_ENABLED === "true"} />
|
||||
)}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Login</h1>
|
||||
<p className="text-balance text-muted-foreground">Fill your login informations</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LoginForm/>
|
||||
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" &&
|
||||
SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive)
|
||||
.length > 0 && (
|
||||
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||
<Separator />
|
||||
<div className="px-2 text-center text-sm">OR</div>
|
||||
<Separator />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
|
||||
<>
|
||||
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||
<Separator/>
|
||||
<div className="px-2 text-center text-sm">OR</div>
|
||||
<Separator/>
|
||||
</div>
|
||||
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
|
||||
</>
|
||||
)}
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length >
|
||||
0 && <SocialAuthButtons providers={SUPPORTED_PROVIDERS} />}
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account ?{" "}
|
||||
<Link href="/register" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
)
|
||||
{env.AUTH_SIGNUP_ENABLED !== "true" ||
|
||||
(env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account ?{" "}
|
||||
<Link href="/register" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form";
|
||||
import {Metadata} from "next";
|
||||
import { PageParams } from "@/types/next";
|
||||
import { RegisterForm } from "@/features/auth/register-form";
|
||||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Register",
|
||||
title: "Register",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<RegisterForm/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (
|
||||
env.AUTH_SIGNUP_ENABLED !== "true" ||
|
||||
env.AUTH_EMAIL_PASSWORD_ENABLED !== "true"
|
||||
) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
|
||||
import { ResetPasswordForm } from "@/features/auth/reset-password-form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
||||
import { CardAuth } from "@/features/layout/card-auth";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
||||
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const { token } = await props.searchParams;
|
||||
|
||||
@@ -23,7 +28,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card className="w-full max-w-md shadow-lg">
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
|
||||
@@ -53,7 +58,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
|
||||
<CardContent>
|
||||
<ResetPasswordForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page} from "@/features/layout/page";
|
||||
import {OrganizationManagement} from "@/components/wrappers/dashboard/admin/organizations/organization/organization-management";
|
||||
import {OrganizationManagement} from "@/features/organizations/admin-organization-management";
|
||||
import {buildOrganizationWithMembers} from "@/utils/common";
|
||||
import {isUUID} from "@/utils/text";
|
||||
import {user} from "@/db/schema/02_user";
|
||||
|
||||
@@ -3,8 +3,8 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
|
||||
import {db} from "@/db";
|
||||
import {
|
||||
AdminOrganizationAddModal
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-organization-add-modal";
|
||||
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-orgnization-list";
|
||||
} from "@/features/organizations/admin-organization-add-modal";
|
||||
import {AdminOrganizationList} from "@/features/organizations/admin-organization-list";
|
||||
import {isNull} from "drizzle-orm";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
|
||||
import {notFound} from "next/navigation";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||
});
|
||||
|
||||
if (!settings) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<PageTitle className="mb-3">System email</PageTitle>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<SettingsEmailSection settings={settings}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
|
||||
import {db} from "@/db";
|
||||
import {notFound} from "next/navigation";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||
});
|
||||
|
||||
if (!settings) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<PageTitle className="mb-3">System storage</PageTitle>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<SettingsStorageSection settings={settings}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {notFound} from "next/navigation";
|
||||
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs";
|
||||
import {SettingsTabs} from "@/features/settings/settings-tabs";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
|
||||
const log = logger.child({module: "dashboard/admin/settings"});
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
@@ -13,6 +17,8 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||
});
|
||||
|
||||
log.debug({settings}, "Settings loaded");
|
||||
|
||||
const storageChannels = await db.query.storageChannel.findMany({
|
||||
with: {
|
||||
organizations: true
|
||||
@@ -21,7 +27,16 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
|
||||
}) as StorageChannelWith[]
|
||||
|
||||
if (!settings || !storageChannels ) {
|
||||
const notificationChannels = await db.query.notificationChannel.findMany({
|
||||
with: {
|
||||
organizations: true
|
||||
},
|
||||
where: isNull(drizzleDb.schemas.notificationChannel.organizationId),
|
||||
orderBy: desc(drizzleDb.schemas.notificationChannel.createdAt)
|
||||
}) as NotificationChannelWith[]
|
||||
|
||||
|
||||
if (!settings || !storageChannels || !notificationChannels) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
@@ -33,7 +48,8 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<SettingsTabs storageChannels={storageChannels} settings={settings} />
|
||||
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels}
|
||||
settings={settings}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,9 @@ import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
|
||||
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
|
||||
import {AdminUserList} from "@/features/users/admin-user-list";
|
||||
import {AdminUserAddModal} from "@/features/users/admin-user-add-modal";
|
||||
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
@@ -21,18 +22,23 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
},
|
||||
});
|
||||
|
||||
const credentialProvider = SUPPORTED_PROVIDERS.find(p => p.id === 'credential');
|
||||
const isPasswordAuthEnabled = credentialProvider?.isActive || false;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<PageTitle className="mb-3">Active users</PageTitle>
|
||||
<PageActions>
|
||||
<AdminUserAddModal organizations={organizations}/>
|
||||
</PageActions>
|
||||
{isPasswordAuthEnabled && (
|
||||
<PageActions>
|
||||
<AdminUserAddModal organizations={organizations}/>
|
||||
</PageActions>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<AdminUserList users={users}/>
|
||||
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,61 +1,88 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import { PageParams } from "@/types/next";
|
||||
import {
|
||||
Page,
|
||||
PageContent,
|
||||
PageDescription,
|
||||
PageTitle,
|
||||
} from "@/features/layout/page";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {notFound} from "next/navigation";
|
||||
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
|
||||
import {AgentDialog} from "@/features/agents/components/agent.dialog";
|
||||
import {AgentType} from "@/features/agents/agents.schema";
|
||||
import {eq, isNull} from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ButtonDeleteAgent } from "@/features/agents/agent-delete-button";
|
||||
import { capitalizeFirstLetter } from "@/utils/text";
|
||||
import { generateEdgeKey } from "@/utils/edge_key";
|
||||
import { getServerUrl } from "@/utils/get-server-url";
|
||||
import { AgentContentPage } from "@/features/agents/agent-content";
|
||||
import { AgentDialog } from "@/features/agents/agent-dialog";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
||||
|
||||
const {agentId} = await props.params
|
||||
export default async function RoutePage(
|
||||
props: PageParams<{ agentId: string }>,
|
||||
) {
|
||||
const { agentId } = await props.params;
|
||||
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
with: {
|
||||
databases: true
|
||||
}
|
||||
})
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
with: {
|
||||
databases: true,
|
||||
organizations: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
notFound()
|
||||
}
|
||||
const organizations = await db.query.organization.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentDialog agent={agent as AgentType & { id: string }} typeTrigger={"edit"}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
if (!agent) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isOwnerByAnOrganization = agent.organizationId
|
||||
|
||||
if (isOwnerByAnOrganization){
|
||||
notFound();
|
||||
}
|
||||
|
||||
const organizationIds = agent.organizations.map(org => org.organizationId)
|
||||
|
||||
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentDialog
|
||||
agent={agent}
|
||||
typeTrigger={"edit"}
|
||||
adminView={true}
|
||||
organizations={organizations}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteAgent organizationIds={organizationIds} agentId={agentId} text={"Delete Agent"} />
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
</div>
|
||||
|
||||
{agent.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage
|
||||
agent={agent}
|
||||
edgeKey={edgeKey}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
{agent.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">
|
||||
{agent.description}
|
||||
</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage agent={agent} edgeKey={edgeKey} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {AgentCard} from "@/features/agents/agent-card";
|
||||
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {desc, eq, not} from "drizzle-orm";
|
||||
import {and, desc, eq, isNull, not} from "drizzle-orm";
|
||||
import {Metadata} from "next";
|
||||
import {AgentDialog} from "@/features/agents/components/agent.dialog";
|
||||
import {AgentDialog} from "@/features/agents/agent-dialog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Agents",
|
||||
@@ -16,13 +16,13 @@ export const metadata: Metadata = {
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const agents = await db.query.agent.findMany({
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
|
||||
where: and(not(eq(drizzleDb.schemas.agent.isArchived, true)),isNull(drizzleDb.schemas.agent.organizationId)),
|
||||
with: {
|
||||
databases: true
|
||||
},
|
||||
orderBy: (fields) => desc(fields.lastContact),
|
||||
});
|
||||
|
||||
|
||||
if (!agents) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {Metadata} from "next";
|
||||
import {db} from "@/db";
|
||||
import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
|
||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||
import {ChannelsSection} from "@/features/channel/channels-section";
|
||||
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -35,12 +35,10 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageHeader>
|
||||
<PageTitle>Notification channels</PageTitle>
|
||||
<PageActions>
|
||||
{/*<NotifierAddEditModal adminView={false}/>*/}
|
||||
<ChannelAddEditModal kind="notification" adminView={false}/>
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
{/*<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>*/}
|
||||
<ChannelsSection kind="notification" organizations={organizations}
|
||||
channels={notificationChannels}/>
|
||||
</PageContent>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Metadata} from "next";
|
||||
import {NotificationLogsList} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-logs-list";
|
||||
import {NotificationLogsList} from "@/features/notifications/notification-logs-list";
|
||||
import {notFound} from "next/navigation";
|
||||
import {getNotificationHistory} from "@/db/services/notification-log";
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Metadata} from "next";
|
||||
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
|
||||
import {ChannelsSection} from "@/features/channel/channels-section";
|
||||
import {db} from "@/db";
|
||||
import {desc, eq, isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Storage Channels",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import {Metadata} from "next";
|
||||
import {db} from "@/db";
|
||||
import {MigrationTool} from "@/features/migration/migration-tool";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Projects",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const organization = await getOrganization({});
|
||||
|
||||
if (!organization) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const projects = await db.query.project.findMany({
|
||||
where: (project, {eq, and, not}) =>
|
||||
and(
|
||||
eq(project.organizationId, organization.id),
|
||||
not(eq(project.isArchived, true))
|
||||
),
|
||||
with: {
|
||||
organization: true,
|
||||
databases: {
|
||||
where: (database, { isNull, not, inArray, and }) =>
|
||||
and(
|
||||
isNull(database.deletedAt),
|
||||
not(inArray(database.dbms, ["valkey", "redis"]))
|
||||
),
|
||||
|
||||
with: {
|
||||
backups: {
|
||||
where: (backup, { isNull, eq, and }) =>
|
||||
and(
|
||||
isNull(backup.deletedAt),
|
||||
eq(backup.status, "success")
|
||||
),
|
||||
orderBy: (backup, {desc}) => [desc(backup.createdAt)],
|
||||
limit: 15,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader className="flex flex-col items-start justify-between mb-6">
|
||||
<PageTitle className="mb-2">
|
||||
Database Migration
|
||||
</PageTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Import backups from a source project into your target database
|
||||
</p>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<MigrationTool projects={projects}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import { DatabaseForm } from "@/components/wrappers/dashboard/database/database-form/database-form";
|
||||
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ databaseId: string }>) {
|
||||
const { databaseId } = await props.params;
|
||||
|
||||
const dbItem = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||
});
|
||||
|
||||
if (!dbItem) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Edit {dbItem.name}</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<DatabaseForm
|
||||
databaseId={databaseId}
|
||||
// @ts-ignore
|
||||
defaultValues={{ ...dbItem, dbms: dbItem.dbms ?? "inactive", description: dbItem.description ?? undefined }}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
+24
-74
@@ -1,23 +1,14 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {notFound, redirect} from "next/navigation";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||
import {Page} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {eq, and, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {getOrganizationProjectDatabases} from "@/lib/services";
|
||||
import {getOrganizationProjectDatabases} from "@/db/services/project";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
|
||||
import {HardDrive, Megaphone} from "lucide-react";
|
||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
|
||||
import {BackupModalProvider} from "@/features/database/backup-modal-context";
|
||||
import {DatabaseContent} from "@/features/database/database-content";
|
||||
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string;
|
||||
@@ -93,11 +84,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
notFound();
|
||||
}
|
||||
|
||||
const organizationChannels = await getOrganizationChannels(organization.id);
|
||||
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
|
||||
|
||||
const organizationStorageChannels = await getOrganizationStorageChannels(organization.id);
|
||||
const activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
|
||||
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({ id: dbItem.id }) : []
|
||||
|
||||
|
||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||
@@ -106,60 +93,23 @@ export default async function RoutePage(props: PageParams<{
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(dbItem.name)}
|
||||
</div>
|
||||
{!isMember && (
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<RetentionPolicySheet database={dbItem}/>
|
||||
<CronButton database={dbItem}/>
|
||||
<ChannelPoliciesModal
|
||||
database={dbItem}
|
||||
kind={"notification"}
|
||||
icon={<Megaphone/>}
|
||||
channels={activeOrganizationChannels}
|
||||
organizationId={organization.id}
|
||||
/>
|
||||
<ChannelPoliciesModal
|
||||
database={dbItem}
|
||||
icon={<HardDrive/>}
|
||||
kind={"storage"}
|
||||
channels={activeOrganizationStorageChannels}
|
||||
organizationId={organization.id}
|
||||
/>
|
||||
<ImportModal database={dbItem}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{dbItem.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
||||
totalBackups={totalBackups}/>
|
||||
<BackupModalProvider>
|
||||
<DatabaseContent
|
||||
activeMember={activeMember}
|
||||
settings={settings}
|
||||
database={dbItem}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
restorations={restorations}
|
||||
backups={backups}
|
||||
/>
|
||||
</BackupModalProvider>
|
||||
</PageContent>
|
||||
<BackupModalProvider>
|
||||
<DatabaseContent
|
||||
activeMember={activeMember}
|
||||
settings={settings}
|
||||
database={dbItem}
|
||||
databaseHealthLogs={databaseHealthLogs}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
restorations={restorations}
|
||||
backups={backups}
|
||||
totalBackups={totalBackups}
|
||||
availableBackups={availableBackups}
|
||||
successRate={successRate}
|
||||
organizationId={organization.id}
|
||||
activeOrganizationChannels={[]}
|
||||
activeOrganizationStorageChannels={[]}
|
||||
/>
|
||||
</BackupModalProvider>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,19 @@ import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageTitle} from "@/features/layout/page";
|
||||
import {
|
||||
ButtonDeleteProject
|
||||
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {ProjectDatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
|
||||
} from "@/features/projects/project-delete-button";
|
||||
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
|
||||
import {ProjectDatabaseCard} from "@/features/projects/project-database-card";
|
||||
import {notFound, redirect} from "next/navigation";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {ProjectDialog} from "@/features/projects/components/project.dialog";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
import {ProjectDialog} from "@/features/projects/project-dialog";
|
||||
import {ProjectWith} from "@/db/schema/06_project";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {getOrganizationAvailableDatabases} from "@/db/services/database";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
@@ -56,19 +55,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
redirect("/dashboard/projects");
|
||||
}
|
||||
|
||||
const availableDatabases = (
|
||||
await db.query.database.findMany({
|
||||
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
|
||||
with: {
|
||||
agent: true,
|
||||
project: true,
|
||||
backups: true,
|
||||
restorations: true,
|
||||
},
|
||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||
})
|
||||
) as DatabaseWith[];
|
||||
|
||||
const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id)
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
return (
|
||||
@@ -102,8 +89,9 @@ export default async function RoutePage(props: PageParams<{
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
cardItem={ProjectDatabaseCard}
|
||||
cardsPerPage={6}
|
||||
cardsPerPage={20}
|
||||
numberOfColumns={3}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
extendedProps={proj}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
|
||||
import {ProjectCard} from "@/features/projects/project-card";
|
||||
import {db} from "@/db";
|
||||
import {notFound} from "next/navigation";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder";
|
||||
import {Metadata} from "next";
|
||||
import {ProjectDialog} from "@/features/projects/components/project.dialog";
|
||||
import {ProjectDialog} from "@/features/projects/project-dialog";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
import {getOrganizationAvailableDatabases} from "@/db/services/database";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Projects",
|
||||
@@ -35,18 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
});
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
const availableDatabases = (
|
||||
await db.query.database.findMany({
|
||||
where: (db, {isNull}) => isNull(db.projectId),
|
||||
with: {
|
||||
agent: true,
|
||||
project: true,
|
||||
backups: true,
|
||||
restorations: true,
|
||||
},
|
||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||
})
|
||||
).filter((db) => db.project == null) as DatabaseWith[];
|
||||
const availableDatabases = await getOrganizationAvailableDatabases(organization.id)
|
||||
|
||||
|
||||
return (
|
||||
@@ -66,11 +56,12 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
organizationSlug={organization.slug}
|
||||
data={projects}
|
||||
cardItem={ProjectCard}
|
||||
cardsPerPage={6}
|
||||
cardsPerPage={12}
|
||||
numberOfColumns={3}
|
||||
pageSizeOptions={[12, 24, 48]}
|
||||
/>
|
||||
) : isMember ? (
|
||||
<EmptyStatePlaceholder text="No project available"/>
|
||||
<EmptyStatePlaceholder state={"empty"} text="No project available"/>
|
||||
) : (
|
||||
<ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {
|
||||
Page,
|
||||
PageContent,
|
||||
PageDescription,
|
||||
PageTitle,
|
||||
} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {notFound} from "next/navigation";
|
||||
import {ButtonDeleteAgent} from "@/features/agents/agent-delete-button";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {AgentContentPage} from "@/features/agents/agent-content";
|
||||
import {AgentDialog} from "@/features/agents/agent-dialog";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||
|
||||
|
||||
export default async function RoutePage(
|
||||
props: PageParams<{ agentId: string }>,
|
||||
) {
|
||||
const {agentId} = await props.params;
|
||||
|
||||
const organization = await getOrganization({});
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember();
|
||||
|
||||
if (!organization || !activeMember || !user) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
|
||||
const {canManageAgents} = computeOrganizationPermissions(activeMember);
|
||||
|
||||
if (!canManageAgents){
|
||||
notFound();
|
||||
}
|
||||
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
with: {
|
||||
databases: true,
|
||||
organizations: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (!agent) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const hasAccess =
|
||||
agent.organizationId === organization.id ||
|
||||
agent.organizations.some(org => org.organizationId === organization.id);
|
||||
|
||||
if (!hasAccess) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isOwned = agent.organizationId
|
||||
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
</div>
|
||||
{isOwned && (
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentDialog
|
||||
agent={agent}
|
||||
typeTrigger={"edit"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteAgent organizationId={organization.id ?? null} agentId={agentId} text={"Delete Agent"}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
</div>
|
||||
|
||||
{agent.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">
|
||||
{agent.description}
|
||||
</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage agent={agent} edgeKey={edgeKey}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function RoutePage() {
|
||||
redirect("/dashboard/settings?tab=agents");
|
||||
}
|
||||
@@ -1,21 +1,26 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {
|
||||
Page,
|
||||
PageContent,
|
||||
PageHeader,
|
||||
PageTitle,
|
||||
} from "@/features/layout/page";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {notFound} from "next/navigation";
|
||||
import {Metadata} from "next";
|
||||
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||
import {OrganizationTabs} from "@/features/organizations/organization-tabs";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
||||
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {DeleteOrganizationButton} from "@/features/organizations/organization-delete-button";
|
||||
import {EditOrganizationDialog} from "@/features/organizations/organization-edit-dialog";
|
||||
import {db} from "@/db";
|
||||
import {isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {getOrganizationAgents} from "@/db/services/agent";
|
||||
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
@@ -24,29 +29,31 @@ export const metadata: Metadata = {
|
||||
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
const organization = await getOrganization({});
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember()
|
||||
const activeMember = await getActiveMember();
|
||||
|
||||
if (!organization || !activeMember || !user) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
||||
const storageChannels = await getOrganizationStorageChannels(organization.id)
|
||||
const notificationChannels = await getOrganizationChannels(organization.id);
|
||||
const storageChannels = await getOrganizationStorageChannels(organization.id);
|
||||
const agents = await getOrganizationAgents(organization.id);
|
||||
const permissions = computeOrganizationPermissions(activeMember);
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt)
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
});
|
||||
|
||||
const organizationWithMembers = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||
with: {
|
||||
projects: true,
|
||||
members: {
|
||||
with: {
|
||||
user: true
|
||||
}
|
||||
}
|
||||
}
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organizationWithMembers) notFound();
|
||||
@@ -55,25 +62,42 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
Organization settings
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||
<EditOrganizationDialog
|
||||
organization={organizationWithMembers}
|
||||
users={users}
|
||||
<div className="min-w-full md:min-w-fit ">Organization settings</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageSettings &&
|
||||
organization.slug !== "default" && (
|
||||
<EditOrganizationDialog
|
||||
organization={organizationWithMembers}
|
||||
users={users}
|
||||
currentUser={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageDangerZone &&
|
||||
organization.slug !== "default" && (
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DeleteOrganizationButton
|
||||
disabled={organizationWithMembers.projects.length > 0}
|
||||
organizationSlug={organization.slug}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</TooltipTrigger>
|
||||
{organizationWithMembers.projects.length > 0 && (
|
||||
<TooltipContent>
|
||||
<p>Your organization has some projects associated with it. Please delete them before deleting the organization.</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
@@ -82,8 +106,9 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
organization={organization}
|
||||
notificationChannels={notificationChannels}
|
||||
storageChannels={storageChannels}
|
||||
agents={agents}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {EvolutionLineChart} from "@/components/wrappers/dashboard/statistics/charts/evolution-line-chart";
|
||||
import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
|
||||
import {EvolutionLineChart} from "@/features/statistics/evolution-line-chart";
|
||||
import {PercentageLineChart} from "@/features/statistics/percentage-line-chart";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import {and, asc, count, eq, inArray} from "drizzle-orm";
|
||||
|
||||
@@ -5,7 +5,7 @@ import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflo
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import {asc, inArray} from "drizzle-orm";
|
||||
import {and, asc, eq, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {listOrganizations} from "@/lib/auth/auth";
|
||||
import {Metadata} from "next";
|
||||
@@ -26,7 +26,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
const agents = await db.query.agent.findMany({});
|
||||
|
||||
const projects = await db.query.project.findMany({
|
||||
where: inArray(drizzleDb.schemas.project.organizationId, organizationIds),
|
||||
where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)),
|
||||
});
|
||||
|
||||
const projectIds = projects.map(project => project.id);
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import React, {ReactNode} from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
|
||||
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
||||
import {Header} from "@/features/layout/Header";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
|
||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/features/layout/app-sidebar";
|
||||
import { Header } from "@/features/layout/header";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { ThemeMetaUpdater } from "@/features/theme/theme-meta-updater";
|
||||
import { ModeToggle } from "@/features/theme/mode-toggle";
|
||||
import { UpdateNotification } from "@/features/updates/update-notification";
|
||||
|
||||
export default async function Layout({children}: { children: ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/login");
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<ThemeMetaUpdater/>
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<ThemeMetaUpdater />
|
||||
<AppSidebar updateNotification={<UpdateNotification />} />
|
||||
<SidebarInset>
|
||||
<Header actions={<ModeToggle />} />
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LoadingSpinner } from "@/components/wrappers/common/loading/loading-spinner";
|
||||
import { LoadingSpinner } from "@/components/common/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentOrganizationSlug } from "@/features/dashboard/organization-cookie";
|
||||
import { getCurrentOrganizationSlug } from "@/features/organizations/organization-cookie";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
|
||||
export default async function Index() {
|
||||
@@ -9,8 +9,4 @@ export default async function Index() {
|
||||
redirect(`/dashboard/home`);
|
||||
}
|
||||
redirect("/login");
|
||||
//Do not delete
|
||||
// return (
|
||||
// <Home/>
|
||||
// );
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {NextResponse} from "next/server";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/agent/backup/helpers"});
|
||||
|
||||
export function withAgentCheck(handler: Function) {
|
||||
return async (request: Request, context: { params: Promise<{ agentId: string }> }) => {
|
||||
@@ -9,28 +12,27 @@ export function withAgentCheck(handler: Function) {
|
||||
const agentId = (await context.params).agentId;
|
||||
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json(
|
||||
{ error: "Agent not found" },
|
||||
{ status: 404 }
|
||||
{error: "Agent not found"},
|
||||
{status: 404}
|
||||
);
|
||||
}
|
||||
|
||||
return handler(request, { ...context, agent });
|
||||
return handler(request, {...context, agent});
|
||||
} catch (err) {
|
||||
console.error("Error in agent middleware:", err);
|
||||
log.error({error: err, name: "withAgentCheck"}, "Error in agent middleware");
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export async function getDatabaseOrThrow(generatedId: string) {
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||
@@ -43,8 +45,8 @@ export async function getDatabaseOrThrow(generatedId: string) {
|
||||
|
||||
if (!database) {
|
||||
throw NextResponse.json(
|
||||
{ error: "Database associated with generatedId not found" },
|
||||
{ status: 404 }
|
||||
{error: "Database associated with generatedId not found"},
|
||||
{status: 404}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ import {db as dbClient, db} from "@/db";
|
||||
import {getDatabaseOrThrow, withAgentCheck} from "./helpers";
|
||||
import {Backup} from "@/db/schema/07_database";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
import {EventKind} from "@/features/notifications/types";
|
||||
import {eventEmitter} from "@/lib/event";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
|
||||
import {EventKind} from "@/features/notifications/notifications.types";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/agent/backup/route"});
|
||||
|
||||
export type BodyPost = {
|
||||
method: "manual" | "automatic"
|
||||
@@ -77,7 +80,6 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
return NextResponse.json(
|
||||
@@ -88,7 +90,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
{status: 200}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in POST for INIT backup:", error);
|
||||
log.error({error: error}, "Error in POST for INIT backup");
|
||||
return NextResponse.json(
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
@@ -102,6 +104,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
}) => {
|
||||
try {
|
||||
const body: BodyPatch = await request.json();
|
||||
log.info({data: body}, "Body from PATH in backup route");
|
||||
|
||||
const status = body.status
|
||||
const backupId = body.backupId
|
||||
@@ -131,7 +134,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
await sendNotificationsBackupRestore(database, `${status}_backup` as EventKind);
|
||||
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -141,9 +144,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
{status: 200}
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
|
||||
console.error("Error in PATCH backup:", error);
|
||||
log.error({error: error}, "Error in PATCH backup")
|
||||
return NextResponse.json(
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
|
||||
@@ -4,7 +4,10 @@ import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {eventEmitter} from "@/lib/event";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/agent/backup/upload/init"});
|
||||
|
||||
export type Body = {
|
||||
generatedId: string
|
||||
@@ -18,7 +21,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
try {
|
||||
const body: Body = await request.json();
|
||||
|
||||
console.log("body", body);
|
||||
log.info({data: body}, "Body for backup upload init");
|
||||
|
||||
const generatedId = body.generatedId;
|
||||
const storageChannelId = body.storageChannelId;
|
||||
@@ -66,7 +69,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
{status: 200}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in POST for INIT backup:", error);
|
||||
log.error({error: error}, "Error in POST for INIT backup");
|
||||
return NextResponse.json(
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
|
||||
@@ -4,7 +4,10 @@ import * as drizzleDb from "@/db";
|
||||
import {db as dbClient, db} from "@/db";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {eventEmitter} from "@/lib/event";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/agent/backup/upload/status"});
|
||||
|
||||
export type Body = {
|
||||
generatedId: string
|
||||
@@ -27,8 +30,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
const backupStorageId = body.backupStorageId;
|
||||
const backupId = body.backupId;
|
||||
|
||||
|
||||
console.log("body", body);
|
||||
log.info({data: body}, "Body for backup upload status");
|
||||
|
||||
const database = await getDatabaseOrThrow(generatedId);
|
||||
|
||||
@@ -68,10 +70,10 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
if (hasSuccessfulStorage && backup.status !== "success") {
|
||||
await db
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set({
|
||||
.set(withUpdatedAt({
|
||||
status: "success",
|
||||
fileSize: fileSize,
|
||||
})
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
}
|
||||
}
|
||||
@@ -85,7 +87,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
{status: 200}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in POST for INIT backup:", error);
|
||||
log.error({error: error},"Error in POST for INIT backup");
|
||||
return NextResponse.json(
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
|
||||
@@ -3,8 +3,11 @@ import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
|
||||
const log = logger.child({module: "api/agent/restore"});
|
||||
|
||||
export type BodyResultRestore = {
|
||||
generatedId: string
|
||||
@@ -19,7 +22,6 @@ export async function POST(
|
||||
) {
|
||||
|
||||
try {
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
const agentId = (await params).agentId
|
||||
const body: BodyResultRestore = await request.json();
|
||||
@@ -33,7 +35,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId)
|
||||
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
|
||||
})
|
||||
if (!agent) {
|
||||
return NextResponse.json({error: "Agent not found"}, {status: 404})
|
||||
@@ -61,22 +63,19 @@ export async function POST(
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: body.status as RestorationStatus})
|
||||
.set(withUpdatedAt({status: body.status as RestorationStatus}))
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
|
||||
await sendNotificationsBackupRestore(database, body.status == "error" ? "error_restore" : "success_restore");
|
||||
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
|
||||
|
||||
const response = {
|
||||
message: true,
|
||||
details: "Restoration successfully updated"
|
||||
status: true,
|
||||
message: "Restoration successfully updated"
|
||||
}
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
return Response.json(response, {status: 200})
|
||||
} catch (error) {
|
||||
console.error('Error in POST handler:', error);
|
||||
log.error({error: error}, "Error in POST handler")
|
||||
return NextResponse.json(
|
||||
{error: 'Internal server error'},
|
||||
{status: 500}
|
||||
|
||||
@@ -2,23 +2,27 @@ import {NextResponse} from "next/server";
|
||||
import {Body} from "./route";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
import {Database, DatabaseWith} from "@/db/schema/07_database";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db, db as dbClient} from "@/db";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import type {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import type {StorageInput} from "@/features/storages/storages.types";
|
||||
import {dispatchStorage} from "@/features/storages/storages.dispatch";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
|
||||
const log = logger.child({module: "api/agent/status/helpers"});
|
||||
|
||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
|
||||
const databasesResponse = [];
|
||||
|
||||
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null) => ({
|
||||
generatedId: database.agentDatabaseId,
|
||||
dbms: database.dbms,
|
||||
storages: storages,
|
||||
encrypt: false,
|
||||
encrypt: settings.encryption,
|
||||
data: {
|
||||
backup: {
|
||||
action: backupAction,
|
||||
@@ -55,7 +59,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
}
|
||||
|
||||
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
|
||||
console.log(`Database type not available: ${db.dbms}`);
|
||||
log.error({name: "handleDatabases"},`Database type not available: ${db.dbms}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -66,12 +70,24 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
name: db.name,
|
||||
dbms: db.dbms as EDbmsSchema,
|
||||
agentDatabaseId: db.generatedId,
|
||||
lastContact: lastContact,
|
||||
lastContact: db.pingStatus ? lastContact : null,
|
||||
healthErrorCount: null
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
if (databaseCreated) {
|
||||
|
||||
|
||||
await dbClient
|
||||
.insert(drizzleDb.schemas.healthcheckLog)
|
||||
.values({
|
||||
kind: "database",
|
||||
status: db.pingStatus ? "success" : "failed",
|
||||
objectId: databaseCreated.id,
|
||||
date: lastContact
|
||||
})
|
||||
|
||||
const storages = await getDatabaseStorageChannels(databaseCreated.id)
|
||||
|
||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
|
||||
@@ -83,11 +99,24 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.set(withUpdatedAt({
|
||||
name: db.name,
|
||||
agentId: agent.id,
|
||||
lastContact: lastContact
|
||||
dbms: db.dbms as EDbmsSchema,
|
||||
lastContact: db.pingStatus ? lastContact : existingDatabase.lastContact,
|
||||
healthErrorCount: db.pingStatus ? null : existingDatabase.healthErrorCount,
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
||||
.returning();
|
||||
|
||||
|
||||
await dbClient
|
||||
.insert(drizzleDb.schemas.healthcheckLog)
|
||||
.values({
|
||||
kind: "database",
|
||||
status: db.pingStatus ? "success" : "failed",
|
||||
objectId: databaseUpdated.id,
|
||||
date: lastContact
|
||||
})
|
||||
|
||||
|
||||
const activeBackup = await dbClient.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
||||
@@ -154,32 +183,31 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
} else {
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "failed"})
|
||||
.set(withUpdatedAt({status: "failed"}))
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
const errorMessage = "Failed to get backup URL";
|
||||
console.error("Restoration failed: ", errorMessage);
|
||||
log.error({error: errorMessage, name: "handleDatabases"}, "Restoration failed");
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Restoration crashed unexpectedly:", err);
|
||||
log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly");
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "failed"})
|
||||
.set(withUpdatedAt({status: "failed"}))
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
continue;
|
||||
}
|
||||
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "ongoing"})
|
||||
.set(withUpdatedAt({status: "ongoing"}))
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
}
|
||||
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
|
||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
|
||||
}
|
||||
}
|
||||
|
||||
return databasesResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,20 @@ import {handleDatabases} from "./helpers";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
|
||||
const log = logger.child({module: "api/agent/status/route"});
|
||||
|
||||
|
||||
export type databaseAgent = {
|
||||
name: string,
|
||||
dbms: EDbmsSchema,
|
||||
generatedId: string
|
||||
pingStatus: boolean
|
||||
}
|
||||
|
||||
export type Body = {
|
||||
@@ -26,14 +31,14 @@ export async function POST(
|
||||
) {
|
||||
try {
|
||||
const agentId = (await params).agentId
|
||||
console.log(agentId)
|
||||
log.debug(`Agent ID: ${agentId}`)
|
||||
const body: Body = await request.json();
|
||||
const lastContact = new Date();
|
||||
let message: string
|
||||
|
||||
if (!isUuidv4(agentId)) {
|
||||
message = "agentId is not a valid uuid"
|
||||
console.error(message)
|
||||
log.error({error: message}, "An error occurred")
|
||||
return NextResponse.json(
|
||||
{error: "agentId is not a valid uuid"},
|
||||
{status: 500}
|
||||
@@ -41,24 +46,38 @@ export async function POST(
|
||||
}
|
||||
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
|
||||
})
|
||||
|
||||
if (!agent) {
|
||||
message = "Agent not found"
|
||||
return NextResponse.json({error: message}, {status: 404})
|
||||
}
|
||||
const databasesResponse = await handleDatabases(body, agent, lastContact)
|
||||
|
||||
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||
if (!settings) {
|
||||
return NextResponse.json({error: "An error occured"}, {status: 404})
|
||||
}
|
||||
|
||||
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.agent)
|
||||
.set(withUpdatedAt({
|
||||
version: body.version,
|
||||
lastContact: lastContact
|
||||
lastContact: lastContact,
|
||||
healthErrorCount: null
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
await db
|
||||
.insert(drizzleDb.schemas.healthcheckLog)
|
||||
.values({
|
||||
kind: "agent",
|
||||
status: "success",
|
||||
objectId: agentId,
|
||||
date: lastContact
|
||||
})
|
||||
|
||||
const response = {
|
||||
agent: {
|
||||
@@ -68,10 +87,9 @@ export async function POST(
|
||||
databases: databasesResponse
|
||||
}
|
||||
|
||||
|
||||
return Response.json(response)
|
||||
} catch (error) {
|
||||
console.error('Error in POST handler:', error);
|
||||
log.error({error: error}, "Error in POST handler")
|
||||
return NextResponse.json(
|
||||
{error: 'Internal server error'},
|
||||
{status: 500}
|
||||
|
||||
@@ -1,4 +1,35 @@
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler);
|
||||
const authHandler = toNextJsHandler(auth.handler);
|
||||
|
||||
async function blockApiKeyCreateForRestrictedUsers(req: NextRequest): Promise<NextResponse | null> {
|
||||
const url = req.nextUrl;
|
||||
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
|
||||
return null;
|
||||
}
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user) {
|
||||
return null;
|
||||
}
|
||||
// @ts-ignore
|
||||
if (session.user.banned || (session.user.role as string) === "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: "Account not eligible to create API keys" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return authHandler.GET(req);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const guard = await blockApiKeyCreateForRestrictedUsers(req);
|
||||
if (guard) return guard;
|
||||
return authHandler.POST(req);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
});
|
||||
return NextResponse.json({
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {EventEmitter} from 'events';
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {NextResponse} from "next/server";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {eventEmitter} from "@/lib/event";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
// export const eventEmitter = new EventEmitter();
|
||||
const log = logger.child({module: "api/events"});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -19,18 +19,17 @@ export async function GET(request: Request) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
console.log('Stream started');
|
||||
log.info("Stream started");
|
||||
const handleModification = (data: any) => {
|
||||
console.log('Modification event triggered:', data);
|
||||
log.info({data: data},"Modification event triggered");
|
||||
controller.enqueue(`event: modification\n`);
|
||||
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
eventEmitter.on('modification', handleModification);
|
||||
|
||||
// Handle client disconnect
|
||||
request.signal.addEventListener('abort', () => {
|
||||
console.log('Client disconnected');
|
||||
log.info("Client disconnected");
|
||||
controller.close();
|
||||
eventEmitter.off('modification', handleModification);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import path from "path";
|
||||
import type {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import type {StorageInput} from "@/features/storages/storages.types";
|
||||
import {dispatchStorage} from "@/features/storages/storages.dispatch";
|
||||
import {Readable} from "node:stream";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/files/backups"});
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -30,7 +33,7 @@ export async function GET(
|
||||
}
|
||||
};
|
||||
|
||||
console.debug(input);
|
||||
log.info({input: input}, "Dispatch Storage");
|
||||
|
||||
const result = await dispatchStorage(input, undefined, storageId);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import {StorageInput} from "@/features/storages/storages.types";
|
||||
import {dispatchStorage} from "@/features/storages/storages.dispatch";
|
||||
import {Readable} from "node:stream";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/files/images"});
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
@@ -56,7 +56,7 @@ export async function GET(
|
||||
const result = await dispatchStorage(input, undefined, storageId);
|
||||
|
||||
if (!result.file || !(result.file instanceof Readable)) {
|
||||
console.error(`An error occurred while getting file :`, result);
|
||||
log.error({error: result}, `An error occurred while getting file`);
|
||||
return NextResponse.json(
|
||||
{error: "Invalid file payload"},
|
||||
{status: 500}
|
||||
@@ -82,7 +82,7 @@ export async function GET(
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error streaming image:", err);
|
||||
log.error({error: err}, `Error streaming image`);
|
||||
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {env} from "@/env.mjs";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/tus/hooks"});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
@@ -12,7 +15,7 @@ export async function POST(request: Request) {
|
||||
const uploadOffset = headers["Upload-Offset"]?.[0];
|
||||
const status = headers["X-Status"]?.[0];
|
||||
|
||||
console.log(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`);
|
||||
log.info(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`);
|
||||
|
||||
if (status === "success") {
|
||||
if (
|
||||
@@ -28,8 +31,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
|
||||
}
|
||||
|
||||
|
||||
const uploadDir = path.join(process.cwd(), "/private/uploads/");
|
||||
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
|
||||
|
||||
const oldFilePath = path.join(uploadDir, "tmp", id);
|
||||
const newFilePath = path.join(uploadDir, filePath);
|
||||
@@ -72,7 +74,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
return NextResponse.json({});
|
||||
} catch (error) {
|
||||
console.error("Hook error:", error);
|
||||
log.error({error: error},"TUS Hook error");
|
||||
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {withApiKey} from "@/lib/api-v1/middleware";
|
||||
import {ApiKeyContext} from "@/lib/api-v1/types";
|
||||
import {NextResponse} from "next/server";
|
||||
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
|
||||
const log = logger.child({ module: "api/v1/agents/[id]/key" });
|
||||
|
||||
export const GET = withApiKey(
|
||||
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||
try {
|
||||
const id = params?.id;
|
||||
if (!id) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const access = await resolveAgentAccess(id, ctx.user);
|
||||
|
||||
if (access === "forbidden") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
if (access === "not_found") return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const agent = await getAgent(id);
|
||||
|
||||
if (!agent) return NextResponse.json({ error: "Agent not found" }, { status: 404 });
|
||||
|
||||
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||
|
||||
return NextResponse.json({ data: edgeKey });
|
||||
} catch (error) {
|
||||
log.error({ error }, "Error in GET /api/v1/agents/[id]");
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {withApiKey} from "@/lib/api-v1/middleware";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {ApiKeyContext} from "@/lib/api-v1/types";
|
||||
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
|
||||
import {deleteAgentService} from "@/features/agents/agent-delete.action";
|
||||
|
||||
const log = logger.child({module: "api/v1/agents/[id]"});
|
||||
|
||||
export const GET = withApiKey(
|
||||
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||
try {
|
||||
const id = params?.id;
|
||||
if (!id) return NextResponse.json({error: "Not found"}, {status: 404});
|
||||
|
||||
const access = await resolveAgentAccess(id, ctx.user);
|
||||
|
||||
if (access === "forbidden") return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||
if (access === "not_found") return NextResponse.json({error: "Not found"}, {status: 404});
|
||||
|
||||
const agent = await getAgent(id, {
|
||||
includeDatabases: true,
|
||||
includeOrganizations: false,
|
||||
});
|
||||
|
||||
if (!agent) return NextResponse.json({error: "Not found"}, {status: 404});
|
||||
return NextResponse.json({data: agent});
|
||||
} catch (error) {
|
||||
log.error({error}, "Error in GET /api/v1/agents/[id]");
|
||||
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const DELETE = withApiKey(
|
||||
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||
try {
|
||||
const id = params?.id;
|
||||
if (!id) return NextResponse.json({error: "Not found"}, {status: 404});
|
||||
|
||||
const access = await resolveAgentAccess(id, ctx.user);
|
||||
if (access === "forbidden") return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||
if (access === "not_found") return NextResponse.json({error: "Not found"}, {status: 404});
|
||||
|
||||
|
||||
const agent = await getAgent(id, {
|
||||
includeDatabases: false,
|
||||
includeOrganizations: true,
|
||||
});
|
||||
|
||||
if (!agent) return NextResponse.json({error: "Agent no found"}, {status: 404});
|
||||
|
||||
const organizationIds = agent.organizations.map(org => org.organizationId)
|
||||
|
||||
const canDeleteGlobalAgent =
|
||||
ctx.user.permissions.isAdmin || ctx.user.permissions.isSuperAdmin;
|
||||
|
||||
if (!agent.organizationId && !canDeleteGlobalAgent) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await deleteAgentService({
|
||||
agentId: agent.id,
|
||||
organizationId: agent.organizationId ?? undefined,
|
||||
organizationIds: organizationIds
|
||||
});
|
||||
|
||||
return new Response(null, {status: 204});
|
||||
} catch (error) {
|
||||
log.error({error}, "Error in DELETE /api/v1/agents/[id]");
|
||||
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { inArray, eq, and, or, isNull } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {createAgentService} from "@/features/agents/agents.action";
|
||||
import { ActionError } from "@/lib/safe-actions/actions";
|
||||
import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents";
|
||||
import {ApiKeyContext} from "@/lib/api-v1/types";
|
||||
import {parseJsonBody} from "@/lib/api-v1/validation/json-body";
|
||||
|
||||
const log = logger.child({ module: "api/v1/agents" });
|
||||
|
||||
export const GET = withApiKey(async (_req: Request, ctx: ApiKeyContext) => {
|
||||
try {
|
||||
const agentIds = await getAccessibleAgentIds(ctx.user);
|
||||
|
||||
if (agentIds.length === 0) {
|
||||
return NextResponse.json({ data: [] });
|
||||
}
|
||||
|
||||
const agents = await db.query.agent.findMany({
|
||||
where: and(
|
||||
inArray(drizzleDb.schemas.agent.id, agentIds),
|
||||
or(
|
||||
eq(drizzleDb.schemas.agent.isArchived, false),
|
||||
isNull(drizzleDb.schemas.agent.isArchived)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: agents });
|
||||
} catch (error) {
|
||||
log.error({ error }, "Error in GET /api/v1/agents");
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
const CreateAgentSchema = z.object({
|
||||
name: z.string().min(1, "name is required"),
|
||||
organizationId: z.string().uuid("organizationId must be a valid UUID").optional(),
|
||||
});
|
||||
|
||||
export const POST = withApiKey(
|
||||
async (req: Request, ctx: ApiKeyContext) => {
|
||||
try {
|
||||
|
||||
const body = await parseJsonBody(req, CreateAgentSchema);
|
||||
|
||||
if (!body.ok) {
|
||||
return body.response;
|
||||
}
|
||||
|
||||
const { name, organizationId } = body.data;
|
||||
|
||||
const org = ctx.organizations.find(
|
||||
(org) => org.id === organizationId
|
||||
);
|
||||
|
||||
if (
|
||||
organizationId &&
|
||||
(!org || !org.permissions.canManageAgents)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const canCreateGlobalAgent =
|
||||
ctx.user.permissions.isAdmin || ctx.user.permissions.isSuperAdmin;
|
||||
|
||||
if (!organizationId && !canCreateGlobalAgent) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const createdAgent = await createAgentService({
|
||||
organizationId,
|
||||
data: {
|
||||
name,
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ data: createdAgent },
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ActionError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
log.error({error},
|
||||
"Error in POST /api/v1/agents"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||
|
||||
const log = logger.child({
|
||||
module: "api/v1/databases/[id]/backup/[backupId]",
|
||||
});
|
||||
|
||||
export const GET = withApiKey(
|
||||
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||
try {
|
||||
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||
|
||||
if (!guard.ok) {
|
||||
return guard.response;
|
||||
}
|
||||
|
||||
const { id } = guard.data;
|
||||
const backupId = params?.backupId;
|
||||
|
||||
if (!backupId) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const backup = await db.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.id, backupId),
|
||||
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||
isNull(drizzleDb.schemas.backup.deletedAt)
|
||||
),
|
||||
with: {
|
||||
storages: {
|
||||
where: (backupStorage, { isNull }) =>
|
||||
isNull(backupStorage.deletedAt),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!backup) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: backup });
|
||||
} catch (error) {
|
||||
log.error(
|
||||
{ error },
|
||||
"Error in GET /api/v1/databases/[id]/backup/[backupId]"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {and, desc, eq, inArray, isNull} from "drizzle-orm";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||
|
||||
const log = logger.child({ module: "api/v1/databases/[id]/backup" });
|
||||
|
||||
export const GET = withApiKey(
|
||||
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||
try {
|
||||
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||
|
||||
if (!guard.ok) {
|
||||
return guard.response;
|
||||
}
|
||||
|
||||
const { id } = guard.data;
|
||||
|
||||
const backups = await db.query.backup.findMany({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||
isNull(drizzleDb.schemas.backup.deletedAt)
|
||||
),
|
||||
orderBy: [desc(drizzleDb.schemas.backup.createdAt)],
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: backups });
|
||||
} catch (error) {
|
||||
log.error({ error }, "Error in GET /api/v1/databases/[id]/backup");
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const POST = withApiKey(
|
||||
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||
try {
|
||||
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||
|
||||
if (!guard.ok) {
|
||||
return guard.response;
|
||||
}
|
||||
|
||||
const { id } = guard.data;
|
||||
|
||||
const existingBackup = await db.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
|
||||
),
|
||||
});
|
||||
|
||||
if (existingBackup) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"A backup is already waiting or ongoing for this database",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const [createdBackup] = await db
|
||||
.insert(drizzleDb.schemas.backup)
|
||||
.values({
|
||||
databaseId: id,
|
||||
status: "waiting",
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!createdBackup) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create backup" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: createdBackup }, { status: 201 });
|
||||
} catch (error) {
|
||||
log.error({ error }, "Error in POST /api/v1/databases/[id]/backup");
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,122 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {and, eq, inArray, isNull} from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||
import { parseJsonBody } from "@/lib/api-v1/validation/json-body";
|
||||
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||
|
||||
const log = logger.child({ module: "api/v1/databases/[id]/restore" });
|
||||
|
||||
const RestoreSchema = z.object({
|
||||
backupId: z.string().uuid("backupId must be a valid UUID"),
|
||||
backupStorageId: z.string().uuid("backupStorageId must be a valid UUID"),
|
||||
});
|
||||
|
||||
export const POST = withApiKey(
|
||||
async (req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||
try {
|
||||
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||
|
||||
if (!guard.ok) {
|
||||
return guard.response;
|
||||
}
|
||||
|
||||
const { id } = guard.data;
|
||||
|
||||
const body = await parseJsonBody(req, RestoreSchema);
|
||||
|
||||
if (!body.ok) {
|
||||
return body.response;
|
||||
}
|
||||
|
||||
const { backupId, backupStorageId } = body.data;
|
||||
|
||||
const backupRecord = await db.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.id, backupId),
|
||||
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||
isNull(drizzleDb.schemas.backup.deletedAt)
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!backupRecord) {
|
||||
return NextResponse.json(
|
||||
{ error: "Backup not found for this database" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const backupStorage = await db.query.backupStorage.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
|
||||
eq(drizzleDb.schemas.backupStorage.backupId, backupId),
|
||||
isNull(drizzleDb.schemas.backupStorage.deletedAt)
|
||||
),
|
||||
});
|
||||
|
||||
if (!backupStorage) {
|
||||
return NextResponse.json(
|
||||
{ error: "Backup storage not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (backupStorage.status !== "success") {
|
||||
return NextResponse.json(
|
||||
{ error: "Backup storage is not in a successful state" },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const existingRestorationBackup = await db.query.restoration.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.restoration.databaseId, id),
|
||||
inArray(drizzleDb.schemas.restoration.status, ["waiting", "ongoing"])
|
||||
),
|
||||
});
|
||||
|
||||
if (existingRestorationBackup) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"A restoration is already waiting or ongoing for this database",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const [restoration] = await db
|
||||
.insert(drizzleDb.schemas.restoration)
|
||||
.values({
|
||||
databaseId: id,
|
||||
backupId,
|
||||
backupStorageId,
|
||||
status: "waiting",
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!restoration) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create restoration" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: restoration }, { status: 201 });
|
||||
} catch (error) {
|
||||
log.error({ error }, "Error in POST /api/v1/databases/[id]/restore");
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||
|
||||
const log = logger.child({ module: "api/v1/databases/[id]" });
|
||||
|
||||
export const GET = withApiKey(
|
||||
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||
try {
|
||||
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||
|
||||
if (!guard.ok) {
|
||||
return guard.response;
|
||||
}
|
||||
|
||||
const { id } = guard.data;
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.database.id, id),
|
||||
isNull(drizzleDb.schemas.database.deletedAt)
|
||||
),
|
||||
});
|
||||
|
||||
if (!database) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: database });
|
||||
} catch (error) {
|
||||
log.error({ error }, "Error in GET /api/v1/databases/[id]");
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||
|
||||
const log = logger.child({ module: "api/v1/databases/[id]/status" });
|
||||
|
||||
export const GET = withApiKey(
|
||||
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||
try {
|
||||
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||
|
||||
if (!guard.ok) {
|
||||
return guard.response;
|
||||
}
|
||||
|
||||
const { id } = guard.data;
|
||||
|
||||
const [database, latestBackup, latestRestoration] = await Promise.all([
|
||||
db.query.database.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.database.id, id),
|
||||
isNull(drizzleDb.schemas.database.deletedAt)
|
||||
),
|
||||
}),
|
||||
|
||||
db.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||
isNull(drizzleDb.schemas.backup.deletedAt)
|
||||
),
|
||||
orderBy: [desc(drizzleDb.schemas.backup.createdAt)],
|
||||
}),
|
||||
|
||||
db.query.restoration.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.restoration.databaseId, id),
|
||||
isNull(drizzleDb.schemas.restoration.deletedAt)
|
||||
),
|
||||
orderBy: [desc(drizzleDb.schemas.restoration.createdAt)],
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!database) {
|
||||
return NextResponse.json(
|
||||
{ error: "Database not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
isWaitingForBackup: database.isWaitingForBackup,
|
||||
lastContact: database.lastContact,
|
||||
latestBackup: latestBackup ?? null,
|
||||
latestRestoration: latestRestoration ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.error({ error }, "Error in GET /api/v1/databases/[id]/status");
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||
import { getAccessibleDatabases } from "@/lib/api-v1/services/databases";
|
||||
|
||||
const log = logger.child({ module: "api/v1/databases" });
|
||||
|
||||
export const GET = withApiKey(async (_req: Request, ctx: ApiKeyContext) => {
|
||||
try {
|
||||
const databases = await getAccessibleDatabases(ctx.user);
|
||||
|
||||
return NextResponse.json({
|
||||
data: databases,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error({ error }, "Error in GET /api/v1/databases");
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Portabase API Docs</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui.css"
|
||||
integrity="sha384-9Q2fpS+xeS4ffJy6CagnwoUl+4ldAYhOs9pgZuEKxypVModhmZFzeMlvVsAjf7uT"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui-bundle.js" integrity="sha384-EYdOaiRwn44zNjrw+Tfs06qYz9BGQVo2f4/pLY5i7VorbjnZNhdplAbTBk8FXHUJ" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui-standalone-preset.js" integrity="sha384-49fpFaVrAWI/qdgl9Vv5E/4NXxRUiJX5vGuLws1NUpTWGtEqzWEx8gHTw2UTehFK" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
SwaggerUIBundle({
|
||||
url: "/api/v1/openapi",
|
||||
dom_id: "#swagger-ui",
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset,
|
||||
],
|
||||
layout: "StandaloneLayout",
|
||||
persistAuthorization: true,
|
||||
filter: true,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export function GET() {
|
||||
return new Response(html, {
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildSpec } from "@/lib/api-v1/openapi/spec";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(buildSpec());
|
||||
}
|
||||
+30
-82
@@ -1,85 +1,33 @@
|
||||
// "use client"
|
||||
//
|
||||
// import {useSearchParams} from "next/navigation"
|
||||
//
|
||||
// enum Error {
|
||||
// Configuration = "Configuration",
|
||||
// }
|
||||
//
|
||||
// const errorMap = {
|
||||
// [Error.Configuration]: (
|
||||
// <p>
|
||||
// There was a problem when trying to authenticate. Please contact us if this
|
||||
// error persists. Unique error code:{" "}
|
||||
// <code className="rounded-sm bg-slate-100 p-1 text-xs">Configuration</code>
|
||||
// </p>
|
||||
// ),
|
||||
// }
|
||||
//
|
||||
// export default function AuthErrorPage() {
|
||||
// const search = useSearchParams()
|
||||
// const error = search.get("error") as Error
|
||||
//
|
||||
// return (
|
||||
// <div className="flex h-screen w-full flex-col items-center justify-center">
|
||||
// <a
|
||||
// href="#"
|
||||
// className="block max-w-sm rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
// >
|
||||
// <h5 className="mb-2 flex flex-row items-center justify-center gap-2 text-xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
// Something went wrong
|
||||
// </h5>
|
||||
// <div className="font-normal text-gray-700 dark:text-gray-400">
|
||||
// {errorMap[error] || "Please contact us if this error persists."}
|
||||
// </div>
|
||||
// </a>
|
||||
// </div>
|
||||
// )
|
||||
// }
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import React, { Suspense } from "react"
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useSession } from "@/lib/auth/auth-client";
|
||||
|
||||
enum Error {
|
||||
Configuration = "Configuration",
|
||||
export default function ErrorPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = searchParams.get("error");
|
||||
const params = new URLSearchParams();
|
||||
if (error) {
|
||||
params.set("error", error);
|
||||
}
|
||||
|
||||
const destination = session ? "/dashboard/home" : "/login";
|
||||
router.replace(`${destination}?${params.toString()}`);
|
||||
}, [isPending, session, router, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const errorMap = {
|
||||
[Error.Configuration]: (
|
||||
<p>
|
||||
There was a problem when trying to authenticate. Please contact us if this
|
||||
error persists. Unique error code:{" "}
|
||||
<code className="rounded-sm bg-slate-100 p-1 text-xs">Configuration</code>
|
||||
</p>
|
||||
),
|
||||
}
|
||||
|
||||
function AuthErrorPageContent() {
|
||||
const search = useSearchParams()
|
||||
const error = search.get("error") as Error
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center">
|
||||
<a
|
||||
href="#"
|
||||
className="block max-w-sm rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
>
|
||||
<h5 className="mb-2 flex flex-row items-center justify-center gap-2 text-xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
Something went wrong
|
||||
</h5>
|
||||
<div className="font-normal text-gray-700 dark:text-gray-400">
|
||||
{errorMap[error] || "Please contact us if this error persists."}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<AuthErrorPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
+26
-11
@@ -6,8 +6,11 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-sans: var(--font-poppins), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-title: var(--font-poppins), var(--font-poppins), sans-serif;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-author: var(--font-author);
|
||||
--font-poppins: var(--font-poppins);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -71,7 +74,7 @@
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary: oklch(0.611 0.204 42.149);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
@@ -82,7 +85,7 @@
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--ring: oklch(0.611 0.204 42.149);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
@@ -90,12 +93,12 @@
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary: oklch(0.611 0.204 42.149);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-ring: oklch(0.611 0.204 42.149);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -105,8 +108,8 @@
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--primary: oklch(0.704 0.191 47.132);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
@@ -116,7 +119,7 @@
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--ring: oklch(0.704 0.191 47.132);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
@@ -124,12 +127,12 @@
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary: oklch(0.704 0.191 47.132);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-ring: oklch(0.704 0.191 47.132);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -137,6 +140,18 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-title;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
+33
-29
@@ -1,38 +1,42 @@
|
||||
import React from "react";
|
||||
import type {Metadata} from "next";
|
||||
import type { Metadata } from "next";
|
||||
import type React from "react";
|
||||
import "./globals.css";
|
||||
import {Providers} from "./providers";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
|
||||
import {inter} from "@/fonts/fonts";
|
||||
import { ConsoleSilencer } from "@/components/common/console-silencer";
|
||||
import { author, geistMono, poppins } from "@/fonts/fonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const title = process.env.PROJECT_NAME ?? "App Title";
|
||||
const title = process.env.PROJECT_NAME ?? "Portabase";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: title,
|
||||
template: `%s - ${title}`
|
||||
},
|
||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||
title: {
|
||||
default: title,
|
||||
template: `%s - ${title}`,
|
||||
},
|
||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content={title}/>
|
||||
</head>
|
||||
<body className={cn(inter.className, "h-full")}>
|
||||
<ConsoleSilencer/>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content={title} />
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
poppins.variable,
|
||||
author.variable,
|
||||
geistMono.variable,
|
||||
"font-sans h-full",
|
||||
)}
|
||||
>
|
||||
<ConsoleSilencer />
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+15
-11
@@ -1,15 +1,19 @@
|
||||
import BackButton from "@/components/wrappers/common/button/back-button";
|
||||
import BackButton from "@/components/common/back-button";
|
||||
|
||||
export default async function NotFound() {
|
||||
return(
|
||||
<div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
|
||||
<div className="w-full space-y-6 text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
|
||||
</div>
|
||||
<BackButton>Go home</BackButton>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
|
||||
<div className="w-full space-y-6 text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
Not found
|
||||
</h1>
|
||||
<p className="leading-7 not-first:mt-6">
|
||||
The content you are trying to view is not available.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
<BackButton>Go home</BackButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+19
-22
@@ -1,31 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import {PropsWithChildren, Suspense} from "react";
|
||||
import {ThemeProvider} from "@/features/theme/theme-provider";
|
||||
|
||||
import {Toaster} from "@/components/ui/sonner";
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
|
||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { type PropsWithChildren, Suspense } from "react";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ErrorLayout } from "@/components/common/error-layout";
|
||||
import { ThemeMetaUpdaterRoot } from "@/features/theme/theme-meta-updater-root";
|
||||
import { ThemeProvider } from "@/features/theme/theme-provider";
|
||||
|
||||
export type ProviderProps = PropsWithChildren<{}>;
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export const Providers = (props: ProviderProps) => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
<ThemeMetaUpdaterRoot/>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster/>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
);
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeMetaUpdaterRoot />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorLayout>
|
||||
<Toaster />
|
||||
{props.children}
|
||||
</ErrorLayout>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: prod
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
TZ: "Europe/Paris"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- portabase-e2e-data:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:18-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-e2e-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=devdb
|
||||
- POSTGRES_USER=devuser
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
portabase-e2e-data:
|
||||
postgres-e2e-data:
|
||||
@@ -0,0 +1,35 @@
|
||||
name: portabase-dev-func
|
||||
|
||||
services:
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:latest
|
||||
command: start-dev --import-realm
|
||||
# environment:
|
||||
# KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
# KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
ports:
|
||||
- "3056:8080"
|
||||
volumes:
|
||||
- keycloak-data:/opt/keycloak/data
|
||||
- ./seeds/keycloak:/opt/keycloak/data/import:ro
|
||||
- ./export:/tmp/export
|
||||
pocket-id:
|
||||
image: ghcr.io/pocket-id/pocket-id
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- APP_URL=http://localhost:3055
|
||||
- ENCRYPTION_KEY=QwHyjbZvSsDUAcjpdmSPsuYxaH6vET6OeBaeLwXccCb43L6Om3W1AoU5pKIJTzYr
|
||||
ports:
|
||||
- 3055:1411
|
||||
volumes:
|
||||
- pocket-id-data:/app/data
|
||||
- ./seeds/pocket-id:/seed:ro
|
||||
healthcheck:
|
||||
test: "curl -f http://localhost:1411/healthz"
|
||||
interval: 1m30s
|
||||
timeout: 5s
|
||||
retries: 2
|
||||
start_period: 10s
|
||||
volumes:
|
||||
keycloak-data:
|
||||
pocket-id-data:
|
||||
+14
-8
@@ -1,23 +1,28 @@
|
||||
name: portabase-prod
|
||||
|
||||
services:
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: prod
|
||||
# image: solucetechnologies/portabase:latest
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: portabase/portabase:1
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
TZ: "Europe/Paris"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- portabase-data:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
container_name: portabase-app-prod
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
@@ -36,4 +41,5 @@ services:
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
portabase-data:
|
||||
postgres-data:
|
||||
|
||||
@@ -30,6 +30,16 @@ services:
|
||||
- "localhost:host-gateway"
|
||||
volumes:
|
||||
- ./private/uploads/tmp:/data/uploads/tmp
|
||||
user: "1000:1000"
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
container_name: portabase-mailpit-dev
|
||||
hostname: mailpit
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8025:8025"
|
||||
- "1025:1025"
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#FROM node:22-bullseye AS base
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
@@ -18,6 +17,21 @@ RUN apt-get update && apt-get install -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
RUN mkdir -p /etc/apt/keyrings \
|
||||
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
|
||||
> /etc/apt/sources.list.d/pgdg.list
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
postgresql-18 \
|
||||
postgresql-client-18 \
|
||||
postgresql-contrib-18 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
|
||||
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
|
||||
|
||||
ARG TUSD_VERSION=2.8.0
|
||||
@@ -76,6 +90,9 @@ ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=80
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV PGDATA=/data/postgres
|
||||
ENV PRIVATE_PATH=/data/private
|
||||
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
@@ -95,13 +112,13 @@ RUN chmod +x /usr/local/bin/tusd
|
||||
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
|
||||
RUN mkdir -p .next /app/private/uploads \
|
||||
&& chown -R nextjs:nodejs .next /app/private /app/public
|
||||
RUN mkdir -p .next /data/private/uploads \
|
||||
&& chown -R nextjs:nodejs .next /data/private /app/public
|
||||
|
||||
|
||||
USER root
|
||||
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
|
||||
RUN chmod +x /app/app-prod-entrypoint.sh
|
||||
|
||||
EXPOSE 80
|
||||
#USER nextjs
|
||||
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
||||
|
||||
@@ -7,9 +7,72 @@ else
|
||||
echo "[WARN] No TZ provided, using default container timezone"
|
||||
fi
|
||||
|
||||
mkdir -p /app/private/uploads/tmp
|
||||
POSTGRES_BIN=$(ls -d /usr/lib/postgresql/*/bin | head -n 1)
|
||||
|
||||
if [ -z "$POSTGRES_BIN" ]; then
|
||||
echo "PostgreSQL binaries not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PATH="$POSTGRES_BIN:$PATH"
|
||||
|
||||
if [ -z "$DATABASE_URL" ]; then
|
||||
echo "[INFO] No DATABASE_URL provided, starting internal Postgres..."
|
||||
|
||||
mkdir -p "$PGDATA"
|
||||
chown -R postgres:postgres "$PGDATA"
|
||||
|
||||
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
||||
echo "[INFO] Initializing database cluster..."
|
||||
if ! su postgres -c "initdb -D '$PGDATA'" > /dev/null 2>&1; then
|
||||
echo "[ERROR] initdb failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! su postgres -c "pg_ctl -D '$PGDATA' \
|
||||
-o \"-c listen_addresses='localhost' -c logging_collector=on\" \
|
||||
-l $PGDATA/postgres.log -w start" > /dev/null 2>&1; then
|
||||
echo "[ERROR] PostgreSQL failed to start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
until su postgres -c "pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "[INFO] PostgreSQL server is up and accepting connections"
|
||||
|
||||
DB_USER="${POSTGRES_USER:-portabase_user}"
|
||||
DB_PASS="${POSTGRES_PASSWORD:-JaB6b1SUtIWYvt7srnOt}"
|
||||
DB_NAME="${POSTGRES_DB:-portabase_db}"
|
||||
|
||||
USER_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" 2>/dev/null)
|
||||
if [ "$USER_EXISTS" != "1" ]; then
|
||||
if ! su postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\"" > /dev/null 2>&1; then
|
||||
echo "[ERROR] Failed creating user"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
DB_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" 2>/dev/null)
|
||||
if [ "$DB_EXISTS" != "1" ]; then
|
||||
if ! su postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\"" > /dev/null 2>&1; then
|
||||
echo "[ERROR] Failed creating database"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
export DATABASE_URL="postgres://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME"
|
||||
|
||||
echo "[SUCCESS] Internal PostgreSQL started successfully"
|
||||
echo "[SUCCESS] Database: $DB_NAME | User: $DB_USER | Host: 127.0.0.1:5432"
|
||||
fi
|
||||
|
||||
|
||||
mkdir -p /data/private/uploads/tmp
|
||||
echo "▶ Starting tusd server..."
|
||||
tusd --base-path /tus/files/ --upload-dir /app/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 &
|
||||
tusd --base-path /tus/files/ --upload-dir /data/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 &
|
||||
|
||||
echo "▶ Starting Next.js server..."
|
||||
PORT=3000 node server.js &
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {users} from "./helpers/auth";
|
||||
import {changeUserRole, create, switchToDefault} from "./helpers/access-management";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const firstOrganization = "Organization A";
|
||||
const secondOrganization = "Organization B";
|
||||
|
||||
test.describe.serial(() => {
|
||||
test("Create an organization from organizations page", async ({page}) => {
|
||||
await page.goto("/dashboard/admin/organizations");
|
||||
await expect(page.getByRole("heading", {name: "Active organizations"})).toBeVisible();
|
||||
|
||||
await create(page, "button", firstOrganization);
|
||||
|
||||
await expect(page.getByText(/Organization has been successfully created\./i)).toBeVisible();
|
||||
await expect(page).toHaveURL("/dashboard/admin/organizations");
|
||||
await expect(page.getByRole("button", {name: firstOrganization, exact: true})).toBeVisible();
|
||||
|
||||
await switchToDefault(page);
|
||||
});
|
||||
|
||||
test("Create an organization from sidebar button", async ({page}) => {
|
||||
await page.goto("/dashboard/home");
|
||||
await expect(page.getByRole("heading", {name: "Dashboard"})).toBeVisible();
|
||||
|
||||
await create(page, "sidebar", secondOrganization);
|
||||
|
||||
await expect(page.getByText(/Organization has been successfully created\./i)).toBeVisible();
|
||||
await expect(page).toHaveURL("/dashboard/home");
|
||||
await expect(page.getByRole("button", {name: secondOrganization, exact: true})).toBeVisible();
|
||||
|
||||
await switchToDefault(page);
|
||||
});
|
||||
|
||||
test("Change John Doe's role from pending to user", async ({page}) => {
|
||||
await page.goto("/dashboard/admin/users");
|
||||
await expect(page.getByText(users.normal.email, {exact: true})).toBeVisible();
|
||||
|
||||
const userRow = page.locator("tr").filter({hasText: users.normal.email}).first();
|
||||
await expect(userRow).toBeVisible();
|
||||
await userRow.locator("button").last().click();
|
||||
await page.getByRole('menuitem', {name: 'Role'}).click();
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Change the user's role"})).toBeVisible();
|
||||
|
||||
await changeUserRole(page, "user")
|
||||
|
||||
await expect(page.getByText("User role changed successfully.")).toBeVisible();
|
||||
await expect(page.locator("tr").filter({hasText: users.normal.email}).getByText("user", {exact: true})).toBeVisible();
|
||||
});
|
||||
|
||||
test("Add John Doe to Organization A", async ({page}) => {
|
||||
await page.goto("/dashboard/admin/organizations");
|
||||
await expect(page.getByRole("heading", {name: "Active organizations"})).toBeVisible();
|
||||
|
||||
const organizationRow = page.locator("tr").filter({hasText: firstOrganization}).first();
|
||||
await expect(organizationRow).toBeVisible();
|
||||
await organizationRow.locator('a[href*="/dashboard/admin/organizations/"], a[href*="organizations/"]').click();
|
||||
|
||||
await expect(page.getByText(firstOrganization, {exact: true})).toBeVisible();
|
||||
await page.getByRole("button", {name: /Add member/i}).click();
|
||||
await expect(page.getByRole("heading", {name: "Add member to your organization"})).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Enter a user email").fill(users.normal.email);
|
||||
await page.getByRole("option", {name: new RegExp(users.normal.email, "i")}).click();
|
||||
await page.getByRole("button", {name: "Confirm"}).click();
|
||||
|
||||
await expect(page.getByText("Member successfully added!")).toBeVisible();
|
||||
await expect(page.getByText(users.normal.email, {exact: true})).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {execSync} from "child_process";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import {createAgentWithDockerDatabases} from "./helpers/agent-cli";
|
||||
import {create, edit, get, remove} from "./helpers/agent";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
const agent = {
|
||||
aName: "Agent A",
|
||||
aUpdatedName: "Agent A Updated",
|
||||
bName: "Agent B",
|
||||
description: "Agent created by Playwright E2E",
|
||||
updatedDescription: "Agent updated by Playwright E2E",
|
||||
};
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
test.describe.serial(() => {
|
||||
let agentWorkspace: string | null = null;
|
||||
|
||||
test("Create agent A from empty state", async ({page}) => {
|
||||
await page.goto("/dashboard/agents");
|
||||
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
await expect(page.getByText("Create new Agent", {exact: true})).toBeVisible();
|
||||
await create(page, "emptyState", agent.aName, agent.description);
|
||||
|
||||
await expect(page.getByText("Success creating agent")).toBeVisible();
|
||||
await expect(get(page, agent.aName)).toBeVisible();
|
||||
await expect(page.getByText("Create new Agent", {exact: true})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Edit agent A", async ({page}) => {
|
||||
await page.goto("/dashboard/agents");
|
||||
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
await expect(get(page, agent.aName)).toBeVisible();
|
||||
|
||||
await edit(page, agent.aName, agent.aUpdatedName, agent.updatedDescription);
|
||||
|
||||
await expect(page.getByText("Success updating agent")).toBeVisible();
|
||||
await expect(page.getByText(agent.aUpdatedName, {exact: true})).toBeVisible();
|
||||
await expect(page.getByText(agent.updatedDescription, {exact: true})).toBeVisible();
|
||||
});
|
||||
|
||||
test("Create agent B from classic button", async ({page}) => {
|
||||
await page.goto("/dashboard/agents");
|
||||
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
await expect(page.getByRole("button", {name: /Create Agent/i})).toBeVisible();
|
||||
await create(page, "button", agent.bName, agent.description);
|
||||
|
||||
await expect(page.getByText("Success creating agent")).toBeVisible();
|
||||
await expect(get(page, agent.bName)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Delete Agent B", async ({page}) => {
|
||||
await page.goto("/dashboard/agents");
|
||||
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
await expect(get(page, agent.bName)).toBeVisible();
|
||||
await remove(page, agent.bName);
|
||||
|
||||
await expect(page.getByText("Agent has been successfully deleted.")).toBeVisible();
|
||||
await expect(page).toHaveURL("/dashboard/agents");
|
||||
await expect(page.getByText(agent.bName)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// test("Launch the updated agent", async ({page}) => {
|
||||
// await page.goto("/dashboard/agents");
|
||||
// await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
// await get(page, agent.aName).click();
|
||||
//
|
||||
// await expect(page).toHaveURL(/\/dashboard\/agents\/.+/);
|
||||
// await expect(page.getByText(agent.aName, {exact: true})).toBeVisible();
|
||||
// await expect(page.getByText("Registration & Setup")).toBeVisible();
|
||||
//
|
||||
// const commandInput = page.locator("input[readonly]").first();
|
||||
// await page.locator("input[readonly]").first().locator("xpath=following-sibling::button[1]").click();
|
||||
// const command = await commandInput.inputValue();
|
||||
//
|
||||
// agentWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "portabase-agent-"));
|
||||
// await createAgentWithDockerDatabases(command, agentWorkspace);
|
||||
// execSync(`portabase start "${agent.aName}"`, {
|
||||
// cwd: agentWorkspace,
|
||||
// stdio: "pipe",
|
||||
// timeout: 120_000,
|
||||
// });
|
||||
//
|
||||
// await expect(page.getByText("Never connected.")).toHaveCount(0, {timeout: 120_000});
|
||||
// await expect(page.getByText("Action Required")).toHaveCount(0);
|
||||
// });
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (agentWorkspace) {
|
||||
try {
|
||||
execSync(`portabase stop "${agent.aName}"`, {
|
||||
cwd: agentWorkspace,
|
||||
stdio: "pipe",
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`portabase uninstall --force "${agent.aName}"`, {
|
||||
cwd: agentWorkspace,
|
||||
stdio: "pipe",
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
fs.rmSync(agentWorkspace, {recursive: true, force: true});
|
||||
agentWorkspace = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, register, users} from "./helpers/auth";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
const TIMEOUT = undefined
|
||||
// const TIMEOUT = 5000
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH})
|
||||
|
||||
test.describe.serial( () => {
|
||||
|
||||
test('Redirect to login if not connected', async ({page}) => {
|
||||
await page.goto('/dashboard/projects');
|
||||
await expect(page).toHaveURL("login?redirect=%2Fdashboard%2Fprojects", {timeout: TIMEOUT});
|
||||
});
|
||||
|
||||
test('Password too short', async ({page}) => {
|
||||
await page.goto('')
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
let password = '123456'
|
||||
await register(page, users["admin"].username, users["admin"].email, password, password)
|
||||
|
||||
const toast = page.locator('text=Must have at least 8 character')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Password too simple', async ({page}) => {
|
||||
await page.goto('')
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
let password = '12345678'
|
||||
await register(page, users["admin"].username, users["admin"].email, password, password)
|
||||
|
||||
const toast = page.locator('text=Your password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Password and confirm password mismatch', async ({page}) => {
|
||||
await page.goto('/')
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
let password = 'testPASS123456!'
|
||||
let confirmPassword = 'testPASS123456!!'
|
||||
await register(page, users["admin"].username, users["admin"].email, password, confirmPassword)
|
||||
|
||||
const toast = page.locator('text=The passwords did not match')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Successful register for admin', async ({page}) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
await register(page, users["admin"].username, users["admin"].email, users["admin"].password, users["admin"].password)
|
||||
|
||||
await expect(page).toHaveURL('/login', {timeout: TIMEOUT})
|
||||
})
|
||||
|
||||
test('User already exists.', async ({page}) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
await register(page, users["admin"].username, users["admin"].email, users["admin"].password, users["admin"].password)
|
||||
|
||||
const toast = page.locator('text=User already exists. Use another email.')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Successful register for normal', async ({page}) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
await register(page, users["normal"].username, users["normal"].email, users["normal"].password, users["normal"].password)
|
||||
|
||||
await expect(page).toHaveURL('/login', {timeout: TIMEOUT})
|
||||
})
|
||||
|
||||
test('Failed login because account not active', async ({page}) => {
|
||||
await page.goto('/login')
|
||||
await login(page, users["normal"].email, users["normal"].password)
|
||||
|
||||
const toast = page.locator('text=Your account is not active.')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Successful login', async ({page}) => {
|
||||
await page.goto('/login')
|
||||
await login(page, users["admin"].email, users["admin"].password)
|
||||
|
||||
await expect(page).toHaveURL('/dashboard/home', {timeout: TIMEOUT})
|
||||
await expect(page.getByRole('link', {name: 'Logo Portabase'})).toBeVisible()
|
||||
await page.context().storageState({path: LOCAL_STORAGE_PATH})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import fs from "fs";
|
||||
import {logout} from "./helpers/auth";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
test.describe( () => {
|
||||
test.afterAll(async () => {
|
||||
if (fs.existsSync(LOCAL_STORAGE_PATH)) {
|
||||
fs.unlinkSync(LOCAL_STORAGE_PATH);
|
||||
}
|
||||
});
|
||||
|
||||
test("Remove shared storage state", async ({page}) => {
|
||||
await test.step("Logout shared authenticated session", async () => {
|
||||
if (!fs.existsSync(LOCAL_STORAGE_PATH)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(LOCAL_STORAGE_PATH, "utf-8").trim();
|
||||
if (!content || content === "{}") {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto('/dashboard/home');
|
||||
await logout(page);
|
||||
await expect(page).toHaveURL(/\/login(?:\?.*)?$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
export function getUserRow(page: Page, email: string) {
|
||||
return page.locator("tr").filter({hasText: email}).first();
|
||||
}
|
||||
|
||||
export function getOrganizationRow(page: Page, organizationName: string) {
|
||||
return page.locator("tr").filter({hasText: organizationName}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an organization from the selected entrypoint.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `button`: the organizations page create button.
|
||||
* - `sidebar`: the sidebar organization switcher.
|
||||
*
|
||||
* Executes from:
|
||||
* - `/dashboard/admin/organizations` for "button" entrypoint
|
||||
* - any `/dashboard/**` page for "sidebar" entrypoint
|
||||
*/
|
||||
export async function create(page: Page, entrypoint: "button" | "sidebar", name: string) {
|
||||
if (entrypoint === "sidebar") {
|
||||
await page.getByRole("button", {name: "Default Organization"}).click();
|
||||
await page.getByText("Create organization", {exact: true}).click();
|
||||
} else {
|
||||
await page.getByRole("button", {name: /Create a new organization/i}).click();
|
||||
}
|
||||
|
||||
await page.getByLabel("Name").fill(name);
|
||||
await page.getByRole("button", {name: "Create"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active organization from the sidebar switcher.
|
||||
*
|
||||
* Executes from: any `/dashboard/**` if the sidebar is visible.
|
||||
*/
|
||||
export async function switchTo(page: Page, name: string) {
|
||||
await page.getByTestId('organization-dropdown').click();
|
||||
await page.getByRole('menuitem', {name: name}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch back to the default organization.
|
||||
*
|
||||
* Executes from: any `/dashboard/**` if the sidebar is visible.
|
||||
*/
|
||||
export async function switchToDefault(page: Page) {
|
||||
await switchTo(page, "Default Organization");
|
||||
}
|
||||
|
||||
const ROLE_LABELS = {
|
||||
pending: "Pending",
|
||||
user: "User",
|
||||
admin: "Admin",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Change the global role of a user from the admin users role modal.
|
||||
*
|
||||
* Executes from: the "Change the user's role" dialog opened from `/dashboard/admin/users`.
|
||||
*/
|
||||
export async function changeUserRole(page: Page, role: keyof typeof ROLE_LABELS) {
|
||||
const roleOption = ROLE_LABELS[role];
|
||||
|
||||
await page.getByRole("combobox").click();
|
||||
await page.getByRole("option", {name: roleOption}).click();
|
||||
await page.getByRole("button", {name: "Validate"}).click();
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as pty from "node-pty";
|
||||
|
||||
type InteractiveStep = {
|
||||
match: RegExp;
|
||||
reply: string;
|
||||
};
|
||||
|
||||
function normalizeOutput(output: string) {
|
||||
return output.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an interactive CLI command and answer prompts in sequence.
|
||||
* Entry point:
|
||||
* - `command` is the full CLI command executed in the PTY.
|
||||
* - `cwd` is the local workspace where the command is launched.
|
||||
* - `steps` defines which prompt text is matched and which reply is sent.
|
||||
* - `timeoutMs` controls when the command is aborted if it stalls.
|
||||
* Executes from: not page-bound; runs from a local test workspace on the Node.js side.
|
||||
*/
|
||||
export async function runInteractiveCommand(
|
||||
command: string,
|
||||
cwd: string,
|
||||
steps: InteractiveStep[],
|
||||
timeoutMs: number = 120_000,
|
||||
) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = pty.spawn("sh", ["-lc", command], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
},
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
});
|
||||
|
||||
let currentStep = 0;
|
||||
let output = "";
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error(`Interactive command timed out.\n\nCommand: ${command}\n\nOutput:\n${normalizeOutput(output)}`));
|
||||
}, timeoutMs);
|
||||
|
||||
const handleChunk = (chunk: string) => {
|
||||
output += chunk;
|
||||
const normalizedOutput = normalizeOutput(output);
|
||||
|
||||
while (currentStep < steps.length && steps[currentStep].match.test(normalizedOutput)) {
|
||||
child.write(steps[currentStep].reply);
|
||||
currentStep += 1;
|
||||
}
|
||||
};
|
||||
|
||||
child.onData(handleChunk);
|
||||
child.onExit(({exitCode}) => {
|
||||
clearTimeout(timer);
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`Interactive command failed with exit code ${exitCode}.\n\nCommand: ${command}\n\nOutput:\n${normalizeOutput(output)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure a Portabase agent with two Docker-backed databases.
|
||||
* Entry point:
|
||||
* - `command` is the CLI setup command copied from the agent details page.
|
||||
* - `cwd` is the local workspace where the agent is installed.
|
||||
* - this helper answers the wizard for PostgreSQL first, then MariaDB.
|
||||
* Executes from: not page-bound; runs from a local test workspace on the Node.js side.
|
||||
*/
|
||||
export async function createAgentWithDockerDatabases(command: string, cwd: string) {
|
||||
await runInteractiveCommand(command, cwd, [
|
||||
{
|
||||
match: /configure.*database|add.*database/i,
|
||||
reply: "y\n",
|
||||
},
|
||||
{
|
||||
match: /docker.*new local container|manual.*external|external.*existing/i,
|
||||
reply: "\r",
|
||||
},
|
||||
{
|
||||
match: /postgresql|mariadb/i,
|
||||
reply: "\r",
|
||||
},
|
||||
{
|
||||
match: /another.*database|add.*another|configure.*another/i,
|
||||
reply: "y\n",
|
||||
},
|
||||
{
|
||||
match: /docker.*new local container|manual.*external|external.*existing/i,
|
||||
reply: "\r",
|
||||
},
|
||||
{
|
||||
match: /postgresql|mariadb/i,
|
||||
reply: "\u001B[B\r",
|
||||
},
|
||||
{
|
||||
match: /another.*database|add.*another|configure.*another/i,
|
||||
reply: "n\n",
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
|
||||
/**
|
||||
* Locate an agent card in the list.
|
||||
|
||||
* Executes from: `/dashboard/agents`.
|
||||
*/
|
||||
export function get(page: Page, name: string) {
|
||||
return page.locator('a[href^="/dashboard/agents"]').filter({hasText: name}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent from the selected entrypoint.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `button`: the classic create button.
|
||||
* - `emptyState`: the empty-state CTA.
|
||||
* - `auto`: uses the classic create button and falls back to the empty-state CTA.
|
||||
*
|
||||
* Executes from: `/dashboard/agents`.
|
||||
*/
|
||||
export async function create(page: Page, entrypoint: "auto" | "emptyState" | "button" = "auto", agentName: string, description: string) {
|
||||
if (entrypoint === "auto") {
|
||||
const createButton = page.getByRole("button", {name: /Create Agent/i});
|
||||
if (await createButton.isVisible()) await page.getByRole("button", {name: /Create Agent/i}).click();
|
||||
await page.getByText("Create new Agent", {exact: true}).click();
|
||||
} else if (entrypoint === "button") {
|
||||
await page.getByRole("button", {name: /Create Agent/i}).click();
|
||||
} else if (entrypoint === "emptyState") {
|
||||
await page.getByText("Create new Agent", {exact: true}).click();
|
||||
}
|
||||
|
||||
await page.getByLabel("Name").fill(agentName);
|
||||
await page.getByLabel("Description").fill(description);
|
||||
await page.getByRole("button", {name: "Create"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing agent (name and description) from its details page.
|
||||
*
|
||||
* Executes from: `/dashboard/agents/[agentId]`.
|
||||
*/
|
||||
export async function edit(page: Page, currentName: string, updatedName: string, updatedDescription: string) {
|
||||
await get(page, currentName).click();
|
||||
|
||||
await page
|
||||
.getByRole("button", {name: /Delete Agent/i})
|
||||
.locator("xpath=ancestor::div[1]/preceding-sibling::div[1]/*[1]")
|
||||
.click();
|
||||
|
||||
await page.getByLabel("Name").fill(updatedName);
|
||||
await page.getByLabel("Description").fill(updatedDescription);
|
||||
await page.getByRole("button", {name: "Update"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an agent from its details page.
|
||||
|
||||
* Executes from: `/dashboard/agents/[agentId]`.
|
||||
*/
|
||||
export async function remove(page: Page, name: string) {
|
||||
await get(page, name).click();
|
||||
await page.getByRole("button", {name: /Delete Agent/i}).click();
|
||||
await page.getByRole("button", {name: "Delete", exact: true}).click();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
export type UserCredentials = {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export const users: Record<string, UserCredentials> = {
|
||||
admin: {username: "Admin", email: "admin@example.com", password: "testPASS123456!"},
|
||||
normal: {username: "John Doe", email: "john.doe@example.com", password: "testPASS123456!"},
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill and submit the registration form.
|
||||
*
|
||||
* Executes from: `/register`.
|
||||
*/
|
||||
export async function register(page: Page, name: string, email: string, password: string, confirmPassword: string) {
|
||||
await page.locator('input[name="name"]').fill(name)
|
||||
await page.locator('input[name="email"]').fill(email)
|
||||
await page.locator('input[name="password"]').fill(password)
|
||||
await page.locator('input[name="confirmPassword"]').fill(confirmPassword)
|
||||
|
||||
await page.click('button[type="submit"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill and submit the login form.
|
||||
*
|
||||
* Executes from: `/login`.
|
||||
*/
|
||||
export async function login(page: Page, email: string, password: string) {
|
||||
await page.locator('input[name="email"]').fill(email)
|
||||
await page.locator('input[name="password"]').fill(password)
|
||||
|
||||
await page.locator('button:has-text("Login")').click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out the current authenticated user.
|
||||
*
|
||||
* Executes from: any authenticated `/dashboard/**` page.
|
||||
*/
|
||||
export async function logout(page: Page) {
|
||||
const profileButton = page.getByTestId('profile-dropdown')
|
||||
await profileButton.first().click();
|
||||
|
||||
await page.getByRole("menuitem").filter({hasText: /Logout/i}).click();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
const REQUIRED_E2E_ENV_VARS = [
|
||||
"E2E_NOTIFICATION_SMTP_HOST",
|
||||
"E2E_NOTIFICATION_SMTP_PORT",
|
||||
"E2E_NOTIFICATION_SMTP_USER",
|
||||
"E2E_NOTIFICATION_SMTP_PASSWORD",
|
||||
"E2E_NOTIFICATION_SMTP_FROM",
|
||||
"E2E_NOTIFICATION_SMTP_TO",
|
||||
"E2E_NOTIFICATION_SLACK_WEBHOOK",
|
||||
"E2E_NOTIFICATION_DISCORD_WEBHOOK",
|
||||
"E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN",
|
||||
"E2E_NOTIFICATION_TELEGRAM_CHAT_ID",
|
||||
"E2E_NOTIFICATION_TELEGRAM_TOPIC_ID",
|
||||
"E2E_NOTIFICATION_GOTIFY_SERVER_URL",
|
||||
"E2E_NOTIFICATION_GOTIFY_APP_TOKEN",
|
||||
"E2E_NOTIFICATION_NTFY_TOPIC",
|
||||
"E2E_NOTIFICATION_NTFY_SERVER_URL",
|
||||
"E2E_NOTIFICATION_NTFY_TOKEN",
|
||||
"E2E_NOTIFICATION_NTFY_USERNAME",
|
||||
"E2E_NOTIFICATION_NTFY_PASSWORD",
|
||||
"E2E_NOTIFICATION_WEBHOOK_URL",
|
||||
"E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER",
|
||||
"E2E_NOTIFICATION_WEBHOOK_SECRET",
|
||||
"E2E_STORAGE_AWS_S3_ENDPOINT_URL",
|
||||
"E2E_STORAGE_AWS_S3_REGION",
|
||||
"E2E_STORAGE_AWS_S3_ACCESS_KEY",
|
||||
"E2E_STORAGE_AWS_S3_SECRET_KEY",
|
||||
"E2E_STORAGE_AWS_S3_BUCKET_NAME",
|
||||
"E2E_STORAGE_AWS_S3_PORT",
|
||||
"E2E_STORAGE_R2_ENDPOINT_URL",
|
||||
"E2E_STORAGE_R2_REGION",
|
||||
"E2E_STORAGE_R2_ACCESS_KEY",
|
||||
"E2E_STORAGE_R2_SECRET_KEY",
|
||||
"E2E_STORAGE_R2_BUCKET_NAME",
|
||||
"E2E_STORAGE_R2_PORT",
|
||||
"E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID",
|
||||
"E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET",
|
||||
"E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID",
|
||||
] as const;
|
||||
|
||||
function assertRequiredEnvVars() {
|
||||
const missingVars = REQUIRED_E2E_ENV_VARS.filter((name) => {
|
||||
const value = process.env[name]?.trim();
|
||||
return !value;
|
||||
});
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required environment variables:\n${missingVars.map((name) => `- ${name}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertRequiredEnvVars();
|
||||
|
||||
export function getEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
|
||||
/**
|
||||
* Locate a notification channel card in the list.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export function get(page: Page, channelName: string) {
|
||||
return page.locator('div.block.transition-all.duration-200.rounded-xl', {
|
||||
has: page.locator('h3', {hasText: channelName}),
|
||||
}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for an existing notification channel.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function edit(page: Page, channelName: string) {
|
||||
const card = get(page, channelName);
|
||||
await card.locator("button").nth(1).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing notification channel.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function remove(page: Page, channelName: string) {
|
||||
const card = get(page, channelName);
|
||||
await card.locator("button").nth(2).click();
|
||||
await page.getByRole("button", {name: "Delete"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the notification channel creation form.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `button`: the classic add button.
|
||||
* - `emptyState`: the empty-state CTA.
|
||||
* - `auto` use the classic add button and falls back to the empty-state CTA.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function create(
|
||||
page: Page,
|
||||
provider: "Discord" | "Gotify" | "ntfy.sh" | "Slack" | "Email" | "Telegram" | "Webhook",
|
||||
channelName: string,
|
||||
fillConfig: (page: Page) => Promise<void>,
|
||||
entrypoint: "auto" | "emptyState" | "button" = "auto",
|
||||
) {
|
||||
if (entrypoint === "auto") {
|
||||
const addButton = page.getByRole("button", {name: /Add notification channel/i});
|
||||
if (await addButton.isVisible()) await page.getByRole("button", {name: /Add notification channel/i}).click();
|
||||
else await page.getByRole("button", {name: /No notification channels configured yet/i}).click();
|
||||
} else if (entrypoint === "button") {
|
||||
await page.getByRole("button", {name: /Add notification channel/i}).click();
|
||||
} else if (entrypoint === "emptyState") {
|
||||
await page.getByRole("button", {name: /No notification channels configured yet/i}).click();
|
||||
}
|
||||
|
||||
await page.getByText(provider, {exact: true}).click();
|
||||
await page.getByLabel(/Channel Name/).fill(channelName);
|
||||
await fillConfig(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the notification channel creation form.
|
||||
*
|
||||
* Executes from: the add notification channel dialog opened from `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function submit(page: Page) {
|
||||
await page.getByRole("button", {name: "Add Channel"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for a notification channel and trigger the test action.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function testFromEdit(page: Page, channelName: string) {
|
||||
await edit(page, channelName);
|
||||
await page.getByRole("button", {name: /Test Channel/i}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current notification channel dialog without saving.
|
||||
*
|
||||
* Executes from: the add or edit notification channel dialog.
|
||||
*/
|
||||
export async function cancel(page: Page) {
|
||||
await page.getByRole("button", {name: "Cancel"}).click();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
|
||||
/**
|
||||
* Locate a project card in the list.
|
||||
*
|
||||
* Executes from: `/dashboard/projects`.
|
||||
*/
|
||||
export function get(page: Page, projectName: string) {
|
||||
return page.locator('a[href^="/dashboard/projects/"]').filter({hasText: projectName}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a project from the selected entrypoint.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `emptyState`: the empty-state CTA.
|
||||
* - `button`: the classic create button.
|
||||
*
|
||||
* * Executes from: `/dashboard/projects`.
|
||||
*/
|
||||
export async function create(page: Page, entrypoint: "emptyState" | "button", projectName: string) {
|
||||
if (entrypoint === "emptyState") {
|
||||
await page.getByText("Create new Project", {exact: true}).click();
|
||||
} else {
|
||||
await page.getByRole("button", {name: /Create Project/i}).click();
|
||||
}
|
||||
|
||||
await page.getByLabel("Name").fill(projectName);
|
||||
await page.getByRole("button", {name: "Create"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing project from its details page.
|
||||
*
|
||||
* Executes from: `/dashboard/projects/[projectId]`.
|
||||
*/
|
||||
export async function edit(page: Page, currentName: string, updatedName: string) {
|
||||
await get(page, currentName).click();
|
||||
|
||||
await page
|
||||
.getByRole("button", {name: /Delete Project/i})
|
||||
.locator("xpath=ancestor::div[1]/preceding-sibling::div[1]/*[1]")
|
||||
.click();
|
||||
|
||||
await page.getByLabel("Name").fill(updatedName);
|
||||
await page.getByRole("button", {name: "Update"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing project from its details page.
|
||||
*
|
||||
* Executes from: `/dashboard/projects/[projectId]`.
|
||||
* */
|
||||
export async function remove(page: Page, projectName: string) {
|
||||
await get(page, projectName).click();
|
||||
await page.getByRole("button", {name: /Delete Project/i}).click();
|
||||
await page.getByRole("button", {name: "Delete", exact: true}).click();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const LOCAL_STORAGE_PATH = "./e2e/local-storage.json";
|
||||
@@ -0,0 +1,103 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
|
||||
/**
|
||||
* Locate a storage channel card in the channels list.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export function get(page: Page, channelName: string) {
|
||||
return page.locator('div.block.transition-all.duration-200.rounded-xl', {
|
||||
has: page.locator('h3', {hasText: channelName}),
|
||||
}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the storage channel creation form.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `button`: the classic add button.
|
||||
* - `emptyState`: the empty-state CTA.
|
||||
* - `auto`: uses the classic add button and falls back to the empty-state CTA.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function create(
|
||||
page: Page,
|
||||
provider: "S3" | "Google Drive",
|
||||
channelName: string,
|
||||
fillConfig: (page: Page) => Promise<void>,
|
||||
entrypoint: "auto" | "emptyState" | "button" = "auto",
|
||||
) {
|
||||
if (entrypoint === "auto") {
|
||||
const addButton = page.getByRole("button", {name: /Add storage channel/i});
|
||||
if (await addButton.isVisible()) await page.getByRole("button", {name: /Add storage channel/i}).click();
|
||||
else await page.getByText("No storage channels configured yet", {exact: true}).click();
|
||||
} else if (entrypoint === "button") {
|
||||
await page.getByRole("button", {name: /Add storage channel/i}).click();
|
||||
} else if (entrypoint === "emptyState") {
|
||||
await page.getByText("No storage channels configured yet", {exact: true}).click();
|
||||
}
|
||||
|
||||
await page.getByText(provider, {exact: true}).click();
|
||||
await page.getByLabel(/Channel Name/).fill(channelName);
|
||||
await fillConfig(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for an existing storage channel.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function edit(page: Page, channelName: string) {
|
||||
const card = get(page, channelName);
|
||||
await card.locator("button").nth(1).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing storage channel.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function remove(page: Page, channelName: string) {
|
||||
const card = get(page, channelName);
|
||||
await card.locator("button").nth(2).click();
|
||||
await page.getByRole("button", {name: "Delete"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the storage connection test in the current dialog.
|
||||
*
|
||||
* Executes from: the add or edit storage channel dialog opened from `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function testConnection(page: Page) {
|
||||
await page.getByRole("button", {name: /Test Storage/i}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the storage channel creation form.
|
||||
*
|
||||
* Executes from: the add storage channel dialog opened from `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function submit(page: Page) {
|
||||
await page.getByRole("button", {name: "Add Channel"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for a storage channel and trigger the test action.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function testFromEdit(page: Page, channelName: string) {
|
||||
await edit(page, channelName);
|
||||
await testConnection(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current storage channel dialog without saving.
|
||||
*
|
||||
* Executes from: the add or edit storage channel dialog.
|
||||
*/
|
||||
export async function cancel(page: Page) {
|
||||
await page.getByRole("button", {name: "Cancel"}).click();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const validChannelName = "Discord E2E Required";
|
||||
const invalidChannelName = "Discord E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channel", () => {
|
||||
// test("Create and test a valid Discord channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Discord", validChannelName, async (page) => {
|
||||
// await page.getByLabel(/Discord Webhook URL/).fill(getEnv("E2E_NOTIFICATION_DISCORD_WEBHOOK"));
|
||||
// });
|
||||
// await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
//
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
|
||||
// await expect(page.getByText("Sent to Discord")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
|
||||
// });
|
||||
//
|
||||
// test("Edit and test a valid Discord channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
|
||||
// await expect(page.getByText("Sent to Discord")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Discord channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Discord", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Discord Webhook URL/).fill("https://discord.com/api/webhooks/123456789012345678/wrong-discord-webhook-token");
|
||||
});
|
||||
await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Delete invalid Discord channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const validChannelName = "Gotify E2E Required";
|
||||
const invalidChannelName = "Gotify E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channel", () => {
|
||||
// test("Create and test a valid Gotify channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Gotify", validChannelName, async (page) => {
|
||||
// await page.getByLabel(/Gotify Server URL/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_SERVER_URL"));
|
||||
// await page.getByLabel(/Application Token/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_APP_TOKEN"));
|
||||
// });
|
||||
// await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
//
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText("Sent to Gotify")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Edit and test a valid Gotify channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText("Sent to Gotify")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Gotify channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Gotify", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Gotify Server URL/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_SERVER_URL"));
|
||||
await page.getByLabel(/Application Token/).fill("wrong-gotify-app-token");
|
||||
});
|
||||
await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid Gotify channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user