Compare commits

..
204 Commits
Author SHA1 Message Date
charlesgauthereau be66856034 chore(release): 1.2.6-rc.1 2026-02-13 22:41:29 +01:00
charlesgauthereau 80e3027107 fix: bug ongoing backup deletion 2026-02-13 22:40:44 +01:00
charlesgauthereau 362ca53efb fix: console.log 2026-02-13 15:58:53 +01:00
Charles GTEandGitHub f0eafb1162 Merge pull request #74 from Portabase/dev
dev
2026-02-13 14:31:52 +01:00
charlesgauthereau ad5baa7d6f Merge branch 'main' into dev 2026-02-13 14:31:03 +01:00
Charles GTEandGitHub 5840e30afb Merge pull request #73 from Portabase/feat/ui-ux-refactoring
feat/ui-ux-refactoring
2026-02-13 14:30:16 +01:00
charlesgauthereau 8f95606dd7 fix: review 2026-02-13 14:28:55 +01:00
Théo LAGACHE ca9bc7ae94 fix: policies reload 2026-02-13 11:24:53 +01:00
charlesgauthereau 288a40e697 chore(release): 1.2.5-rc.16 2026-02-12 20:25:29 +01:00
Charles GTEandGitHub fa91e8c289 Merge pull request #72 from Portabase/fix/encryption
fix/encryption
2026-02-12 20:20:25 +01:00
charlesgauthereau 4ab5fe4c6f fix: .gitignore 2026-02-12 20:18:56 +01:00
charlesgauthereau d87782d180 fix: Adding encryption of the settings. 2026-02-12 20:18:26 +01:00
charlesgauthereau 1c97c97fa1 fix: refactoring. 2026-02-12 19:07:34 +01:00
charlesgauthereau 2426166676 fix: working on AEA-GCM encryption. 2026-02-12 18:46:11 +01:00
Théo LAGACHE 6f6c1c7c52 fix 2026-02-12 15:48:51 +01:00
charlesgauthereau 7b9cb2ecb9 Merge remote-tracking branch 'origin/feat/fix-react-cve' into feat/agent-update 2026-02-11 19:42:57 +01:00
charlesgauthereau fc029af654 Merge branch 'feature' into feat/agent-update 2026-02-11 19:42:49 +01:00
Théo LAGACHE 110a69dc4b fix: react cve 19.2.4 2026-02-11 15:43:03 +01:00
Théo LAGACHE a61791fdb2 update: fix ui sidebar 2026-02-11 14:42:07 +01:00
charlesgauthereau c01440f9d4 chore(release): 1.2.5-rc.15 2026-02-11 12:34:12 +01:00
Charles GTEandGitHub 39584dd7a1 Merge pull request #69 from Portabase/feature
UI/UX enhancements
2026-02-11 12:33:16 +01:00
charlesgauthereau d9badeacba Merge branch 'feature' into feat/agent-update 2026-02-11 12:21:22 +01:00
Charles GTEandGitHub d4cde2ba9c Merge pull request #66 from Portabase/feat/refresh
Feat/refresh pagination
2026-02-11 12:18:52 +01:00
charlesgauthereau 824ef4528e fix: review and refactoring. 2026-02-11 12:18:25 +01:00
charlesgauthereau feb06a85ae Merge branch 'feature' into feat/refresh
# Conflicts:
#	app/api/agent/[agentId]/backup/route.ts
#	app/api/events/route.ts
#	src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx
#	src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts
#	src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx
#	src/lib/tasks/database/index.ts
2026-02-11 12:10:33 +01:00
Charles GTEandGitHub cfad645e33 Merge pull request #68 from Portabase/fix/large-files-storage-backend-v3
fix/large-files-storage-backend-v3
2026-02-11 12:00:57 +01:00
charlesgauthereau 675f8dec20 Merge branch 'main' into fix/large-files-storage-backend-v3
# Conflicts:
#	src/features/storages/providers/s3.ts
2026-02-11 11:54:09 +01:00
charlesgauthereau 11e85b613c chore(release): 1.2.5-rc.14 2026-02-10 20:50:33 +01:00
charlesgauthereau eb82833c7e chore(release): 1.2.5-rc.13 2026-02-10 20:47:04 +01:00
charlesgauthereau 399a3e09de chore(release): 1.2.5-rc.12 2026-02-10 20:39:55 +01:00
charlesgauthereau 1a735bda42 chore(release): 1.2.5-rc.11 2026-02-10 20:39:09 +01:00
charlesgauthereau 991196cc30 chore(release): 1.2.5-rc.10 2026-02-10 20:34:26 +01:00
charlesgauthereau 5f48903a73 chore(release): 1.2.5-rc.9 2026-02-10 20:25:54 +01:00
charlesgauthereau 5dfe1b758e fix: refactoring 2026-02-10 19:11:01 +01:00
Théo LAGACHE d24e534828 update: ui 2026-02-10 17:02:06 +01:00
Théo LAGACHE 80bdca138e add: agent update notifier, pagination 2026-02-10 15:52:08 +01:00
Théo LAGACHE 0a2ca8c0b6 add: font 2026-02-10 15:29:20 +01:00
Théo LAGACHE 921a355ae0 fix: project details table refresh 2026-02-10 11:44:45 +01:00
charlesgauthereau eff7b24b7a fix: nginx configration 2026-02-09 23:39:14 +01:00
charlesgauthereau c328620f3d fix: new storage architecture 2026-02-08 20:42:42 +01:00
Charles GTEandGitHub a49b6d6573 Merge pull request #61 from Portabase/revert-57-fix/s3
Revert "chore(release): 1.2.5-rc.1"
2026-02-04 09:57:01 +01:00
Charles GTEandGitHub baffd2efaf Revert "chore(release): 1.2.5-rc.1" 2026-02-04 09:56:34 +01:00
Charles GTEandGitHub 292693a1ed Merge pull request #57 from Portabase/fix/s3
chore(release): 1.2.5-rc.1
2026-02-04 09:56:11 +01:00
charlesgauthereau cdd01e695c fix: new storage architecture 2026-02-02 21:46:53 +01:00
charlesgauthereau 614a9e057c chore(release): 1.2.5-rc.8 2026-02-01 22:04:07 +01:00
charlesgauthereau 24d90bb750 chore(release): 1.2.5-rc.7 2026-02-01 21:14:35 +01:00
charlesgauthereau c2726e6b18 chore(release): 1.2.5-rc.6 2026-02-01 20:22:38 +01:00
charlesgauthereau a746ce9169 chore(release): 1.2.5-rc.5 2026-02-01 19:59:23 +01:00
charlesgauthereau c00b967179 chore(release): 1.2.5-rc.4 2026-02-01 19:56:02 +01:00
charlesgauthereau f6e749ecf9 chore(release): 1.2.5-rc.3 2026-02-01 19:51:16 +01:00
charlesgauthereau 8f5e0686c8 chore(release): 1.2.5-rc.3 2026-02-01 19:16:42 +01:00
charlesgauthereau b9f0d9279b chore(release): 1.2.5-rc.2 2026-02-01 18:25:00 +01:00
charlesgauthereau cd69f0c82f chore(release): 1.2.5-rc.1 2026-02-01 18:24:53 +01:00
charlesgauthereau 884746f3a5 chore(release): 1.2.5-rc.2 2026-02-01 18:24:22 +01:00
charlesgauthereau 9c4d862297 fix: refactoring, adding express api. 2026-02-01 15:53:50 +01:00
charlesgauthereau cc87cf68b5 fix: refactoring, adding express api. 2026-01-31 22:15:01 +01:00
killianlarcher db1a0235ed chore(release): 1.2.5-rc.1 2026-01-31 14:18:12 +01:00
Killian LarcherandGitHub 2cd6a7cae0 Merge pull request #56 from Portabase/fix/s3
fix(storage): add test button when creating channel (not only for existing one) and add region parameter for S3.
2026-01-31 13:03:58 +01:00
killianlarcher bd80fdc960 fix(storage): add test button when creating channel (not only for existing one) and add region parameter for S3. 2026-01-31 12:41:01 +01:00
charlesgauthereau 7c5d57ddc1 Merge branch 'main' into feature
# Conflicts:
#	CITATION.cff
#	package.json
2026-01-29 23:48:18 +01:00
charlesgauthereau 5653ff18c8 chore(release): 1.2.4 2026-01-29 23:47:56 +01:00
Charles GTEandGitHub 83ea444675 Merge pull request #52 from Portabase/fix/dialogs
fix/dialogs
2026-01-29 23:46:53 +01:00
charlesgauthereau 966edfa85a fix: some fix responsive 2026-01-29 23:46:04 +01:00
charlesgauthereau 30b9fc7765 fix: server/client error on some dialogs. 2026-01-29 23:11:33 +01:00
charlesgauthereau 95a60e9a96 fix: server/client error on some dialogs. 2026-01-29 19:10:59 +01:00
Killian LarcherandGitHub 9327331adc Merge pull request #51 from Portabase/feat/github-updater
fix: check if update is available based on auto-detected version channel.
2026-01-28 21:43:47 +01:00
killian-larcher e8baa441cd fix: check if update is available based on auto-detected version channel. 2026-01-28 21:40:55 +01:00
Killian LarcherandGitHub 61d142afbd Merge pull request #50 from Portabase/feat/github-updater
fix: check if update is available based on auto-detected version channel
2026-01-28 20:23:06 +01:00
killian-larcher 46022e8711 fix: check if update is available based on auto-detected version channel. 2026-01-28 20:22:14 +01:00
Killian LarcherandGitHub e59f3c46fd Merge pull request #49 from Portabase/feat/organization-dropdown
fix: adjust organisation dropdown size.
2026-01-28 19:49:23 +01:00
killian-larcher 7fcb3f2c31 fix: adjust organisation dropdown size. 2026-01-28 19:48:40 +01:00
Killian LarcherandGitHub a2243585a0 Merge pull request #48 from Portabase/feat/github-updater
Feat/GitHub updater
2026-01-28 19:18:22 +01:00
killian-larcher 6231002043 fix: error in package.json 2026-01-28 19:17:31 +01:00
killian-larcher 33eb476009 Merge branch 'main' into feat/github-updater
# Conflicts:
#	CITATION.cff
#	package.json
2026-01-28 19:14:33 +01:00
Killian LarcherandGitHub ebb0a4c7e5 Merge pull request #47 from Portabase/feat/modal-page
Feat/modal page
2026-01-28 19:07:55 +01:00
Théo LAGACHE d010ed6bc5 chore(release): 1.2.4-rc.8 2026-01-28 16:55:43 +01:00
Théo LAGACHE 1941e29175 chore(release): 1.2.4-rc.7 2026-01-28 16:39:53 +01:00
Théo LAGACHE 6e8932ff52 chore(release): 1.2.4-rc.6 2026-01-28 16:39:46 +01:00
Théo LAGACHE f9bfae9b9d chore(release): 1.2.4-rc.5 2026-01-28 15:56:24 +01:00
Théo LAGACHE 2fca928cf0 chore(release): 1.2.4-rc.4 2026-01-28 15:55:57 +01:00
Théo LAGACHE 8f3a362a3a chore(release): 1.2.4-rc.3 2026-01-28 15:55:28 +01:00
Théo LAGACHE 62a6189788 chore(release): 1.2.4-rc.3 2026-01-28 15:54:59 +01:00
Théo LAGACHE 2a6d631719 chore(release): 1.2.4-rc.1 2026-01-28 15:54:03 +01:00
Théo LAGACHEandGitHub 4942591d7f Merge pull request #45 from Portabase/feat/organization-dropdown
update: new org combobox
2026-01-28 13:57:08 +01:00
Théo LAGACHE 1760c1b852 update: new org combobox 2026-01-28 13:51:14 +01:00
charlesgauthereau 86d7ab9864 chore(release): 1.2.4-rc.2 2026-01-24 19:34:56 +01:00
charlesgauthereau f201e4593a Merge branch 'main' into feature 2026-01-24 19:34:49 +01:00
charlesgauthereau 71129e556e chore(release): 1.2.4-rc.1 2026-01-24 19:06:47 +01:00
Charles GTEandGitHub 0e7ebb79db Merge pull request #42 from Portabase/fix/ntfy
fix: added support for Basic Auth ntfy instance
2026-01-24 19:05:46 +01:00
charlesgauthereau 727d9eb57c fix: added support for Basic Auth ntfy instance 2026-01-24 19:04:49 +01:00
charlesgauthereau 228ff75468 chore(release): 1.2.3 2026-01-24 13:18:01 +01:00
Charles GTEandGitHub 80018cac08 Merge pull request #40 from Portabase/feat/google-drive-storage
feat/google-drive-storage
2026-01-24 13:17:06 +01:00
charlesgauthereau b2c19e7ed0 fix: refactoring of storage method 2026-01-24 13:14:38 +01:00
charlesgauthereau a64a80910d feat: Working version with google drive storage. 2026-01-24 12:13:55 +01:00
charlesgauthereau 4eeeef3427 feat: start working on google drive storage 2026-01-24 00:26:09 +01:00
charlesgauthereau 657586ddff feat: start working on google drive storage 2026-01-24 00:26:05 +01:00
Charles GTEandGitHub 797b287899 chore: README.md 2026-01-22 10:40:09 +01:00
Charles GTEandGitHub db2d95f2c5 Create FUNDING.yml
create and added buy me a coffee sponsor
2026-01-22 10:31:22 +01:00
charlesgauthereau 16a8f259ab chore(release): 1.2.2 2026-01-21 21:29:33 +01:00
Charles GTEandGitHub ee1cd8d08d Merge pull request #36 from Portabase/feat/arm-architecture
feat/arm-architecture
2026-01-21 21:22:45 +01:00
charlesgauthereau e53046324c fix: docker-compose.prod.yml 2026-01-21 21:21:51 +01:00
charlesgauthereau dd71977317 chore(release): 1.2.2-rc.2 2026-01-21 21:00:58 +01:00
charlesgauthereau bee0da1454 chore(release): 1.2.2-rc.2 2026-01-21 20:56:58 +01:00
charlesgauthereau b60feaa447 feat: added support for arm and amd docker images. 2026-01-21 20:56:23 +01:00
charlesgauthereau 743e54614a chore(release): 1.2.2-rc.1 2026-01-21 11:41:17 +01:00
charlesgauthereau bd96713af4 fix: some refactoring 2026-01-21 11:41:00 +01:00
Charles GTEandGitHub 8f7f9b457a Merge pull request #33 from Portabase/feat/avatar-upload
fix: Upload avatar image error for s3 storage
2026-01-21 11:38:47 +01:00
charlesgauthereau 4c0d68eab8 fix: Upload avatar image error for s3 storage 2026-01-21 11:36:50 +01:00
Charles GTEandGitHub 8211407cf2 chore: README.md 2026-01-20 22:59:47 +01:00
charlesgauthereau 5fb6c7d36a chore(release): 1.2.1 2026-01-20 22:53:08 +01:00
Charles GTEandGitHub 30b1cf4493 Merge pull request #31 from Portabase/feat/add-mongodb
feat/add-mongodb
2026-01-20 22:51:36 +01:00
charlesgauthereau f4789ac5fe fix: attribute default storage at startup in init. Adding support for mongodb. 2026-01-20 22:47:23 +01:00
charlesgauthereau 5fd826ae71 fix: attribute default storage at startup in init. 2026-01-20 20:31:42 +01:00
charlesgauthereau 22ce08f06c Merge branch 'feature' 2026-01-20 19:50:40 +01:00
charlesgauthereau 696c88f03f chore(release): 1.2.1-rc.3 2026-01-20 19:43:59 +01:00
charlesgauthereau 9d5d87e47b fix: smtp error for SMTP_SECURE false. Added in .env 2026-01-20 19:41:49 +01:00
charlesgauthereau e32539e02c chore(release): 1.2.1-rc.2 2026-01-19 13:58:35 +01:00
charlesgauthereau 46ba931a6c fix: Retention cron error. 2026-01-19 13:58:04 +01:00
charlesgauthereau 4bac5143d6 feat: working on mongodb 2026-01-18 21:19:19 +01:00
charlesgauthereau bcb1674491 feat: adding support for mongodb 2026-01-18 20:20:10 +01:00
Killian LarcherandGitHub 3cbd9677c2 Merge pull request #28 from Portabase/feat/coming-soon-storage-preview
feat(ui): add preview for upcoming storage provider
2026-01-18 16:08:41 +01:00
killianlarcher b216ee1a80 feat(ui): add preview for upcoming storage provider 2026-01-18 16:00:13 +01:00
charlesgauthereau 32ec482449 Merge branch 'feature' 2026-01-18 15:30:30 +01:00
charlesgauthereau 5074748055 chore(release): 1.2.1-rc.1 2026-01-18 15:30:14 +01:00
charlesgauthereau 6fa11f834b fix: retention delete backup cron action 2026-01-18 15:29:29 +01:00
charlesgauthereau 25c7702127 chore(release): 1.2.0 2026-01-18 15:21:37 +01:00
charlesgauthereau b546556d08 chore(release): 1.2.0 2026-01-18 15:04:11 +01:00
charlesgauthereau 379a85f6f6 chore: .env.example 2026-01-18 15:03:54 +01:00
Charles GTEandGitHub 92d2c14d6a Merge pull request #27 from Portabase/feat/storage
Feat/storage
2026-01-18 15:01:52 +01:00
charlesgauthereau a6c1f7d3f2 chore: Merging errors due to merge with main. 2026-01-18 15:00:53 +01:00
charlesgauthereau 6fa6187cc4 Merge remote-tracking branch 'origin/main' into feat/storage
# Conflicts:
#	src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.tsx
#	src/components/wrappers/dashboard/admin/notifications/helpers.tsx
#	src/db/migrations/meta/0020_snapshot.json
#	src/db/migrations/meta/_journal.json
2026-01-18 14:40:26 +01:00
charlesgauthereau 848e7a1609 chore: .gitignore 2026-01-18 14:35:39 +01:00
Charles GTEandGitHub 3e8c0a2896 Delete .vscode directory 2026-01-18 14:20:12 +01:00
Charles GTEandGitHub 11900b1500 Delete .idea directory 2026-01-18 14:19:16 +01:00
charlesgauthereau 85044fe3e0 chore(release): 1.2.0-rc.1 2026-01-18 14:16:40 +01:00
charlesgauthereau c0499d40ea fix: restoration columns.tsx is not backup storage linked, legacy 2026-01-18 14:14:49 +01:00
charlesgauthereau 8ee44ffea1 fix: migration errors by testing on a production instance. 2026-01-18 14:10:35 +01:00
Killian LarcherandGitHub 5668a1411f Merge pull request #26 from Portabase/feat/coming-soon-preview
feat(ui): add preview for upcoming notification provider
2026-01-18 12:37:29 +01:00
killianlarcher 6db60f111d feat(ui): add preview for upcoming notification provider 2026-01-18 12:36:12 +01:00
charlesgauthereau 3d695709d5 fix: migration storage backend and also user avatar upload. 2026-01-18 11:45:36 +01:00
Killian LarcherandGitHub 5c682addc9 Merge pull request #25 from Portabase/fix/profile
fix(ui) : improve profile modal
2026-01-18 10:29:38 +01:00
Killian LarcherandGitHub 24810cc818 Merge pull request #24 from Portabase/feat/last-connected
feat : add last login date
2026-01-18 10:29:19 +01:00
Killian LarcherandGitHub 8caeb11da5 Merge pull request #21 from Portabase/feat/connection-indicator
feat(ui): add pulse effect to connection indicator
2026-01-18 10:28:52 +01:00
Killian LarcherandGitHub 5e07b446fc Merge pull request #23 from Portabase/fix/development-conf
fix: update configuration for local development
2026-01-18 10:28:37 +01:00
charlesgauthereau f29444a219 fix: deleteBackupCronAction with new backend storage system 2026-01-18 09:48:14 +01:00
charlesgauthereau bce1c5d9b8 feat: adding storage system ping method in order to test. 2026-01-17 22:41:39 +01:00
charlesgauthereau 5b980fb382 feat: adding system settings page. And did some refactoring. 2026-01-17 22:26:44 +01:00
charlesgauthereau e63e62d3e0 fix: Sidebar focus on some items 2026-01-17 21:13:54 +01:00
charlesgauthereau 53f82792d8 fix: Upload with new storage backend system. 2026-01-17 20:56:57 +01:00
killianlarcher 760388307c fix(ui): reduce opacity on avatar hover and add bottom padding to profile modal 2026-01-17 20:40:57 +01:00
killianlarcher eebd8500ee feat: add lastConnectedAt to user schema and display it in user settings 2026-01-17 20:27:06 +01:00
killianlarcher 12112afa37 feat(ui):resize connection indicator and add pulse effect for all use cases. 2026-01-17 20:06:01 +01:00
charlesgauthereau 424fa581b9 feat: backup delete/restore actions. 2026-01-17 19:55:17 +01:00
killianlarcher cddb0a7e05 feat(ui): add pulse effect to connection indicator 2026-01-17 19:36:28 +01:00
charlesgauthereau 83ef0ee74e feat: backup delete/restore actions. 2026-01-17 19:23:15 +01:00
killianlarcher 21b317e4f1 fix: Update .env.example and docker-compose.yml for local development configuration 2026-01-17 17:44:31 +01:00
charlesgauthereau 7d818206b0 refactor: API route for backup/restore 2026-01-17 17:27:33 +01:00
charlesgauthereau 0216c40bfe feat: Working on UI/UX for multiple storage backends. 2026-01-17 15:57:37 +01:00
charlesgauthereau ecf80280f1 Merge branch 'main' into feat/storage 2026-01-16 23:21:04 +01:00
charlesgauthereau 6e9568b4fb Merge branch 'main' into feat/storage 2026-01-16 22:53:33 +01:00
charlesgauthereau 8d968e7d63 Merge branch 'main' into feature 2026-01-16 22:53:13 +01:00
charlesgauthereau bb264f0b9b chore(release): 1.1.13 2026-01-16 22:48:40 +01:00
Soluce TechnologiesandGitHub be4f770c35 Merge pull request #19 from Portabase/feat/no-email-config-error
Do not crash if no email config is provided in .env file.
2026-01-16 21:44:22 +00:00
charlesgauthereau 17d955dd69 chore(release): 1.1.13-rc.2 2026-01-16 22:24:08 +01:00
charlesgauthereau 3f31e85c06 fix: Do not crash if no email config is provided in .env file. 2026-01-16 22:22:38 +01:00
charlesgauthereau 5a0c230ead chore: working on backup api for new storage system. 2026-01-16 21:54:16 +01:00
Théo LAGACHEandGitHub e10350268d Merge pull request #17 from Portabase/feat/guard-cookie
fix: bad cookie name with https
2026-01-16 11:34:11 +00:00
Théo LAGACHE 969a48bef2 chore(release): 1.1.13-rc.1 2026-01-16 12:21:50 +01:00
Théo LAGACHE 8158934cb9 fix: bad cookie name with https 2026-01-16 12:15:18 +01:00
charlesgauthereau 670f6a1974 chore: working on backup api for new storage system. 2026-01-15 21:27:36 +01:00
charlesgauthereau dd1f791b3e chore: migration for the storage-backend feature 2026-01-14 23:01:06 +01:00
charlesgauthereau 91f25bc25d feat: Working on storage modal in database page in order to add multiple backend storages. Adding a system to manage independently channels from organizations and from system admin. 2026-01-14 21:54:13 +01:00
charlesgauthereau d8e9e2dac3 feat: Working on backend storage providers. 2026-01-13 22:22:01 +01:00
charlesgauthereau 67a89a12ac feat: backend storage 2026-01-12 21:16:57 +01:00
charlesgauthereau faaa4033bd feat: backend storage 2026-01-12 21:16:52 +01:00
charlesgauthereau f3eedf6f30 fix(ci): docker.yml 2026-01-11 14:30:52 +01:00
charlesgauthereau 29718d2a0c chore(release): 1.1.12 2026-01-11 14:26:10 +01:00
charlesgauthereau c7e6bd8b72 Merge branch 'feature'
# Conflicts:
#	CITATION.cff
#	package.json
2026-01-11 14:26:01 +01:00
charlesgauthereau a67ff12786 fix(ci): docker.yml 2026-01-11 14:24:13 +01:00
charlesgauthereau c92e671622 chore(release): 1.1.12-rc.4 2026-01-11 14:19:49 +01:00
charlesgauthereau 2bd25353da chore(release): 1.1.12-rc.3 2026-01-11 14:13:58 +01:00
charlesgauthereau e1bee19c18 fix(ci): docker.yml 2026-01-11 14:13:51 +01:00
charlesgauthereau 0ec49af72a chore(release): 1.1.12-rc.2 2026-01-11 14:12:43 +01:00
charlesgauthereau 6d4eb836ab fix(ci): working on github actions to add new docker repository 2026-01-11 14:12:40 +01:00
charlesgauthereau 0c3cfecb1d chore(release): 1.1.12-rc.1 2026-01-11 13:54:35 +01:00
charlesgauthereau f9186e7354 fix: overflow in agent-card.tsx 2026-01-11 13:54:22 +01:00
charlesgauthereau bc603a08cf fix: Remove card in some forms. 2026-01-11 13:52:02 +01:00
charlesgauthereau da5c65d5a6 chore(release): 1.1.11 2026-01-11 13:41:24 +01:00
charlesgauthereau 077aada056 chore(release): 1.1.11-rc.2 2026-01-11 13:36:57 +01:00
charlesgauthereau 4ed5f9872d fix: delete backup crash if no file. 2026-01-11 13:36:11 +01:00
charlesgauthereau 2d6be20b30 fix: improving organization-form.tsx UX/UI and removed the card. 2026-01-11 12:41:39 +01:00
charlesgauthereau be8f3bd98c fix: organization settings page responsive on mobile. 2026-01-11 12:37:57 +01:00
charlesgauthereau a3a20bee4a fix: agent-card.tsx overflow on mobile. 2026-01-11 12:23:32 +01:00
charlesgauthereau 09a1e75acf fix: notifier-card.tsx overflow on mobile. 2026-01-11 12:12:14 +01:00
charlesgauthereau ce6bdd01ef fix: overflow mobile view on the notification provider card in detabase details page. 2026-01-11 11:57:18 +01:00
charlesgauthereau a03a1d635f fix: responsive in project details. 2026-01-11 10:59:42 +01:00
charlesgauthereau 419b6d4891 chore(release): 1.1.11-rc.1 2026-01-09 19:16:17 +01:00
charlesgauthereau b4840318d5 fix: database page Backup button and route api. 2026-01-09 19:15:27 +01:00
Théo LAGACHE bf81f437d1 chore(release): 1.1.10 2026-01-07 15:29:53 +01:00
Théo LAGACHEandGitHub 81a4d33df1 Merge pull request #7 from Portabase/feat/webhooks
Feat/webhooks
2026-01-07 15:29:30 +01:00
Théo LAGACHE baab364b56 chore(release): 1.1.10-rc.2 2026-01-07 15:00:21 +01:00
Théo LAGACHE 585ee041ec ui fixes 2026-01-07 15:00:11 +01:00
Théo LAGACHE da3d4350cc chore(release): 1.1.10-rc.1 2026-01-07 13:33:42 +01:00
Théo LAGACHE df9390847f add: new notifiers and ui fixes 2026-01-07 13:33:23 +01:00
347 changed files with 50842 additions and 7221 deletions
+9 -15
View File
@@ -1,37 +1,31 @@
# Environnement # Environnement
NODE_ENV=production NODE_ENV=development
# Base de données # Base de données
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
# Projet # Projet
PROJECT_NAME="Portabase" PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager" PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://app.portabase.io PROJECT_URL=http://localhost:8887
PROJECT_SECRET= PROJECT_SECRET=
# SMTP (email) # SMTP (email)
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM= SMTP_FROM=
#SMTP_SECURE="true"
#Port 587 -> secure: false
#Port 465 -> secure: true
#By default : true
# Google # Google
AUTH_GOOGLE_ID= AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET= AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD= AUTH_GOOGLE_METHOD=
# S3
S3_ENDPOINT=http://app.s3.portabase.io
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET_NAME=portabase
S3_PORT=9000
S3_USE_SSL=true
# Storage Type (s3, local)
STORAGE_TYPE=local
# Retention # Retention
RETENTION_CRON="* * * * *" RETENTION_CRON="* * * * *"
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: portabase
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+78 -19
View File
@@ -6,7 +6,7 @@ on:
image_name: image_name:
required: false required: false
type: string type: string
default: 'solucetechnologies/portabase' default: 'portabase/portabase'
add_latest: add_latest:
required: false required: false
type: boolean type: boolean
@@ -26,38 +26,97 @@ on:
required: true required: true
jobs: jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to Docker Hub build:
name: Build and push Docker images
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
strategy:
fail-fast: false
matrix:
platform: [ linux/amd64, linux/arm64 ]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set tags - name: Set image tag
id: set-tags id: set-tags
run: | run: |
REF_NAME=${GITHUB_REF#refs/tags/} REF_NAME=${GITHUB_REF#refs/tags/}
TAGS="${{ inputs.image_name }}:$REF_NAME" if [ "${{ matrix.platform }}" = "linux/amd64" ]; then
if [[ "${{ inputs.add_latest }}" == "true" ]]; then IMAGE="${{ inputs.image_name }}:$REF_NAME-amd64"
TAGS="$TAGS,${{ inputs.image_name }}:latest" else
IMAGE="${{ inputs.image_name }}:$REF_NAME-arm64"
fi fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT echo "image=$IMAGE" >> $GITHUB_OUTPUT
- name: Build and push Docker image - name: Build and push image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ${{ inputs.dockerfile }} file: ${{ inputs.dockerfile }}
platforms: ${{ matrix.platform }}
push: true push: true
tags: ${{ steps.set-tags.outputs.tags }} tags: ${{ steps.set-tags.outputs.image }}
target: ${{ inputs.target }} target: ${{ inputs.target }}
- name: Prepare artifact name
id: artifact
run: |
platform=${{ matrix.platform }}
echo "safe_platform=${platform//\//-}" >> $GITHUB_OUTPUT
echo "${{ steps.set-tags.outputs.image }}" > image.txt
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: image-${{ steps.artifact.outputs.safe_platform }}
path: image.txt
if-no-files-found: warn
compression-level: 6
overwrite: false
include-hidden-files: false
create-manifest:
name: Create multi-arch Docker manifest (Portabase)
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
with:
name: image-linux-amd64
path: /tmp/digests/amd64
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
with:
name: image-linux-arm64
path: /tmp/digests/arm64
- name: Login to Docker
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create and push manifest list
working-directory: /tmp/digests
run: |
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
REF_NAME=${GITHUB_REF#refs/tags/}
MANIFEST_IMAGE="${{ inputs.image_name }}:$REF_NAME"
docker buildx imagetools create $DOCKER_IMAGES -t $MANIFEST_IMAGE
docker buildx imagetools inspect $MANIFEST_IMAGE
if [ "${{ inputs.add_latest }}" = "true" ]; then
docker buildx imagetools create $DOCKER_IMAGES -t ${{ inputs.image_name }}:latest
docker buildx imagetools inspect ${{ inputs.image_name }}:latest
fi
+2 -2
View File
@@ -15,8 +15,8 @@ jobs:
with: with:
add_latest: false add_latest: false
secrets: secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release: github_release:
needs: docker_publish needs: docker_publish
+2 -2
View File
@@ -16,8 +16,8 @@ jobs:
with: with:
add_latest: true add_latest: true
secrets: secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release: github_release:
needs: docker_publish needs: docker_publish
+4
View File
@@ -1,5 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea .idea
.vscode
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp
@@ -39,5 +40,8 @@ public/uploads/*
!public/uploads/ !public/uploads/
private/uploads/* private/uploads/*
private/keys/*
/.env /.env
certificates
-8
View File
@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/portabase.iml" filepath="$PROJECT_DIR$/.idea/portabase.iml" />
</modules>
</component>
</project>
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
-3
View File
@@ -1,3 +0,0 @@
{
"WillLuke.nextjs.hasPrompted": true
}
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.1.9 version: 1.2.6-rc.1
date-released: "2026-01-07" date-released: "2026-02-13"
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright 2024 Soluce Technologies Copyright 2024 Portabase
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
+3
View File
@@ -14,10 +14,12 @@
[![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE) [![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/solucetechnologies/portabase?color=brightgreen)](https://hub.docker.com/r/solucetechnologies/portabase) [![Docker Pulls](https://img.shields.io/docker/pulls/solucetechnologies/portabase?color=brightgreen)](https://hub.docker.com/r/solucetechnologies/portabase)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase) [![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase)
[![Support Portabase](https://img.shields.io/badge/Support-Portabase-orange)](https://www.buymeacoffee.com/portabase)
[![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/)
[![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)
@@ -61,6 +63,7 @@ You have 4 ways to install Portabase:
[![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](https://github.com/Portabase/portabase/graphs/contributors) [![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](https://github.com/Portabase/portabase/graphs/contributors)
[!["Support Portabase"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/portabase)
## License ## License
+3 -3
View File
@@ -8,15 +8,15 @@ import {CardAuth} from "@/features/layout/card-auth";
export default async function GuardPage() { export default async function GuardPage() {
const cookieStore = await cookies(); const cookieStore = await cookies();
const token = cookieStore.get("better-auth.two_factor")?.value; const token = cookieStore.get("better-auth.two_factor")?.value || cookieStore.get("__Secure-better-auth.two_factor")?.value;
if (!token) { if (!token) {
redirect("/login"); redirect("/login");
} }
return ( return (
<TooltipProvider> <TooltipProvider>
<CardAuth className="w-full max-w-md"> <CardAuth className="w-full">
<CardHeader> <CardHeader>
<div className="grid gap-2 text-center mb-2"> <div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Two-factor verification</h1> <h1 className="text-3xl font-bold">Two-factor verification</h1>
+13 -6
View File
@@ -2,6 +2,8 @@ import React from "react";
import {redirect} from "next/navigation"; import {redirect} from "next/navigation";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section"; import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
import {env} from "@/env.mjs";
import {Heart} from "lucide-react";
export default async function Layout({children}: { children: React.ReactNode }) { export default async function Layout({children}: { children: React.ReactNode }) {
@@ -12,13 +14,18 @@ export default async function Layout({children}: { children: React.ReactNode })
} }
return ( return (
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 "> <div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
<div className="mx-auto w-full max-w-md"> <div className="flex flex-col items-center justify-center flex-1">
<AuthLogoSection/> <div className="mx-auto w-full max-w-md">
<div>{children}</div> <AuthLogoSection/>
<div className="mt-4">{children}</div>
</div>
</div> </div>
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground"> <footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
Powered by <span className="font-medium">Soluce Technologies</span> <p className="flex items-center justify-center gap-1">
Made with <Heart className="size-3 fill-red-500 text-red-500" /> by <span className="font-medium text-foreground">Portabase</span>
</p>
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
</footer> </footer>
</div> </div>
) )
+4 -3
View File
@@ -1,9 +1,10 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form"; import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar"; import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
import {CardAuth} from "@/features/layout/card-auth";
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) { export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
@@ -23,7 +24,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
return ( return (
<TooltipProvider> <TooltipProvider>
<Card className="w-full max-w-md shadow-lg"> <CardAuth className="w-full">
<CardHeader className="space-y-4"> <CardHeader className="space-y-4">
<div className="space-y-1 text-center"> <div className="space-y-1 text-center">
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1> <h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
@@ -53,7 +54,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
<CardContent> <CardContent>
<ResetPasswordForm /> <ResetPasswordForm />
</CardContent> </CardContent>
</Card> </CardAuth>
</TooltipProvider> </TooltipProvider>
); );
} }
@@ -0,0 +1,40 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db";
import {notFound} from "next/navigation";
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs";
import {desc, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
const storageChannels = await db.query.storageChannel.findMany({
with: {
organizations: true
},
where: isNull(drizzleDb.schemas.storageChannel.organizationId),
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
}) as StorageChannelWith[]
if (!settings || !storageChannels ) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System settings</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsTabs storageChannels={storageChannels} settings={settings} />
</PageContent>
</Page>
);
}
@@ -1,32 +0,0 @@
import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
import { notFound } from "next/navigation";
import { db } from "@/db";
export default async function RoutePage(
props: PageParams<{
agentId: string;
}>
) {
const { agentId } = await props.params;
const agent = await db.query.agent.findFirst({
where: (fields, { eq }) => eq(fields.id, agentId),
});
if (!agent) {
notFound();
}
return (
<Page>
<PageHeader>
<PageTitle>Edit {agent.name}</PageTitle>
</PageHeader>
<PageContent>
<AgentForm defaultValues={agent} agentId={agent.id} />
</PageContent>
</Page>
);
}
@@ -1,8 +1,5 @@
import Link from "next/link";
import {GearIcon} from "@radix-ui/react-icons";
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {buttonVariants} from "@/components/ui/button";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
@@ -12,6 +9,8 @@ import {capitalizeFirstLetter} from "@/utils/text";
import {generateEdgeKey} from "@/utils/edge_key"; import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url"; import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content"; import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
import {AgentDialog} from "@/features/agents/components/agent.dialog";
import {AgentType} from "@/features/agents/agents.schema";
export default async function RoutePage(props: PageParams<{ agentId: string }>) { export default async function RoutePage(props: PageParams<{ agentId: string }>) {
@@ -29,20 +28,23 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
} }
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id); const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return ( return (
<Page> <Page>
<div className="justify-between gap-2 sm:flex"> <div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center"> <PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
{capitalizeFirstLetter(agent.name)} <div className="min-w-full md:min-w-fit ">
<Link className={buttonVariants({variant: "outline"})} {capitalizeFirstLetter(agent.name)}
href={`/dashboard/agents/${agent.id}/edit`}> </div>
<GearIcon className="w-7 h-7"/> <div className="flex items-center gap-2 md:justify-between w-full ">
</Link> <div className="flex items-center gap-2">
<AgentDialog agent={agent as AgentType & { id: string }} typeTrigger={"edit"}/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</div>
</div>
</PageTitle> </PageTitle>
<PageActions className="justify-between">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</PageActions>
</div> </div>
{agent.description && ( {agent.description && (
@@ -1,21 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {AgentForm} from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Create Agent",
};
export default async function RoutePage(props: PageParams<{}>) {
return (
<Page>
<PageHeader>
<PageTitle>Create new agent</PageTitle>
</PageHeader>
<PageContent>
<AgentForm/>
</PageContent>
</Page>
);
}
@@ -1,15 +1,13 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card"; import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {Button} from "@/components/ui/button";
import Link from "next/link";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {desc, eq, not} from "drizzle-orm"; import {desc, eq, not} from "drizzle-orm";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {Metadata} from "next"; import {Metadata} from "next";
import {AgentDialog} from "@/features/agents/components/agent.dialog";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Agents", title: "Agents",
@@ -22,12 +20,9 @@ export default async function RoutePage(props: PageParams<{}>) {
with: { with: {
databases: true databases: true
}, },
orderBy: (fields) => desc(fields.createdAt), orderBy: (fields) => desc(fields.lastContact),
}); });
console.log(agents);
if (!agents) { if (!agents) {
notFound(); notFound();
} }
@@ -38,9 +33,7 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageTitle>Agents</PageTitle> <PageTitle>Agents</PageTitle>
{agents.length > 0 && ( {agents.length > 0 && (
<PageActions> <PageActions>
<Link href={"/dashboard/agents/new"}> <AgentDialog typeTrigger={"create"} />
<Button>+ Create Agent</Button>
</Link>
</PageActions> </PageActions>
)} )}
</PageHeader> </PageHeader>
@@ -48,12 +41,9 @@ export default async function RoutePage(props: PageParams<{}>) {
{agents.length > 0 ? ( {agents.length > 0 ? (
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/> <CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
) : ( ) : (
<EmptyStatePlaceholder <AgentDialog typeTrigger="empty"/>
url={"/dashboard/agents/new"}
text={"Create new Agent"}
/>
)} )}
</PageContent> </PageContent>
</Page> </Page>
); );
} }
@@ -1,13 +1,12 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Metadata} from "next"; import {Metadata} from "next";
import {
NotificationChannelsSection
} from "@/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section";
import {db} from "@/db"; import {db} from "@/db";
import {notificationChannel, NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
import {desc, isNull} from "drizzle-orm"; import {desc, isNull} from "drizzle-orm";
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import * as drizzleDb from "@/db";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Notification Channels", title: "Notification Channels",
@@ -19,6 +18,7 @@ export default async function RoutePage(props: PageParams<{}>) {
with: { with: {
organizations: true organizations: true
}, },
where: isNull(drizzleDb.schemas.notificationChannel.organizationId),
orderBy: desc(notificationChannel.createdAt) orderBy: desc(notificationChannel.createdAt)
}) as NotificationChannelWith[] }) as NotificationChannelWith[]
@@ -35,11 +35,14 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageHeader> <PageHeader>
<PageTitle>Notification channels</PageTitle> <PageTitle>Notification channels</PageTitle>
<PageActions> <PageActions>
<NotifierAddEditModal adminView={false}/> {/*<NotifierAddEditModal adminView={false}/>*/}
<ChannelAddEditModal kind="notification" adminView={false}/>
</PageActions> </PageActions>
</PageHeader> </PageHeader>
<PageContent> <PageContent>
<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/> {/*<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>*/}
<ChannelsSection kind="notification" organizations={organizations}
channels={notificationChannels}/>
</PageContent> </PageContent>
</Page> </Page>
); );
@@ -0,0 +1,50 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Metadata} from "next";
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
import {db} from "@/db";
import {desc, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
export const metadata: Metadata = {
title: "Storage Channels",
};
export default async function RoutePage(props: PageParams<{}>) {
const storageChannels = await db.query.storageChannel.findMany({
with: {
organizations: true
},
where: isNull(drizzleDb.schemas.storageChannel.organizationId),
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
}) as StorageChannelWith[]
const organizations = await db.query.organization.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
members: true,
},
});
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
});
return (
<Page>
<PageHeader>
<PageTitle>Storage channels</PageTitle>
<PageActions>
<ChannelAddEditModal kind={"storage"} adminView={false}/>
</PageActions>
</PageHeader>
<PageContent>
<ChannelsSection defaultStorageChannelId={settings?.defaultStorageChannelId} kind={"storage"} organizations={organizations} channels={storageChannels}/>
</PageContent>
</Page>
);
}
@@ -1,20 +1,13 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {notFound, redirect} from "next/navigation"; import {notFound, redirect} from "next/navigation";
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {Page} from "@/features/layout/page";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {db} from "@/db"; import {db} from "@/db";
import {eq, and, inArray} from "drizzle-orm"; import {eq, and, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/lib/services"; import {getOrganizationProjectDatabases} from "@/lib/services";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet"; import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {capitalizeFirstLetter} from "@/utils/text"; import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
export default async function RoutePage(props: PageParams<{ export default async function RoutePage(props: PageParams<{
projectId: string; projectId: string;
@@ -39,7 +32,8 @@ export default async function RoutePage(props: PageParams<{
with: { with: {
project: true, project: true,
retentionPolicy: true, retentionPolicy: true,
alertPolicies: true alertPolicies: true,
storagePolicies: true
} }
}); });
@@ -51,6 +45,11 @@ export default async function RoutePage(props: PageParams<{
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id), where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: { with: {
restorations: true, restorations: true,
storages: {
with: {
storageChannel: true
}
}
}, },
orderBy: (b, {desc}) => [desc(b.createdAt)], orderBy: (b, {desc}) => [desc(b.createdAt)],
}); });
@@ -60,7 +59,7 @@ export default async function RoutePage(props: PageParams<{
orderBy: (r, {desc}) => [desc(r.createdAt)], orderBy: (r, {desc}) => [desc(r.createdAt)],
}); });
const isAlreadyBackup = backups.some((b) => b.status === "waiting"); const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting"); const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id}) const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
@@ -84,8 +83,6 @@ export default async function RoutePage(props: PageParams<{
notFound(); notFound();
} }
const organizationChannels = await getOrganizationChannels(organization.id);
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
@@ -93,44 +90,22 @@ export default async function RoutePage(props: PageParams<{
return ( return (
<Page> <Page>
<BackupModalProvider>
<div className="justify-between gap-2 sm:flex"> <DatabaseContent
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full "> activeMember={activeMember}
<div className="min-w-full md:min-w-fit "> settings={settings}
{capitalizeFirstLetter(dbItem.name)} database={dbItem}
</div> isAlreadyRestore={isAlreadyRestore}
{!isMember && ( restorations={restorations}
<div className="flex items-center gap-2 md:justify-between w-full "> backups={backups}
<div className="flex items-center gap-2"> totalBackups={totalBackups}
{/* Do not delete*/} availableBackups={availableBackups}
{/*<EditButton/>*/} successRate={successRate}
<RetentionPolicySheet database={dbItem}/> organizationId={organization.id}
<CronButton database={dbItem}/> activeOrganizationChannels={[]}
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels} activeOrganizationStorageChannels={[]}
organizationId={organization.id}/> />
<ImportModal database={dbItem}/> </BackupModalProvider>
</div>
<div className="flex items-center gap-2">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
</div>
</div>
)}
</PageTitle>
</div>
{dbItem.description && (
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full">
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
totalBackups={totalBackups}/>
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}/>
</PageContent>
</Page> </Page>
); );
} }
@@ -1,65 +0,0 @@
import {notFound} from "next/navigation";
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {DatabaseWith} from "@/db/schema/07_database";
export default async function RoutePage(props: PageParams<{ projectId: string }>) {
const {projectId} = await props.params;
const proj = await db.query.project.findFirst({
where: eq(drizzleDb.schemas.project.id, projectId),
with: {
databases: true,
},
});
if (!proj) {
notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, "default"),
});
if (!org) {
notFound();
}
const availableDatabases = (
await db.query.database.findMany({
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
)
// .filter((db) => db.project !== null) as DatabaseWith[];
return (
<Page>
<PageHeader>
<PageTitle>Edit {proj.name}</PageTitle>
</PageHeader>
<PageContent>
<ProjectForm
organization={org}
databases={availableDatabases as DatabaseWith[]}
defaultValues={{...proj, databases: proj.databases.map((db) => db.id)}}
projectId={proj.id}
/>
</PageContent>
</Page>
);
}
@@ -1,8 +1,5 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageTitle} from "@/features/layout/page";
import {buttonVariants} from "@/components/ui/button";
import {GearIcon} from "@radix-ui/react-icons";
import Link from "next/link";
import { import {
ButtonDeleteProject ButtonDeleteProject
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project"; } from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
@@ -15,6 +12,11 @@ import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text"; import {capitalizeFirstLetter} from "@/utils/text";
import {ProjectDialog} from "@/features/projects/components/project.dialog";
import {DatabaseWith} from "@/db/schema/07_database";
import {ProjectWith} from "@/db/schema/06_project";
import {isUuidv4} from "@/utils/verify-uuid";
export default async function RoutePage(props: PageParams<{ export default async function RoutePage(props: PageParams<{
projectId: string projectId: string
@@ -23,6 +25,10 @@ export default async function RoutePage(props: PageParams<{
projectId projectId
} = await props.params; } = await props.params;
if (!isUuidv4(projectId)) {
notFound()
}
const organization = await getOrganization({}); const organization = await getOrganization({});
const activeMember = await getActiveMember() const activeMember = await getActiveMember()
@@ -50,27 +56,45 @@ export default async function RoutePage(props: PageParams<{
redirect("/dashboard/projects"); redirect("/dashboard/projects");
} }
const availableDatabases = (
await db.query.database.findMany({
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
) as DatabaseWith[];
const isMember = activeMember?.role === "member"; const isMember = activeMember?.role === "member";
return ( return (
<Page> <Page>
<div className="justify-between gap-2 sm:flex"> <div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center"> <PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
{capitalizeFirstLetter(proj.name)} <div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(proj.name)}
</div>
{!isMember && ( {!isMember && (
<Link className={buttonVariants({variant: "outline"})} <div className="flex items-center gap-2 md:justify-between w-full ">
href={`/dashboard/projects/${proj.id}/edit`}> <div className="flex items-center gap-2">
<GearIcon className="w-7 h-7"/> <ProjectDialog
</Link> databases={availableDatabases}
organization={org}
project={proj as ProjectWith}
isEdit={true}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</div>
</div>
)} )}
</PageTitle> </PageTitle>
{!isMember && (
<PageActions className="justify-between">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</PageActions>
)}
</div> </div>
<PageDescription>The list of associated databases</PageDescription>
<PageContent className="flex flex-col w-full h-full"> <PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? ( {proj.databases.length > 0 ? (
<CardsWithPagination <CardsWithPagination
@@ -78,8 +102,8 @@ export default async function RoutePage(props: PageParams<{
organizationSlug={organization.slug} organizationSlug={organization.slug}
// @ts-ignore // @ts-ignore
cardItem={ProjectDatabaseCard} cardItem={ProjectDatabaseCard}
cardsPerPage={4} cardsPerPage={6}
numberOfColumns={1} numberOfColumns={3}
extendedProps={proj} extendedProps={proj}
/> />
) : ( ) : (
@@ -91,4 +115,4 @@ export default async function RoutePage(props: PageParams<{
</PageContent> </PageContent>
</Page> </Page>
); );
} }
@@ -1,50 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {DatabaseWith} from "@/db/schema/07_database";
export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({});
if (!organization) {
notFound();
}
const availableDatabases = (
await db.query.database.findMany({
where: (db, {isNull}) => isNull(db.projectId),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
).filter((db) => db.project == null) as DatabaseWith[];
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!org) notFound();
return (
<Page>
<PageHeader>
<PageTitle>Create new project</PageTitle>
</PageHeader>
<PageContent>
<ProjectForm databases={availableDatabases} organization={org}/>
</PageContent>
</Page>
);
}
@@ -1,8 +1,5 @@
import Link from "next/link";
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {Button} from "@/components/ui/button";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card"; import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
import {db} from "@/db"; import {db} from "@/db";
@@ -10,6 +7,8 @@ import {notFound} from "next/navigation";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder"; import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {Metadata} from "next"; import {Metadata} from "next";
import {ProjectDialog} from "@/features/projects/components/project.dialog";
import {DatabaseWith} from "@/db/schema/07_database";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Projects", title: "Projects",
@@ -36,6 +35,19 @@ export default async function RoutePage(props: PageParams<{}>) {
}); });
const isMember = activeMember?.role === "member"; const isMember = activeMember?.role === "member";
const availableDatabases = (
await db.query.database.findMany({
where: (db, {isNull}) => isNull(db.projectId),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
).filter((db) => db.project == null) as DatabaseWith[];
return ( return (
<Page> <Page>
@@ -43,9 +55,7 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageTitle>Projects</PageTitle> <PageTitle>Projects</PageTitle>
{(projects.length > 0 && !isMember) && ( {(projects.length > 0 && !isMember) && (
<PageActions> <PageActions>
<Link href={`/dashboard/projects/new`}> <ProjectDialog databases={availableDatabases} organization={organization}/>
<Button>+ Create Project</Button>
</Link>
</PageActions> </PageActions>
)} )}
</PageHeader> </PageHeader>
@@ -56,15 +66,15 @@ export default async function RoutePage(props: PageParams<{}>) {
organizationSlug={organization.slug} organizationSlug={organization.slug}
data={projects} data={projects}
cardItem={ProjectCard} cardItem={ProjectCard}
cardsPerPage={4} cardsPerPage={9}
numberOfColumns={1} numberOfColumns={3}
/> />
) : isMember ? ( ) : isMember ? (
<EmptyStatePlaceholder text="No project available"/> <EmptyStatePlaceholder text="No project available"/>
) : ( ) : (
<EmptyStatePlaceholder url="/dashboard/projects/new" text="Create new Project"/> <ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/>
)} )}
</PageContent> </PageContent>
</Page> </Page>
); );
} }
@@ -1,37 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/organization-form/organization-form";
import {getOrganization} from "@/lib/auth/auth";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
import {currentUser} from "@/lib/auth/current-user";
export default async function RoutePage(props: PageParams<{
}>) {
const user = await currentUser();
const organization = await getOrganization({});
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt)
});
if (!user || !users || !organization || organization.slug == "default") {
notFound();
}
return(
<Page>
<PageHeader>
<PageTitle>
Edit {organization.name}
</PageTitle>
</PageHeader>
<PageContent>
<OrganizationForm currentUser={user} users={users} defaultValues={organization}/>
</PageContent>
</Page>
)
}
@@ -3,14 +3,19 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {
DeleteOrganizationButton
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
import {Metadata} from "next"; import {Metadata} from "next";
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs"; import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
import {getOrganizationChannels} from "@/db/services/notification-channel"; import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
import {Button} from "@/components/ui/button";
import {GearIcon} from "@radix-ui/react-icons";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Settings", title: "Settings",
@@ -21,38 +26,64 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
const user = await currentUser(); const user = await currentUser();
const activeMember = await getActiveMember() const activeMember = await getActiveMember()
if (!organization || !activeMember) { 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 permissions = computeOrganizationPermissions(activeMember); const permissions = computeOrganizationPermissions(activeMember);
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt)
});
const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id),
with: {
members: {
with: {
user: true
}
}
}
});
if (!organizationWithMembers) notFound();
return ( return (
<Page> <Page>
<PageHeader> <PageHeader>
<PageTitle className="flex items-center"> <PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
Organization settings <div className="min-w-full md:min-w-fit ">
{permissions.canManageSettings && organization.slug !== "default" && ( Organization settings
<EditButtonSettings/> </div>
)} <div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
{permissions.canManageSettings && organization.slug !== "default" && (
<EditOrganizationDialog
organization={organizationWithMembers}
users={users}
currentUser={user}
/>
)}
</div>
<div className="flex items-center gap-2">
{permissions.canManageDangerZone && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</div>
</div>
</PageTitle> </PageTitle>
<PageActions>
{permissions.canManageDangerZone && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</PageActions>
</PageHeader> </PageHeader>
<PageContent> <PageContent>
<OrganizationTabs <OrganizationTabs
activeMember={activeMember} activeMember={activeMember}
organization={organization} organization={organization}
notificationChannels={notificationChannels} notificationChannels={notificationChannels}
storageChannels={storageChannels}
/> />
</PageContent> </PageContent>
</Page> </Page>
) )
} }
+1
View File
@@ -6,6 +6,7 @@ import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sid
import {Header} from "@/features/layout/Header"; import {Header} from "@/features/layout/Header";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater"; import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
export default async function Layout({children}: { children: ReactNode }) { export default async function Layout({children}: { children: ReactNode }) {
const user = await currentUser(); const user = await currentUser();
+46 -50
View File
@@ -1,56 +1,52 @@
import fs from "node:fs"; import { NextResponse } from "next/server";
import forge from "node-forge"; import { eq } from "drizzle-orm";
import {EventPayload} from "@/features/notifications/types"; import { db } from "@/db";
import {dispatchNotification} from "@/features/notifications/dispatch"; import * as drizzleDb from "@/db";
import {Database, DatabaseWith} from "@/db/schema/07_database";
export function withAgentCheck(handler: Function) {
return async (request: Request, context: { params: Promise<{ agentId: string }> }) => {
try {
const agentId = (await context.params).agentId;
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
});
if (!agent) {
return NextResponse.json(
{ error: "Agent not found" },
{ status: 404 }
);
}
return handler(request, { ...context, agent });
} catch (err) {
console.error("Error in agent middleware:", err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
};
}
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> { export async function getDatabaseOrThrow(generatedId: string) {
const privateKeyPem = fs.readFileSync("private/keys/server_private.pem", "utf8"); const database = await db.query.database.findFirst({
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem); where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
with: {
// Decrypt AES key with RSA-OAEP project: true,
const encryptedAesKey = forge.util.hexToBytes(aesKeyHex); alertPolicies: true,
const aesKey = privateKey.decrypt(encryptedAesKey, "RSA-OAEP", { storagePolicies: true
md: forge.md.sha256.create(), }
mgf1: {md: forge.md.sha256.create()},
}); });
// Read encrypted file content if (!database) {
const encryptedBuffer = Buffer.from(await file.arrayBuffer()); throw NextResponse.json(
const iv = forge.util.hexToBytes(ivHex); { error: "Database associated with generatedId not found" },
{ status: 404 }
// AES decryption );
const decipher = forge.cipher.createDecipher("AES-CBC", aesKey);
decipher.start({iv});
decipher.update(forge.util.createBuffer(encryptedBuffer.toString("binary")));
const success = decipher.finish();
if (!success) {
throw new Error("Decryption failed");
} }
const decryptedBytes = decipher.output.getBytes(); return database;
const decryptedBuffer = Buffer.from(decryptedBytes, "binary"); }
// Return a File so you can use file.arrayBuffer() later
return new File(
[decryptedBuffer],
file.name.replace(/\.enc$/, fileExtension),
{type: "application/octet-stream"}
);
}
export function getFileExtension(dbType: string) {
switch (dbType) {
case "postgresql":
return ".dump";
case "mysql":
return ".sql";
default:
return ".dump";
}
}
+112 -154
View File
@@ -1,87 +1,63 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
import {v4 as uuidv4} from "uuid";
import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {Backup} from "@/db/schema/07_database";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {env} from "@/env.mjs"; import * as drizzleDb from "@/db";
import {db as dbClient, db} from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "./helpers";
import {Backup} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {decryptedDump, getFileExtension} from "./helpers"; import {eventEmitter} from "@/features/shared/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {EventKind} from "@/features/notifications/types";
export async function POST( export type BodyPost = {
request: Request, method: "manual" | "automatic"
{params}: { params: Promise<{ agentId: string }> } generatedId: string
) { }
export type BodyPatch = {
backupId: string
status: "success" | "failed"
size: number
generatedId: string
}
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
params: Promise<{ agentId: string }>,
agent: any
}) => {
try { try {
const contentType = request.headers.get("Content-Type"); const body: BodyPost = await request.json();
const method = body.method
if (!contentType || !contentType.includes("multipart/form-data")) { const database = await getDatabaseOrThrow(body.generatedId);
return NextResponse.json(
{error: "Unsupported or missing Content-Type"},
{status: 400}
);
}
eventEmitter.emit('modification', {update: true});
const agentId = (await params).agentId;
const formData = await request.formData();
const aesKeyHex = formData.get("aes_key") as string;
const ivHex = formData.get("iv") as string;
const generatedId = formData.get("generatedId") as string | null;
const method = formData.get("method") as string | null;
if (!generatedId || !isUuidv4(generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid UUID"},
{status: 400}
);
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
});
if (!agent) {
return NextResponse.json(
{error: "Agent not found"},
{status: 404}
);
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
with: {
project: true,
alertPolicies: true
}
});
if (!database) {
return NextResponse.json(
{error: "Database associated with generatedId not found"},
{status: 404}
);
}
let backup: Backup | null | undefined = null; let backup: Backup | null | undefined = null;
if (method === "automatic") { if (method === "automatic") {
[backup] = await db
.insert(drizzleDb.schemas.backup)
.values({
status: 'ongoing',
databaseId: database.id,
})
.returning();
const ongoingBackup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.status, 'ongoing'),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) { if (!ongoingBackup) {
[backup] = await db
.insert(drizzleDb.schemas.backup)
.values({
status: 'ongoing',
databaseId: database.id,
})
.returning();
if (!backup) {
return NextResponse.json(
{error: "Unable to create an automatic backup"},
{status: 500}
);
}
} else {
return NextResponse.json( return NextResponse.json(
{error: "Unable to create an automatic backup"}, {error: "A backup is already ongoing"},
{status: 500} {status: 500}
); );
} }
@@ -93,7 +69,6 @@ export async function POST(
), ),
}); });
if (!backup) { if (!backup) {
return NextResponse.json( return NextResponse.json(
{error: "Unable to find the corresponding backup"}, {error: "Unable to find the corresponding backup"},
@@ -102,94 +77,77 @@ export async function POST(
} }
} }
const status = formData.get("status") as string | null;
if (status === "success") { eventEmitter.emit('modification', {update: true});
const file = formData.get("file") as File | null;
const extension = formData.get("extension") as string | null;
if (!aesKeyHex || !ivHex) {
return NextResponse.json({error: "Missing fields"}, {status: 400});
}
return NextResponse.json(
if (!file) { {
return NextResponse.json( message: "Init backup success",
{error: "File is required for successful backup"}, backup: backup,
{status: 400} },
); {status: 200}
} );
const fileSizeBytes = file.size;
// const fileExtension = '.' + (file.name.split('.').pop()?.toLowerCase() || '');
const fileExtension = extension ? extension : getFileExtension(database.dbms)
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
const uuid = uuidv4();
const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
throw new Error("System settings not found.");
}
let success: boolean, message: string, filePath: string;
const result =
settings.storage === "local"
? await uploadLocalPrivate(fileName, buffer)
: await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
({success, message, filePath} = result);
if (!success) {
return NextResponse.json(
{error: message},
{status: 500}
);
}
await db
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
file: fileName,
fileSize: fileSizeBytes,
status: 'success',
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, "success_backup");
return NextResponse.json(
{
message: "Backup successfully uploaded",
},
{status: 200}
);
} else {
await db
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({status: 'failed'}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, "error_backup");
return NextResponse.json(
{
message: "Backup successfully updated with status failed",
},
{status: 200}
);
}
} catch (error) { } catch (error) {
console.error("Error in POST handler:", error); console.error("Error in POST for INIT backup:", error);
return NextResponse.json( return NextResponse.json(
{error: "Internal server error"}, {error: "Internal server error"},
{status: 500} {status: 500}
); );
} }
} });
export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
params: Promise<{ agentId: string }>,
agent: any
}) => {
try {
const body: BodyPatch = await request.json();
const status = body.status
const backupId = body.backupId
const backupSize = body.size
const database = await getDatabaseOrThrow(body.generatedId);
const backup = await db.query.backup.findFirst({
where: eq(drizzleDb.schemas.backup.id, backupId),
});
if (!backup) {
return NextResponse.json(
{error: "No backup found"},
{status: 500}
);
}
const [backupUpdated] = await dbClient
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
status: status,
fileSize: backupSize
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id))
.returning();
eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, `${status}_backup` as EventKind);
return NextResponse.json(
{
message: "Backup successfully updated",
backup: backupUpdated,
},
{status: 200}
);
} catch (error) {
console.error("Error in PATCH backup:", error);
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
);
}
});
@@ -0,0 +1,76 @@
import {NextResponse} from "next/server";
import {and, eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
import {isUuidv4} from "@/utils/verify-uuid";
import {eventEmitter} from "@/features/shared/event";
export type Body = {
generatedId: string
storageChannelId: string
backupId: string
}
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
params: Promise<{ agentId: string }>,
agent: any
}) => {
try {
const body: Body = await request.json();
console.log("body", body);
const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId;
const backupId = body.backupId;
if (!generatedId || !isUuidv4(generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid UUID"},
{status: 400}
);
}
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
return NextResponse.json(
{error: "Unable to find the corresponding backup"},
{status: 404}
);
}
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: backup.id,
storageChannelId: storageChannelId,
status: "pending",
})
.returning();
eventEmitter.emit('modification', {update: true});
return NextResponse.json(
{
message: "Backup storage successfully created",
backupStorage: backupStorage
},
{status: 200}
);
} catch (error) {
console.error("Error in POST for INIT backup:", error);
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
);
}
});
@@ -0,0 +1,95 @@
import {NextResponse} from "next/server";
import {and, eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db as dbClient, db} from "@/db";
import {withUpdatedAt} from "@/db/utils";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
import {eventEmitter} from "@/features/shared/event";
export type Body = {
generatedId: string
status: "success" | "failed"
backupStorageId: string
path: string
size: number
backupId: string
}
export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
params: Promise<{ agentId: string }>,
agent: any
}) => {
try {
const body: Body = await request.json();
const generatedId = body.generatedId;
const status = body.status;
const filePath = body.path;
const fileSize = body.size;
const backupStorageId = body.backupStorageId;
const backupId = body.backupId;
console.log("body", body);
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
with: {
storages: true
}
});
if (!backup) {
return NextResponse.json(
{error: "Unable to find the corresponding backup"},
{status: 404}
);
}
const [backupStorage] = await dbClient
.update(drizzleDb.schemas.backupStorage)
.set(withUpdatedAt({
status: status,
path: filePath,
size: fileSize
}))
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorageId))
.returning();
if (backup.storages.length > 0) {
const hasSuccessfulStorage = backup.storages.some(
(storage) => storage.status === "success"
);
if (hasSuccessfulStorage && backup.status !== "success") {
await db
.update(drizzleDb.schemas.backup)
.set({
status: "success",
fileSize: fileSize,
})
.where(eq(drizzleDb.schemas.backup.id, backup.id));
}
}
eventEmitter.emit('modification', {update: true});
return NextResponse.json({
message: "Backup status successfully updated",
backupStorage: backupStorage
},
{status: 200}
);
} catch (error) {
console.error("Error in POST for INIT backup:", error);
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
);
}
});
+1 -4
View File
@@ -1,10 +1,10 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {eventEmitter} from "@/features/shared/event";
export type BodyResultRestore = { export type BodyResultRestore = {
generatedId: string generatedId: string
@@ -19,12 +19,10 @@ export async function POST(
) { ) {
try { try {
eventEmitter.emit('modification', {update: true});
const agentId = (await params).agentId const agentId = (await params).agentId
const body: BodyResultRestore = await request.json(); const body: BodyResultRestore = await request.json();
console.log(body)
if (!isUuidv4(body.generatedId)) { if (!isUuidv4(body.generatedId)) {
return NextResponse.json( return NextResponse.json(
@@ -73,7 +71,6 @@ export async function POST(
details: "Restoration successfully updated" details: "Restoration successfully updated"
} }
eventEmitter.emit('modification', {update: true});
return Response.json(response, {status: 200}) return Response.json(response, {status: 200})
} catch (error) { } catch (error) {
+136 -57
View File
@@ -1,24 +1,25 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {Body} from "./route"; import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action";
import {Agent} from "@/db/schema/08_agent"; import {Agent} from "@/db/schema/08_agent";
import {Database} from "@/db/schema/07_database"; import {Database, DatabaseWith} from "@/db/schema/07_database";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db as dbClient} from "@/db"; import {db, db as dbClient} from "@/db";
import {and, eq} from "drizzle-orm"; import {and, eq, inArray} from "drizzle-orm";
import {EDbmsSchema} from "@/db/schema/types"; import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {ServerActionResult} from "@/types/action-type";
import {SafeActionResult} from "next-safe-action";
import {ZodString} from "zod";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {Setting} from "@/db/schema/01_setting";
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) { export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
const databasesResponse = []; const databasesResponse = [];
const formatDatabase = (database: Database, backupAction: boolean, restoreAction: boolean, UrlBackup: string) => ({ const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null) => ({
generatedId: database.agentDatabaseId, generatedId: database.agentDatabaseId,
dbms: database.dbms, dbms: database.dbms,
storages: storages,
encrypt: settings.encryption,
data: { data: {
backup: { backup: {
action: backupAction, action: backupAction,
@@ -27,6 +28,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
restore: { restore: {
action: restoreAction, action: restoreAction,
file: UrlBackup, file: UrlBackup,
metaFile: urlMeta
}, },
}, },
}); });
@@ -34,12 +36,16 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
for (const db of body.databases) { for (const db of body.databases) {
const existingDatabase = await dbClient.query.database.findFirst({ const existingDatabase = await dbClient.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId) where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId),
with: {
project: true
}
}); });
let backupAction: boolean = false let backupAction: boolean = false
let restoreAction: boolean = false let restoreAction: boolean = false
let urlBackup: string = "" let urlBackup: string | null = null;
let urlMeta: string | null = null
if (!existingDatabase) { if (!existingDatabase) {
if (!isUuidv4(db.generatedId)) { if (!isUuidv4(db.generatedId)) {
@@ -48,6 +54,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
{status: 500} {status: 500}
); );
} }
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
console.log(`Database type not available: ${db.dbms}`);
continue;
}
const [databaseCreated] = await dbClient const [databaseCreated] = await dbClient
.insert(drizzleDb.schemas.database) .insert(drizzleDb.schemas.database)
.values({ .values({
@@ -59,8 +71,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
}) })
.returning(); .returning();
if (databaseCreated) { if (databaseCreated) {
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup)); const storages = await getDatabaseStorageChannels(databaseCreated.id)
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
} }
} else { } else {
@@ -74,77 +89,77 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id)) .where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
.returning(); .returning();
const activeBackup = await dbClient.query.backup.findFirst({
const backup = await dbClient.query.backup.findFirst({ where: and(
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting")) eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
)
}) })
const restoration = await dbClient.query.restoration.findFirst({ const restoration = await dbClient.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")) where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")),
with: {
backupStorage: true
}
}) })
if (activeBackup && activeBackup.status == "waiting") {
if (backup) {
backupAction = true backupAction = true
await dbClient await dbClient
.update(drizzleDb.schemas.backup) .update(drizzleDb.schemas.backup)
.set(withUpdatedAt({status: "ongoing"})) .set(withUpdatedAt({status: "ongoing"}))
.where(eq(drizzleDb.schemas.backup.id, backup.id)); .where(eq(drizzleDb.schemas.backup.id, activeBackup.id));
} }
if (restoration) { if (restoration) {
restoreAction = true restoreAction = true
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
const backupToRestore = await dbClient.query.backup.findFirst({ restoreAction = false
where: eq(drizzleDb.schemas.backup.id, restoration.backupId), continue;
with: {
database: {
with: {
project: true
}
}
}
})
const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return NextResponse.json(
{error: "Unable to find settings"},
{status: 500}
);
} }
const input: StorageInput = {
action: "get",
data: {
path: restoration.backupStorage.path,
signedUrl: true,
},
metadata: {
storageId: restoration.backupStorage.storageChannelId,
fileKind: "backups"
}
};
const fileName = backupToRestore?.file const inputMeta: StorageInput = {
action: "get",
data: {
path: `${restoration.backupStorage.path}.meta`,
signedUrl: true,
},
metadata: {
storageId: restoration.backupStorage.storageChannelId,
fileKind: "backups"
}
};
let data: SafeActionResult<string, ZodString, readonly [], {
_errors?: string[] | undefined;
}, readonly [], ServerActionResult<string>, object> | undefined
try { try {
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
const resultMeta = await dispatchStorage(inputMeta, undefined, restoration.backupStorage.storageChannelId);
if (settings.storage == "local") { if (result.success) {
data = await getFileUrlPresignedLocal({fileName: fileName!}) urlBackup = result.url ?? null;
} else if (settings.storage == "s3") { urlMeta = resultMeta.url ?? null
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
}
if (data?.data?.success) {
urlBackup = data.data.value ?? "";
} else { } else {
await dbClient await dbClient
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "failed"}) .set({status: "failed"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
// @ts-ignore const errorMessage = "Failed to get backup URL";
const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
console.error("Restoration failed: ", errorMessage); console.error("Restoration failed: ", errorMessage);
continue; continue;
} }
} catch (err) { } catch (err) {
@@ -153,17 +168,81 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "failed"}) .set({status: "failed"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
continue; continue;
} }
await dbClient await dbClient
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "ongoing"}) .set({status: "ongoing"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
} }
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup)); const storages = await getDatabaseStorageChannels(databaseUpdated.id)
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
} }
} }
return databasesResponse; return databasesResponse;
} }
type PingDatabaseStorageChannels = {
id: string;
config: any
provider: string
}
async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatabaseStorageChannels[]> {
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
}
});
if (!database) {
return []
}
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: {storageChannel: true},
});
const defaultStorageChannel: PingDatabaseStorageChannels[] = settings?.storageChannel
? [{
id: settings.storageChannel.id,
provider: settings.storageChannel.provider,
config: settings.storageChannel.config,
}]
: [];
const enabledDatabaseStorageChannels = await Promise.all(
(database.storagePolicies ?? [])
.filter(p => p.enabled)
.map(async policy => {
const storageChannel = await db.query.storageChannel.findFirst({
where: eq(drizzleDb.schemas.storageChannel.id, policy.storageChannelId),
});
if (!storageChannel) return null;
return {
id: storageChannel.id,
config: storageChannel.config,
provider: storageChannel.provider,
} as PingDatabaseStorageChannels;
})
);
const filteredChannels: PingDatabaseStorageChannels[] = enabledDatabaseStorageChannels.filter(
(c): c is PingDatabaseStorageChannels => c !== null
);
return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel;
}
+12 -15
View File
@@ -1,13 +1,13 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
import {handleDatabases} from "./helpers"; import {handleDatabases} from "./helpers";
import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {EDbmsSchema} from "@/db/schema/types"; import {EDbmsSchema} from "@/db/schema/types";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {eventEmitter} from "@/features/shared/event";
import {notFound} from "next/navigation";
export type databaseAgent = { export type databaseAgent = {
name: string, name: string,
@@ -20,13 +20,6 @@ export type Body = {
databases: databaseAgent[] databases: databaseAgent[]
} }
// Function to test the get file url presigned local
export async function GET(request: Request) {
const url = await getFileUrlPresignedLocal({fileName: "d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
return Response.json({
message: url
})
}
export async function POST( export async function POST(
request: Request, request: Request,
@@ -34,14 +27,14 @@ export async function POST(
) { ) {
try { try {
const agentId = (await params).agentId const agentId = (await params).agentId
console.log(agentId)
const body: Body = await request.json(); const body: Body = await request.json();
const lastContact = new Date(); const lastContact = new Date();
let message: string let message: string
if (!isUuidv4(agentId)) { if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid" message = "agentId is not a valid uuid"
console.log(message) console.error(message)
return NextResponse.json( return NextResponse.json(
{error: "agentId is not a valid uuid"}, {error: "agentId is not a valid uuid"},
{status: 500} {status: 500}
@@ -54,11 +47,16 @@ export async function POST(
if (!agent) { if (!agent) {
message = "Agent not found" message = "Agent not found"
console.log(message)
return NextResponse.json({error: message}, {status: 404}) return NextResponse.json({error: message}, {status: 404})
} }
const databasesResponse = await handleDatabases(body, agent, lastContact)
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return NextResponse.json({error: "An error occured"}, {status: 404})
}
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
await db await db
.update(drizzleDb.schemas.agent) .update(drizzleDb.schemas.agent)
@@ -68,7 +66,6 @@ export async function POST(
})) }))
.where(eq(drizzleDb.schemas.agent.id, agentId)); .where(eq(drizzleDb.schemas.agent.id, agentId));
eventEmitter.emit('modification', {update: true});
const response = { const response = {
agent: { agent: {
@@ -77,7 +74,7 @@ export async function POST(
}, },
databases: databasesResponse databases: databasesResponse
} }
console.log(response)
return Response.json(response) return Response.json(response)
} catch (error) { } catch (error) {
+2 -17
View File
@@ -1,9 +1,7 @@
import {EventEmitter} from 'events';
import {auth} from "@/lib/auth/auth"; import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers"; import {headers} from "next/headers";
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {eventEmitter} from "@/features/shared/event";
export const eventEmitter = new EventEmitter();
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -19,7 +17,6 @@ export async function GET(request: Request) {
new ReadableStream({ new ReadableStream({
start(controller) { start(controller) {
console.log('Stream started'); console.log('Stream started');
const handleModification = (data: any) => { const handleModification = (data: any) => {
console.log('Modification event triggered:', data); console.log('Modification event triggered:', data);
controller.enqueue(`event: modification\n`); controller.enqueue(`event: modification\n`);
@@ -28,7 +25,6 @@ export async function GET(request: Request) {
eventEmitter.on('modification', handleModification); eventEmitter.on('modification', handleModification);
// Handle client disconnect
request.signal.addEventListener('abort', () => { request.signal.addEventListener('abort', () => {
console.log('Client disconnected'); console.log('Client disconnected');
controller.close(); controller.close();
@@ -45,15 +41,4 @@ export async function GET(request: Request) {
}, },
} }
); );
} }
// export async function POST(request: Request) {
// console.log('POST request received');
// const data = await request.json();
// console.log('Data received:', data);
//
// // Emit the event to all connected clients
// eventEmitter.emit('modification', data);
//
// return new Response('Event sent', {status: 200});
// }
@@ -1,29 +1,46 @@
import * as fs from "node:fs";
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import path from "path"; import path from "path";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {Readable} from "node:stream";
export async function GET( export async function GET(
request: Request, request: Request,
{params}: { params: Promise<{ fileName: string }> }
) { ) {
const {searchParams} = new URL(request.url); const {searchParams} = new URL(request.url);
const token = searchParams.get('token'); const token = searchParams.get('token');
const expires = searchParams.get('expires'); const expires = searchParams.get('expires');
const fileName = (await params).fileName const pathFromUrl = searchParams.get('path');
const storageId = searchParams.get('storageId');
const uploadsDir = "private/uploads/files/"; if (!pathFromUrl || !storageId) {
const uploadPath = path.join(uploadsDir, fileName); return NextResponse.json({error: "Missing search params"}, {status: 404})
const crypto = require('crypto');
let filePath = null;
if (fs.existsSync(uploadPath)) {
filePath = uploadPath;
} else {
return NextResponse.json({error: "File not found"}, {status: 404})
} }
const input: StorageInput = {
action: "get",
data: {
path: pathFromUrl,
signedUrl: true,
},
metadata: {
storageId: storageId,
fileKind: "backups",
}
};
console.debug(input);
const result = await dispatchStorage(input, undefined, storageId);
if (!result.success) {
return NextResponse.json({error: "Enable to get file from provided storage channel, an error occurred !"})
}
const fileName = path.basename(pathFromUrl);
const crypto = require('crypto');
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex'); const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
if (token !== expectedToken) { if (token !== expectedToken) {
return NextResponse.json( return NextResponse.json(
@@ -40,7 +57,15 @@ export async function GET(
); );
} }
const fileStream = fs.createReadStream(filePath); if (!result.file || !(result.file instanceof Readable)) {
return NextResponse.json(
{error: "Invalid file payload"},
{status: 500}
);
}
const fileStream = Readable.from(result.file as Readable);
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk)); fileStream.on('data', (chunk) => controller.enqueue(chunk));
+88
View File
@@ -0,0 +1,88 @@
import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {Readable} from "node:stream";
export async function GET(
req: Request,
{params}: { params: Promise<{ fileName: string }> }
) {
const {searchParams} = new URL(req.url);
const fileName = (await params).fileName;
const storageId = searchParams.get('storageId');
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
const session = await auth.api.getSession({headers: await headers()});
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
if (!storageId) {
return NextResponse.json({error: "Missing storageId in search params"}, {status: 404})
}
const ext = fileName.split(".").pop()?.toLowerCase();
const contentType =
ext === "png"
? "image/png"
: ext === "jpg" || ext === "jpeg"
? "image/jpeg"
: ext === "gif"
? "image/gif"
: ext === "webp"
? "image/webp"
: "application/octet-stream";
try {
const path = `images/${fileName}`;
const input: StorageInput = {
action: "get",
data: {
path: path,
},
metadata: {
storageId: storageId,
fileKind: "images"
}
}
const result = await dispatchStorage(input, undefined, storageId);
if (!result.file || !(result.file instanceof Readable)) {
console.error(`An error occurred while getting file :`, result);
return NextResponse.json(
{error: "Invalid file payload"},
{status: 500}
);
}
const fileStream = Readable.from(result.file as Readable);
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(stream, {
headers: {
'Content-Disposition': `inline; filename="${fileName}"`,
"Cache-Control": "no-store",
"Content-Type": contentType,
},
});
} catch (err) {
console.error("Error streaming image:", err);
return NextResponse.json({error: "Error fetching file"}, {status: 500});
}
}
+23
View File
@@ -0,0 +1,23 @@
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const success = Boolean(code);
return new Response(
`
<!DOCTYPE html>
<html>
<body>
<h1>${success ? "Success" : "Failed"}</h1>
</body>
</html>
`,
{
status: success ? 200 : 400,
headers: {
"Content-Type": "text/html; charset=utf-8",
},
}
);
}
-103
View File
@@ -1,103 +0,0 @@
import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {checkFileExistsInBucket, getObjectFromClient} from "@/utils/s3-file-management";
import {env} from "@/env.mjs";
import * as stream from "node:stream";
import path from "path";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import fs from "fs/promises";
function nodeStreamToWebStream(nodeStream: stream.Readable) {
return new ReadableStream({
start(controller) {
nodeStream.on("data", chunk => controller.enqueue(chunk));
nodeStream.on("end", () => controller.close());
nodeStream.on("error", err => controller.error(err));
},
cancel() {
nodeStream.destroy();
},
});
}
const privateS3ImageDir = "images/";
export async function GET(
req: Request,
{params}: { params: Promise<{ fileName: string }> }
) {
const fileName = (await params).fileName;
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
const session = await auth.api.getSession({headers: await headers()});
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) throw new Error("System settings not found.");
const storageType = settings.storage; // "local" or "s3"
const ext = fileName.split(".").pop()?.toLowerCase();
const contentType =
ext === "png"
? "image/png"
: ext === "jpg" || ext === "jpeg"
? "image/jpeg"
: ext === "gif"
? "image/gif"
: ext === "webp"
? "image/webp"
: "application/octet-stream";
try {
if (storageType === "local") {
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
try {
await fs.access(filePath);
const file = await fs.readFile(filePath);
return new NextResponse(file, {
headers: {
"Content-Type": contentType,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="${fileName}"`,
},
});
} catch {
// if not found locally, fallback to S3
}
}
const exists = await checkFileExistsInBucket({
bucketName: env.S3_BUCKET_NAME!,
fileName: `${privateS3ImageDir}${fileName}`,
});
if (!exists) return NextResponse.json({error: "File not found"}, {status: 404});
const nodeStream = await getObjectFromClient({
bucketName: env.S3_BUCKET_NAME!,
fileName: `${privateS3ImageDir}${fileName}`,
});
const webStream = nodeStreamToWebStream(nodeStream);
return new NextResponse(webStream, {
headers: {
"Content-Type": contentType,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="${fileName}"`,
},
});
} catch (err) {
console.error("Error streaming image:", err);
return NextResponse.json({error: "Error fetching file"}, {status: 500});
}
}
+78
View File
@@ -0,0 +1,78 @@
import {NextResponse} from "next/server";
import fs from "fs";
import path from "path";
export async function POST(request: Request) {
try {
const body = await request.json();
const event = body.Event
const headers = event.HTTPRequest.Header
const uploadLength = headers["X-File-Size"]?.[0];
const uploadOffset = headers["Upload-Offset"]?.[0];
const status = headers["X-Status"]?.[0];
console.log(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`);
if (status === "success") {
if (
body.Type === "post-receive" &&
event.Upload.SizeIsDeferred === false &&
event.Upload.Offset === event.Upload.Size
) {
const id = event.Upload.ID;
const fileName = headers["X-File-Name"]?.[0];
const filePath = headers["X-File-Path"]?.[0];
if (!filePath) {
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
}
const uploadDir = path.join(process.cwd(), "/private/uploads/");
const oldFilePath = path.join(uploadDir, "tmp", id);
const newFilePath = path.join(uploadDir, filePath);
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
let retries = 10;
while (!fs.existsSync(oldFilePath)) {
if (retries-- === 0) {
return NextResponse.json({error: `Upload file not found: ${oldFilePath}`}, {status: 500});
}
await new Promise(r => setTimeout(r, 200));
}
fs.renameSync(oldFilePath, newFilePath);
const infoFilePath = `${oldFilePath}.info`;
if (fs.existsSync(infoFilePath)) {
fs.unlinkSync(infoFilePath);
}
const metadataHeaderB64 = headers["Upload-Metadata"]?.[0];
if (metadataHeaderB64) {
const metadataHeader = Buffer.from(metadataHeaderB64, "base64").toString("utf-8");
if (metadataHeader) {
const tomlContent = metadataHeader
.split(",")
.map((pair) => {
const [key, value] = pair.split(" ");
const escapedValue = value.replace(/"/g, '\\"');
return `${key} = "${escapedValue}"`;
})
.join("\n");
const metaFilePath = `${newFilePath}.meta`;
fs.writeFileSync(metaFilePath, tomlContent, "utf-8");
}
}
}
}
return NextResponse.json({});
} catch (error) {
console.error("Hook error:", error);
return NextResponse.json({error: "Internal server error"}, {status: 500});
}
}
+26 -11
View File
@@ -6,8 +6,11 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-poppins), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-title: var(--font-poppins), var(--font-poppins), sans-serif;
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-author: var(--font-author);
--font-poppins: var(--font-poppins);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -71,7 +74,7 @@
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.611 0.204 42.149);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
@@ -82,7 +85,7 @@
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.611 0.204 42.149);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
@@ -90,12 +93,12 @@
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.611 0.204 42.149);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.611 0.204 42.149);
} }
.dark { .dark {
@@ -105,8 +108,8 @@
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.704 0.191 47.132);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
@@ -116,7 +119,7 @@
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.704 0.191 47.132);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
@@ -124,12 +127,12 @@
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.704 0.191 47.132);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.704 0.191 47.132);
} }
@layer base { @layer base {
@@ -137,6 +140,18 @@
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground font-sans;
}
h1, h2, h3, h4, h5, h6 {
@apply font-title;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
} }
} }
+2 -2
View File
@@ -4,7 +4,7 @@ import "./globals.css";
import {Providers} from "./providers"; import {Providers} from "./providers";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer"; import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
import {inter} from "@/fonts/fonts"; import {author, geistMono, poppins} from "@/fonts/fonts";
const title = process.env.PROJECT_NAME ?? "App Title"; const title = process.env.PROJECT_NAME ?? "App Title";
@@ -27,7 +27,7 @@ export default async function RootLayout({
<head> <head>
<meta name="apple-mobile-web-app-title" content={title}/> <meta name="apple-mobile-web-app-title" content={title}/>
</head> </head>
<body className={cn(inter.className, "h-full")}> <body className={cn(poppins.variable, author.variable, geistMono.variable, "font-sans h-full")}>
<ConsoleSilencer/> <ConsoleSilencer/>
<Providers> <Providers>
{children} {children}
+2 -2
View File
@@ -21,8 +21,8 @@ export const Providers = (props: ProviderProps) => {
> >
<ThemeMetaUpdaterRoot/> <ThemeMetaUpdaterRoot/>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Toaster/> <Toaster/>
{props.children} {props.children}
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider> </ThemeProvider>
</Suspense> </Suspense>
+5 -5
View File
@@ -3,11 +3,11 @@ name: portabase-prod
services: services:
app: app:
# build: build:
# context: . context: .
# dockerfile: docker/dockerfile/Dockerfile dockerfile: docker/dockerfile/Dockerfile
# target: prod target: prod
image: solucetechnologies/portabase:1.1.4-rc.2 image: portabase/portabase:1.2.5-rc.9
ports: ports:
- '8887:80' - '8887:80'
environment: environment:
+15 -1
View File
@@ -2,7 +2,7 @@ name: portabase-dev
services: services:
db: db:
image: postgres:16-alpine image: postgres:17-alpine
ports: ports:
- "5433:5432" - "5433:5432"
volumes: volumes:
@@ -17,5 +17,19 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
tusd:
image: tusproject/tusd:v2.8.0
ports:
- "1080:8080"
command: >
-upload-dir /data/uploads/tmp
-hooks-http http://localhost:8887/api/tus/hooks
-max-size 21474836480
-base-path /tus/files/
extra_hosts:
- "localhost:host-gateway"
volumes:
- ./private/uploads/tmp:/data/uploads/tmp
volumes: volumes:
postgres-data: postgres-data:
+56 -54
View File
@@ -1,53 +1,65 @@
FROM node:22-alpine AS base #FROM node:22-bullseye AS base
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
RUN apt-get update && apt-get install -y \
curl \
ca-certificates \
bash \
tzdata \
libc6 \
build-essential \
g++ \
make \
autoconf \
automake \
libtool \
git \
nginx \
&& rm -rf /var/lib/apt/lists/*
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
ARG TUSD_VERSION=2.8.0
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
RUN git clone https://github.com/tus/tusd.git . \
&& git checkout v${TUSD_VERSION}
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \
go build -ldflags="-s -w" -o /tusddist/tusd ./cmd/tusd
FROM scratch AS tusd-dist
COPY --from=tusd-base /tusddist/tusd /tusd
RUN apk add --update --no-cache \
libc6-compat \
openssl \
tzdata
FROM base AS build-env FROM base AS build-env
RUN apk add --update --no-cache \
build-base \
g++ \
make \
libtool \
autoconf \
automake
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
FROM build-env AS deps FROM build-env AS deps
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm i --frozen-lockfile RUN pnpm i --frozen-lockfile
FROM build-env AS dev FROM build-env AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY --from=tusd-base /usr/local/bin/tusd /usr/local/bin/tusd
WORKDIR /app
COPY . . COPY . .
USER root USER root
RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"] ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
FROM build-env AS builder FROM build-env AS builder
WORKDIR /app WORKDIR /app
@@ -55,51 +67,41 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build RUN pnpm run build
FROM base AS prod FROM base AS prod
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=80
ENV HOSTNAME="0.0.0.0"
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
RUN mkdir .next
RUN chown nextjs:nodejs .next
RUN mkdir -p /app/private/uploads
RUN chown -R nextjs:nodejs /app/private
RUN chown -R nextjs:nodejs /app/public
COPY --from=builder /app/next.config.ts ./ COPY --from=builder /app/next.config.ts ./
COPY --from=builder /app/portabase.config.ts ./ COPY --from=builder /app/portabase.config.ts ./
COPY --from=builder /app/drizzle.config.ts ./ COPY --from=builder /app/drizzle.config.ts ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs src/db ./src/db COPY --chown=1001:1001 src/db ./src/db
COPY --from=deps /app/node_modules ./node_modules
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
RUN chmod +x /usr/local/bin/tusd
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
RUN mkdir -p .next /app/private/uploads \
&& chown -R nextjs:nodejs .next /app/private /app/public
USER root USER root
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
RUN chmod +x /app/app-prod-entrypoint.sh RUN chmod +x /app/app-prod-entrypoint.sh
EXPOSE 80 EXPOSE 80
#USER nextjs
ENV PORT=80
ENV HOSTNAME="0.0.0.0"
USER nextjs
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"] ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
+8 -2
View File
@@ -7,8 +7,14 @@ else
echo "[WARN] No TZ provided, using default container timezone" echo "[WARN] No TZ provided, using default container timezone"
fi fi
mkdir -p /app/private/uploads/tmp
echo "▶ Starting tusd server..."
tusd --base-path /tus/files/ --upload-dir /app/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 &
node server.js echo "▶ Starting Next.js server..."
PORT=3000 node server.js &
echo "▶ Starting nginx..."
exec nginx -g "daemon off;"
exec "$@"
+41
View File
@@ -0,0 +1,41 @@
events {}
http {
client_max_body_size 20G;
ignore_invalid_headers off;
server {
listen 80;
location /tus/ {
proxy_pass http://127.0.0.1:1080/tus/;
proxy_pass_request_headers on;
proxy_request_buffering off;
proxy_buffering off;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
+13 -1
View File
@@ -1,6 +1,8 @@
import type {NextConfig} from "next"; import type {NextConfig} from "next";
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config"; import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config";
const isDev = process.env.NODE_ENV === "development";
function buildCSPHeader(): string { function buildCSPHeader(): string {
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY; const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY;
@@ -46,8 +48,18 @@ const nextConfig: NextConfig = {
bodySizeLimit: "10gb", bodySizeLimit: "10gb",
}, },
proxyClientMaxBodySize: '10gb', proxyClientMaxBodySize: '10gb',
}, },
async rewrites() {
if (!isDev) return [];
return [
{
source: "/tus/:path*",
destination: "http://localhost:1080/tus/:path*",
},
];
},
async headers() { async headers() {
return [ return [
{ {
+85 -78
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.1.9", "version": "1.2.6-rc.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -14,111 +14,118 @@
"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"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.10", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.6", "@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.9", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.10", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.14", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.13", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.6", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.14", "@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.12", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.6", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.6", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.8", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.4", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.6", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.4", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.13", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.8", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^0.0.41", "@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.4", "@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.76.1", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.17", "@types/express": "^5.0.6",
"@types/nodemailer": "^6.4.22",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2", "@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.0", "argon2": "^0.43.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-auth": "1.4.5", "better-auth": "1.4.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dockerode": "^4.0.6", "dockerode": "^4.0.9",
"dotenv": "^16.5.0", "dotenv": "^16.6.1",
"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",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"minio": "^8.0.5", "minio": "^8.0.6",
"motion": "^12.23.24", "motion": "^12.34.0",
"next": "16.0.10", "next": "16.1.5",
"next-safe-action": "^7.10.8", "next-safe-action": "^7.10.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-forge": "^1.3.1", "node-forge": "^1.3.3",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.13",
"npm-check-updates": "^18.0.1", "npm-check-updates": "^18.3.1",
"pg": "^8.16.0", "pg": "^8.18.0",
"react": "^19.2.0", "prettier": "^3.8.1",
"react": "^19.2.4",
"react-day-picker": "9.7.0", "react-day-picker": "9.7.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.4",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.4.1",
"react-email": "^4.0.13", "react-email": "^4.3.2",
"react-hook-form": "^7.56.3", "react-hook-form": "^7.71.1",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
"react-resizable-panels": "^3.0.2", "react-resizable-panels": "^3.0.6",
"react-twc": "^1.4.2", "react-twc": "^1.5.1",
"react-use-measure": "^2.1.7", "react-use-measure": "^2.1.7",
"recharts": "^2.15.3", "recharts": "^2.15.4",
"socket.io": "^4.8.1", "sharp": "^0.34.5",
"socket.io-client": "^4.8.1", "socket.io": "^4.8.3",
"sonner": "^2.0.3", "socket.io-client": "^4.8.3",
"tailwind-merge": "^3.3.0", "sonner": "^2.0.7",
"swiper": "^12.1.0",
"tailwind-merge": "^3.4.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"ws": "^8.18.2", "ws": "^8.19.0",
"zod": "^3.24.4" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@iconify/react": "^6.0.0", "@iconify/react": "^6.0.2",
"@react-email/preview-server": "4.3.2", "@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.1", "@react-email/render": "^2.0.4",
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.18",
"@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18", "@types/node": "^22.19.11",
"@types/node-forge": "^1", "@types/node-forge": "^1.3.14",
"@types/pg": "^8.15.2", "@types/pg": "^8.16.0",
"@types/react": "^19.1.4", "@types/react": "^19.2.14",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.2.3",
"@zenstackhq/openapi": "^2.14.2", "@zenstackhq/openapi": "^2.22.1",
"@zenstackhq/tanstack-query": "^2.14.2", "@zenstackhq/tanstack-query": "^2.22.2",
"baseline-browser-mapping": "^2.8.32", "baseline-browser-mapping": "^2.9.19",
"drizzle-kit": "^0.31.1", "drizzle-kit": "^0.31.9",
"eslint": "^9.39.0", "esbuild": "^0.27.3",
"eslint-config-next": "^16.0.1", "eslint": "^9.39.2",
"eslint-plugin-tailwindcss": "^3.18.0", "eslint-config-next": "^16.1.6",
"framer-motion": "^12.24.7", "eslint-plugin-tailwindcss": "^3.18.2",
"postcss": "^8.5.3", "framer-motion": "^12.34.0",
"tailwindcss": "^4.1.7", "postcss": "^8.5.6",
"tsx": "^4.19.4", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.2.9", "tsx": "^4.21.0",
"typescript": "^5.8.3", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"zenstack": "2.14.2" "zenstack": "2.14.2"
}, },
"packageManager": "pnpm@10.27.0" "packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
} }
+2260 -2487
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1,2 +1,13 @@
onlyBuiltDependencies: onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- argon2
- bcrypt
- cpu-features
- esbuild
- prisma
- protobufjs
- sharp
- ssh2
- unrs-resolver
- zenstack - zenstack
+6 -3
View File
@@ -53,12 +53,15 @@ function checkRouteExists(pathname: string) {
const routePatterns = [ const routePatterns = [
/^\/api\/agent\/[^/]+\/status\/?$/, /^\/api\/agent\/[^/]+\/status\/?$/,
/^\/api\/agent\/[^/]+\/backup\/?$/, /^\/api\/agent\/[^/]+\/backup\/?$/,
/^\/api\/agent\/[^/]+\/backup\/upload\/init\/?$/,
/^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/,
/^\/api\/agent\/[^/]+\/restore\/?$/, /^\/api\/agent\/[^/]+\/restore\/?$/,
/^\/api\/files\/[^/]+\/?$/, /^\/api\/files\/images\/[^/]+\/?$/,
/^\/api\/images\/[^/]+\/?$/, /^\/api\/files\/backups\/?$/,
/^\/api\/tus\/hooks\/?$/,
/^\/api\/events\/?$/, /^\/api\/events\/?$/,
/^\/api\/init\/?$/,
/^\/api\/config\/?$/, /^\/api\/config\/?$/,
/^\/api\/google\/drive\/callback\/?$/,
]; ];
return routePatterns.some((pattern) => pattern.test(pathname)); return routePatterns.some((pattern) => pattern.test(pathname));
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+6
View File
@@ -0,0 +1,6 @@
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
@@ -0,0 +1,52 @@
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();
+2
View File
@@ -46,7 +46,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: R
...classNames, ...classNames,
}} }}
components={{ components={{
// @ts-ignore
IconLeft: ({ className, children: _children, ...props }) => <ChevronLeftIcon className={cn("size-4", className)} {...props} />, IconLeft: ({ className, children: _children, ...props }) => <ChevronLeftIcon className={cn("size-4", className)} {...props} />,
// @ts-ignore
IconRight: ({ className, children: _children, ...props }) => <ChevronRightIcon className={cn("size-4", className)} {...props} />, IconRight: ({ className, children: _children, ...props }) => <ChevronRightIcon className={cn("size-4", className)} {...props} />,
}} }}
{...props} {...props}
+1 -1
View File
@@ -58,7 +58,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"max-h-[90vh] overflow-y-auto", "max-h-[90vh] overflow-y-auto overflow-x-hidden",
className className
)} )}
{...props} {...props}
+1 -1
View File
@@ -7,7 +7,7 @@ import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'mo
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const githubButtonVariants = cva( const githubButtonVariants = cva(
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0', 'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
{ {
variants: { variants: {
variant: { variant: {
+1 -1
View File
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex w-full min-w-0 rounded-md border bg-transparent px-4 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
+1 -1
View File
@@ -374,7 +374,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto scrollbar-hide group-data-[collapsible=icon]:overflow-hidden",
className className
)} )}
{...props} {...props}
@@ -41,9 +41,6 @@ export const AuthLogoSection = () => {
/> />
)} )}
<span className="absolute bottom-10 right-5 text-sm text-muted-foreground" style={style}>
v{env.NEXT_PUBLIC_PROJECT_VERSION}
</span>
</div> </div>
); );
}; };
@@ -68,7 +68,7 @@ export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
/> />
<div className="flex flex-col items-center gap-y-6 w-full"> <div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}> <ButtonWithLoading className="mt-2 w-full h-11" isPending={mutation.isPending}>
Send reset link Send reset link
</ButtonWithLoading> </ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline"> <Link href="/login" className="group flex items-center text-sm hover:underline">
@@ -3,7 +3,6 @@
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner"; import {toast} from "sonner";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"; import {Form} from "@/components/ui/form";
@@ -63,7 +62,6 @@ export const LoginForm = (props: loginFormProps) => {
toast.success("Login success"); toast.success("Login success");
}, },
onError: (error) => { onError: (error) => {
console.log(error);
toast.error(error.error.message); toast.error(error.error.message);
}, },
} }
@@ -87,7 +85,7 @@ export const LoginForm = (props: loginFormProps) => {
<FormItem> <FormItem>
<FormLabel>Email Address</FormLabel> <FormLabel>Email Address</FormLabel>
<FormControl> <FormControl>
<Input autoComplete="email" autoFocus placeholder="exemple@portabase.io" {...field} /> <Input autoComplete="email" autoFocus placeholder="example@portabase.io" {...field} />
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
</FormItem> </FormItem>
@@ -115,7 +113,7 @@ export const LoginForm = (props: loginFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<ButtonWithLoading className="mt-2" isPending={mutation.isPending}> <ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
Login Login
</ButtonWithLoading> </ButtonWithLoading>
</Form> </Form>
@@ -28,7 +28,6 @@ export const resetPasswordAction = action
} }
const user = await (await auth.$context).internalAdapter.findUserById(verification.value); const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
console.log(user)
if (!user) { if (!user) {
return { return {
success: false, success: false,
@@ -39,28 +38,6 @@ export const resetPasswordAction = action
}; };
} }
const hashedPassword = await (await auth.$context).password.hash(parsedInput.schema.password);
console.log(hashedPassword)
console.log("ok")
// await (await auth.$context).internalAdapter.updatePassword(user.id, hashedPassword);
console.log("ici")
// await (await auth.$context).internalAdapter.deleteSessions(user.id);
// console.log("ici2")
// await (await auth.$context).internalAdapter.deleteVerificationValue(verification.id);
// console.log("ici3");
//
// (await auth.$context).internalAdapter.updateUser(user.id, {
// lastChangedPasswordAt: new Date(),
// });
// await auth.api.resetPassword({
// headers: await headers(),
// body: {
// newPassword: parsedInput.schema.password,
// token: parsedInput.token,
// },
// });
await ( await (
await auth.$context await auth.$context
).internalAdapter.updateUser(user.id, { ).internalAdapter.updateUser(user.id, {
@@ -44,7 +44,6 @@ export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
setTimeout(() => router.push("/"), 1400); setTimeout(() => router.push("/"), 1400);
}, },
onError: (error: BetterAuthError) => { onError: (error: BetterAuthError) => {
console.log(error)
toast.error("An error occurred while resetting password"); toast.error("An error occurred while resetting password");
}, },
}); });
@@ -83,7 +82,7 @@ export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
/> />
<div className="flex flex-col items-center gap-y-6 w-full"> <div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}> <ButtonWithLoading className="mt-2 w-full h-11" isPending={mutation.isPending}>
Reset Reset
</ButtonWithLoading> </ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline"> <Link href="/login" className="group flex items-center text-sm hover:underline">
@@ -1,21 +1,25 @@
"use client"; "use client";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {Info} from "lucide-react";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner"; import {toast} from "sonner";
import {CardContent, CardHeader} from "@/components/ui/card";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"; import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button";
import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip"; import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip";
import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema"; import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema";
import {PasswordInput} from "@/components/ui/password-input";
import {signUp} from "@/lib/auth/auth-client"; import {signUp} from "@/lib/auth/auth-client";
import Link from "next/link"; import {useZodForm} from "@/components/ui/form";
import {CardAuth} from "@/features/layout/card-auth"; import {CardAuth} from "@/features/layout/card-auth";
import {FormField} from "@/components/ui/form";
import {FormItem} from "@/components/ui/form";
import {FormLabel} from "@/components/ui/form";
import {FormControl} from "@/components/ui/form";
import {FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {PasswordInput} from "@/components/ui/password-input";
import {Button} from "@/components/ui/button";
import Link from "next/link";
import {Info} from "lucide-react";
export type registerFormProps = { export type registerFormProps = {
defaultValues?: RegisterType; defaultValues?: RegisterType;
@@ -26,9 +30,11 @@ export const RegisterForm = (props: registerFormProps) => {
const form = useZodForm({ const form = useZodForm({
schema: RegisterSchema, schema: RegisterSchema,
}); });
const router = useRouter(); const router = useRouter();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (values: RegisterType) => { mutationFn: async (values: RegisterType) => {
// @ts-ignore
await signUp.email(values, { await signUp.email(values, {
onSuccess: () => { onSuccess: () => {
toast.success(`Account successfully created`); toast.success(`Account successfully created`);
@@ -36,7 +42,6 @@ export const RegisterForm = (props: registerFormProps) => {
router.push(`/login`); router.push(`/login`);
}, },
onError: (error) => { onError: (error) => {
console.log(error);
toast.error(error.error.message); toast.error(error.error.message);
}, },
}); });
@@ -50,7 +55,7 @@ export const RegisterForm = (props: registerFormProps) => {
<CardHeader> <CardHeader>
<div className="grid gap-2 text-center mb-2"> <div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Create an account</h1> <h1 className="text-3xl font-bold">Create an account</h1>
<p className="text-balance text-muted-foreground">Enter your informations bellow to register</p> <p className="text-balance text-muted-foreground">Enter your informations below to register</p>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -83,7 +88,7 @@ export const RegisterForm = (props: registerFormProps) => {
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input placeholder="exemple@portabase.io" {...field} /> <Input placeholder="example@portabase.io" {...field} />
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
</FormItem> </FormItem>
@@ -133,7 +138,7 @@ export const RegisterForm = (props: registerFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" className="h-11" disabled={mutation.isPending}>
Sign up Sign up
</Button> </Button>
<div className="mt-4 text-center text-sm"> <div className="mt-4 text-center text-sm">
@@ -6,7 +6,7 @@ import {Form, FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/c
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator"; import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
import {ResetPasswordSchema, ResetPasswordType} from "@/components/wrappers/auth/reset-password/reset-password-schema"; import {ResetPasswordSchema, ResetPasswordType} from "@/components/wrappers/auth/reset-password/reset-password-schema";
import {PasswordInput} from "@/components/ui/password-input"; import {PasswordInput} from "@/components/ui/password-input";
import {Card, CardContent, CardHeader} from "@/components/ui/card"; import {CardContent, CardHeader} from "@/components/ui/card";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {authClient} from "@/lib/auth/auth-client"; import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner"; import {toast} from "sonner";
@@ -31,7 +31,6 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
token, token,
}, { }, {
onSuccess: (response) => { onSuccess: (response) => {
console.log(response);
toast.success("Password changed successfully."); toast.success("Password changed successfully.");
setTimeout(() => router.push("/"), 1000); setTimeout(() => router.push("/"), 1000);
}, },
@@ -48,7 +47,7 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
<CardHeader> <CardHeader>
<div className="grid gap-2 text-center mb-2"> <div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Reset Password</h1> <h1 className="text-3xl font-bold">Reset Password</h1>
<p className="text-balance text-muted-foreground">Fill information bellow to change your <p className="text-balance text-muted-foreground">Fill information below to change your
password</p> password</p>
</div> </div>
</CardHeader> </CardHeader>
@@ -1,62 +1,18 @@
// "use client";
//
// import { Button } from "@/components/ui/button";
// import { useState } from "react";
// import { Loader2 } from "lucide-react";
//
// export type VariantButton = {
// secondary: string;
// default: string;
// outline: string;
// ghost: string;
// link: string;
// destructive: string;
// };
//
// export type ButtonWithConfirmProps = {
// icon?: any;
// text: string;
// variant?: keyof VariantButton;
// className?: string;
// onClick?: () => void;
// isPending?: boolean;
// };
//
// export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
// const [isConfirming, setIsConfirming] = useState(false);
//
// return (
// <Button
// onClick={() => {
// if (isConfirming && props.onClick) {
// props.onClick();
// } else {
// setIsConfirming(true);
// }
// }}
// variant={props.variant ? props.variant : "default"}
// className={props.className}
// >
// {props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
// {isConfirming ? "Are you sure ?" : `${props.text}`}
// <>{props.icon ? props.icon : null}</>
// </Button>
// );
// };
"use client" "use client"
import {Button, ButtonVariantsProps} from "@/components/ui/button"; import {Button, ButtonVariantsProps} from "@/components/ui/button";
import {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 {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip"; 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;
text?: string; text?: string;
icon?: any; icon?: any;
variant?: ButtonVariantsProps["variant"]; variant?: ButtonVariantsProps["variant"];
@@ -81,6 +37,11 @@ export type ButtonWithConfirmProps = {
onClick?: () => void; onClick?: () => void;
}; };
}; };
children?: ReactNode;
onConfirm?: (e: React.MouseEvent) => void;
onCancel?: (e: React.MouseEvent) => void;
confirmButtonText?: string;
cancelButtonText?: string;
isPending?: boolean; isPending?: boolean;
}; };
@@ -88,39 +49,86 @@ export type ButtonWithConfirmProps = {
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => { export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
const [isConfirming, setIsConfirming] = useState(false); const [isConfirming, setIsConfirming] = useState(false);
return ( const isLegacy = !!props.button;
<Popover open={isConfirming} onOpenChange={!props.button.main.disabled ? setIsConfirming : undefined}> const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
const handleConfirm = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isLegacy) {
props.button?.confirm.onClick?.();
} else {
props.onConfirm?.(e);
}
setIsConfirming(false);
};
const handleCancel = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isLegacy) {
props.button?.cancel.onClick?.();
} else {
props.onCancel?.(e);
}
setIsConfirming(false);
};
const triggerContent = isLegacy ? (
<Button
type={props.button?.main.type}
disabled={isDisabled}
variant={props.button?.main.variant ?? "default"}
size={props.button?.main.size ?? "default"}
className={props.button?.main.className}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!isDisabled) setIsConfirming(true);
}}
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button?.main.icon}
{props.button?.main.text && <span>{props.button.main.text}</span>}
</Button>
) : (
<div onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsConfirming(true);
}}>
{props.children}
</div>
);
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? (
<TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<PopoverTrigger asChild> <div>{triggerContent}</div>
<span
className={cn(
"inline-flex",
props.button.main.disabled ? "cursor-not-allowed" : ""
)}
role="button">
<Button
disabled={!!props.button.main.disabled}
variant={props.button.main.variant ?? "default"}
size={props.button.main.size ?? "default"}
className={props.button.main.className}
onClick={() => {
if (!props.button.main.disabled) setIsConfirming(true);
}}
>
{props.button.main.icon}
{props.button.main.text && <span>{props.button.main.text}</span>}
</Button>
</span>
</PopoverTrigger>
</TooltipTrigger> </TooltipTrigger>
{props.button.main.tooltipText && props.button.main.disabled && ( <TooltipContent>
<TooltipContent> <p>{props.button?.main.tooltipText}</p>
<p>{props.button.main.tooltipText}</p> </TooltipContent>
</TooltipContent>
)}
</Tooltip> </Tooltip>
<PopoverContent className="w-80"> </TooltipProvider>
) : triggerContent;
return (
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
<PopoverTrigger asChild>
{withTooltip}
</PopoverTrigger>
<PopoverContent
className="w-80"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
onMouseMove={(e) => e.stopPropagation()}
onMouseEnter={(e) => e.stopPropagation()}
onMouseLeave={(e) => e.stopPropagation()}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="grid gap-4"> <div className="grid gap-4">
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium leading-none">{props.title}</h4> <h4 className="font-medium leading-none">{props.title}</h4>
@@ -128,34 +136,26 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Button <Button
onClick={() => { onClick={handleConfirm}
if (isConfirming && props.button.confirm.onClick) { variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"}
props.button.confirm.onClick(); size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
setIsConfirming(false); className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
} else {
setIsConfirming(true);
}
}}
variant={props.button.confirm.variant ? props.button.confirm.variant : "default"}
size={props.button.main.size ?? "default"}
className={cn(props.button.main.className, "w-full")}
> >
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>} {props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button.confirm.icon ? props.button.confirm.icon : null} {isLegacy && props.button?.confirm.icon}
<span>{props.button.confirm.text}</span> <span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</span>
</Button> </Button>
<Button <Button
variant={props.button.cancel.variant ? props.button.cancel.variant : "default"} variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"}
onClick={props.button.cancel.onClick ? () => props.button.cancel.onClick!() : () => setIsConfirming(false)} onClick={handleCancel}
className={cn(props.button.main.className, "w-full")} className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
size={props.button.main.size ?? "default"} size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
> >
Cancel {isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")}
</Button> </Button>
</div> </div>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
}; };
@@ -83,7 +83,7 @@ export type ButtonWithLoadingProps = {
icon?: ReactNode; icon?: ReactNode;
variant?: keyof VariantButton; variant?: keyof VariantButton;
className?: string; className?: string;
onClick?: () => void; onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
isPending?: boolean; isPending?: boolean;
size?: keyof SizeButton; size?: keyof SizeButton;
} & ButtonHTMLAttributes<HTMLButtonElement>; } & ButtonHTMLAttributes<HTMLButtonElement>;
@@ -100,7 +100,7 @@ export const ButtonWithLoading = ({
}: ButtonWithLoadingProps) => { }: ButtonWithLoadingProps) => {
return ( return (
<Button <Button
onClick={() => onClick?.()} onClick={(e) => onClick?.(e)}
variant={variant} variant={variant}
className={className} className={className}
size={size} size={size}
@@ -109,7 +109,6 @@ export const ButtonWithLoading = ({
{isPending && <Loader2 className="mr-2 animate-spin" size={16} />} {isPending && <Loader2 className="mr-2 animate-spin" size={16} />}
{children && children} {children && children}
<>{icon ? icon : null}</> <>{icon ? icon : null}</>
{/*{icon && <span className="ml-2">{icon}</span>}*/}
</Button> </Button>
); );
}; };
@@ -26,7 +26,6 @@ export const CopyButton = (props: CopyButtonProps) => {
return ( return (
<Button <Button
// className={cn(buttonVariants({size: "sm"}))}
onClick={() => { onClick={() => {
copyToClipboardWithMeta(value); copyToClipboardWithMeta(value);
setHasCopied(true); setHasCopied(true);
+35 -10
View File
@@ -1,21 +1,46 @@
import { PropsWithChildren } from "react"; "use client";
import { Button } from "@/components/ui/button";
import {PropsWithChildren, useState} from "react";
import {Button} from "@/components/ui/button";
import {Check, Copy, Terminal} from "lucide-react";
import {copyToClipboardWithMeta} from "@/components/wrappers/common/button/copy-button";
import {cn} from "@/lib/utils";
export type CodeSnippetProps = PropsWithChildren<{ export type CodeSnippetProps = PropsWithChildren<{
code: string; code: string;
title?: string;
className?: string;
}>; }>;
export const CodeSnippet = (props: CodeSnippetProps) => { export const CodeSnippet = (props: CodeSnippetProps) => {
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async () => {
await copyToClipboardWithMeta(props.code);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return ( return (
<div className="rounded-lg border bg-background"> <div className={cn("relative group rounded-md bg-muted/50 border overflow-hidden", props.className)}>
<div className="flex items-center justify-between border-b bg-muted px-4 py-2"> {props.title && (
<div className="text-sm font-medium">.env</div> <div className="flex items-center px-4 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
<Button variant="ghost" size="icon" className="hover:bg-muted/50 text-muted-foreground"></Button> {props.title}
</div> </div>
<div className="p-4 font-mono text-sm leading-6 text-foreground truncate"> )}
<pre className="language-javascript overflow-x-auto"> <div className="flex items-center p-4">
<code className="mb-6">{props.code}</code> <pre className="flex-1 overflow-x-auto font-mono text-sm leading-relaxed">
<code className="break-all whitespace-pre-wrap">{props.code}</code>
</pre> </pre>
<Button
variant="ghost"
size="icon"
className="ml-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
onClick={handleCopy}
>
{isCopied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
<span className="sr-only">Copy code</span>
</Button>
</div> </div>
</div> </div>
); );
@@ -1,31 +0,0 @@
import {cn} from "@/lib/utils";
export type ConnectionCircleProps = {
date?: Date | null;
};
export const ConnectionCircle = ({date}: ConnectionCircleProps) => {
let style = "bg-gray-300 border-gray-400";
if (date instanceof Date && !isNaN(date.getTime())) {
const now = Date.now();
const timestamp = date.getTime();
const interval_seconds = (now - timestamp) / 1000;
console.log({now, timestamp, interval_seconds});
if (interval_seconds < 55) {
style = "bg-green-400 border-green-600";
} else if (interval_seconds <= 60) {
style = "bg-orange-400 border-orange-600";
} else {
style = "bg-red-400 border-red-600";
}
} else {
console.warn("Invalid date passed to ConnectionCircle:", date);
}
console.log(style);
return <div className={cn("w-5 h-5 rounded-full border-4", style)}/>;
};
@@ -0,0 +1,46 @@
import {cn} from "@/lib/utils";
export type ConnectionIndicatorProps = {
date?: Date | null;
};
export const ConnectionIndicator = ({date}: ConnectionIndicatorProps) => {
let style = "bg-gray-300";
if (date instanceof Date && !isNaN(date.getTime())) {
const intervalSeconds = (Date.now() - date.getTime()) / 1000;
if (intervalSeconds < 55) {
style = "bg-green-500";
} else if (intervalSeconds <= 60) {
style = "bg-orange-400";
} else {
style = "bg-red-500";
}
}
return (
<div className="relative w-3 h-3">
<span
className={cn(
"absolute -inset-0.25 rounded-full opacity-60 animate-ping",
style
)}
style={{
animationDuration: "2s",
}}
/>
<div
className={cn(
"relative w-3 h-3 rounded-full shadow-sm animate-pulse",
style
)}
style={{
animationDuration: "2s",
}}
/>
</div>
);
};
+1 -1
View File
@@ -23,7 +23,7 @@ export const AgentRegistrationDialog = (props: agentRegistrationDialogProps) =>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right"> <Label htmlFor="name" className="text-right">
EDGE KEY CONNECTION KEY
</Label> </Label>
<Input id="name" value="Pedro Duarte" readOnly className="col-span-3" /> <Input id="name" value="Pedro Duarte" readOnly className="col-span-3" />
</div> </div>
@@ -1,20 +1,22 @@
import Link from "next/link"; import Link from "next/link";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {Plus} from "lucide-react"; import {Plus} from "lucide-react";
import {forwardRef, HTMLAttributes} from "react";
type EmptyStatePlaceholderProps = { type EmptyStatePlaceholderProps = {
url?: string; url?: string;
onClick?: () => void; onClick?: () => void;
text: string; text: string;
className?: string; className?: string;
}; } & HTMLAttributes<HTMLDivElement>;
export const EmptyStatePlaceholder = ({ export const EmptyStatePlaceholder = forwardRef<HTMLDivElement, EmptyStatePlaceholderProps>(({
url, url,
onClick, onClick,
text, text,
className, className,
}: EmptyStatePlaceholderProps) => { ...props
}, ref) => {
const Container = ( const Container = (
<div <div
className={cn( className={cn(
@@ -37,5 +39,7 @@ export const EmptyStatePlaceholder = ({
); );
} }
return <div className={cn(className)}>{Container}</div>; return <div className={cn(className)} ref={ref} {...props}>{Container}</div>;
}; });
EmptyStatePlaceholder.displayName = "EmptyStatePlaceholder";
@@ -34,7 +34,7 @@ export const GitHubStarsButtonCustom = () => {
<GithubButton <GithubButton
initialStars={0} initialStars={0}
targetStars={stars} targetStars={stars}
label="Github Stars" label=""
size="sm" size="sm"
separator={true} separator={true}
roundStars={true} roundStars={true}
@@ -15,7 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
* Variants for the multi-select component to handle different styles. * Variants for the multi-select component to handle different styles.
* Uses class-variance-authority (cva) to define different styles based on "variant" prop. * Uses class-variance-authority (cva) to define different styles based on "variant" prop.
*/ */
const multiSelectVariants = cva("m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300", { const multiSelectVariants = cva("m-1 transition ease-in-out duration-300", {
variants: { variants: {
variant: { variant: {
default: "border-foreground/10 text-foreground bg-card hover:bg-card/80", default: "border-foreground/10 text-foreground bg-card hover:bg-card/80",
@@ -177,13 +177,13 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
return ( return (
<Badge <Badge
key={value} key={value}
className={cn(isAnimating ? "animate-bounce" : "", multiSelectVariants({ variant }))} className={cn(isAnimating ? "animate-bounce" : "", "max-w-full", multiSelectVariants({ variant }))}
style={{ animationDuration: `${animation}s` }} style={{ animationDuration: `${animation}s` }}
> >
{IconComponent && <IconComponent className="h-4 w-4 mr-2" />} {IconComponent && <IconComponent className="h-4 w-4 mr-2" />}
{option?.label} <span className="truncate">{option?.label}</span>
<XCircle <XCircle
className="ml-2 h-4 w-4 cursor-pointer" className="ml-2 h-4 w-4 cursor-pointer shrink-0"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
toggleOption(value); toggleOption(value);
@@ -196,6 +196,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
<Badge <Badge
className={cn( className={cn(
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent", "bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
"max-w-full",
isAnimating ? "animate-bounce" : "", isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant }) multiSelectVariants({ variant })
)} )}
@@ -203,7 +204,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
> >
{`+ ${selectedValues.length - maxCount} more`} {`+ ${selectedValues.length - maxCount} more`}
<XCircle <XCircle
className="ml-2 h-4 w-4 cursor-pointer" className="ml-2 h-4 w-4 cursor-pointer shrink-0"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
clearExtraOptions(); clearExtraOptions();
@@ -15,6 +15,8 @@ export type paginationNavigationProps = {
export const PaginationNavigation = (props: paginationNavigationProps) => { export const PaginationNavigation = (props: paginationNavigationProps) => {
const { className, totalPages, currentPage, goToPage, goToPrevPage, goToNextPage, maxVisiblePages = 3 } = props; const { className, totalPages, currentPage, goToPage, goToPrevPage, goToNextPage, maxVisiblePages = 3 } = props;
if (totalPages <= 1) return null;
return ( return (
<Pagination className={cn("", className)}> <Pagination className={cn("", className)}>
<PaginationContent> <PaginationContent>
@@ -14,7 +14,7 @@ import {
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import {ReactNode, useEffect, useState} from "react"; import {ReactNode, useMemo, useState} from "react";
import {TablePagination} from "./table-pagination"; import {TablePagination} from "./table-pagination";
import {Checkbox} from "@/components/ui/checkbox"; import {Checkbox} from "@/components/ui/checkbox";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
@@ -63,35 +63,38 @@ export function DataTable<TData, TValue>({
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({}); const [rowSelection, setRowSelection] = useState({});
useEffect(() => { const finalColumns = useMemo(() => {
setRowSelection({});
}, [data]);
if (enableSelect && data.length > 0) {
const selectColumnExists = columns.some((column) => column.id === "select"); const selectColumnExists = columns.some((column) => column.id === "select");
if (enableSelect && data.length > 0 && !selectColumnExists) {
if (!selectColumnExists) { return [
columns.unshift({ {
id: "select", id: "select",
header: ({table}) => ( header: ({table}: {table: any}) => (
<Checkbox <Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all" aria-label="Select all"
/> />
), ),
cell: ({row}) => <Checkbox checked={row.getIsSelected()} cell: ({row}: {row: any}) => (
onCheckedChange={(value) => row.toggleSelected(!!value)} <Checkbox
aria-label="Select row"/>, checked={row.getIsSelected()}
enableSorting: false, onCheckedChange={(value) => row.toggleSelected(!!value)}
enableHiding: false, aria-label="Select row"
}); />
),
enableSorting: false,
enableHiding: false,
},
...columns,
];
} }
} return columns;
}, [columns, enableSelect, data.length]);
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns: finalColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting, onSortingChange: setSorting,
@@ -99,6 +102,10 @@ export function DataTable<TData, TValue>({
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getRowId: (row: any) => row.id || row.uuid,
autoResetPageIndex: false,
autoResetExpanded: false,
enableRowSelection: true,
state: { state: {
sorting, sorting,
columnFilters, columnFilters,
@@ -14,6 +14,10 @@ interface tablePaginationProps {
export function TablePagination(props: tablePaginationProps) { export function TablePagination(props: tablePaginationProps) {
const {className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50]} = props; const {className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50]} = props;
const totalPages = table.getPageCount();
if (totalPages <= 1) return null;
return ( return (
<div <div
className={cn("flex gap-x-4", className)} className={cn("flex gap-x-4", className)}
@@ -0,0 +1,150 @@
"use client"
import {Pencil, Plus} from "lucide-react";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {useIsMobile} from "@/hooks/use-mobile";
import {useEffect, useState} from "react";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelForm} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
ChannelOrganisationForm
} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization-form";
type ChannelAddModalProps = {
channel?: NotificationChannelWith | StorageChannelWith
organization?: OrganizationWithMembers;
open?: boolean;
onOpenChangeAction?: (open: boolean) => void;
adminView?: boolean;
organizations?: OrganizationWithMembers[]
trigger?: boolean;
kind: ChannelKind;
}
export const ChannelAddEditModal = ({
organization,
channel,
open = false,
onOpenChangeAction,
adminView,
organizations,
trigger = true,
kind
}: ChannelAddModalProps) => {
const isMobile = useIsMobile();
const [openInternal, setOpen] = useState(open);
const isLocalSystem = channel?.provider == "local";
const isCreate = !Boolean(channel);
useEffect(() => {
setOpen(open);
}, [open])
const channelText = getChannelTextBasedOnKind(kind)
return (
<Dialog open={openInternal} onOpenChange={(state) => {
onOpenChangeAction?.(state);
setOpen(state);
}}>
{trigger && (
<DialogTrigger asChild>
{isCreate ?
<Button>
<Plus/>{!isMobile && `Add ${channelText} channel`}
</Button>
:
<Button
variant="ghost"
size="icon"
>
<Pencil className="h-4 w-4"/>
</Button>
}
</DialogTrigger>
)}
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle> {isCreate ? "Add" : "Edit"} {channelText} Channel</DialogTitle>
<DialogDescription>
Configure your {channelText.toLowerCase()} channel preferences.
</DialogDescription>
</DialogHeader>
<div>
<>
{!isLocalSystem ? (
<>
{adminView ?
<Tabs className="flex flex-col flex-1" defaultValue="configuration">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="configuration">Configuration</TabsTrigger>
<TabsTrigger value="organizations">Organizations</TabsTrigger>
</TabsList>
<TabsContent className="h-full justify-between" value="configuration">
<ChannelForm
kind={kind}
adminView={adminView}
defaultValues={channel}
organization={organization}
onSuccessAction={() => {
onOpenChangeAction?.(false)
setOpen(false);
}}
/>
</TabsContent>
<TabsContent className="h-full justify-between" value="organizations">
<ChannelOrganisationForm
defaultValues={channel}
kind={kind}
organizations={organizations}
/>
</TabsContent>
</Tabs>
:
<>
<ChannelForm
kind={kind}
adminView={adminView}
defaultValues={channel}
organization={organization}
onSuccessAction={() => {
onOpenChangeAction?.(false)
setOpen(false);
}}
/>
</>
}
</>
)
:
<>
<ChannelOrganisationForm
defaultValues={channel}
kind={kind}
organizations={organizations}
/>
</>
}
</>
</div>
</DialogContent>
</Dialog>
)
}
@@ -5,20 +5,31 @@ import {useRouter} from "next/navigation";
import {Trash2} from "lucide-react"; import {Trash2} from "lucide-react";
import { import {
removeNotificationChannelAction, removeNotificationChannelAction,
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action"; } from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
import {toast} from "sonner"; import {toast} from "sonner";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
removeStorageChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action";
export type DeleteNotifierButtonProps = { export type DeleteChannelButtonProps = {
notificationChannelId: string; channelId: string;
kind: ChannelKind;
organizationId?: string; organizationId?: string;
}; };
export const DeleteNotifierButton = ({notificationChannelId, organizationId}: DeleteNotifierButtonProps) => { export const DeleteChannelButton = ({channelId, organizationId, kind}: DeleteChannelButtonProps) => {
const router = useRouter(); const router = useRouter();
const channelText = getChannelTextBasedOnKind(kind)
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const result = await removeNotificationChannelAction({organizationId, notificationChannelId})
const result = kind === "notification" ? await removeNotificationChannelAction({
organizationId,
notificationChannelId: channelId
}) : await removeStorageChannelAction({organizationId, id: channelId})
const inner = result?.data; const inner = result?.data;
if (inner?.success) { if (inner?.success) {
@@ -32,8 +43,8 @@ export const DeleteNotifierButton = ({notificationChannelId, organizationId}: De
return ( return (
<ButtonWithConfirm <ButtonWithConfirm
title="Delete Notifier" title={`Delete ${channelText.toLowerCase()} channel`}
description="Are you sure you want to remove this notifier ? This action cannot be undone and will delete all alert policies related to this channel !" description={`Are you sure you want to remove this ${channelText.toLowerCase()} channel ? This action cannot be undone and will delete all alert policies related to this channel !`}
button={{ button={{
main: { main: {
size: "icon", size: "icon",
@@ -0,0 +1,94 @@
"use client";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {Switch} from "@/components/ui/switch";
import {toast} from "sonner";
import {useState} from "react";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
updateNotificationChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import {
updateStorageChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action";
export type EditChannelButtonProps = {
channel: NotificationChannelWith | StorageChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
kind: ChannelKind;
};
export const EditChannelButton = ({
organizations,
adminView = false,
channel,
organization,
kind
}: EditChannelButtonProps) => {
const router = useRouter();
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const isLocalSystem = channel.provider == "local";
const mutation = useMutation({
mutationFn: async (value: boolean) => {
const payload = {
data: {
name: channel.name,
provider: channel.provider,
config: channel.config as Record<string, any>,
enabled: value
},
id: channel.id
};
let result: any;
if (kind == "notification") {
// @ts-ignore
result = await updateNotificationChannelAction(payload)
} else if (kind == "storage") {
// @ts-ignore
result = await updateStorageChannelAction(payload)
} else {
toast.error("An error occurred while updating storage channel")
return;
}
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
},
});
return (
<>
{!isLocalSystem && (
<Switch checked={channel.enabled} onCheckedChange={async () => {
await mutation.mutateAsync(!channel.enabled)
}}/>
)}
<ChannelAddEditModal
kind={kind}
organizations={organizations}
adminView={adminView}
organization={organization}
channel={channel}
open={isAddModalOpen}
onOpenChangeAction={setIsAddModalOpen}
/>
</>
);
};
@@ -0,0 +1,88 @@
"use client";
import {Card} from "@/components/ui/card";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {Badge} from "@/components/ui/badge";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {
EditChannelButton
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/button-edit-channel";
import {ChannelKind, getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
DeleteChannelButton
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/button-delete-channel";
export type ChannelCardProps = {
data: NotificationChannelWith | StorageChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
kind?: ChannelKind;
defaultStorageChannelId?: string | null | undefined
};
export const ChannelCard = (props: ChannelCardProps) => {
const {data, organization, kind, adminView, defaultStorageChannelId} = props;
const isMobile = useIsMobile()
const isDefaultSystemStorage = defaultStorageChannelId === data.id;
const isOwned = data.organizationId ? true : !organization;
const isLocalSystem = data.provider == "local";
return (
<div className="block transition-all duration-200 rounded-xl">
<Card className="flex flex-row justify-between p-4">
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
{getChannelIcon(data.provider)}
</div>
<div className={`h-2 w-2 rounded-full ${data.enabled ? "bg-green-600" : "bg-muted"}`}/>
</div>
<div className="flex justify-start w-full">
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
<h3 className="font-medium text-foreground">{isMobile ? truncateWords(data.name, 2) : data.name}</h3>
<Badge variant="secondary" className="text-xs font-mono">
{data.provider}
</Badge>
{isDefaultSystemStorage && (
<Badge variant="secondary" className="text-xs font-mono">
default
</Badge>
)}
</div>
</div>
{kind && (
<div className="flex items-center gap-2">
{(isOwned) && (
<>
<EditChannelButton
organizations={props.organizations}
adminView={props.adminView}
organization={organization}
channel={data}
kind={kind}
/>
{!isLocalSystem && (
<DeleteChannelButton
kind={kind}
organizationId={organization?.id}
channelId={data.id}
/>
)}
</>
)}
</div>
)}
</Card>
</div>
);
};
@@ -0,0 +1,27 @@
import {z} from "zod";
export const ChannelFormSchema = z.object({
name: z
.string()
.min(5, "Name must be at least 5 characters long")
.max(40, "Name must be at most 40 characters long"),
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
enabled: z.boolean().default(true),
});
export const NotificationChannelFormSchema = ChannelFormSchema.extend({
provider: z.enum(
["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"],
{required_error: "Provider is required"}
),
});
export const StorageChannelFormSchema = ChannelFormSchema.extend({
provider: z.enum(["local", "s3", "google-drive"], {required_error: "Provider is required"}),
});
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
export type StorageChannelFormType = z.infer<typeof StorageChannelFormSchema>;
@@ -0,0 +1,255 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Input} from "@/components/ui/input";
import {
addStorageChannelAction, updateStorageChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action";
import {toast} from "sonner";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {Button} from "@/components/ui/button";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {useEffect} from "react";
import {cn} from "@/lib/utils";
import {Card} from "@/components/ui/card";
import {ArrowLeft} from "lucide-react";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {
NotificationChannelFormSchema, NotificationChannelFormType, StorageChannelFormSchema, StorageChannelFormType
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
import {
ChannelKind,
renderChannelForm
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {storageProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
import {notificationProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
import {
ChannelTestButton
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-test-button";
import {
addNotificationChannelAction, updateNotificationChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
type NotifierFormProps = {
onSuccessAction?: () => void;
organization?: OrganizationWithMembers;
defaultValues?: NotificationChannelWith | StorageChannelWith
adminView?: boolean
kind: ChannelKind
};
export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}: NotifierFormProps) => {
const router = useRouter();
const isCreate = !Boolean(defaultValues);
const form = useZodForm({
schema: kind == "notification" ? NotificationChannelFormSchema : StorageChannelFormSchema,
// @ts-ignore
defaultValues: {...defaultValues},
});
useEffect(() => {
form.reset(defaultValues ? {...defaultValues} : {enabled: true});
}, [defaultValues]);
const mutationAddNotificationChannel = useMutation({
mutationFn: async (values: NotificationChannelFormType | StorageChannelFormType) => {
const payload = {
data: values,
...(organization && {organizationId: organization.id}),
...((defaultValues && {id: defaultValues.id}))
};
let result: any;
if (kind === "notification") {
// @ts-ignore
result = isCreate ? await addNotificationChannelAction(payload) : await updateNotificationChannelAction(payload);
} else if (kind === "storage") {
// @ts-ignore
result = isCreate ? await addStorageChannelAction(payload) : await updateStorageChannelAction(payload);
} else {
toast.error("An error occurred");
return;
}
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
isCreate && onSuccessAction?.();
router.refresh();
} else {
toast.error(inner?.actionError?.message);
isCreate && onSuccessAction?.();
}
}
});
const provider = form.watch("provider");
const channelTypes = kind == "notification" ? notificationProviders : storageProviders
const selectedProviderDetails = channelTypes.find(t => t.value === provider);
if (isCreate && !provider) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4">
{channelTypes.filter(p => p.value != "local").map((type) => {
const Icon = type.icon;
return (
<Card
key={type.value}
className={cn(
"relative flex flex-col items-center justify-center gap-3 p-4 transition-all border",
type.preview
? "cursor-not-allowed bg-muted/60 border-border text-muted-foreground"
: "cursor-pointer bg-background hover:bg-accent/40 hover:border-primary/50"
)}
onClick={() => {
if (type.preview) return;
form.setValue("provider", type.value as any);
form.setValue("config", {});
}}
>
{type.preview && (
<div
className="absolute bottom-0 right-0 overflow-hidden w-20 h-20 pointer-events-none">
<div
className="absolute bottom-0 right-0 w-38 h-5 flex items-center justify-center
transform -rotate-45 translate-x-14 -translate-y-4"
style={{backgroundColor: "#FE6702"}}
>
<span className="text-white text-[7px] pr-3 font-medium text-center w-full">COMING SOON</span>
</div>
</div>
)}
<div
className={cn(
"h-10 w-10 rounded-full flex items-center justify-center transition-colors",
type.preview
? "bg-muted"
: "bg-accent text-accent-foreground"
)}
>
<Icon
className={cn(
"h-6 w-6",
type.preview ? "text-muted-foreground" : "text-foreground"
)}
/>
</div>
<span
className={cn(
"font-medium text-sm text-center leading-tight",
type.preview ? "text-muted-foreground" : "text-foreground"
)}
>
{type.label}
</span>
</Card>
);
})}
</div>
);
}
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationAddNotificationChannel.mutateAsync(values);
}}
>
<div className="flex items-center gap-3 mb-2 p-3 bg-secondary/30 rounded-lg border border-border">
{selectedProviderDetails && (
<div
className="h-10 w-10 bg-background rounded-full flex items-center justify-center border border-border shadow-sm">
<selectedProviderDetails.icon className="h-5 w-5"/>
</div>
)}
<div className="flex-1">
<p className="text-sm font-medium">Configuring {selectedProviderDetails?.label}</p>
<p className="text-xs text-muted-foreground">{isCreate ? "New Channel" : "Edit Channel"}</p>
</div>
{isCreate && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
form.setValue("provider", undefined as any);
form.setValue("config", undefined);
}}
>
<ArrowLeft className="h-4 w-4 mr-2"/>
Change
</Button>
)}
</div>
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem>
<FormLabel>Channel Name</FormLabel>
<FormControl>
<Input {...field} placeholder={`My ${selectedProviderDetails?.label} Channel`}
value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="provider"
render={({field}) => (
<input type="hidden" {...field} value={field.value || ""}/>
)}
/>
{renderChannelForm(provider, form)}
<div className="flex justify-between mt-4">
<div>
{kind == "storage" && (
<ChannelTestButton
kind={kind}
organizationId={organization?.id}
channel={form.getValues()}
/>
)}
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
onSuccessAction?.();
form.reset();
}}
>
Cancel
</Button>
<ButtonWithLoading isPending={mutationAddNotificationChannel.isPending}>
{isCreate ? "Add" : "Save"} Channel
</ButtonWithLoading>
</div>
</div>
</Form>
);
};
@@ -0,0 +1,92 @@
"use client"
import {useMutation} from "@tanstack/react-query";
import {Send, ShieldCheck} from "lucide-react";
import {toast} from "sonner";
import {Button} from "@/components/ui/button";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {dispatchNotification} from "@/features/notifications/dispatch";
import {EventPayload} from "@/features/notifications/types";
import {useIsMobile} from "@/hooks/use-mobile";
import {cn} from "@/lib/utils";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import {ChannelKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
type NotifierTestChannelButtonProps = {
channel: NotificationChannel | StorageChannel;
organizationId?: string;
kind: ChannelKind;
}
export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestChannelButtonProps) => {
const isMobile = useIsMobile()
const mutation = useMutation({
mutationFn: async () => {
if (kind === "notification") {
const payload: EventPayload = {
title: 'Test Channel',
message: `We are testing channel ${channel.name}`,
level: 'info',
};
const result = await dispatchNotification(payload, undefined, channel, organizationId);
if (result.success) {
toast.success(result.message);
} else {
toast.error("An error occurred while testing the notification channel, check your configuration");
}
} else if (kind === "storage") {
const input: StorageInput = {
action: "ping",
};
const result = await dispatchStorage(input, undefined, undefined, channel);
if (result.success) {
toast.success("Successfully connected to storage channel");
} else {
console.error(result);
toast.error("An error occurred while testing the storage channel, check your configuration");
}
} else {
toast.error("Not yet supported");
}
},
});
return (
<Button
type="button"
variant="default"
onClick={() => mutation.mutateAsync()}
disabled={mutation.isPending}
className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"
>
{mutation.isPending ? (
<>
<div
className={cn(" h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white", !isMobile && "mr-2")}/>
{!isMobile && `Sending...`}
</>
) : (
<div className="flex flex-row justify-center items-center">
{kind === "notification" ?
<>
<Send className={cn("h-4 w-4", !isMobile && "mr-2")}/>{!isMobile && ` Test Channel`}
</>
:
<>
<ShieldCheck className={cn("h-4 w-4", !isMobile && "mr-2")}/>{!isMobile && ` Test Storage`}
</>
}
</div>
)}
</Button>
)
}
@@ -6,11 +6,11 @@ import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions"; import {userAction} from "@/lib/safe-actions/actions";
import {NotificationChannel} from "@/db/schema/09_notification-channel"; import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {db} from "@/db"; import {db} from "@/db";
import {
NotificationChannelFormSchema
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {
NotificationChannelFormSchema
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
export const addNotificationChannelAction = userAction.schema( export const addNotificationChannelAction = userAction.schema(
@@ -20,7 +20,6 @@ export const addNotificationChannelAction = userAction.schema(
}) })
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => { ).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
const {organizationId, data} = parsedInput; const {organizationId, data} = parsedInput;
try { try {
const [channel] = await db const [channel] = await db
.insert(drizzleDb.schemas.notificationChannel) .insert(drizzleDb.schemas.notificationChannel)
@@ -29,6 +28,7 @@ export const addNotificationChannelAction = userAction.schema(
name: data.name, name: data.name,
config: data.config, config: data.config,
enabled: data.enabled ?? true, enabled: data.enabled ?? true,
organizationId: organizationId ?? null,
}) })
.returning(); .returning();
@@ -128,11 +128,11 @@ export const removeNotificationChannelAction = userAction.schema(
export const updateNotificationChannelAction = userAction.schema( export const updateNotificationChannelAction = userAction.schema(
z.object({ z.object({
notificationChannelId: z.string(), id: z.string(),
data: NotificationChannelFormSchema data: NotificationChannelFormSchema
}) })
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => { ).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
const {notificationChannelId, data} = parsedInput; const {id, data} = parsedInput;
try { try {
const [channel] = await db const [channel] = await db
@@ -143,7 +143,7 @@ export const updateNotificationChannelAction = userAction.schema(
config: data.config, config: data.config,
enabled: data.enabled ?? true, enabled: data.enabled ?? true,
})) }))
.where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId)) .where(eq(drizzleDb.schemas.notificationChannel.id, id))
.returning(); .returning();
return { return {
@@ -154,7 +154,7 @@ export const updateNotificationChannelAction = userAction.schema(
}, },
actionSuccess: { actionSuccess: {
message: `Notification channel "${channel.name}" has been successfully updated.`, message: `Notification channel "${channel.name}" has been successfully updated.`,
messageParams: {notificationChannelId: channel.id}, messageParams: {id: channel.id},
}, },
}; };
} catch (error) { } catch (error) {
@@ -165,7 +165,7 @@ export const updateNotificationChannelAction = userAction.schema(
message: "Failed to update notification channel.", message: "Failed to update notification channel.",
status: 500, status: 500,
cause: error instanceof Error ? error.message : "Unknown error", cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {notificationChannelId: ""}, messageParams: {id: ""},
}, },
}; };
} }
@@ -0,0 +1,30 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierDiscordFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.discordWebhook"
render={({field}) => (
<FormItem>
<FormLabel>Discord Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,43 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierGotifyFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.gotifyServerUrl"
render={({field}) => (
<FormItem>
<FormLabel>Gotify Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://gotify.example.com"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.gotifyAppToken"
render={({field}) => (
<FormItem>
<FormLabel>Application Token</FormLabel>
<FormControl>
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,90 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierNtfyFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.ntfyTopic"
render={({field}) => (
<FormItem>
<FormLabel>Topic Name</FormLabel>
<FormControl>
<Input {...field} placeholder="my-secret-topic"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyServerUrl"
render={({field}) => (
<FormItem>
<FormLabel>Server URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://ntfy.sh (default)"/>
</FormControl>
<p className="text-xs text-muted-foreground">Leave empty to use the official ntfy.sh server.</p>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyToken"
render={({field}) => (
<FormItem>
<FormLabel>Access Token (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="tk_..."/>
</FormControl>
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
<FormMessage/>
</FormItem>
)}
/>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.ntfyUsername"
render={({field}) => (
<FormItem>
<FormLabel>Basic Auth Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="username"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyPassword"
render={({field}) => (
<FormItem>
<FormLabel>Basic Auth Password (Optional)</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="password"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}

Some files were not shown because too many files have changed in this diff Show More