Compare commits

..
145 Commits
Author SHA1 Message Date
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
49 changed files with 3291 additions and 1688 deletions
+1
View File
@@ -35,6 +35,7 @@ AUTH_OIDC_DISCOVERY_ENDPOINT=""
AUTH_OIDC_JWKS_ENDPOINT="" AUTH_OIDC_JWKS_ENDPOINT=""
AUTH_OIDC_PKCE=true AUTH_OIDC_PKCE=true
ALLOWED_GROUP="admin" ALLOWED_GROUP="admin"
AUTH_OIDC_ROLE_MAP=""
AUTH_ALLOW_LINKING=true AUTH_ALLOW_LINKING=true
AUTH_ALLOW_UNLINKING=true AUTH_ALLOW_UNLINKING=true
+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** 3. **Push your branch**
```bash ```bash
git push origin feature/<feature-name> git push origin feat/<feature-name>
``` ```
4. **Open a Pull Request (PR)** 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 ## 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. - Use meaningful variable names and include comments where necessary.
- Tests before submitting your changes. - 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! 🙌 Thank you for contributing! 🙌
--- ---
+49
View File
@@ -0,0 +1,49 @@
name: Auto Release bot
on:
pull_request:
types: [ closed ]
branches:
- main
jobs:
release:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: write
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: ${{ github.base_ref }}
- 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]'
- run: git config --global user.email 'github-actions[bot]@users.noreply.github.com'
- name: Fetch tags
run: git fetch --tags
- name: Run release-it
id: release_step
run: |
git pull origin main
pnpm exec release-it --ci
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
@@ -1,16 +1,8 @@
name: GitHub Release name: Discord Notification
on: on:
workflow_call: workflow_call:
inputs: inputs:
prerelease:
required: false
type: boolean
default: false
make_latest:
required: false
type: boolean
default: false
discord_title: discord_title:
required: true required: true
type: string type: string
@@ -27,74 +19,24 @@ on:
required: true required: true
jobs: jobs:
create-release: notify-discord:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
steps: 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 - name: Send Discord Notification
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GH_TOKEN }} GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: | run: |
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author) RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name) RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url) RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body) RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
AUTHOR_NAME="Portabase" AUTHOR_NAME="Portabase"
AUTHOR_ICON="https://github.com/Portabase.png" AUTHOR_ICON="https://github.com/Portabase.png"
PAYLOAD=$(jq -n \ PAYLOAD=$(jq -n \
--arg title "$RELEASE_TITLE" \ --arg title "$RELEASE_TITLE" \
@@ -105,7 +47,7 @@ jobs:
--arg discord_title "${{ inputs.discord_title }}" \ --arg discord_title "${{ inputs.discord_title }}" \
--arg discord_footer "${{ inputs.discord_footer }}" \ --arg discord_footer "${{ inputs.discord_footer }}" \
--argjson discord_color ${{ inputs.discord_color }} \ --argjson discord_color ${{ inputs.discord_color }} \
'{ '{
content: $discord_title, content: $discord_title,
embeds: [{ embeds: [{
title: $title, title: $title,
@@ -121,8 +63,8 @@ jobs:
} }
}] }]
}' }'
) )
curl -H "Content-Type: application/json" \ curl -H "Content-Type: application/json" \
-d "$PAYLOAD" \ -d "$PAYLOAD" \
"$DISCORD_WEBHOOK" "$DISCORD_WEBHOOK"
+40 -48
View File
@@ -6,7 +6,7 @@ on:
image_name: image_name:
required: false required: false
type: string type: string
default: 'portabase/portabase' default: "portabase/portabase"
add_latest: add_latest:
required: false required: false
type: boolean type: boolean
@@ -14,11 +14,11 @@ on:
target: target:
required: false required: false
type: string type: string
default: 'prod' default: "prod"
dockerfile: dockerfile:
required: false required: false
type: string type: string
default: './docker/dockerfile/Dockerfile' default: "./docker/dockerfile/Dockerfile"
secrets: secrets:
DOCKER_USERNAME: DOCKER_USERNAME:
required: true required: true
@@ -26,34 +26,32 @@ on:
required: true required: true
jobs: jobs:
build: build:
name: Build and push Docker images name: Build architectures
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }} runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
platform: [ linux/amd64, linux/arm64 ] platform: [linux/amd64, linux/arm64]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker - name: Login to Docker
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set image tag - name: Prepare Image Tags
id: set-tags id: prep
run: | run: |
REF_NAME=${GITHUB_REF#refs/tags/} ARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
if [ "${{ matrix.platform }}" = "linux/amd64" ]; then echo "image=${{ inputs.image_name }}:${GITHUB_REF#refs/tags/}-$ARCH" >> $GITHUB_OUTPUT
IMAGE="${{ inputs.image_name }}:$REF_NAME-amd64" echo "safe_platform=${{ matrix.platform == 'linux/amd64' && 'linux-amd64' || 'linux-arm64' }}" >> $GITHUB_OUTPUT
else
IMAGE="${{ inputs.image_name }}:$REF_NAME-arm64"
fi
echo "image=$IMAGE" >> $GITHUB_OUTPUT
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -62,61 +60,55 @@ jobs:
file: ${{ inputs.dockerfile }} file: ${{ inputs.dockerfile }}
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: true push: true
tags: ${{ steps.set-tags.outputs.image }} tags: ${{ steps.prep.outputs.image }}
target: ${{ inputs.target }} 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 - name: Save image name for manifest
id: artifact run: echo "${{ steps.prep.outputs.image }}" > image.txt
run: |
platform=${{ matrix.platform }}
echo "safe_platform=${platform//\//-}" >> $GITHUB_OUTPUT
echo "${{ steps.set-tags.outputs.image }}" > image.txt
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f - uses: actions/upload-artifact@v4
with: with:
name: image-${{ steps.artifact.outputs.safe_platform }} name: image-${{ steps.prep.outputs.safe_platform }}
path: image.txt path: image.txt
if-no-files-found: warn retention-days: 1
compression-level: 6
overwrite: false
include-hidden-files: false
create-manifest: create-manifest:
name: Create multi-arch Docker manifest (Portabase) name: Create multi-arch manifest
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
steps: steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 - uses: actions/download-artifact@v4
with: with:
name: image-linux-amd64 name: image-linux-amd64
path: /tmp/digests/amd64 path: /tmp/digests/amd64
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 - uses: actions/download-artifact@v4
with: with:
name: image-linux-arm64 name: image-linux-arm64
path: /tmp/digests/arm64 path: /tmp/digests/arm64
- name: Login to Docker - name: Login to Docker
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} 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 - name: Create and push manifest list
working-directory: /tmp/digests working-directory: /tmp/digests
run: | run: |
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)" DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
REF_NAME=${GITHUB_REF#refs/tags/} TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
MANIFEST_IMAGE="${{ inputs.image_name }}:$REF_NAME" docker buildx imagetools create $DOCKER_IMAGES $TAG_ARGS
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
+47
View File
@@ -0,0 +1,47 @@
name: PR Checker
on:
pull_request:
branches:
- main
- dev
jobs:
validate-code:
name: Code Integrity Check
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check code formatting
run: pnpm run format:check
continue-on-error: true
- name: Run Linter
run: pnpm run lint
continue-on-error: true
- name: Type Check
run: pnpm run typecheck
continue-on-error: true
- name: Run Tests
run: pnpm run test
continue-on-error: true
- name: Test Application Build
run: pnpm run build
+8 -9
View File
@@ -3,29 +3,28 @@ name: Publish Docker image for release candidate
on: on:
push: push:
tags: tags:
- '*-rc*' - "*.*.*-rc*"
- "!*-*-rc*"
permissions: permissions:
contents: write contents: read
packages: write packages: write
jobs: jobs:
docker_publish: docker_publish:
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
add_latest: false add_latest: true
secrets: secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release: discord_notification:
needs: docker_publish needs: docker_publish
uses: ./.github/workflows/github.yml uses: ./.github/workflows/discord.yml
with: with:
prerelease: true discord_title: "New Release Candidate: ${{ github.ref_name }}"
make_latest: false discord_color: 2044800
discord_title: "||@release-dashboard|| New release candidate published"
discord_color: 16776960
discord_footer: "Portabase" discord_footer: "Portabase"
secrets: secrets:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
+35 -11
View File
@@ -1,16 +1,26 @@
name: Publish Docker image for release name: Publish Docker image for release
on: on:
release:
types: [ created ]
push: push:
tags: tags:
- '*.*.*' - "*.*.*"
- '!*-*' - "!*-*"
permissions: permissions:
contents: write contents: read
packages: write packages: write
jobs: jobs:
log-draft-tag:
runs-on: ubuntu-latest
steps:
- name: Read draft tag
run: |
echo ${{ github.event.release.id }}
docker_publish: docker_publish:
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
@@ -18,16 +28,30 @@ jobs:
secrets: secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
publish_release:
github_release:
needs: docker_publish needs: docker_publish
uses: ./.github/workflows/github.yml runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Publish GitHub Release
if: ${{ github.event.release.draft == true }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# gh release edit v${{ github.ref_name }} --draft=false
run: |
gh release edit ${{ github.event.release.id }} --draft=false
discord_notification:
needs: docker_publish
uses: ./.github/workflows/discord.yml
with: with:
prerelease: false discord_title: "New Release: v${{ github.ref_name }}"
make_latest: true discord_color: 3066993
discord_title: "||@release-dashboard|| New release published"
discord_color: 5814783
discord_footer: "Portabase" discord_footer: "Portabase"
secrets: secrets:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -4
View File
@@ -5,7 +5,7 @@ on:
branches: [ main ] branches: [ main ]
jobs: jobs:
sca-deps: # Dependency & container scan sca-deps:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -17,15 +17,16 @@ jobs:
ignore-unfixed: true ignore-unfixed: true
secrets-gitleaks: # Secrets exposure secrets-gitleaks:
if: github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 # Fetch full history for gitleaks fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2 - uses: gitleaks/gitleaks-action@v2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
with: 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 v${version}",
"requireCleanWorkingDir": true,
"tag": true,
"tagName": "v${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 cff-version: 1.2.0
title: Portabase 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 type: software
authors: authors:
- family-names: Gauthereau - family-names: Gauthereau
@@ -9,7 +9,12 @@ authors:
given-names: Killian given-names: Killian
repository-code: https://github.com/Portabase/portabase repository-code: https://github.com/Portabase/portabase
url: https://portabase.io 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: keywords:
- docker - docker
- kubernetes - kubernetes
@@ -26,5 +31,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.4.1 version: 1.4.42
date-released: "2026-02-27" 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/) [![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/) [![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/) [![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/) [![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) [![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) [![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 { PageParams } from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {
import {currentUser} from "@/lib/auth/current-user"; Page,
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; PageActions,
import {notFound} from "next/navigation"; PageContent,
import {Metadata} from "next"; PageHeader,
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs"; PageTitle,
import {getOrganizationChannels} from "@/db/services/notification-channel"; } from "@/features/layout/page";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; import { currentUser } from "@/lib/auth/current-user";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button"; import { notFound } from "next/navigation";
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog"; import { Metadata } from "next";
import {Button} from "@/components/ui/button"; import { OrganizationTabs } from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
import {GearIcon} from "@radix-ui/react-icons"; import { getOrganizationChannels } from "@/db/services/notification-channel";
import {db} from "@/db"; import { computeOrganizationPermissions } from "@/lib/acl/organization-acl";
import {isNull} from "drizzle-orm"; 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 * as drizzleDb from "@/db";
import {eq} from "drizzle-orm"; import { eq } from "drizzle-orm";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Settings", title: "Settings",
}; };
export default async function RoutePage(props: PageParams<{ slug: string }>) { export default async function RoutePage(props: PageParams<{ slug: string }>) {
const organization = await getOrganization({}); const organization = await getOrganization({});
const user = await currentUser(); const user = await currentUser();
const activeMember = await getActiveMember() const activeMember = await getActiveMember();
if (!organization || !activeMember || !user) { if (!organization || !activeMember || !user) {
notFound(); notFound();
} }
const notificationChannels = await getOrganizationChannels(organization.id) const notificationChannels = await getOrganizationChannels(organization.id);
const storageChannels = await getOrganizationStorageChannels(organization.id) const storageChannels = await getOrganizationStorageChannels(organization.id);
const permissions = computeOrganizationPermissions(activeMember); const permissions = computeOrganizationPermissions(activeMember);
const users = await db.query.user.findMany({ const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt) where: (fields) => isNull(fields.deletedAt),
}); });
const organizationWithMembers = await db.query.organization.findFirst({ const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id), where: eq(drizzleDb.schemas.organization.id, organization.id),
with: {
members: {
with: { with: {
members: { user: true,
with: { },
user: true },
} },
} });
}
});
if (!organizationWithMembers) notFound(); if (!organizationWithMembers) notFound();
return ( return (
<Page> <Page>
<PageHeader> <PageHeader>
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full "> <PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit "> <div className="min-w-full md:min-w-fit ">Organization settings</div>
Organization settings <div className="flex items-center gap-2 md:justify-between w-full ">
</div> <div className="flex items-center gap-2">
<div className="flex items-center gap-2 md:justify-between w-full "> {permissions.canManageSettings &&
<div className="flex items-center gap-2"> organization.slug !== "default" && (
{permissions.canManageSettings && organization.slug !== "default" && ( <EditOrganizationDialog
<EditOrganizationDialog organization={organizationWithMembers}
organization={organizationWithMembers} users={users}
users={users} currentUser={user}
currentUser={user} />
/> )}
)} </div>
</div> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> {permissions.canManageDangerZone &&
{permissions.canManageDangerZone && organization.slug !== "default" && ( organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/> <DeleteOrganizationButton
)} organizationSlug={organization.slug}
</div> />
</div> )}
</PageTitle> </div>
</PageHeader> </div>
<PageContent> </PageTitle>
<OrganizationTabs </PageHeader>
activeMember={activeMember} <PageContent>
organization={organization} <OrganizationTabs
notificationChannels={notificationChannels} activeMember={activeMember}
storageChannels={storageChannels} organization={organization}
/> notificationChannels={notificationChannels}
</PageContent> storageChannels={storageChannels}
</Page> />
) </PageContent>
</Page>
);
} }
+2 -1
View File
@@ -102,6 +102,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
}) => { }) => {
try { try {
const body: BodyPatch = await request.json(); const body: BodyPatch = await request.json();
console.log("body", body);
const status = body.status const status = body.status
const backupId = body.backupId const backupId = body.backupId
@@ -131,7 +132,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
eventEmitter.emit('modification', {update: true}); 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( return NextResponse.json(
{ {
+1 -2
View File
@@ -63,8 +63,7 @@ export async function POST(
.set({status: body.status as RestorationStatus}) .set({status: body.status as RestorationStatus})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
await sendNotificationsBackupRestore(database, body.status == "error" ? "error_restore" : "success_restore");
const response = { const response = {
message: true, message: true,
-3
View File
@@ -1,4 +1,3 @@
#FROM node:22-bullseye AS base
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
RUN apt-get update && apt-get install -y \ 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 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 \ RUN mkdir -p .next /data/private/uploads \
&& chown -R nextjs:nodejs .next /data/private /app/public && chown -R nextjs:nodejs .next /data/private /app/public
+6 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.4.1", "version": "1.4.42",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -11,7 +11,8 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:drop": "drizzle-kit drop", "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": { "dependencies": {
"@better-auth/passkey": "^1.4.19", "@better-auth/passkey": "^1.4.19",
@@ -49,7 +50,6 @@
"@t3-oss/env-nextjs": "^0.13.10", "@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/express": "^5.0.6",
"@types/nodemailer": "^6.4.23", "@types/nodemailer": "^6.4.23",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2", "@zenstackhq/runtime": "2.14.2",
@@ -65,7 +65,6 @@
"drizzle-orm": "^0.43.1", "drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1", "drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"express": "^5.2.1",
"googleapis": "^170.1.0", "googleapis": "^170.1.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
@@ -106,6 +105,8 @@
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@react-email/preview-server": "4.3.2", "@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.4", "@react-email/render": "^2.0.4",
"@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.5",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.19.11", "@types/node": "^22.19.11",
@@ -123,6 +124,7 @@
"eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-tailwindcss": "^3.18.2",
"framer-motion": "^12.34.3", "framer-motion": "^12.34.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"release-it": "^19.2.4",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"tw-animate-css": "^1.4.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" "use client";
import {Button, ButtonVariantsProps} from "@/components/ui/button"; import { Button, ButtonVariantsProps } from "@/components/ui/button";
import {ReactNode, useState} from "react"; import { ReactNode, useState } from "react";
import {Loader2} from "lucide-react"; import { Loader2 } from "lucide-react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"; import {
import {cn} from "@/lib/utils"; Popover,
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export type ButtonWithConfirmProps = { export type ButtonWithConfirmProps = {
title: string; title: string;
description: string; description: string;
button?: { button?: {
main: { main: {
className?: string; className?: string;
type?: "button" | "submit" | "reset" | undefined; type?: "button" | "submit" | "reset" | undefined;
text?: string; text?: string;
icon?: any; icon?: any;
variant?: ButtonVariantsProps["variant"]; variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"]; size?: ButtonVariantsProps["size"];
disabled?: boolean; disabled?: boolean;
tooltipText?: string; 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;
};
}; };
children?: ReactNode; confirm: {
onConfirm?: (e: React.MouseEvent) => void; className?: string;
onCancel?: (e: React.MouseEvent) => void; text: string;
confirmButtonText?: string; icon?: any;
cancelButtonText?: string; variant?: ButtonVariantsProps["variant"];
isPending?: boolean; 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) => { export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
const [isConfirming, setIsConfirming] = useState(false); const [isConfirming, setIsConfirming] = useState(false);
const isLegacy = !!props.button; const isLegacy = !!props.button;
const isDisabled = isLegacy ? !!props.button?.main.disabled : false; 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (isLegacy) { if (!isDisabled) setIsConfirming(true);
props.button?.confirm.onClick?.(); }}
} else { >
props.onConfirm?.(e); {props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
} {props.button?.main.icon}
setIsConfirming(false); {props.button?.main.text && <span>{props.button.main.text}</span>}
}; </Button>
) : (
const handleCancel = (e: React.MouseEvent) => { <div
onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (isLegacy) { setIsConfirming(true);
props.button?.cancel.onClick?.(); }}
} else { >
props.onCancel?.(e); {props.children}
} </div>
setIsConfirming(false); );
};
const triggerContent = isLegacy ? ( const withTooltip =
<Button isLegacy && props.button?.main.tooltipText && isDisabled ? (
type={props.button?.main.type} <TooltipProvider>
disabled={isDisabled} <Tooltip>
variant={props.button?.main.variant ?? "default"} <TooltipTrigger asChild>
size={props.button?.main.size ?? "default"} <div>{triggerContent}</div>
className={props.button?.main.className} </TooltipTrigger>
onClick={(e) => { <TooltipContent>
e.preventDefault(); <p>{props.button?.main.tooltipText}</p>
e.stopPropagation(); </TooltipContent>
if (!isDisabled) setIsConfirming(true); </Tooltip>
}} </TooltipProvider>
>
{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) => { triggerContent
e.preventDefault();
e.stopPropagation();
setIsConfirming(true);
}}>
{props.children}
</div>
); );
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? ( return (
<TooltipProvider> <Popover open={isConfirming} onOpenChange={setIsConfirming}>
<Tooltip> <PopoverTrigger asChild>{withTooltip}</PopoverTrigger>
<TooltipTrigger asChild> <PopoverContent
<div>{triggerContent}</div> className="w-80"
</TooltipTrigger> onPointerDownOutside={(e) => e.preventDefault()}
<TooltipContent> onInteractOutside={(e) => e.preventDefault()}
<p>{props.button?.main.tooltipText}</p> onClick={(e) => e.stopPropagation()}
</TooltipContent> onMouseMove={(e) => e.stopPropagation()}
</Tooltip> onMouseEnter={(e) => e.stopPropagation()}
</TooltipProvider> onMouseLeave={(e) => e.stopPropagation()}
) : triggerContent; onOpenAutoFocus={(e) => e.preventDefault()}
>
return ( <div className="grid gap-4">
<Popover open={isConfirming} onOpenChange={setIsConfirming}> <div className="space-y-2">
<PopoverTrigger asChild> <h4 className="font-medium leading-none">{props.title}</h4>
{withTooltip} <p className="text-sm text-muted-foreground">{props.description}</p>
</PopoverTrigger> </div>
<PopoverContent <div className="grid gap-2">
className="w-80" <Button
onPointerDownOutside={(e) => e.preventDefault()} onClick={handleConfirm}
onInteractOutside={(e) => e.preventDefault()} variant={
onClick={(e) => e.stopPropagation()} isLegacy
onMouseMove={(e) => e.stopPropagation()} ? (props.button?.confirm.variant ?? "default")
onMouseEnter={(e) => e.stopPropagation()} : "default"
onMouseLeave={(e) => e.stopPropagation()} }
onOpenAutoFocus={(e) => e.preventDefault()} size={
isLegacy ? (props.button?.confirm.size ?? "default") : "default"
}
className={cn(
isLegacy ? props.button?.confirm.className : "",
"w-full",
)}
> >
<div className="grid gap-4"> {props.isPending && (
<div className="space-y-2"> <Loader2 className="animate-spin mr-4" size={16} />
<h4 className="font-medium leading-none">{props.title}</h4> )}
<p className="text-sm text-muted-foreground">{props.description}</p> {isLegacy && props.button?.confirm.icon}
</div> <span>
<div className="grid gap-2"> {isLegacy
<Button ? props.button?.confirm.text
onClick={handleConfirm} : (props.confirmButtonText ?? "Confirm")}
variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"} </span>
size={isLegacy ? (props.button?.main.size ?? "default") : "default"} </Button>
className={cn(isLegacy ? props.button?.main.className : "", "w-full")} <Button
> variant={
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>} isLegacy
{isLegacy && props.button?.confirm.icon} ? (props.button?.cancel.variant ?? "outline")
<span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</span> : "outline"
</Button> }
<Button onClick={handleCancel}
variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"} className={cn(
onClick={handleCancel} isLegacy ? props.button?.cancel.className : "",
className={cn(isLegacy ? props.button?.main.className : "", "w-full")} "w-full",
size={isLegacy ? (props.button?.main.size ?? "default") : "default"} )}
> size={
{isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")} isLegacy ? (props.button?.cancel.size ?? "default") : "default"
</Button> }
</div> >
</div> {isLegacy
</PopoverContent> ? (props.button?.cancel.text ?? "Cancel")
</Popover> : (props.cancelButtonText ?? "Cancel")}
); </Button>
}; </div>
</div>
</PopoverContent>
</Popover>
);
};
@@ -1,26 +1,69 @@
import {z} from "zod"; 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 name: z
.string() .string()
.min(5, "Name must be at least 5 characters long") .min(5, "Name must be at least 5 characters long")
.max(40, "Name must be at most 40 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), 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({ export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
provider: z.enum( BaseChannelFormSchema.extend({
["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"], provider: z.literal("s3"),
{required_error: "Provider is required"} config: S3ChannelConfigSchema,
), }),
}); BaseChannelFormSchema.extend({
provider: z.literal("google-drive"),
export const StorageChannelFormSchema = ChannelFormSchema.extend({ config: GoogleDriveChannelConfigSchema,
provider: z.enum(["local", "s3", "google-drive"], {required_error: "Provider is required"}), }),
}); BaseChannelFormSchema.extend({
provider: z.literal("local"),
config: LocalChannelConfigSchema
})
]);
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>; export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
@@ -1,30 +1,38 @@
import {UseFormReturn} from "react-hook-form"; import { UseFormReturn } from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; import {
import {Input} from "@/components/ui/input"; FormControl,
import {Separator} from "@/components/ui/separator"; FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierDiscordFormProps = { type NotifierDiscordFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>;
} };
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => { export const NotifierDiscordForm = ({ form }: NotifierDiscordFormProps) => {
return ( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1" />
<FormField <FormField
control={form.control} control={form.control}
name="config.discordWebhook" name="config.discordWebhook"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Discord Webhook URL</FormLabel> <FormLabel>Discord Webhook URL</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/> <PasswordInput
</FormControl> {...field}
<FormMessage/> placeholder="https://discord.com/api/webhooks/..."
</FormItem> />
)} </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 type { UseFormReturn } from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; import {
import {Input} from "@/components/ui/input"; FormControl,
import {Separator} from "@/components/ui/separator"; 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 = { type NotifierGotifyFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>;
} };
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => { export const NotifierGotifyForm = ({ form }: NotifierGotifyFormProps) => {
return ( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1" />
<FormField <FormField
control={form.control} control={form.control}
name="config.gotifyServerUrl" name="config.gotifyServerUrl"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Gotify Server URL</FormLabel> <FormLabel>Gotify Server URL</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="https://gotify.example.com"/> <Input {...field} placeholder="https://gotify.example.com" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.gotifyAppToken" name="config.gotifyAppToken"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Application Token</FormLabel> <FormLabel>Application Token</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/> <PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </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 {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator"; import {Separator} from "@/components/ui/separator";
import {PasswordInput} from "@/components/ui/password-input";
type NotifierNtfyFormProps = { type NotifierNtfyFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>
@@ -48,7 +49,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
<FormItem> <FormItem>
<FormLabel>Access Token (Optional)</FormLabel> <FormLabel>Access Token (Optional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="tk_..."/> <PasswordInput {...field} placeholder="tk_..."/>
</FormControl> </FormControl>
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p> <p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
<FormMessage/> <FormMessage/>
@@ -79,7 +80,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
<FormItem> <FormItem>
<FormLabel>Basic Auth Password (Optional)</FormLabel> <FormLabel>Basic Auth Password (Optional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="password" placeholder="password"/> <PasswordInput placeholder="password" {...field} />
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
</FormItem> </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 {UseFormReturn} from "react-hook-form"; import {
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; FormControl,
import {Input} from "@/components/ui/input"; FormField,
import {Separator} from "@/components/ui/separator"; FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierSmtpFormProps = { type NotifierSmtpFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>;
} };
export const NotifierSlackForm = ({form}: NotifierSmtpFormProps) => { export const NotifierSlackForm = ({ form }: NotifierSmtpFormProps) => {
return( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1" />
<FormField <FormField
control={form.control} control={form.control}
name="config.slackWebhook" name="config.slackWebhook"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Slack Webhook URL</FormLabel> <FormLabel>Slack Webhook URL</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="https://hooks.slack.com/services/..."/> <PasswordInput
</FormControl> {...field}
<FormMessage/> placeholder="https://hooks.slack.com/services/..."
</FormItem> />
)} </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 { UseFormReturn } from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; import {
import {Input} from "@/components/ui/input"; FormControl,
import {Separator} from "@/components/ui/separator"; 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 = { type NotifierTelegramFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>;
} };
export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => { export const NotifierTelegramForm = ({ form }: NotifierTelegramFormProps) => {
return ( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1" />
<FormField <FormField
control={form.control} control={form.control}
name="config.telegramBotToken" name="config.telegramBotToken"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Telegram Bot Token</FormLabel> <FormLabel>Telegram Bot Token</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"/> <PasswordInput
</FormControl> {...field}
<FormMessage/> placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
</FormItem> />
)} </FormControl>
/> <FormMessage />
<FormField </FormItem>
control={form.control} )}
name="config.telegramChatId" />
render={({field}) => ( <FormField
<FormItem> control={form.control}
<FormLabel>Telegram Chat ID</FormLabel> name="config.telegramChatId"
<FormControl> render={({ field }) => (
<Input {...field} placeholder="-100123456789 or 123456789"/> <FormItem>
</FormControl> <FormLabel>Telegram Chat ID</FormLabel>
<p className="text-xs text-muted-foreground"> <FormControl>
You must start the conversation with the bot first (<strong>/start</strong>). <br/> <Input {...field} placeholder="-100123456789 or 123456789" />
For groups, add the bot to the group. <br/> </FormControl>
You can use <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="underline">@userinfobot</a> to find your ID. <p className="text-xs text-muted-foreground">
</p> You must start the conversation with the bot first (
<FormMessage/> <strong>/start</strong>). <br />
</FormItem> 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 { UseFormReturn } from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; import {
import {Input} from "@/components/ui/input"; FormControl,
import {Separator} from "@/components/ui/separator"; 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 = { type NotifierWebhookFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>;
} };
export const NotifierWebhookForm = ({form}: NotifierWebhookFormProps) => { export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
return ( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1" />
<FormField <FormField
control={form.control} control={form.control}
name="config.webhookUrl" name="config.webhookUrl"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Webhook URL</FormLabel> <FormLabel>Webhook URL</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="https://example.com/api/webhook"/> <Input {...field} placeholder="https://example.com/api/webhook" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex-1"> <div className="flex-1">
<FormField <FormField
control={form.control} control={form.control}
name="config.webhookSecretHeader" name="config.webhookSecretHeader"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Header Name (Optional)</FormLabel> <FormLabel>Header Name (Optional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="X-Webhook-Secret"/> <Input {...field} placeholder="X-Webhook-Secret" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<FormField <FormField
control={form.control} control={form.control}
name="config.webhookSecret" name="config.webhookSecret"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Secret Value (Optional)</FormLabel> <FormLabel>Secret Value (Optional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="Secret value..."/> <PasswordInput {...field} placeholder="Secret value..." />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground mt-2"> <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>). If provided, the secret will be sent in the specified header (defaults
</p> 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 { UseFormReturn } from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; import {
import {Input} from "@/components/ui/input"; FormControl,
import {Separator} from "@/components/ui/separator"; FormField,
import {PasswordInput} from "@/components/ui/password-input"; FormItem,
import {Switch} from "@/components/ui/switch"; 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 = { type StorageS3FormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>;
} };
export const StorageS3Form = ({form}: StorageS3FormProps) => { export const StorageS3Form = ({ form }: StorageS3FormProps) => {
return ( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1" />
<FormField <FormField
control={form.control} control={form.control}
name="config.endPointUrl" name="config.endPointUrl"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Endpoint URL</FormLabel> <FormLabel>Endpoint URL</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="s3.amazonaws.com"/> <Input {...field} placeholder="s3.amazonaws.com" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.region" name="config.region"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Region</FormLabel> <FormLabel>Region</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="us-east-1"/> <Input {...field} placeholder="us-east-1" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.accessKey" name="config.accessKey"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Access Key</FormLabel> <FormLabel>Access Key</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/> <Input {...field} placeholder="A1b2C3d4E5f6G7h" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.secretKey" name="config.secretKey"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Secret Key</FormLabel> <FormLabel>Secret Key</FormLabel>
<FormControl> <FormControl>
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h"/> <PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.bucketName" name="config.bucketName"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Bucket name</FormLabel> <FormLabel>Bucket name</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="portabase-dev"/> <Input {...field} placeholder="portabase-dev" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.port" name="config.port"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Port</FormLabel> <FormLabel>Port</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="number" placeholder="443" /> <Input {...field} type="number" placeholder="443" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.ssl" name="config.ssl"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Use SSL</FormLabel> <FormLabel>Use SSL</FormLabel>
<FormControl> <FormControl>
<Switch <Switch checked={field.value} onCheckedChange={field.onChange} />
checked={field.value} </FormControl>
onCheckedChange={field.onChange} <FormMessage />
/> </FormItem>
</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" "use client";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
import {useState} from "react"; import { useState } from "react";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder"; import { EmptyStatePlaceholder } from "@/components/wrappers/common/empty-state-placeholder";
import {OrganizationWithMembers} from "@/db/schema/03_organization"; import { OrganizationWithMembers } from "@/db/schema/03_organization";
import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import { StorageChannelWith } from "@/db/schema/12_storage-channel";
import {ChannelCard} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card"; 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 { ChannelAddEditModal } from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; import {
ChannelKind,
getChannelTextBasedOnKind,
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
type ChannelsSectionProps = { type ChannelsSectionProps = {
channels: NotificationChannelWith[] | StorageChannelWith[], channels: NotificationChannelWith[] | StorageChannelWith[];
organizations: OrganizationWithMembers[], organizations: OrganizationWithMembers[];
kind: ChannelKind, kind: ChannelKind;
defaultStorageChannelId?: string | null | undefined defaultStorageChannelId?: string | null | undefined;
} };
export const ChannelsSection = ({ export const ChannelsSection = ({
organizations, organizations,
channels, channels,
kind, kind,
defaultStorageChannelId defaultStorageChannelId,
}: ChannelsSectionProps) => { }: ChannelsSectionProps) => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const channelText = getChannelTextBasedOnKind(kind);
const hasChannels = channels.length > 0;
const [isAddModalOpen, setIsAddModalOpen] = useState(false); return (
const channelText = getChannelTextBasedOnKind(kind) <div className="h-full">
const hasChannels = channels.length > 0 <ChannelAddEditModal
kind={kind}
open={isAddModalOpen}
return ( onOpenChangeAction={setIsAddModalOpen}
adminView={false}
trigger={false}
/>
{hasChannels ? (
<div className="h-full"> <div className="h-full">
<ChannelAddEditModal kind={kind} open={isAddModalOpen} onOpenChangeAction={setIsAddModalOpen} <CardsWithPagination
adminView={false} data={channels}
trigger={false}/> cardItem={ChannelCard}
{hasChannels ? ( cardsPerPage={8}
<div className="h-full"> numberOfColumns={2}
<CardsWithPagination adminView={true}
data={channels} organizations={organizations}
cardItem={ChannelCard} kind={kind}
cardsPerPage={8} defaultStorageChannelId={defaultStorageChannelId}
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> </div>
); ) : (
} <EmptyStatePlaceholder
text={`No ${channelText} channels configured yet`}
onClick={() => {
setIsAddModalOpen(true);
}}
className="h-full"
/>
)}
</div>
);
};
@@ -1,62 +1,241 @@
"use client" "use client";
import {Eye} from "lucide-react";
import {useState} from "react";
import { import {
Dialog, AlertCircle,
DialogContent, Braces,
DialogDescription, Database,
DialogHeader, ExternalLink,
DialogTitle, Eye,
DialogTrigger 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"; } from "@/components/ui/dialog";
import {Button} from "@/components/ui/button"; import type { NotificationLogWithRelations } from "@/db/services/notification-log";
import {Separator} from "@/components/ui/separator";
import {NotificationLogWithRelations} from "@/db/services/notification-log";
type NotificationLogModalProps = { type NotificationLogModalProps = {
notificationLog: NotificationLogWithRelations; notificationLog: NotificationLogWithRelations;
} };
export const NotificationLogModal = ({notificationLog}: NotificationLogModalProps) => { const getTroubleshootingForError = (errorMsg?: any) => {
const [open, setOpen] = useState(false); if (!errorMsg || String(errorMsg).toLowerCase() === "null") return null;
return ( const msg = String(errorMsg).toLowerCase();
<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"/>
<div className="space-y-2 mb-4"> if (msg.includes("database connection") || msg.includes("agent")) {
<div> return {
<span className="font-semibold">Title:</span>{" "} title: "Agent Connection Issue",
<span>{notificationLog.content.title}</span> resolution:
</div> "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.",
<div> docLink:
<span className="font-semibold">Message:</span>{" "} "https://portabase.io/docs/agent/troubleshooting/agent-connection",
<span>{notificationLog.content.message}</span> };
</div> }
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> </div>
<span className="font-semibold text-sm text-primary">
JSON Payload
</span>
</div>
</div>
{notificationLog.payload && ( <div className="flex flex-col">
<> {Object.entries(notificationLog.payload).map(
<Separator className="mb-3" /> ([key, value], index, arr) => {
<div className="text-sm font-mono bg-gray-100 dark:bg-gray-800 dark:text-gray-200 p-4 rounded-md overflow-auto"> const isActualError =
{JSON.stringify(notificationLog.payload, null, 2)} key === "error" &&
</div> value !== null &&
</> String(value).toLowerCase() !== "null";
)}
</DialogHeader> return (
</DialogContent> <div
</Dialog> 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"; "use client";
import {useMutation, useQueryClient} from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import {useRouter} from "next/navigation"; import { useRouter } from "next/navigation";
import {toast} from "sonner"; import { toast } from "sonner";
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action"; import { backupButtonAction } from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import { Check, DatabaseZap, X } from "lucide-react";
import {Database, DatabaseZap} from "lucide-react"; import { useIsMobile } from "@/hooks/use-mobile";
import {useIsMobile} from "@/hooks/use-mobile"; import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
export type BackupButtonProps = { export type BackupButtonProps = {
databaseId: string; databaseId: string;
disable: boolean; disable: boolean;
}; };
export const BackupButton = (props: BackupButtonProps) => { export const BackupButton = (props: BackupButtonProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile() const isMobile = useIsMobile();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (databaseId: string) => { mutationFn: async (databaseId: string) => {
const backup = await backupButtonAction(databaseId); const backup = await backupButtonAction(databaseId);
if (backup?.data?.success) { if (backup?.data?.success) {
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!"); toast.success(
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]}); backup.data.actionSuccess?.message || "Backup created successfully!",
router.refresh(); );
} else { queryClient.invalidateQueries({
toast.error(backup?.serverError || "Failed to create backup."); 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 />,
}, },
}); confirm: {
const HandleAction = async () => { className: "w-full",
await mutation.mutateAsync(props.databaseId); text: "Yes, create backup",
}; icon: <Check />,
variant: "default",
return ( onClick: async () => {
<ButtonWithLoading await HandleAction();
icon={<DatabaseZap/>} },
disabled={props.disable} },
isPending={mutation.isPending} cancel: {
size={"default"} className: "w-full",
onClick={async () => { text: "No, cancel",
await HandleAction(); icon: <X />,
}} variant: "outline",
>{isMobile ? "" : "Backup"}</ButtonWithLoading> },
); }}
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" "use client";
import {DatabaseBackupActionsModal} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal"; import { DatabaseBackupActionsModal } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs"; import { DatabaseTabs } from "@/components/wrappers/dashboard/projects/database/database-tabs";
import {Setting} from "@/db/schema/01_setting"; import { Setting } from "@/db/schema/01_setting";
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database"; import { BackupWith, DatabaseWith, Restoration } from "@/db/schema/07_database";
import {MemberWithUser} from "@/db/schema/03_organization"; import { MemberWithUser } from "@/db/schema/03_organization";
import {useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; import { useBackupModal } from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi"; import { DatabaseKpi } from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {useQuery, useQueryClient} from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import {getDatabaseDataAction} from "@/components/wrappers/dashboard/database/backup/actions/get-data.action"; import { getDatabaseDataAction } from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
import {PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {
import {capitalizeFirstLetter} from "@/utils/text"; PageContent,
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet"; PageDescription,
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button"; PageTitle,
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal"; } from "@/features/layout/page";
import {HardDrive, Megaphone} from "lucide-react"; import { capitalizeFirstLetter } from "@/utils/text";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal"; import { RetentionPolicySheet } from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button"; 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 = { export type DatabaseContentProps = {
settings: Setting, settings: Setting;
backups: BackupWith[], backups: BackupWith[];
restorations: Restoration[], restorations: Restoration[];
isAlreadyRestore: boolean, isAlreadyRestore: boolean;
database: DatabaseWith, database: DatabaseWith;
activeMember: MemberWithUser, activeMember: MemberWithUser;
totalBackups: number, totalBackups: number;
availableBackups: number, availableBackups: number;
successRate: number | null, successRate: number | null;
organizationId: string, organizationId: string;
activeOrganizationChannels: any[], activeOrganizationChannels: any[];
activeOrganizationStorageChannels: any[] activeOrganizationStorageChannels: any[];
} };
export const DatabaseContent = (props: DatabaseContentProps) => { export const DatabaseContent = (props: DatabaseContentProps) => {
const {} = useBackupModal(); const {} = useBackupModal();
const { data } = useQuery({
const {data} = useQuery({ queryKey: ["database-data", props.database.id],
queryKey: ["database-data", props.database.id], queryFn: async () => {
queryFn: async () => { const result = await getDatabaseDataAction({
const result = await getDatabaseDataAction({databaseId: props.database.id}); databaseId: props.database.id,
return result?.data; });
}, return result?.data;
initialData: { },
// TODO : to be patched initialData: {
// @ts-ignore // TODO : to be patched
database: { // @ts-ignore
...props.database, database: {
project: props.database.project ?? null, ...props.database,
}, project: props.database.project ?? null,
backups: props.backups, },
restorations: props.restorations, backups: props.backups,
activeOrganizationChannels: props.activeOrganizationChannels, restorations: props.restorations,
activeOrganizationStorageChannels: props.activeOrganizationStorageChannels, activeOrganizationChannels: props.activeOrganizationChannels,
stats: { activeOrganizationStorageChannels:
totalBackups: props.totalBackups, props.activeOrganizationStorageChannels,
availableBackups: props.availableBackups, stats: {
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 ?? {
totalBackups: props.totalBackups, totalBackups: props.totalBackups,
availableBackups: props.availableBackups, availableBackups: props.availableBackups,
successRate: props.successRate successRate: props.successRate,
}; },
},
staleTime: 0,
gcTime: 0,
refetchInterval: 1000,
});
const isAlreadyRestore = restorations.some((r) => r.status === "waiting"); const database = data?.database ?? props.database;
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing"); 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 ( const isMember = props.activeMember.role === "member";
<>
<div className="justify-between gap-2 sm:flex"> return (
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full "> <>
<div className="min-w-full md:min-w-fit "> <div className="justify-between gap-2 sm:flex">
{capitalizeFirstLetter(database.name)} <PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
</div> <div className="min-w-full md:min-w-fit ">
{!isMember && ( {capitalizeFirstLetter(database.name)}
<div className="flex items-center gap-2 md:justify-between w-full "> </div>
<div className="flex items-center gap-2"> {!isMember && (
<RetentionPolicySheet database={database}/> <div className="flex items-center gap-2 md:justify-between w-full ">
<CronButton database={database}/> <div className="flex items-center gap-2">
<ChannelPoliciesModal <RetentionPolicySheet database={database} />
database={database} <CronButton database={database} />
kind={"notification"} <ChannelPoliciesModal
icon={<Megaphone/>} database={database}
channels={activeOrganizationChannels} kind={"notification"}
organizationId={props.organizationId} icon={<Megaphone />}
/> channels={activeOrganizationChannels}
<ChannelPoliciesModal organizationId={props.organizationId}
database={database} />
icon={<HardDrive/>} <ChannelPoliciesModal
kind={"storage"} database={database}
channels={activeOrganizationStorageChannels} icon={<HardDrive />}
organizationId={props.organizationId} kind={"storage"}
/> channels={activeOrganizationStorageChannels}
<ImportModal database={database}/> organizationId={props.organizationId}
</div> />
<div className="flex items-center gap-2"> <ImportModal database={database} />
<BackupButton disable={isAlreadyBackup} databaseId={database.id}/> </div>
</div> <div className="flex items-center gap-2">
</div> <BackupButton
)} disable={isAlreadyBackup}
</PageTitle> databaseId={database.id}
/>
</div>
</div> </div>
)}
</PageTitle>
</div>
{database.description && ( {database.description && (
<PageDescription className="mt-5 sm:mt-0">{database.description}</PageDescription> <PageDescription className="mt-5 sm:mt-0">
)} {database.description}
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full"> <PageContent className="flex flex-col w-full h-full">
<DatabaseKpi <DatabaseKpi
successRate={stats.successRate} successRate={stats.successRate}
database={database} database={database}
availableBackups={stats.availableBackups} availableBackups={stats.availableBackups}
totalBackups={stats.totalBackups} totalBackups={stats.totalBackups}
/> />
<DatabaseBackupActionsModal/> <DatabaseBackupActionsModal />
<DatabaseTabs <DatabaseTabs
activeMember={props.activeMember} activeMember={props.activeMember}
settings={props.settings} settings={props.settings}
database={database} database={database}
isAlreadyRestore={isAlreadyRestore} isAlreadyRestore={isAlreadyRestore}
backups={backups} backups={backups}
restorations={restorations} restorations={restorations}
/> />
</PageContent> </PageContent>
</> </>
) );
} };
+156 -121
View File
@@ -2,148 +2,183 @@
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button"; 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 { ReloadIcon } from "@radix-ui/react-icons";
import { StatusBadge } from "@/components/wrappers/common/status-badge"; import { StatusBadge } from "@/components/wrappers/common/status-badge";
import {Restoration} from "@/db/schema/07_database"; import { Restoration } from "@/db/schema/07_database";
import {formatLocalizedDate} from "@/utils/date-formatting"; import { formatLocalizedDate } from "@/utils/date-formatting";
import {useMutation, useQueryClient} from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
deleteRestoreAction, deleteRestoreAction,
rerunRestorationAction rerunRestorationAction,
} from "@/features/dashboard/restore/restore.action"; } from "@/features/dashboard/restore/restore.action";
import {toast} from "sonner"; import { toast } from "sonner";
import {TooltipCustom} from "@/components/wrappers/common/tooltip-custom"; import { TooltipCustom } from "@/components/wrappers/common/tooltip-custom";
import {MemberWithUser} from "@/db/schema/03_organization"; import { MemberWithUser } from "@/db/schema/03_organization";
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
export function restoreColumns( export function restoreColumns(
isAlreadyRestore: boolean, isAlreadyRestore: boolean,
activeMember: MemberWithUser activeMember: MemberWithUser,
): ColumnDef<Restoration>[] { ): ColumnDef<Restoration>[] {
return[ return [
{ {
accessorKey: "id", accessorKey: "id",
header: "Reference", header: "Reference",
}, },
{ {
accessorKey: "createdAt", accessorKey: "createdAt",
header: "Created At", header: "Created At",
cell: ({ row }) => { cell: ({ row }) => {
return formatLocalizedDate(row.getValue("createdAt")) return formatLocalizedDate(row.getValue("createdAt"));
}, },
}, },
{ {
accessorKey: "status", accessorKey: "status",
header: "Status", header: "Status",
cell: ({ row }) => { cell: ({ row }) => {
return <StatusBadge status={row.getValue("status")} />; return <StatusBadge status={row.getValue("status")} />;
}, },
}, },
{ {
id: "actions", id: "actions",
cell: ({ row }) => { cell: ({ row }) => {
const status = row.getValue("status"); const status = row.getValue("status");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const rowData: Restoration = row.original; const rowData: Restoration = row.original;
const mutationDeleteRestore = useMutation({
const mutationDeleteRestore = useMutation({ mutationFn: async () => {
mutationFn: async () => { const restoration = await deleteRestoreAction({
const restoration = await deleteRestoreAction({ restorationId: rowData.id,
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);
}
},
}); });
// @ts-ignore
const mutationRerunRestore = useMutation({ if (restoration.data.success) {
mutationFn: async () => { // @ts-ignore
const restoration = await rerunRestorationAction({ toast.success(restoration.data.actionSuccess.message);
restorationId: rowData.id, queryClient.invalidateQueries({
}); queryKey: ["database-data", rowData.databaseId],
// @ts-ignore });
if (restoration.data.success) { } else {
// @ts-ignore // @ts-ignore
toast.success(restoration.data.actionSuccess.message); toast.error(restoration.data.actionError.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();
} }
},
});
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 ( const handleDelete = async () => {
<> await mutationDeleteRestore.mutateAsync();
{activeMember.role != "member" && ( };
<DropdownMenu>
<DropdownMenuTrigger asChild> const handleRerunRestore = async () => {
<Button variant="ghost" className="h-8 w-8 p-0" type="button" onClick={(e) => e.stopPropagation()}> await mutationRerunRestore.mutateAsync();
<span className="sr-only">Open menu</span> };
<MoreHorizontal className="h-4 w-4"/>
</Button> return (
</DropdownMenuTrigger> <>
<DropdownMenuContent align="end"> {activeMember.role !== "member" && (
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenu>
{rowData.backupStorageId && ( <DropdownMenuTrigger asChild>
<TooltipCustom disabled={isAlreadyRestore} text="Already a restoration waiting"> <Button
<DropdownMenuItem variant="ghost"
disabled={mutationRerunRestore.isPending || isAlreadyRestore} className="h-8 w-8 p-0"
onClick={async () => { type="button"
await handleRerunRestore(); onClick={(e) => e.stopPropagation()}
}} >
> <span className="sr-only">Open menu</span>
<ReloadIcon/> Rerun <MoreHorizontal className="h-4 w-4" />
</DropdownMenuItem> </Button>
</TooltipCustom> </DropdownMenuTrigger>
)} <DropdownMenuContent align="end">
<DropdownMenuSeparator/> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem {rowData.backupStorageId && (
disabled={status == "waiting"} <TooltipCustom
className="text-red-600" disabled={isAlreadyRestore}
onClick={async () => { text="Already a restoration waiting"
await handleDelete(); >
}} <DropdownMenuItem
> disabled={
<Trash2/> Delete mutationRerunRestore.isPending || isAlreadyRestore
</DropdownMenuItem> }
</DropdownMenuContent> onClick={async () => {
</DropdownMenu> 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'; 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( export async function sendNtfy(
config: { ntfyServerUrl?: string; ntfyTopic: string; ntfyToken?: string, ntfyUsername?: string, ntfyPassword?: string }, config: { ntfyServerUrl?: string; ntfyTopic: string; ntfyToken?: string, ntfyUsername?: string, ntfyPassword?: string },
payload: EventPayload payload: EventPayload
@@ -11,9 +42,9 @@ export async function sendNtfy(
const body = { const body = {
topic: ntfyTopic, topic: ntfyTopic,
title: payload.title, title: payload.title,
message: payload.message + (payload.data ? `\n\nData:\n${JSON.stringify(payload.data, null, 2)}` : ''), message: payload.message + formatData(payload.data),
priority: 1, priority: getPriority(payload.level),
tags: [payload.level === 'critical' ? 'rotating_light' : payload.level === 'warning' ? 'warning' : 'information_source'], tags: getTags(payload.level),
}; };
const headers: Record<string, string> = { 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( export async function sendTelegram(
config: { telegramBotToken: string; telegramChatId: string }, config: {
payload: EventPayload telegramBotToken: string;
telegramChatId: string;
telegramTopicId?: string;
},
payload: EventPayload,
): Promise<DispatchResult> { ): Promise<DispatchResult> {
const {telegramBotToken, telegramChatId} = config; const { telegramBotToken, telegramChatId, telegramTopicId } = config;
// Helper to escape HTML characters // Helper to escape HTML characters
const escapeHtml = (unsafe: string) => { const escapeHtml = (unsafe: string) => {
return unsafe return unsafe
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;") .replace(/"/g, "&quot;")
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
}; };
const title = escapeHtml(payload.title); const title = escapeHtml(payload.title);
const message = escapeHtml(payload.message); const message = escapeHtml(payload.message);
const level = escapeHtml(payload.level.toUpperCase()); const level = escapeHtml(payload.level.toUpperCase());
const dataString = payload.data ? escapeHtml(JSON.stringify(payload.data, null, 2).substring(0, 1000)) : ''; 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 = { const body: Record<string, any> = {
chat_id: telegramChatId, chat_id: telegramChatId,
text, text,
parse_mode: 'HTML', parse_mode: "HTML",
}; };
const res = await fetch(`https://api.telegram.org/bot${telegramBotToken}/sendMessage`, { if (telegramTopicId) {
method: 'POST', body.message_thread_id = Number(telegramTopicId);
body: JSON.stringify(body), }
headers: {'Content-Type': 'application/json'},
});
if (!res.ok) { const res = await fetch(
const err = await res.text(); `https://api.telegram.org/bot${telegramBotToken}/sendMessage`,
throw new Error(`Telegram error: ${res.status} ${err}`); {
} method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
},
);
return { if (!res.ok) {
success: true, const err = await res.text();
provider: 'telegram', throw new Error(`Telegram error: ${res.status} ${err}`);
message: 'Sent to Telegram', }
response: await res.text(),
}; return {
success: true,
provider: "telegram",
message: "Sent to Telegram",
response: await res.text(),
};
} }