Compare commits

..
185 Commits
Author SHA1 Message Date
github-actions[bot] c7d949b1d1 chore: release 1.4.56 2026-03-06 20:47:32 +00:00
6593a8e324 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 21:47:00 +01:00
github-actions[bot] 0126a829dd chore: release 1.4.55 2026-03-06 20:33:33 +00:00
b04a2750a3 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 21:33:05 +01:00
github-actions[bot] 0cb27f2637 chore: release 1.4.54 2026-03-06 18:25:30 +00:00
1497c7385b fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 19:24:55 +01:00
github-actions[bot] 93327b0a6a chore: release 1.4.53 2026-03-06 18:00:01 +00:00
11c74d4117 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 18:59:29 +01:00
c08bb8f8e0 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 18:38:53 +01:00
ecc80c3c01 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 18:36:48 +01:00
b68a5f32ca fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 18:35:19 +01:00
cd2da4441d fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 18:30:11 +01:00
github-actions[bot] 45166a3b9c chore: release 1.4.52 2026-03-06 17:15:05 +00:00
687b59e397 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 18:14:35 +01:00
0c23c24651 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 18:12:57 +01:00
39cb9b99f6 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 18:07:51 +01:00
github-actions[bot] 5f61c7baa6 chore: release 1.4.51 2026-03-06 17:03:58 +00:00
e3c5ec9a58 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 18:03:32 +01:00
github-actions[bot] 6acdf73557 chore: release 1.4.50 2026-03-06 16:43:28 +00:00
4405b61e03 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 17:43:01 +01:00
github-actions[bot] 4016191497 chore: release 1.4.49 2026-03-06 16:37:57 +00:00
94b36c1622 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 17:37:14 +01:00
github-actions[bot] 01b7a58c75 chore: release 1.4.48 2026-03-06 16:30:16 +00:00
2b6c661fea fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 17:29:41 +01:00
fdc613fe8e fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 17:26:45 +01:00
github-actions[bot] 8479b6c822 chore: release 1.4.47 2026-03-06 16:07:42 +00:00
21bb165103 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 17:07:08 +01:00
1decd9d7a5 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 17:05:09 +01:00
d0091cfed3 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 16:59:42 +01:00
github-actions[bot] f85d12448c chore: release 1.4.46 2026-03-06 15:07:34 +00:00
82c8ad98b4 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 16:07:03 +01:00
github-actions[bot] 4c70d8aac7 chore: release 1.4.45 2026-03-06 15:01:50 +00:00
ba1dbcc9f3 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 16:01:15 +01:00
github-actions[bot] 8e4e41ca07 chore: release 1.4.44 2026-03-06 12:31:40 +00:00
a8eb3f5f1a fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 13:31:03 +01:00
github-actions[bot] 73f7ac057c chore: release 1.4.43 2026-03-06 12:25:50 +00:00
d359d53fb7 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 13:25:14 +01:00
github-actions[bot] ba2fc55188 chore: release v1.4.42 2026-03-06 12:16:19 +00:00
a6896c9b50 dev (#176)
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 13:15:49 +01:00
github-actions[bot] 8dd74f310d chore: release v1.4.41 2026-03-06 12:08:08 +00:00
44ed9e9ca6 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 13:07:39 +01:00
github-actions[bot] c925407f05 chore: release v1.4.40 2026-03-06 10:51:11 +00:00
622fc369d0 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 11:50:44 +01:00
github-actions[bot] 9580ad3934 chore: release v1.4.39 2026-03-06 10:48:15 +00:00
b33c0810d5 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 11:47:44 +01:00
8fbe639aa1 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 11:45:11 +01:00
github-actions[bot] d7e2ec3b08 chore: release v1.4.38 2026-03-06 10:25:41 +00:00
fdaca0e8a7 dev (#171)
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 11:25:13 +01:00
github-actions[bot] 7959f903bb chore: release v1.4.37 2026-03-06 10:22:43 +00:00
59eb91f28f fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 11:22:16 +01:00
64724d2a92 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 10:53:49 +01:00
github-actions[bot] 91a6d672fa chore: release v1.4.36 2026-03-06 09:42:31 +00:00
50809476fa fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 10:40:27 +01:00
282ffb543c fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 10:34:13 +01:00
68fea480ae fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 10:30:09 +01:00
ec64ea8229 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 10:25:44 +01:00
github-actions[bot] b13433e554 chore: release v1.4.37 2026-03-06 09:03:53 +00:00
d9fd382cb2 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 10:03:23 +01:00
github-actions[bot] 4d324d885d chore: release v1.4.36 2026-03-06 08:59:45 +00:00
af1a437523 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 09:59:15 +01:00
github-actions[bot] 77901ae1bc chore: release v1.4.35 2026-03-06 08:55:03 +00:00
1aeff542e5 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 09:54:33 +01:00
github-actions[bot] b81ad079ee chore: release v1.4.34 2026-03-06 08:49:01 +00:00
044edcda07 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 09:48:32 +01:00
github-actions[bot] 3f7eb4528e chore: release v1.4.33 2026-03-06 08:37:28 +00:00
0008f70552 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 09:36:59 +01:00
github-actions[bot] 2695715180 chore: release v1.4.32 2026-03-06 08:34:59 +00:00
b3be1a561c fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 09:34:31 +01:00
github-actions[bot] d297f1a27b chore: release v1.4.31 2026-03-06 08:31:07 +00:00
b513509b01 fix: workflow
* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 09:30:39 +01:00
github-actions[bot] 25744efc66 chore: release v1.4.30 2026-03-06 08:25:40 +00:00
21e37366fb fix: workflow
* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 09:25:07 +01:00
github-actions[bot] 3f6a47c39d chore: release v1.4.29 2026-03-06 08:22:18 +00:00
4dace0a32b fix: workflow
* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 09:21:47 +01:00
github-actions[bot] 7b6ca12744 chore: release v1.4.28 2026-03-06 08:18:49 +00:00
da1b5a969c fix: workflow
* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 09:18:19 +01:00
github-actions[bot] 8e5d6eec6c chore: release v1.4.27 2026-03-06 08:13:08 +00:00
github-actions[bot] 92f1400a35 chore: release v1.4.26 2026-03-06 08:13:05 +00:00
47f1938136 fix: workflow
Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 09:12:30 +01:00
github-actions[bot] f226218b3a chore: release v1.4.25 2026-03-06 08:07:38 +00:00
Charles GTEandGitHub 8f7fa0dea1 Merge pull request #153 from Portabase/dev
fix: workflow
2026-03-06 09:07:05 +01:00
charlesgauthereau 292b3c805d Merge branch 'main' into dev 2026-03-06 09:06:17 +01:00
charlesgauthereau ded26f92af fix 2026-03-06 09:05:43 +01:00
github-actions[bot] 8cce7bbaf4 chore: release v1.4.24 2026-03-06 08:03:42 +00:00
228b8c3ec7 fix: workflow
* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 09:03:15 +01:00
charlesgauthereau 2e298ce757 Merge branch 'main' into dev
# Conflicts:
#	.release-it.json
2026-03-06 09:02:45 +01:00
charlesgauthereau 81daa9f0b0 fix: .release-it.json 2026-03-06 09:01:55 +01:00
github-actions[bot] 713c8d9177 chore: release v1.4.23 2026-03-06 07:58:23 +00:00
f571162eed fix: workflow
* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 08:57:54 +01:00
charlesgauthereau 19d2872943 Merge branch 'main' into dev 2026-03-06 08:57:25 +01:00
charlesgauthereau 0752b8bf7f fix: .release-it.json 2026-03-06 08:57:11 +01:00
github-actions[bot] c01097196a chore: release v1.4.22 2026-03-06 07:50:24 +00:00
5f1bee018d fix: workflow
* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 08:49:56 +01:00
charlesgauthereau 8655b81618 Merge branch 'main' into dev 2026-03-06 08:49:05 +01:00
charlesgauthereau 1702e43373 fix: .release-it.json 2026-03-06 08:48:53 +01:00
github-actions[bot] a7d411f6ff chore: release v1.4.21 2026-03-06 07:47:06 +00:00
6a84da0f96 fix: github workflow
* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 08:46:34 +01:00
charlesgauthereau 79425f4490 fix: .release-it.json 2026-03-06 08:45:30 +01:00
charlesgauthereau 2fcddea0fa Merge branch 'main' into dev 2026-03-06 08:45:08 +01:00
github-actions[bot] ac1c8552e1 chore: release v1.4.20 2026-03-06 07:40:29 +00:00
6a18eefe34 fix: workflow
* fix: .release-it.json

* fix: .release-it.json

* fix: .release-it.json

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 08:39:57 +01:00
charlesgauthereau a43eedaf9f Merge remote-tracking branch 'origin/dev' into dev 2026-03-06 08:39:24 +01:00
charlesgauthereau 77bc24990e fix: .release-it.json 2026-03-06 08:38:39 +01:00
charlesgauthereau edbc79c89e fix: .release-it.json 2026-03-06 08:38:06 +01:00
github-actions[bot] 527a090f1b chore: release v1.4.19 2026-03-06 07:23:15 +00:00
370b86337f fix: github workflow
Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 08:22:43 +01:00
charlesgauthereau 1ce3acdde0 fix: .release-it.json 2026-03-06 08:21:38 +01:00
github-actions[bot] 1a72be323b chore: release v1.4.18 2026-03-04 15:55:52 +00:00
Théo LAGACHEandGitHub bf252f22f2 fix: Fix (#146) 2026-03-04 16:55:22 +01:00
github-actions[bot] 937d92f686 chore: release v1.4.17 2026-03-04 15:33:49 +00:00
5572957731 fix: Fix/draft release (#145)
* fix

* Update release.yml

* Update release.yml

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 16:33:06 +01:00
github-actions[bot] d2db5722b7 chore: release v1.4.16 2026-03-04 15:31:21 +00:00
5404185a6c fix: Fix/draft release (#144)
* fix

* Update release.yml

* Update release.yml

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 16:30:40 +01:00
github-actions[bot] 800f2dfbbb chore: release v1.4.15 2026-03-04 15:11:36 +00:00
ed0b5410f6 fix: Fix/draft release (#143)
* fix

* Update release.yml

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 16:11:00 +01:00
github-actions[bot] ae72e262aa chore: release v1.4.14 2026-03-04 14:49:21 +00:00
Charles GTEandGitHub c4c7050c3f fix: Notification Logs Modal
fix: Notification Logs Modal
2026-03-04 15:48:29 +01:00
charlesgauthereau 81e5227270 fix: modal notification-log-modal.tsx 2026-03-04 15:44:33 +01:00
Charles GTEandGitHub 39319d9162 Merge pull request #141 from Portabase/feat/json-provider-notification
Feat/json provider notification
2026-03-04 15:38:10 +01:00
Charles GTEandGitHub 12206fa856 Merge branch 'dev' into feat/json-provider-notification 2026-03-04 15:35:43 +01:00
Théo LAGACHE 9d8c6e26f1 Update notification-log-modal.tsx 2026-03-04 15:09:55 +01:00
Théo LAGACHE eb624672e6 Update notification-log-modal.tsx 2026-03-04 14:18:46 +01:00
Théo LAGACHE 1b026b5f08 Update notification-log-modal.tsx 2026-03-04 14:12:23 +01:00
github-actions[bot] 21b9b8b44d chore: release v1.4.13 2026-03-04 12:37:48 +00:00
6ef7fcc0c2 fix: Feat/changelog icon (#137)
* Update .release-it.json

* Update .release-it.json

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 13:37:19 +01:00
fc4ce178cb fix: Update .release-it.json (#136)
Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 13:33:58 +01:00
github-actions[bot] 98b72988c4 chore: release v1.4.12 2026-03-04 12:30:32 +00:00
317473e528 fix: Update CONTRIBUTING.md (#135)
Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 13:30:04 +01:00
github-actions[bot] e13c5ddf51 chore: release v1.4.11 2026-03-04 12:24:36 +00:00
90be3ab731 fix: password input on sensible fields (#134)
Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 13:24:10 +01:00
github-actions[bot] 85c0445a64 chore: release v1.4.10 2026-03-04 12:02:54 +00:00
6a227ccd7c fix: Feat/flow (#133)
* fix

* fix

* fix

* fix

* Update docker.yml

* Update auto-release.yml

* Update pr-checker.yml

* Update pr-checker.yml

* fix: citation version

* Update .release-it.json

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 13:02:24 +01:00
github-actions[bot] 3d9a21f854 chore: release v1.4.9 2026-03-04 11:58:24 +00:00
d4229a985d fix: Feat/flow (#132)
* fix

* fix

* fix

* fix

* Update docker.yml

* Update auto-release.yml

* Update pr-checker.yml

* Update pr-checker.yml

* fix: citation version

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 12:57:56 +01:00
github-actions[bot] c6e0921dea chore: release v1.4.8 2026-03-04 10:57:55 +00:00
346a8a4375 fix: Feat/flow (#131)
* fix

* fix

* fix

* fix

* Update docker.yml

* Update auto-release.yml

* Update pr-checker.yml

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 11:57:11 +01:00
github-actions[bot] 4799faa827 chore: release v1.4.7 2026-03-04 10:34:00 +00:00
2c6f5c38ed fix: Feat/flow (#130)
* fix

* fix

* fix

* fix

* Update docker.yml

* Update auto-release.yml

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 11:33:25 +01:00
github-actions[bot] 4a884ab5cc chore: release v1.4.6 2026-03-04 10:27:05 +00:00
e59beaf749 fix: feat/flow (#129)
* fix

* fix

* fix

* fix

* Update docker.yml

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 11:26:28 +01:00
github-actions[bot] 0d4e1229ab chore: release v1.4.5 2026-03-04 10:23:35 +00:00
8810fd91c9 fix: Feat/flow (#128)
* fix

* fix

* fix

* fix

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 11:22:51 +01:00
charlesgauthereau 095444610d fix: removed express api 2026-03-04 11:01:39 +01:00
github-actions[bot] 7cf43c5e7c chore: release v1.4.4 2026-03-04 09:29:30 +00:00
77183af08d fix: Feat/flow (#127)
* fix

* fix

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 10:23:52 +01:00
3a2c5be5dd fix: bug
Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 10:21:02 +01:00
Théo LAGACHE 11c764c2f3 fix 2026-03-04 10:17:37 +01:00
d128234ae6 fix: Feat/workflow (#125)
* update: release

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

* Update auto-release.yml

* Update auto-release.yml

* fix

* Update auto-release.yml

* fix

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 09:46:47 +01:00
Théo LAGACHE ee31ee225d fix 2026-03-04 09:44:30 +01:00
e2195616d3 fix: Feat/workflow (#124)
* update: release

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

* Update auto-release.yml

* Update auto-release.yml

* fix

* Update auto-release.yml

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 09:34:32 +01:00
Théo LAGACHE 2ca818b996 Update auto-release.yml 2026-03-04 09:33:51 +01:00
131f2b4538 fix Feat/workflow (#123)
* update: release

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

* Update auto-release.yml

* Update auto-release.yml

* fix

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 09:30:26 +01:00
6731c3fea9 fix: Feat/new workflow (#122)
* update: release

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

* Update auto-release.yml

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 09:27:00 +01:00
d3da5e8c13 fix: Feat/new workflow (#121)
* update: release

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

* Update auto-release.yml

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-04 09:20:10 +01:00
3b0d4225ce fix: Feat/new workflow (#118)
* update: release

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

* Update auto-release.yml

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-03 17:00:01 +01:00
66f0930164 fix: Feat/new workflow (#117)
* update: release

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-03 16:50:36 +01:00
3979b83a0a fix: Feat/new workflow (#116)
* update: release

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-03 16:47:59 +01:00
b65e432564 fix: Feat/new workflow (#115)
* update: release

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-03 16:45:31 +01:00
340e7c3e00 fix: new workflow CI (#114)
* update: release

* fix

* fix

* fix

* Update docker.yml

* fix

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
2026-03-03 16:39:59 +01:00
charlesgauthereau dcdce63e15 chore(release): 1.4.3 2026-03-02 20:30:10 +01:00
Charles GTEandGitHub c2eaed757d Merge pull request #109 from Portabase/dev
dev
2026-03-02 20:29:30 +01:00
charlesgauthereau 920743bea7 fix: backup-button.tsx and s3.schema.ts 2026-03-02 20:28:47 +01:00
Charles GTEandGitHub 0970f5baae Merge pull request #105 from Portabase/feat/provider-validation
Feat/provider validation
2026-03-02 20:05:14 +01:00
Charles GTEandGitHub 291aa13e9a Merge pull request #107 from Portabase/feat/sensible-actions
Feat/sensible actions
2026-03-02 20:03:25 +01:00
Théo LAGACHE e644cde514 update: sensible action button confirm 2026-03-02 11:21:20 +01:00
Théo LAGACHE 2350e68f4f update: sensible action button confirm 2026-03-02 11:19:11 +01:00
Théo LAGACHE cce3b681e3 old 2026-03-02 11:18:42 +01:00
Théo LAGACHE 755799f19d remove: old edit page 2026-03-02 11:18:21 +01:00
Théo LAGACHE ec02dc8579 fix: button confirm 2026-03-02 11:17:54 +01:00
Théo LAGACHE 7028982ba1 add: provider validation 2026-03-02 10:30:57 +01:00
Théo LAGACHE f04503e38e add: topic channel id support 2026-03-02 10:27:21 +01:00
Charles GTEandGitHub f1edffed17 Update README.md 2026-03-02 08:46:12 +01:00
charlesgauthereau fac87acee7 chore(release): 1.4.2 2026-02-28 17:54:45 +01:00
charlesgauthereau c46dd91ede Merge branch 'main' into dev 2026-02-28 17:53:58 +01:00
Charles GTEandGitHub 648753f8cf Merge pull request #99 from formless63/fix/ntfy-priority-formatting-tags
fix(ntfy): fix silent delivery, malformed payload, and tag fallthrough
2026-02-28 17:51:54 +01:00
charlesgauthereau 76b3bda313 chore: ci 2026-02-28 17:50:59 +01:00
charlesgauthereau 8102dd1a8f Merge branch 'main' into dev 2026-02-28 17:41:39 +01:00
charlesgauthereau aceb4e7f4c fix: status in sendNotificationsBackupRestore 2026-02-28 17:39:20 +01:00
charlesgauthereau 4224389e96 fix: add PasswordInput in ntfy.form.tsx 2026-02-28 17:22:02 +01:00
formless63andGitHub 7c66c10b6d fix(ntfy): fix silent delivery, malformed payload, and tag fallthrough
### Description
Fixes three issues with the `ntfy` notification provider to improve mobile delivery and readability.

**Previous Behavior:**
1. **Silent Delivery:** Priority was hardcoded to `1` (Min). On Android and iOS, priority 1 notifications are delivered completely silently (no sound, no vibration, hidden from lock screen). 
2. **Malformed Payload:** The `payload.data` object was appended as a raw, stringified JSON blob, making the notification body difficult to read.
3. **Tag Fallthrough:** The chained ternary logic for tags caused `error` level events to fall through to the default `information_source` icon.

### Changes Made
- **Dynamic Priority Mapping:** Added `getPriority` to map severity levels to Ntfy's 1-5 scale. `info`/default maps to `2` (quiet ping), `warning` to `3` (default alert), `error` to `4` (high), and `critical` to `5` (max).
- **Plain Text Data Formatting:** Added `formatData` to parse the JSON payload into a clean `- key: value` list. Markdown was purposely avoided to ensure clean rendering in mobile OS lock screen previews.
- **Improved Tagging:** Refactored into a `getTags` helper. Added a base `floppy_disk` icon to instantly identify the notifications, and properly mapped `error` payloads to the `x` icon.

### Related Issues
None (Direct Fix)
2026-02-28 10:31:18 -05:00
charlesgauthereau bf06a2e50c fix: .env.example 2026-02-27 18:42:14 +01:00
Charles GTEandGitHub 8c69fdef95 Merge pull request #98 from Portabase/dev
dev
2026-02-27 17:38:35 +01:00
charlesgauthereau 883c1d2f57 chore(release): 1.4.1 2026-02-27 17:36:05 +01:00
charlesgauthereau e7f1652b4e fix(auth): linking default 2026-02-27 17:02:20 +01:00
charlesgauthereau ea07224a52 fix: docker-compose.prod.yml 2026-02-27 16:25:53 +01:00
50 changed files with 3389 additions and 1753 deletions
+5
View File
@@ -35,6 +35,11 @@ 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=""
+3 -3
View File
@@ -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! 🙌
---
---
+73
View File
@@ -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"
+89 -47
View File
@@ -3,10 +3,13 @@ name: Docker Publish
on:
workflow_call:
inputs:
version:
required: true
type: string
image_name:
required: false
type: string
default: 'portabase/portabase'
default: "portabase/portabase"
add_latest:
required: false
type: boolean
@@ -14,11 +17,11 @@ 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
@@ -26,10 +29,9 @@ on:
required: true
jobs:
build:
name: Build and push Docker images
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:
@@ -38,22 +40,22 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- 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
@@ -62,61 +64,101 @@ 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: Extract Docker metadata
# id: meta
# uses: docker/metadata-action@v5
# with:
# images: ${{ inputs.image_name }}
# tags: |
# type=semver,pattern={{version}}
# type=semver,pattern={{major}}.{{minor}}
# type=semver,pattern={{major}}
# type=raw,value=latest,enable=${{ inputs.add_latest }}
#
# # - name: Create and push manifest list
# # working-directory: /tmp/digests
# # run: |
# # DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
# # TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
# # docker buildx imagetools create $DOCKER_IMAGES $TAG_ARGS
#
#
# - name: Create and push manifest list
# working-directory: /tmp/digests
# run: |
# DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
# TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
# echo $TAG_ARGS
# docker buildx imagetools create $TAG_ARGS $DOCKER_IMAGES
- name: Generate semantic tags
id: tags
run: |
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
echo "LATEST_TAG=latest" >> $GITHUB_OUTPUT
fi
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
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_name }}:$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_name }}:latest
docker buildx imagetools inspect ${{ inputs.image_name }}: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
-128
View File
@@ -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"
+8 -9
View File
@@ -3,29 +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_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 }}
+109 -13
View File
@@ -1,32 +1,128 @@
name: Publish Docker image for release
name: Auto Release & Publish
on:
push:
tags:
- '*.*.*'
- '!*-*'
pull_request:
types: [ closed ]
branches:
- main
permissions:
contents: write
packages: write
jobs:
docker_publish:
create-release:
if: github.event.pull_request.merged == true
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=$VERSION"
# echo "version=$VERSION" >> $GITHUB_OUTPUT
#
# OUTPUT=$(pnpm exec release-it --ci)
# echo "$OUTPUT"
# DRAFT_TAG=$(echo "$OUTPUT" | grep -o 'releases/tag/[^ ]*' | sed 's|releases/tag/||')
# echo "draft_tag=$DRAFT_TAG" >> $GITHUB_OUTPUT
# env:
# GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
- 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
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.create-release.outputs.version }}
add_latest: true
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release:
needs: docker_publish
uses: ./.github/workflows/github.yml
finalize-release:
needs:
- create-release
- publish-docker
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
- create-release
- finalize-release
uses: ./.github/workflows/discord.yml
with:
prerelease: false
make_latest: true
discord_title: "||@release-dashboard|| New release published"
discord_color: 5814783
release_tag: ${{ needs.create-release.outputs.version }}
discord_title: "New Release"
discord_color: 3066993
discord_footer: "Portabase"
secrets:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
+5 -4
View File
@@ -5,7 +5,7 @@ on:
branches: [ main ]
jobs:
sca-deps: # Dependency & container scan
sca-deps:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -17,15 +17,16 @@ jobs:
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
+58
View File
@@ -0,0 +1,58 @@
{
"github": {
"release": true,
"draft": true,
"tokenRef": "GITHUB_TOKEN",
"update": true
},
"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"
}
}
}
}
+9 -4
View File
@@ -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,7 +9,12 @@ 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
@@ -26,5 +31,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.4.0
date-released: "2026-02-27"
version: 1.4.56
date-released: '2026-03-02'
+1
View File
@@ -19,6 +19,7 @@
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/)
[![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/)
[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/)
[![SQLite](https://img.shields.io/badge/-SQLite-blue?logo=sqlite&logoColor=white)](https://sqlite.org/)
[![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
@@ -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>
);
}
@@ -1,89 +1,95 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, 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 {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 {db} from "@/db";
import {isNull} from "drizzle-orm";
import { PageParams } from "@/types/next";
import {
Page,
PageActions,
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 { 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 { db } from "@/db";
import { isNull } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import { eq } from "drizzle-orm";
export const metadata: Metadata = {
title: "Settings",
title: "Settings",
};
export default async function RoutePage(props: PageParams<{ slug: string }>) {
const organization = await getOrganization({});
const user = await currentUser();
const activeMember = await getActiveMember()
const organization = await getOrganization({});
const user = await currentUser();
const activeMember = await getActiveMember();
if (!organization || !activeMember || !user) {
notFound();
}
if (!organization || !activeMember || !user) {
notFound();
}
const notificationChannels = await getOrganizationChannels(organization.id)
const storageChannels = await getOrganizationStorageChannels(organization.id)
const permissions = computeOrganizationPermissions(activeMember);
const notificationChannels = await getOrganizationChannels(organization.id);
const storageChannels = await getOrganizationStorageChannels(organization.id);
const permissions = computeOrganizationPermissions(activeMember);
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt)
});
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
});
const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id),
const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id),
with: {
members: {
with: {
members: {
with: {
user: true
}
}
}
});
user: true,
},
},
},
});
if (!organizationWithMembers) notFound();
if (!organizationWithMembers) notFound();
return (
<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}
currentUser={user}
/>
)}
</div>
<div className="flex items-center gap-2">
{permissions.canManageDangerZone && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</div>
</div>
</PageTitle>
</PageHeader>
<PageContent>
<OrganizationTabs
activeMember={activeMember}
organization={organization}
notificationChannels={notificationChannels}
storageChannels={storageChannels}
/>
</PageContent>
</Page>
)
return (
<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}
currentUser={user}
/>
)}
</div>
<div className="flex items-center gap-2">
{permissions.canManageDangerZone &&
organization.slug !== "default" && (
<DeleteOrganizationButton
organizationSlug={organization.slug}
/>
)}
</div>
</div>
</PageTitle>
</PageHeader>
<PageContent>
<OrganizationTabs
activeMember={activeMember}
organization={organization}
notificationChannels={notificationChannels}
storageChannels={storageChannels}
/>
</PageContent>
</Page>
);
}
+2 -1
View File
@@ -102,6 +102,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
}) => {
try {
const body: BodyPatch = await request.json();
console.log("body", body);
const status = body.status
const backupId = body.backupId
@@ -131,7 +132,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(
{
+1 -2
View File
@@ -63,8 +63,7 @@ export async function POST(
.set({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,
+1 -1
View File
@@ -4,7 +4,7 @@ services:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1.2.7-rc.1
image: portabase/portabase:1.4.0
ports:
- '8887:80'
environment:
-3
View File
@@ -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 \
@@ -112,8 +111,6 @@ 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
+6 -4
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.4.0",
"version": "1.4.56",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -11,7 +11,8 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:drop": "drizzle-kit drop",
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts",
"release": "release-it"
},
"dependencies": {
"@better-auth/passkey": "^1.4.19",
@@ -49,7 +50,6 @@
"@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@types/express": "^5.0.6",
"@types/nodemailer": "^6.4.23",
"@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2",
@@ -65,7 +65,6 @@
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"express": "^5.2.1",
"googleapis": "^170.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.553.0",
@@ -106,6 +105,8 @@
"@iconify/react": "^6.0.2",
"@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.4",
"@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.5",
"@tailwindcss/postcss": "^4.2.1",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.19.11",
@@ -123,6 +124,7 @@
"eslint-plugin-tailwindcss": "^3.18.2",
"framer-motion": "^12.34.3",
"postcss": "^8.5.6",
"release-it": "^19.2.4",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
+1498 -343
View File
File diff suppressed because it is too large Load Diff
-95
View File
@@ -1,95 +0,0 @@
#!/bin/bash
set -e
if [ -z "$1" ]; then
echo "Usage: ./release <version>"
echo "Example: ./release 1.0.0"
exit 1
fi
VERSION=$1
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
#if [ "$CURRENT_BRANCH" = "main" ]; then
# if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
# echo "Error: On 'main' branch, only release tags (X.Y.Z) are allowed."
# exit 1
# fi
#else
# if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
# echo "Error: On branch '$CURRENT_BRANCH', only RC tags matching X.Y.Z-rc.W are allowed (e.g., 1.0.0-rc.1)."
# exit 1
# fi
#fi
CLEAN_VERSION=${VERSION#v}
CURRENT_DATE=$(date +%Y-%m-%d)
echo "Preparing release $VERSION..."
# package.json
if [ -f package.json ]; then
echo "Updating package.json..."
if sed --version >/dev/null 2>&1; then
sed -i "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
else
sed -i '' "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
fi
fi
# pyproject.toml
if [ -f pyproject.toml ]; then
echo "Updating pyproject.toml..."
if sed --version >/dev/null 2>&1; then
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
else
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
fi
fi
# Cargo.toml
if [ -f Cargo.toml ]; then
echo "Updating Cargo.toml..."
if sed --version >/dev/null 2>&1; then
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
else
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
fi
fi
# CITATION.cff
if [ -f CITATION.cff ]; then
echo "Updating CITATION.cff..."
if sed --version >/dev/null 2>&1; then
sed -i "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
sed -i "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
else
sed -i '' "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
sed -i '' "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
fi
fi
git add .
if ! git diff-index --quiet HEAD --; then
echo "Committing changes..."
git commit -m "chore(release): $VERSION"
else
echo "No changes to commit. Proceeding to tag..."
fi
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "Tag $VERSION already exists. Aborting."
exit 1
fi
echo "Creating tag $VERSION..."
git tag -a "$VERSION" -m "Release $VERSION"
echo "Pushing changes and tags to remote..."
git push
git push origin "$VERSION"
echo "Successfully released $VERSION!"
-6
View File
@@ -1,6 +0,0 @@
import express from "express";
import router from "../src/features/api/router";
export function mountApi(app: express.Express) {
app.use("/services/v1", router);
}
-52
View File
@@ -1,52 +0,0 @@
import next from "next";
import express from "express";
import {mountApi} from "./api";
import http from "http";
const port = Number(process.env.PORT) || 8887;
const dev = process.env.NODE_ENV !== "production";
async function start() {
const nextApp = next({
dev,
hostname: "0.0.0.0",
turbopack: dev,
port
});
const handle = nextApp.getRequestHandler();
await nextApp.prepare();
const app = express();
app.use((req, res, next) => {
req.setTimeout(0);
res.setTimeout(0);
next();
});
mountApi(app);
app.use((req, res, next) => {
if (req.path.startsWith('/services/v1')) return next();
handle(req, res).catch((err) => {
console.error('Next.js App Router error:', err);
res.status(500).send('Internal Server Error');
});
});
const server = http.createServer(app);
server.setTimeout(0);
server.headersTimeout = 0;
server.requestTimeout = 0;
server.keepAliveTimeout = 0;
server.listen(port, "0.0.0.0", () => {
console.log(`NEXT APP → http://localhost:${port}`);
console.log(`API → http://localhost:${port}/services/v1`);
});
}
start();
@@ -1,161 +1,198 @@
"use client"
import {Button, ButtonVariantsProps} from "@/components/ui/button";
import {ReactNode, useState} from "react";
import {Loader2} from "lucide-react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
import {cn} from "@/lib/utils";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
"use client";
import { Button, ButtonVariantsProps } from "@/components/ui/button";
import { ReactNode, useState } from "react";
import { Loader2 } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export type ButtonWithConfirmProps = {
title: string;
description: string;
button?: {
main: {
className?: string;
type?: "button" | "submit" | "reset" | undefined;
text?: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
disabled?: boolean;
tooltipText?: string;
};
confirm: {
className?: string;
text: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
onClick?: () => void;
};
cancel: {
className?: string;
text: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
onClick?: () => void;
};
title: string;
description: string;
button?: {
main: {
className?: string;
type?: "button" | "submit" | "reset" | undefined;
text?: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
disabled?: boolean;
tooltipText?: string;
};
children?: ReactNode;
onConfirm?: (e: React.MouseEvent) => void;
onCancel?: (e: React.MouseEvent) => void;
confirmButtonText?: string;
cancelButtonText?: string;
isPending?: boolean;
confirm: {
className?: string;
text: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
onClick?: () => void;
};
cancel: {
className?: string;
text: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
onClick?: () => void;
};
};
children?: ReactNode;
onConfirm?: (e: React.MouseEvent) => void;
onCancel?: (e: React.MouseEvent) => void;
confirmButtonText?: string;
cancelButtonText?: string;
isPending?: boolean;
};
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
const [isConfirming, setIsConfirming] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const isLegacy = !!props.button;
const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
const isLegacy = !!props.button;
const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
const handleConfirm = (e: React.MouseEvent) => {
const handleConfirm = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isLegacy) {
props.button?.confirm.onClick?.();
} else {
props.onConfirm?.(e);
}
setIsConfirming(false);
};
const handleCancel = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isLegacy) {
props.button?.cancel.onClick?.();
} else {
props.onCancel?.(e);
}
setIsConfirming(false);
};
const triggerContent = isLegacy ? (
<Button
type={props.button?.main.type}
disabled={isDisabled}
variant={props.button?.main.variant ?? "default"}
size={props.button?.main.size ?? "default"}
className={props.button?.main.className}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (isLegacy) {
props.button?.confirm.onClick?.();
} else {
props.onConfirm?.(e);
}
setIsConfirming(false);
};
const handleCancel = (e: React.MouseEvent) => {
if (!isDisabled) setIsConfirming(true);
}}
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
{props.button?.main.icon}
{props.button?.main.text && <span>{props.button.main.text}</span>}
</Button>
) : (
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (isLegacy) {
props.button?.cancel.onClick?.();
} else {
props.onCancel?.(e);
}
setIsConfirming(false);
};
setIsConfirming(true);
}}
>
{props.children}
</div>
);
const triggerContent = isLegacy ? (
<Button
type={props.button?.main.type}
disabled={isDisabled}
variant={props.button?.main.variant ?? "default"}
size={props.button?.main.size ?? "default"}
className={props.button?.main.className}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!isDisabled) setIsConfirming(true);
}}
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button?.main.icon}
{props.button?.main.text && <span>{props.button.main.text}</span>}
</Button>
const withTooltip =
isLegacy && props.button?.main.tooltipText && isDisabled ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{triggerContent}</div>
</TooltipTrigger>
<TooltipContent>
<p>{props.button?.main.tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<div onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsConfirming(true);
}}>
{props.children}
</div>
triggerContent
);
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{triggerContent}</div>
</TooltipTrigger>
<TooltipContent>
<p>{props.button?.main.tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : triggerContent;
return (
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
<PopoverTrigger asChild>
{withTooltip}
</PopoverTrigger>
<PopoverContent
className="w-80"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
onMouseMove={(e) => e.stopPropagation()}
onMouseEnter={(e) => e.stopPropagation()}
onMouseLeave={(e) => e.stopPropagation()}
onOpenAutoFocus={(e) => e.preventDefault()}
return (
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
<PopoverTrigger asChild>{withTooltip}</PopoverTrigger>
<PopoverContent
className="w-80"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
onMouseMove={(e) => e.stopPropagation()}
onMouseEnter={(e) => e.stopPropagation()}
onMouseLeave={(e) => e.stopPropagation()}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">{props.title}</h4>
<p className="text-sm text-muted-foreground">{props.description}</p>
</div>
<div className="grid gap-2">
<Button
onClick={handleConfirm}
variant={
isLegacy
? (props.button?.confirm.variant ?? "default")
: "default"
}
size={
isLegacy ? (props.button?.confirm.size ?? "default") : "default"
}
className={cn(
isLegacy ? props.button?.confirm.className : "",
"w-full",
)}
>
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">{props.title}</h4>
<p className="text-sm text-muted-foreground">{props.description}</p>
</div>
<div className="grid gap-2">
<Button
onClick={handleConfirm}
variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"}
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{isLegacy && props.button?.confirm.icon}
<span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</span>
</Button>
<Button
variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"}
onClick={handleCancel}
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
>
{isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
{props.isPending && (
<Loader2 className="animate-spin mr-4" size={16} />
)}
{isLegacy && props.button?.confirm.icon}
<span>
{isLegacy
? props.button?.confirm.text
: (props.confirmButtonText ?? "Confirm")}
</span>
</Button>
<Button
variant={
isLegacy
? (props.button?.cancel.variant ?? "outline")
: "outline"
}
onClick={handleCancel}
className={cn(
isLegacy ? props.button?.cancel.className : "",
"w-full",
)}
size={
isLegacy ? (props.button?.cancel.size ?? "default") : "default"
}
>
{isLegacy
? (props.button?.cancel.text ?? "Cancel")
: (props.cancelButtonText ?? "Cancel")}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
@@ -1,26 +1,69 @@
import {z} from "zod";
import {SlackChannelConfigSchema} from "./providers/notifications/forms/slack.schema";
import {SmtpChannelConfigSchema} from "./providers/notifications/forms/smtp.schema";
import {DiscordChannelConfigSchema} from "./providers/notifications/forms/discord.schema";
import {TelegramChannelConfigSchema} from "./providers/notifications/forms/telegram.schema";
import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema";
import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema";
import {WebhookChannelConfigSchema} from "./providers/notifications/forms/webhook.schema";
import {S3ChannelConfigSchema} from "./providers/storages/forms/s3.schema";
import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema";
import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema";
export const ChannelFormSchema = z.object({
const BaseChannelFormSchema = z.object({
name: z
.string()
.min(5, "Name must be at least 5 characters long")
.max(40, "Name must be at most 40 characters long"),
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
enabled: z.boolean().default(true),
});
export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
BaseChannelFormSchema.extend({
provider: z.literal("slack"),
config: SlackChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("smtp"),
config: SmtpChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("discord"),
config: DiscordChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("telegram"),
config: TelegramChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("gotify"),
config: GotifyChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("ntfy"),
config: NtfyChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("webhook"),
config: WebhookChannelConfigSchema,
}),
]);
export const NotificationChannelFormSchema = ChannelFormSchema.extend({
provider: z.enum(
["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"],
{required_error: "Provider is required"}
),
});
export const StorageChannelFormSchema = ChannelFormSchema.extend({
provider: z.enum(["local", "s3", "google-drive"], {required_error: "Provider is required"}),
});
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
BaseChannelFormSchema.extend({
provider: z.literal("s3"),
config: S3ChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("google-drive"),
config: GoogleDriveChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("local"),
config: LocalChannelConfigSchema
})
]);
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
@@ -1,30 +1,38 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierDiscordFormProps = {
form: UseFormReturn<any, any, any>
}
form: UseFormReturn<any, any, any>;
};
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.discordWebhook"
render={({field}) => (
<FormItem>
<FormLabel>Discord Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
export const NotifierDiscordForm = ({ form }: NotifierDiscordFormProps) => {
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.discordWebhook"
render={({ field }) => (
<FormItem>
<FormLabel>Discord Webhook URL</FormLabel>
<FormControl>
<PasswordInput
{...field}
placeholder="https://discord.com/api/webhooks/..."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,5 @@
import {z} from "zod";
export const DiscordChannelConfigSchema = z.object({
discordWebhook: z.string().url("Must be a valid URL"),
});
@@ -1,43 +1,49 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierGotifyFormProps = {
form: UseFormReturn<any, any, any>
}
form: UseFormReturn<any, any, any>;
};
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.gotifyServerUrl"
render={({field}) => (
<FormItem>
<FormLabel>Gotify Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://gotify.example.com"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.gotifyAppToken"
render={({field}) => (
<FormItem>
<FormLabel>Application Token</FormLabel>
<FormControl>
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
export const NotifierGotifyForm = ({ form }: NotifierGotifyFormProps) => {
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.gotifyServerUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Gotify Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://gotify.example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.gotifyAppToken"
render={({ field }) => (
<FormItem>
<FormLabel>Application Token</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,6 @@
import {z} from "zod";
export const GotifyChannelConfigSchema = z.object({
gotifyServerUrl: z.string().url("Must be a valid URL"),
gotifyAppToken: z.string().min(1, "App token is required"),
});
@@ -2,6 +2,7 @@ import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import {PasswordInput} from "@/components/ui/password-input";
type NotifierNtfyFormProps = {
form: UseFormReturn<any, any, any>
@@ -48,7 +49,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
<FormItem>
<FormLabel>Access Token (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="tk_..."/>
<PasswordInput {...field} placeholder="tk_..."/>
</FormControl>
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
<FormMessage/>
@@ -79,7 +80,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
<FormItem>
<FormLabel>Basic Auth Password (Optional)</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="password"/>
<PasswordInput placeholder="password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
@@ -0,0 +1,9 @@
import {z} from "zod";
export const NtfyChannelConfigSchema = z.object({
ntfyTopic: z.string().min(1, "Topic is required"),
ntfyServerUrl: z.string().url("Must be a valid URL").optional().or(z.literal('')),
ntfyToken: z.string().optional(),
ntfyUsername: z.string().optional(),
ntfyPassword: z.string().optional(),
});
@@ -1,31 +1,38 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierSmtpFormProps = {
form: UseFormReturn<any, any, any>
}
form: UseFormReturn<any, any, any>;
};
export const NotifierSlackForm = ({form}: NotifierSmtpFormProps) => {
return(
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.slackWebhook"
render={({field}) => (
<FormItem>
<FormLabel>Slack Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://hooks.slack.com/services/..."/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
export const NotifierSlackForm = ({ form }: NotifierSmtpFormProps) => {
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.slackWebhook"
render={({ field }) => (
<FormItem>
<FormLabel>Slack Webhook URL</FormLabel>
<FormControl>
<PasswordInput
{...field}
placeholder="https://hooks.slack.com/services/..."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,5 @@
import {z} from "zod";
export const SlackChannelConfigSchema = z.object({
slackWebhook: z.string().url("Must be a valid URL"),
});
@@ -0,0 +1,10 @@
import {z} from "zod";
export const SmtpChannelConfigSchema = z.object({
host: z.string().min(1, "Host is required"),
port: z.coerce.number().min(1, "Port is required"),
user: z.string().min(1, "User is required"),
password: z.string().min(1, "Password is required"),
from: z.string().min(1, "From is required"),
to: z.string().min(1, "To is required"),
});
@@ -1,48 +1,84 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierTelegramFormProps = {
form: UseFormReturn<any, any, any>
}
form: UseFormReturn<any, any, any>;
};
export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.telegramBotToken"
render={({field}) => (
<FormItem>
<FormLabel>Telegram Bot Token</FormLabel>
<FormControl>
<Input {...field} placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.telegramChatId"
render={({field}) => (
<FormItem>
<FormLabel>Telegram Chat ID</FormLabel>
<FormControl>
<Input {...field} placeholder="-100123456789 or 123456789"/>
</FormControl>
<p className="text-xs text-muted-foreground">
You must start the conversation with the bot first (<strong>/start</strong>). <br/>
For groups, add the bot to the group. <br/>
You can use <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="underline">@userinfobot</a> to find your ID.
</p>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
export const NotifierTelegramForm = ({ form }: NotifierTelegramFormProps) => {
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.telegramBotToken"
render={({ field }) => (
<FormItem>
<FormLabel>Telegram Bot Token</FormLabel>
<FormControl>
<PasswordInput
{...field}
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.telegramChatId"
render={({ field }) => (
<FormItem>
<FormLabel>Telegram Chat ID</FormLabel>
<FormControl>
<Input {...field} placeholder="-100123456789 or 123456789" />
</FormControl>
<p className="text-xs text-muted-foreground">
You must start the conversation with the bot first (
<strong>/start</strong>). <br />
For groups, add the bot to the group. <br />
You can use{" "}
<a
href="https://t.me/userinfobot"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
@userinfobot
</a>{" "}
to find your ID.
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.telegramTopicId"
render={({ field }) => (
<FormItem>
<FormLabel>Telegram Topic ID</FormLabel>
<FormControl>
<Input {...field} placeholder="123456" />
</FormControl>
<p className="text-xs text-muted-foreground">
Unique identifier for the target message thread (topic) of the
forum; for forum supergroups only
</p>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,20 @@
import { z } from "zod";
export const TelegramChannelConfigSchema = z
.object({
telegramBotToken: z.string().min(1, "Bot token is required"),
telegramChatId: z.string().min(1, "Chat ID is required"),
telegramTopicId: z.string().optional(),
})
.refine(
(data) => {
if (data.telegramTopicId && !data.telegramChatId) {
return false;
}
return true;
},
{
message: "Chat ID is required when a Topic ID is provided",
path: ["telegramChatId"],
},
);
@@ -1,65 +1,72 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierWebhookFormProps = {
form: UseFormReturn<any, any, any>
}
form: UseFormReturn<any, any, any>;
};
export const NotifierWebhookForm = ({form}: NotifierWebhookFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.webhookUrl"
render={({field}) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://example.com/api/webhook"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex gap-4">
<div className="flex-1">
<FormField
control={form.control}
name="config.webhookSecretHeader"
render={({field}) => (
<FormItem>
<FormLabel>Header Name (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="X-Webhook-Secret"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<div className="flex-1">
<FormField
control={form.control}
name="config.webhookSecret"
render={({field}) => (
<FormItem>
<FormLabel>Secret Value (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Secret value..."/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
If provided, the secret will be sent in the specified header (defaults to <code>X-Webhook-Secret</code>).
</p>
</>
)
}
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://example.com/api/webhook" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4">
<div className="flex-1">
<FormField
control={form.control}
name="config.webhookSecretHeader"
render={({ field }) => (
<FormItem>
<FormLabel>Header Name (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="X-Webhook-Secret" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex-1">
<FormField
control={form.control}
name="config.webhookSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Value (Optional)</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="Secret value..." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
If provided, the secret will be sent in the specified header (defaults
to <code>X-Webhook-Secret</code>).
</p>
</>
);
};
@@ -0,0 +1,7 @@
import {z} from "zod";
export const WebhookChannelConfigSchema = z.object({
webhookUrl: z.string().url("Must be a valid URL"),
webhookSecretHeader: z.string().optional(),
webhookSecret: z.string().optional(),
});
@@ -0,0 +1,8 @@
import {z} from "zod";
export const GoogleDriveChannelConfigSchema = z.object({
clientId: z.string().min(1, "Client ID is required"),
clientSecret: z.string().min(1, "Client Secret is required"),
folderId: z.string().min(1, "Folder ID is required"),
refreshToken: z.string().optional(),
});
@@ -0,0 +1,3 @@
import {z} from "zod";
export const LocalChannelConfigSchema = z.object({});
@@ -1,115 +1,116 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import {PasswordInput} from "@/components/ui/password-input";
import {Switch} from "@/components/ui/switch";
import { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
type StorageS3FormProps = {
form: UseFormReturn<any, any, any>
}
form: UseFormReturn<any, any, any>;
};
export const StorageS3Form = ({form}: StorageS3FormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.endPointUrl"
render={({field}) => (
<FormItem>
<FormLabel>Endpoint URL</FormLabel>
<FormControl>
<Input {...field} placeholder="s3.amazonaws.com"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.region"
render={({field}) => (
<FormItem>
<FormLabel>Region</FormLabel>
<FormControl>
<Input {...field} placeholder="us-east-1"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.accessKey"
render={({field}) => (
<FormItem>
<FormLabel>Access Key</FormLabel>
<FormControl>
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.secretKey"
render={({field}) => (
<FormItem>
<FormLabel>Secret Key</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.bucketName"
render={({field}) => (
<FormItem>
<FormLabel>Bucket name</FormLabel>
<FormControl>
<Input {...field} placeholder="portabase-dev"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input {...field} type="number" placeholder="443" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
export const StorageS3Form = ({ form }: StorageS3FormProps) => {
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.endPointUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint URL</FormLabel>
<FormControl>
<Input {...field} placeholder="s3.amazonaws.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.region"
render={({ field }) => (
<FormItem>
<FormLabel>Region</FormLabel>
<FormControl>
<Input {...field} placeholder="us-east-1" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.accessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key</FormLabel>
<FormControl>
<Input {...field} placeholder="A1b2C3d4E5f6G7h" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.secretKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Key</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.bucketName"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket name</FormLabel>
<FormControl>
<Input {...field} placeholder="portabase-dev" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input {...field} type="number" placeholder="443" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ssl"
render={({ field }) => (
<FormItem>
<FormLabel>Use SSL</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)
}
<FormField
control={form.control}
name="config.ssl"
render={({ field }) => (
<FormItem>
<FormLabel>Use SSL</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,11 @@
import {z} from "zod";
export const S3ChannelConfigSchema = z.object({
endPointUrl: z.string().min(1, "Endpoint URL is required"),
region: z.string().optional(),
accessKey: z.string().min(1, "Access Key is required"),
secretKey: z.string().min(1, "Secret Key is required"),
bucketName: z.string().min(1, "Bucket name is required"),
port: z.coerce.number().optional(),
ssl: z.boolean().optional().default(true),
});
@@ -1,60 +1,65 @@
"use client"
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {useState} from "react";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelCard} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
"use client";
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
import { useState } from "react";
import { EmptyStatePlaceholder } from "@/components/wrappers/common/empty-state-placeholder";
import { OrganizationWithMembers } from "@/db/schema/03_organization";
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
import { ChannelCard } from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
import { ChannelAddEditModal } from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import {
ChannelKind,
getChannelTextBasedOnKind,
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
type ChannelsSectionProps = {
channels: NotificationChannelWith[] | StorageChannelWith[],
organizations: OrganizationWithMembers[],
kind: ChannelKind,
defaultStorageChannelId?: string | null | undefined
}
channels: NotificationChannelWith[] | StorageChannelWith[];
organizations: OrganizationWithMembers[];
kind: ChannelKind;
defaultStorageChannelId?: string | null | undefined;
};
export const ChannelsSection = ({
organizations,
channels,
kind,
defaultStorageChannelId
}: ChannelsSectionProps) => {
organizations,
channels,
kind,
defaultStorageChannelId,
}: ChannelsSectionProps) => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const channelText = getChannelTextBasedOnKind(kind);
const hasChannels = channels.length > 0;
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const channelText = getChannelTextBasedOnKind(kind)
const hasChannels = channels.length > 0
return (
return (
<div className="h-full">
<ChannelAddEditModal
kind={kind}
open={isAddModalOpen}
onOpenChangeAction={setIsAddModalOpen}
adminView={false}
trigger={false}
/>
{hasChannels ? (
<div className="h-full">
<ChannelAddEditModal kind={kind} open={isAddModalOpen} onOpenChangeAction={setIsAddModalOpen}
adminView={false}
trigger={false}/>
{hasChannels ? (
<div className="h-full">
<CardsWithPagination
data={channels}
cardItem={ChannelCard}
cardsPerPage={8}
numberOfColumns={2}
adminView={true}
organizations={organizations}
kind={kind}
defaultStorageChannelId={defaultStorageChannelId}
/>
</div>
) : (
<EmptyStatePlaceholder
text={`No ${channelText} channels configured yet`}
onClick={() => {
setIsAddModalOpen(true)
}}
className="h-full"
/>
)}
<CardsWithPagination
data={channels}
cardItem={ChannelCard}
cardsPerPage={8}
numberOfColumns={2}
adminView={true}
organizations={organizations}
kind={kind}
defaultStorageChannelId={defaultStorageChannelId}
/>
</div>
);
}
) : (
<EmptyStatePlaceholder
text={`No ${channelText} channels configured yet`}
onClick={() => {
setIsAddModalOpen(true);
}}
className="h-full"
/>
)}
</div>
);
};
@@ -1,62 +1,241 @@
"use client"
import {Eye} from "lucide-react";
import {useState} from "react";
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
AlertCircle,
Braces,
Database,
ExternalLink,
Eye,
Hash,
Server,
Sparkles,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {Separator} from "@/components/ui/separator";
import {NotificationLogWithRelations} from "@/db/services/notification-log";
import type { NotificationLogWithRelations } from "@/db/services/notification-log";
type NotificationLogModalProps = {
notificationLog: NotificationLogWithRelations;
}
notificationLog: NotificationLogWithRelations;
};
export const NotificationLogModal = ({notificationLog}: NotificationLogModalProps) => {
const [open, setOpen] = useState(false);
const getTroubleshootingForError = (errorMsg?: any) => {
if (!errorMsg || String(errorMsg).toLowerCase() === "null") return null;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" onClick={() => setOpen(true)} className="relative">
<Eye/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Notification details</DialogTitle>
<DialogDescription>
Details of the notification sent
</DialogDescription>
<Separator className="mt-3 mb-3"/>
const msg = String(errorMsg).toLowerCase();
<div className="space-y-2 mb-4">
<div>
<span className="font-semibold">Title:</span>{" "}
<span>{notificationLog.content.title}</span>
</div>
<div>
<span className="font-semibold">Message:</span>{" "}
<span>{notificationLog.content.message}</span>
</div>
if (msg.includes("database connection") || msg.includes("agent")) {
return {
title: "Agent Connection Issue",
resolution:
"It appears the agent cannot reach the database. Please ensure that your agent is actively running, your PostgreSQL credentials are correct, and port 5432 is open on your firewall.",
docLink:
"https://portabase.io/docs/agent/troubleshooting/agent-connection",
};
}
if (msg.includes("timeout")) {
return {
title: "Timeout",
resolution:
"The agent is taking too long to respond. This could be due to high server load, network issues, or a large database.",
docLink: "https://portabase.io/docs/agent/troubleshooting/timeout",
};
}
return {
title: "Unexpected Execution Error",
resolution:
"An unexpected error occurred during execution. Please review the raw system logs or contact support if the issue persists.",
docLink: "https://discord.gg/Wgv7xZ8fWJ",
};
};
const getIconForKey = (key: string, value: any) => {
const isActualError =
key.toLowerCase().includes("error") &&
value !== null &&
String(value).toLowerCase() !== "null";
if (key.toLowerCase().includes("id"))
return <Hash className="w-3.5 h-3.5 text-muted-foreground" />;
if (key.toLowerCase().includes("host"))
return <Server className="w-3.5 h-3.5 text-muted-foreground" />;
if (isActualError)
return <AlertCircle className="w-3.5 h-3.5 text-destructive" />;
return <Database className="w-3.5 h-3.5 text-muted-foreground" />;
};
export const NotificationLogModal = ({
notificationLog,
}: NotificationLogModalProps) => {
const [open, setOpen] = useState(false);
// TODO : to be fixed
// @ts-ignore
const payloadError = notificationLog.payload?.error;
const troubleshooting = getTroubleshootingForError(payloadError);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
onClick={() => setOpen(true)}
className="relative"
>
<Eye className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl bg-background">
<DialogHeader>
<DialogTitle>Notification Details</DialogTitle>
<DialogDescription>Execution logs and payload data</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2 max-h-[70vh] overflow-y-auto px-1 pb-2">
<div className="relative border rounded-xl bg-card shadow-sm flex flex-col">
<div className="bg-primary/5 border-b px-4 py-2.5 flex items-center gap-2 rounded-t-xl">
<div className="p-1 bg-primary/10 rounded-md">
<Database className="w-4 h-4 text-primary" />
</div>
<span className="font-semibold text-sm text-primary">
Event Trigger
</span>
</div>
<div className="p-4 flex flex-col gap-4">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-1 sm:gap-4">
<span className="text-xs font-medium text-muted-foreground min-w-20 pt-0.5">
Title
</span>
<span className="text-sm sm:text-right flex-1 wrap-break-words">
{notificationLog.content.title}
</span>
</div>
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-1 sm:gap-4">
<span className="text-xs font-medium text-muted-foreground min-w-20 pt-0.5">
Message
</span>
<span className="text-sm sm:text-right flex-1 wrap-break-word text-muted-foreground">
{notificationLog.content.message}
</span>
</div>
</div>
{notificationLog.payload && (
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background z-10" />
)}
</div>
<div className="w-px h-6 bg-border mx-auto -my-4 relative z-0" />
{notificationLog.payload &&
Object.keys(notificationLog.payload).length > 0 && (
<div className="relative border rounded-xl bg-card shadow-sm flex flex-col">
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background z-10" />
<div className="bg-primary/5 border-b px-4 py-2.5 flex items-center justify-between rounded-t-xl">
<div className="flex items-center gap-2">
<div className="p-1 bg-primary/10 rounded-md">
<Braces className="w-4 h-4 text-primary" />
</div>
<span className="font-semibold text-sm text-primary">
JSON Payload
</span>
</div>
</div>
{notificationLog.payload && (
<>
<Separator className="mb-3" />
<div className="text-sm font-mono bg-gray-100 dark:bg-gray-800 dark:text-gray-200 p-4 rounded-md overflow-auto">
{JSON.stringify(notificationLog.payload, null, 2)}
</div>
</>
)}
</DialogHeader>
</DialogContent>
</Dialog>
)
}
<div className="flex flex-col">
{Object.entries(notificationLog.payload).map(
([key, value], index, arr) => {
const isActualError =
key === "error" &&
value !== null &&
String(value).toLowerCase() !== "null";
return (
<div
key={key}
className={`flex flex-col sm:flex-row sm:items-center justify-between gap-2 px-4 py-3 hover:bg-muted/50 transition-colors ${
index !== arr.length - 1
? "border-b border-border/50"
: ""
}`}
>
<div className="flex items-center gap-2 min-w-[150px]">
{getIconForKey(key, value)}
<span className="text-sm font-medium">{key}</span>
</div>
<div className="flex-1 sm:text-right">
{isActualError ? (
<span className="inline-flex items-center px-2 py-1 rounded-md bg-destructive/10 text-destructive text-xs font-mono break-all">
{String(value)}
</span>
) : (
<span className="text-sm text-muted-foreground font-mono bg-muted px-2 py-1 rounded-md break-all">
{String(value)}
</span>
)}
</div>
</div>
);
},
)}
</div>
{troubleshooting && (
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background z-10" />
)}
</div>
)}
{troubleshooting && (
<>
<div className="w-px h-6 bg-border mx-auto -my-4 relative z-0" />
<div className="relative border border-destructive/20 rounded-xl bg-gradient-to-b from-destructive/5 to-transparent shadow-sm flex flex-col">
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-destructive rounded-full border-2 border-background z-10" />
<div className="bg-destructive/10 border-b border-destructive/10 px-4 py-2.5 flex items-center justify-between rounded-t-xl">
<div className="flex items-center gap-2">
<div className="p-1 bg-destructive/20 rounded-md">
<Sparkles className="w-4 h-4 text-destructive" />
</div>
<span className="font-semibold text-sm text-destructive">
Suggested Resolution
</span>
</div>
</div>
<div className="p-4 flex flex-col gap-2">
<h4 className="text-sm font-semibold text-foreground">
{troubleshooting.title}
</h4>
<p className="text-sm text-muted-foreground leading-relaxed">
{troubleshooting.resolution}
</p>
<div className="mt-2">
<a
href={troubleshooting.docLink}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-xs font-semibold text-destructive hover:text-destructive/80 transition-colors"
>
Read the documentation{" "}
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
};
@@ -1,49 +1,72 @@
"use client";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Database, DatabaseZap} from "lucide-react";
import {useIsMobile} from "@/hooks/use-mobile";
import { backupButtonAction } from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
import { Check, DatabaseZap, X } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
export type BackupButtonProps = {
databaseId: string;
disable: boolean;
databaseId: string;
disable: boolean;
};
export const BackupButton = (props: BackupButtonProps) => {
const queryClient = useQueryClient();
const router = useRouter();
const isMobile = useIsMobile()
const queryClient = useQueryClient();
const router = useRouter();
const isMobile = useIsMobile();
const mutation = useMutation({
mutationFn: async (databaseId: string) => {
const backup = await backupButtonAction(databaseId);
if (backup?.data?.success) {
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
router.refresh();
} else {
toast.error(backup?.serverError || "Failed to create backup.");
}
const mutation = useMutation({
mutationFn: async (databaseId: string) => {
const backup = await backupButtonAction(databaseId);
if (backup?.data?.success) {
toast.success(
backup.data.actionSuccess?.message || "Backup created successfully!",
);
queryClient.invalidateQueries({
queryKey: ["database-data", props.databaseId],
});
router.refresh();
} else {
toast.error(backup?.serverError || "Failed to create backup.");
}
},
});
const HandleAction = async () => {
await mutation.mutateAsync(props.databaseId);
};
return (
<ButtonWithConfirm
title="Create Backup"
description={"Are you sure you want to create a backup?"}
button={{
main: {
disabled: props.disable,
text: isMobile ? "" : "Backup",
variant: "default",
icon: <DatabaseZap />,
},
});
const HandleAction = async () => {
await mutation.mutateAsync(props.databaseId);
};
return (
<ButtonWithLoading
icon={<DatabaseZap/>}
disabled={props.disable}
isPending={mutation.isPending}
size={"default"}
onClick={async () => {
await HandleAction();
}}
>{isMobile ? "" : "Backup"}</ButtonWithLoading>
);
confirm: {
className: "w-full",
text: "Yes, create backup",
icon: <Check />,
variant: "default",
onClick: async () => {
await HandleAction();
},
},
cancel: {
className: "w-full",
text: "No, cancel",
icon: <X />,
variant: "outline",
},
}}
isPending={mutation.isPending}
/>
);
};
@@ -1,17 +0,0 @@
"use client";
import { buttonVariants } from "@/components/ui/button";
import { GearIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { usePathname } from "next/navigation";
export type EditButtonProps = {};
export const EditButton = (props: EditButtonProps) => {
const pathname = usePathname();
return (
<Link className={buttonVariants({ variant: "outline" })} href={`${pathname}/edit`}>
<GearIcon className="w-7 h-7" />
</Link>
);
};
@@ -1,144 +1,158 @@
"use client"
import {DatabaseBackupActionsModal} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
import {Setting} from "@/db/schema/01_setting";
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
import {MemberWithUser} from "@/db/schema/03_organization";
import {useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {useQuery, useQueryClient} from "@tanstack/react-query";
import {getDatabaseDataAction} from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
import {PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {capitalizeFirstLetter} from "@/utils/text";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
import {HardDrive, Megaphone} from "lucide-react";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
"use client";
import { DatabaseBackupActionsModal } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
import { DatabaseTabs } from "@/components/wrappers/dashboard/projects/database/database-tabs";
import { Setting } from "@/db/schema/01_setting";
import { BackupWith, DatabaseWith, Restoration } from "@/db/schema/07_database";
import { MemberWithUser } from "@/db/schema/03_organization";
import { useBackupModal } from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import { DatabaseKpi } from "@/components/wrappers/dashboard/projects/database/database-kpi";
import { useQuery } from "@tanstack/react-query";
import { getDatabaseDataAction } from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
import {
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
import { capitalizeFirstLetter } from "@/utils/text";
import { RetentionPolicySheet } from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import { CronButton } from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import { ChannelPoliciesModal } from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
import { HardDrive, Megaphone } from "lucide-react";
import { ImportModal } from "@/components/wrappers/dashboard/database/import/import-modal";
import { BackupButton } from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
export type DatabaseContentProps = {
settings: Setting,
backups: BackupWith[],
restorations: Restoration[],
isAlreadyRestore: boolean,
database: DatabaseWith,
activeMember: MemberWithUser,
totalBackups: number,
availableBackups: number,
successRate: number | null,
organizationId: string,
activeOrganizationChannels: any[],
activeOrganizationStorageChannels: any[]
}
settings: Setting;
backups: BackupWith[];
restorations: Restoration[];
isAlreadyRestore: boolean;
database: DatabaseWith;
activeMember: MemberWithUser;
totalBackups: number;
availableBackups: number;
successRate: number | null;
organizationId: string;
activeOrganizationChannels: any[];
activeOrganizationStorageChannels: any[];
};
export const DatabaseContent = (props: DatabaseContentProps) => {
const {} = useBackupModal();
const {} = useBackupModal();
const {data} = useQuery({
queryKey: ["database-data", props.database.id],
queryFn: async () => {
const result = await getDatabaseDataAction({databaseId: props.database.id});
return result?.data;
},
initialData: {
// TODO : to be patched
// @ts-ignore
database: {
...props.database,
project: props.database.project ?? null,
},
backups: props.backups,
restorations: props.restorations,
activeOrganizationChannels: props.activeOrganizationChannels,
activeOrganizationStorageChannels: props.activeOrganizationStorageChannels,
stats: {
totalBackups: props.totalBackups,
availableBackups: props.availableBackups,
successRate: props.successRate
}
},
staleTime: 0,
gcTime: 0,
refetchInterval: 1000,
});
const database = data?.database ?? props.database;
const backups = data?.backups ?? props.backups;
const restorations = data?.restorations ?? props.restorations;
const activeOrganizationChannels = data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
const activeOrganizationStorageChannels = data?.activeOrganizationStorageChannels ?? props.activeOrganizationStorageChannels;
const stats = data?.stats ?? {
const { data } = useQuery({
queryKey: ["database-data", props.database.id],
queryFn: async () => {
const result = await getDatabaseDataAction({
databaseId: props.database.id,
});
return result?.data;
},
initialData: {
// TODO : to be patched
// @ts-ignore
database: {
...props.database,
project: props.database.project ?? null,
},
backups: props.backups,
restorations: props.restorations,
activeOrganizationChannels: props.activeOrganizationChannels,
activeOrganizationStorageChannels:
props.activeOrganizationStorageChannels,
stats: {
totalBackups: props.totalBackups,
availableBackups: props.availableBackups,
successRate: props.successRate
};
successRate: props.successRate,
},
},
staleTime: 0,
gcTime: 0,
refetchInterval: 1000,
});
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const database = data?.database ?? props.database;
const backups = data?.backups ?? props.backups;
const restorations = data?.restorations ?? props.restorations;
const activeOrganizationChannels =
data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
const activeOrganizationStorageChannels =
data?.activeOrganizationStorageChannels ??
props.activeOrganizationStorageChannels;
const stats = data?.stats ?? {
totalBackups: props.totalBackups,
availableBackups: props.availableBackups,
successRate: props.successRate,
};
const isMember = props.activeMember.role === "member";
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const isAlreadyBackup = backups.some(
(b) => b.status === "waiting" || b.status === "ongoing",
);
return (
<>
<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(database.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<RetentionPolicySheet database={database}/>
<CronButton database={database}/>
<ChannelPoliciesModal
database={database}
kind={"notification"}
icon={<Megaphone/>}
channels={activeOrganizationChannels}
organizationId={props.organizationId}
/>
<ChannelPoliciesModal
database={database}
icon={<HardDrive/>}
kind={"storage"}
channels={activeOrganizationStorageChannels}
organizationId={props.organizationId}
/>
<ImportModal database={database}/>
</div>
<div className="flex items-center gap-2">
<BackupButton disable={isAlreadyBackup} databaseId={database.id}/>
</div>
</div>
)}
</PageTitle>
const isMember = props.activeMember.role === "member";
return (
<>
<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(database.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<RetentionPolicySheet database={database} />
<CronButton database={database} />
<ChannelPoliciesModal
database={database}
kind={"notification"}
icon={<Megaphone />}
channels={activeOrganizationChannels}
organizationId={props.organizationId}
/>
<ChannelPoliciesModal
database={database}
icon={<HardDrive />}
kind={"storage"}
channels={activeOrganizationStorageChannels}
organizationId={props.organizationId}
/>
<ImportModal database={database} />
</div>
<div className="flex items-center gap-2">
<BackupButton
disable={isAlreadyBackup}
databaseId={database.id}
/>
</div>
</div>
)}
</PageTitle>
</div>
{database.description && (
<PageDescription className="mt-5 sm:mt-0">{database.description}</PageDescription>
)}
{database.description && (
<PageDescription className="mt-5 sm:mt-0">
{database.description}
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full">
<DatabaseKpi
successRate={stats.successRate}
database={database}
availableBackups={stats.availableBackups}
totalBackups={stats.totalBackups}
/>
<DatabaseBackupActionsModal/>
<DatabaseTabs
activeMember={props.activeMember}
settings={props.settings}
database={database}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}
/>
</PageContent>
</>
)
}
<PageContent className="flex flex-col w-full h-full">
<DatabaseKpi
successRate={stats.successRate}
database={database}
availableBackups={stats.availableBackups}
totalBackups={stats.totalBackups}
/>
<DatabaseBackupActionsModal />
<DatabaseTabs
activeMember={props.activeMember}
settings={props.settings}
database={database}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}
/>
</PageContent>
</>
);
};
+2 -2
View File
@@ -69,8 +69,8 @@ export const env = createEnv({
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: z.enum(["true", "false"]).default("true"),
AUTH_ROLE_MAP: z.string().optional(),
AUTH_DEFAULT_ROLE: z.string().optional().default("pending"),
AUTH_ALLOW_LINKING: z.enum(["true", "false"]).default("false"),
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("false"),
AUTH_ALLOW_LINKING: z.enum(["true", "false"]).default("true"),
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("true"),
PRIVATE_PATH: z.string().optional(),
},
+156 -121
View File
@@ -2,148 +2,183 @@
import { ColumnDef } from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import {MoreHorizontal, Trash2} from "lucide-react";
import { Check, MoreHorizontal, Trash2, X } from "lucide-react";
import { ReloadIcon } from "@radix-ui/react-icons";
import { StatusBadge } from "@/components/wrappers/common/status-badge";
import {Restoration} from "@/db/schema/07_database";
import {formatLocalizedDate} from "@/utils/date-formatting";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import { Restoration } from "@/db/schema/07_database";
import { formatLocalizedDate } from "@/utils/date-formatting";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
deleteRestoreAction,
rerunRestorationAction
deleteRestoreAction,
rerunRestorationAction,
} from "@/features/dashboard/restore/restore.action";
import {toast} from "sonner";
import {TooltipCustom} from "@/components/wrappers/common/tooltip-custom";
import {MemberWithUser} from "@/db/schema/03_organization";
import { toast } from "sonner";
import { TooltipCustom } from "@/components/wrappers/common/tooltip-custom";
import { MemberWithUser } from "@/db/schema/03_organization";
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
export function restoreColumns(
isAlreadyRestore: boolean,
activeMember: MemberWithUser
isAlreadyRestore: boolean,
activeMember: MemberWithUser,
): ColumnDef<Restoration>[] {
return[
return [
{
accessorKey: "id",
header: "Reference",
accessorKey: "id",
header: "Reference",
},
{
accessorKey: "createdAt",
header: "Created At",
cell: ({ row }) => {
return formatLocalizedDate(row.getValue("createdAt"))
},
accessorKey: "createdAt",
header: "Created At",
cell: ({ row }) => {
return formatLocalizedDate(row.getValue("createdAt"));
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
return <StatusBadge status={row.getValue("status")} />;
},
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
return <StatusBadge status={row.getValue("status")} />;
},
},
{
id: "actions",
cell: ({ row }) => {
const status = row.getValue("status");
id: "actions",
cell: ({ row }) => {
const status = row.getValue("status");
const queryClient = useQueryClient();
const rowData: Restoration = row.original;
const queryClient = useQueryClient();
const rowData: Restoration = row.original;
const mutationDeleteRestore = useMutation({
mutationFn: async () => {
const restoration = await deleteRestoreAction({
restorationId: rowData.id,
});
// @ts-ignore
if (restoration.data.success) {
// @ts-ignore
toast.success(restoration.data.actionSuccess.message);
queryClient.invalidateQueries({queryKey: ["database-data", rowData.databaseId]});
} else {
// @ts-ignore
toast.error(restoration.data.actionError.message);
}
},
const mutationDeleteRestore = useMutation({
mutationFn: async () => {
const restoration = await deleteRestoreAction({
restorationId: rowData.id,
});
const mutationRerunRestore = useMutation({
mutationFn: async () => {
const restoration = await rerunRestorationAction({
restorationId: rowData.id,
});
// @ts-ignore
if (restoration.data.success) {
// @ts-ignore
toast.success(restoration.data.actionSuccess.message);
queryClient.invalidateQueries({queryKey: ["database-data", rowData.databaseId]});
} else {
// @ts-ignore
toast.error(restoration.data.actionError.message);
}
},
});
const handleDelete = async () => {
await mutationDeleteRestore.mutateAsync();
};
const handleRerunRestore = async () => {
await mutationRerunRestore.mutateAsync();
// @ts-ignore
if (restoration.data.success) {
// @ts-ignore
toast.success(restoration.data.actionSuccess.message);
queryClient.invalidateQueries({
queryKey: ["database-data", rowData.databaseId],
});
} else {
// @ts-ignore
toast.error(restoration.data.actionError.message);
}
},
});
const mutationRerunRestore = useMutation({
mutationFn: async () => {
const restoration = await rerunRestorationAction({
restorationId: rowData.id,
});
// @ts-ignore
if (restoration.data.success) {
// @ts-ignore
toast.success(restoration.data.actionSuccess.message);
queryClient.invalidateQueries({
queryKey: ["database-data", rowData.databaseId],
});
} else {
// @ts-ignore
toast.error(restoration.data.actionError.message);
}
},
});
return (
<>
{activeMember.role != "member" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0" type="button" onClick={(e) => e.stopPropagation()}>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4"/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{rowData.backupStorageId && (
<TooltipCustom disabled={isAlreadyRestore} text="Already a restoration waiting">
<DropdownMenuItem
disabled={mutationRerunRestore.isPending || isAlreadyRestore}
onClick={async () => {
await handleRerunRestore();
}}
>
<ReloadIcon/> Rerun
</DropdownMenuItem>
</TooltipCustom>
)}
<DropdownMenuSeparator/>
<DropdownMenuItem
disabled={status == "waiting"}
className="text-red-600"
onClick={async () => {
await handleDelete();
}}
>
<Trash2/> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
);
},
const handleDelete = async () => {
await mutationDeleteRestore.mutateAsync();
};
const handleRerunRestore = async () => {
await mutationRerunRestore.mutateAsync();
};
return (
<>
{activeMember.role !== "member" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
type="button"
onClick={(e) => e.stopPropagation()}
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{rowData.backupStorageId && (
<TooltipCustom
disabled={isAlreadyRestore}
text="Already a restoration waiting"
>
<DropdownMenuItem
disabled={
mutationRerunRestore.isPending || isAlreadyRestore
}
onClick={async () => {
await handleRerunRestore();
}}
>
<ReloadIcon /> Rerun
</DropdownMenuItem>
</TooltipCustom>
)}
<DropdownMenuSeparator />
<DropdownMenuItem>
<ButtonWithConfirm
title="Delete Restoration"
description={
"Are you sure you want to delete this restoration?"
}
button={{
main: {
text: "Delete",
variant: "ghost",
icon: (
<Trash2 className="text-red-500 size-4 mr-px" />
),
disabled: status === "waiting",
className:
"text-red-500 hover:text-red-500 p-0 h-auto has-[>svg]:px-0",
},
confirm: {
className: "w-full",
text: "Yes, delete",
icon: <Check />,
variant: "destructive",
onClick: async () => {
await handleDelete();
},
},
cancel: {
className: "w-full",
text: "No, cancel",
icon: <X />,
variant: "outline",
},
}}
isPending={mutationDeleteRestore.isPending}
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
);
},
},
];
];
}
+34 -3
View File
@@ -1,5 +1,36 @@
import type {EventPayload, DispatchResult} from '../types';
const getPriority = (level?: string): number => {
switch (level) {
case 'critical': return 5;
case 'error': return 4;
case 'warning': return 3;
case 'info':
default: return 2;
}
};
const formatData = (data: any): string => {
if (!data || typeof data !== 'object') return '';
return '\n\nDetails:\n' + Object.entries(data)
.map(([key, value]) => {
const stringVal = value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value);
return `- ${key}: ${stringVal}`;
})
.join('\n');
};
const getTags = (level?: string): string[] => {
const baseTags = ['floppy_disk'];
if (level === 'critical') baseTags.push('rotating_light');
else if (level === 'error') baseTags.push('x');
else if (level === 'warning') baseTags.push('warning');
else baseTags.push('information_source');
return baseTags;
};
export async function sendNtfy(
config: { ntfyServerUrl?: string; ntfyTopic: string; ntfyToken?: string, ntfyUsername?: string, ntfyPassword?: string },
payload: EventPayload
@@ -11,9 +42,9 @@ export async function sendNtfy(
const body = {
topic: ntfyTopic,
title: payload.title,
message: payload.message + (payload.data ? `\n\nData:\n${JSON.stringify(payload.data, null, 2)}` : ''),
priority: 1,
tags: [payload.level === 'critical' ? 'rotating_light' : payload.level === 'warning' ? 'warning' : 'information_source'],
message: payload.message + formatData(payload.data),
priority: getPriority(payload.level),
tags: getTags(payload.level),
};
const headers: Record<string, string> = {
@@ -1,49 +1,62 @@
import type {EventPayload, DispatchResult} from '../types';
import type { EventPayload, DispatchResult } from "../types";
export async function sendTelegram(
config: { telegramBotToken: string; telegramChatId: string },
payload: EventPayload
config: {
telegramBotToken: string;
telegramChatId: string;
telegramTopicId?: string;
},
payload: EventPayload,
): Promise<DispatchResult> {
const {telegramBotToken, telegramChatId} = config;
const { telegramBotToken, telegramChatId, telegramTopicId } = config;
// Helper to escape HTML characters
const escapeHtml = (unsafe: string) => {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
// Helper to escape HTML characters
const escapeHtml = (unsafe: string) => {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
const title = escapeHtml(payload.title);
const message = escapeHtml(payload.message);
const level = escapeHtml(payload.level.toUpperCase());
const dataString = payload.data ? escapeHtml(JSON.stringify(payload.data, null, 2).substring(0, 1000)) : '';
const title = escapeHtml(payload.title);
const message = escapeHtml(payload.message);
const level = escapeHtml(payload.level.toUpperCase());
const dataString = payload.data
? escapeHtml(JSON.stringify(payload.data, null, 2).substring(0, 1000))
: "";
const text = `<b>${title}</b>\n\n${message}\n\nLevel: <code>${level}</code>${payload.data ? `\n\nData:\n<pre>${dataString}</pre>` : ''}`;
const text = `<b>${title}</b>\n\n${message}\n\nLevel: <code>${level}</code>${payload.data ? `\n\nData:\n<pre>${dataString}</pre>` : ""}`;
const body = {
chat_id: telegramChatId,
text,
parse_mode: 'HTML',
};
const body: Record<string, any> = {
chat_id: telegramChatId,
text,
parse_mode: "HTML",
};
const res = await fetch(`https://api.telegram.org/bot${telegramBotToken}/sendMessage`, {
method: 'POST',
body: JSON.stringify(body),
headers: {'Content-Type': 'application/json'},
});
if (telegramTopicId) {
body.message_thread_id = Number(telegramTopicId);
}
if (!res.ok) {
const err = await res.text();
throw new Error(`Telegram error: ${res.status} ${err}`);
}
const res = await fetch(
`https://api.telegram.org/bot${telegramBotToken}/sendMessage`,
{
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
},
);
return {
success: true,
provider: 'telegram',
message: 'Sent to Telegram',
response: await res.text(),
};
if (!res.ok) {
const err = await res.text();
throw new Error(`Telegram error: ${res.status} ${err}`);
}
return {
success: true,
provider: "telegram",
message: "Sent to Telegram",
response: await res.text(),
};
}