Compare commits

...
136 Commits
Author SHA1 Message Date
github-actions[bot] 21b9b8b44d chore: release v1.4.13 2026-03-04 12:37:48 +00:00
6ef7fcc0c2 fix: Feat/changelog icon (#137)
* Update .release-it.json

* Update .release-it.json

---------

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

* fix

* fix

* fix

* Update docker.yml

* Update auto-release.yml

* Update pr-checker.yml

* Update pr-checker.yml

* fix: citation version

* Update .release-it.json

---------

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

* fix

* fix

* fix

* Update docker.yml

* Update auto-release.yml

* Update pr-checker.yml

* Update pr-checker.yml

* fix: citation version

---------

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

* fix

* fix

* fix

* Update docker.yml

* Update auto-release.yml

* Update pr-checker.yml

---------

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

* fix

* fix

* fix

* Update docker.yml

* Update auto-release.yml

---------

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

* fix

* fix

* fix

* Update docker.yml

---------

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

* fix

* fix

* fix

---------

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

* fix

---------

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

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

* Update auto-release.yml

* Update auto-release.yml

* fix

* Update auto-release.yml

* fix

---------

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

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

* Update auto-release.yml

* Update auto-release.yml

* fix

* Update auto-release.yml

---------

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

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

* Update auto-release.yml

* Update auto-release.yml

* fix

---------

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

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

* Update auto-release.yml

---------

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

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

* Update auto-release.yml

---------

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

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

* Update auto-release.yml

---------

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

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

* add: bumper

---------

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

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

* Update auto-release.yml

---------

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

* fix

* fix

* fix

* Update docker.yml

* fix

* Update auto-release.yml

---------

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

* fix

* fix

* fix

* Update docker.yml

* fix

---------

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

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

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

### Related Issues
None (Direct Fix)
2026-02-28 10:31:18 -05:00
charlesgauthereau bf06a2e50c fix: .env.example 2026-02-27 18:42:14 +01:00
Charles GTEandGitHub 8c69fdef95 Merge pull request #98 from Portabase/dev
dev
2026-02-27 17:38:35 +01:00
charlesgauthereau 883c1d2f57 chore(release): 1.4.1 2026-02-27 17:36:05 +01:00
charlesgauthereau e7f1652b4e fix(auth): linking default 2026-02-27 17:02:20 +01:00
charlesgauthereau ea07224a52 fix: docker-compose.prod.yml 2026-02-27 16:25:53 +01:00
charlesgauthereau b655154182 chore(release): 1.4.0 2026-02-27 16:10:51 +01:00
Charles GTEandGitHub a173892163 Merge pull request #75 from Portabase/feat/oidc
add: oidc,passkey,disable email/password,sign-up
2026-02-27 16:07:14 +01:00
Théo LAGACHE 14f48c4a35 Update profile-security.tsx 2026-02-27 15:38:55 +01:00
Théo LAGACHE 88aa4daf74 Update profile-security.tsx 2026-02-27 15:31:37 +01:00
Théo LAGACHE 25326dc9be Update profile-security.tsx 2026-02-27 15:30:55 +01:00
Théo LAGACHE 030cd13f7c fix: passkey, error mapping 2026-02-27 15:17:57 +01:00
Théo LAGACHE d557620c81 fix: passkey table 2026-02-27 14:58:52 +01:00
charlesgauthereau 3273f8dd11 fix: pocket 2026-02-27 14:46:16 +01:00
Théo LAGACHE 646719557c fix: disable register,link/unlink 2026-02-27 11:49:38 +01:00
Théo LAGACHE 1640b326be Update master-realm.json 2026-02-27 11:36:38 +01:00
Théo LAGACHE cc19c9ec19 fix 2026-02-27 11:34:09 +01:00
Théo LAGACHE bb84ab9697 fix: change port 2026-02-27 10:54:10 +01:00
Théo LAGACHE 0f7664201a Update Makefile 2026-02-27 10:07:12 +01:00
Théo LAGACHE 0b8a8213e6 Update Makefile 2026-02-27 10:07:05 +01:00
Théo LAGACHE 476b3da66b fix 2026-02-27 10:03:45 +01:00
Théo LAGACHE 41c632ccfa Update auth.ts 2026-02-27 09:53:24 +01:00
Théo LAGACHE dbf5addf12 fix: lower case role provisioning 2026-02-27 09:51:42 +01:00
Théo LAGACHE 8d414080a4 fix: toast error message,social legacy 2026-02-26 15:29:00 +01:00
Théo LAGACHE c8d390726c fix: error layout global app 2026-02-25 13:00:52 +01:00
Théo LAGACHE 31285dd0d6 fix: link social, admin delete account,update 2026-02-25 09:58:15 +01:00
Théo LAGACHE b9ef58044e Merge branch 'dev' into feat/oidc 2026-02-24 16:49:16 +01:00
charlesgauthereau e320f74dfc chore(release): 1.3.0 2026-02-24 08:44:33 +01:00
charlesgauthereau b9deb3cdf0 chore(release): 1.3.0-rc.1 2026-02-23 21:56:38 +01:00
Charles GTEandGitHub 5dbf293e1e Merge pull request #94 from Portabase/feat/sqlite
feat: add sqlite support
2026-02-23 21:50:37 +01:00
Théo LAGACHE 47f2308acc fix: improve oidc,fix truncate 2026-02-23 16:47:01 +01:00
Théo LAGACHE 496be692e7 fix 2026-02-23 12:04:48 +01:00
Théo LAGACHE 5df6dd169a fix 2026-02-23 12:04:26 +01:00
charlesgauthereau 7232f17cb7 feat: add sqlite support, first working version. 2026-02-22 21:46:54 +01:00
charlesgauthereau 5b1c3d0606 chore(release): 1.2.9 2026-02-19 21:13:33 +01:00
Charles GTEandGitHub 8e6ca64fa2 Merge pull request #93 from Portabase/fix/s3-port
fix: type error when user add port for s3 storages.
2026-02-19 21:12:09 +01:00
charlesgauthereau 9116b4d126 fix: type error when user add port for s3 storages. 2026-02-19 21:09:56 +01:00
charlesgauthereau 4ce38d9857 chore(release): 1.2.8 2026-02-19 13:49:16 +01:00
charlesgauthereau 8811c48384 Merge branch 'dev'
# Conflicts:
#	README.md
2026-02-19 13:48:20 +01:00
Killian LarcherandGitHub 078d0d1ee2 Update demo video link and dashboard image 2026-02-19 11:51:08 +01:00
Killian LarcherandGitHub 56d755afa6 Change demo video link and update image
Updated demo link and image in README.
2026-02-19 11:35:41 +01:00
Killian LarcherandGitHub 9e3abb366f Change dashboard image to video link
Updated the dashboard image link to a video.
2026-02-19 11:34:10 +01:00
charlesgauthereau 4ebac1c46f fix: ts-ignore 2026-02-18 19:09:20 +01:00
Charles GTEandGitHub 1476df7a1f Merge pull request #91 from Portabase/feat/manage-master-key
feat(master_key): download master key file
2026-02-18 19:08:17 +01:00
charlesgauthereau 833872603a feat(master_key): download master key file 2026-02-18 19:07:14 +01:00
charlesgauthereau 737834310b fix 2026-02-18 18:24:56 +01:00
charlesgauthereau 4474c8ee5e Merge branch 'dev' into feat/oidc
# Conflicts:
#	portabase.config.ts
#	src/env.mjs
2026-02-18 17:59:19 +01:00
Charles GTEandGitHub ebe5c63a7b Merge pull request #90 from Portabase/fix/add-backup-cleanup
fix/add-backup-cleanup
2026-02-18 17:49:27 +01:00
charlesgauthereau afd98ae652 chore(release): 1.2.8-rc.1 2026-02-17 08:48:52 +01:00
charlesgauthereau 8f905c8128 fix: stuck backups with ongoing status but deleted, for legacy. 2026-02-17 08:47:57 +01:00
charlesgauthereau f43f30c9b9 chore(release): 1.2.7 2026-02-16 16:40:01 +01:00
charlesgauthereau 6b1bea9b17 Merge remote-tracking branch 'origin/main' 2026-02-16 16:39:31 +01:00
charlesgauthereau cdaed9766e fix: twice email sent by better auth 2026-02-16 16:39:19 +01:00
Charles GTEandGitHub d31a9ab2d2 Merge pull request #87 from Portabase/dev
1.2.7
2026-02-16 16:38:36 +01:00
Killian LarcherandGitHub 07d49749fb Merge pull request #86 from Portabase/fix/test-button-notif
Fix/test button notif
2026-02-16 16:35:44 +01:00
killianlarcher a1f1da8a07 chore(release): 1.2.7-rc.3 2026-02-16 16:17:30 +01:00
killian-larcher 1b791fd287 fix: re-enable Test Button when editing notification channel. 2026-02-16 16:14:41 +01:00
charlesgauthereau 2a4af58a51 Merge remote-tracking branch 'origin/dev' into dev 2026-02-16 15:37:37 +01:00
charlesgauthereau b12f8ca044 chore(release): 1.2.7-rc.2 2026-02-16 15:37:06 +01:00
Charles GTEandGitHub 519b3abd1d Merge pull request #84 from Portabase/fix/twice-email-session
fix: twice email sent by better auth
2026-02-16 15:36:06 +01:00
charlesgauthereau e08a5bd6b6 fix: twice email sent by better auth 2026-02-16 15:34:31 +01:00
Killian LarcherandGitHub f6c597b42d Merge pull request #83 from Portabase/fix/optional-csp
Conditionally enable UPGRADE_INSECURE_REQUESTS based on PROJECT_URL scheme
2026-02-16 15:23:49 +01:00
killianlarcher f3b9032174 chore(release): 1.2.7-rc.1 2026-02-16 13:23:36 +01:00
killianlarcher 44b4ee2953 fix(csp): Conditionally sets UPGRADE_INSECURE_REQUESTS based on the PROJECT_URL scheme. 2026-02-16 13:21:09 +01:00
killianlarcher b77f79f42d fix(csp): Conditionally sets UPGRADE_INSECURE_REQUESTS based on the PROJECT_URL scheme. 2026-02-16 13:18:58 +01:00
Charles GTEandGitHub 8017bf7c8a Merge pull request #80 from Portabase/dev
fix: README.md
2026-02-15 11:50:54 +01:00
charlesgauthereau 85f4cda752 fix: README.md 2026-02-15 11:49:01 +01:00
charlesgauthereau 1fd2f553ed chore(release): 1.2.6 2026-02-15 10:59:43 +01:00
Charles GTEandGitHub d68170eda8 Merge pull request #79 from Portabase/dev
Dev
2026-02-15 10:59:09 +01:00
Charles GTEandGitHub 51f27226e7 Merge branch 'main' into dev 2026-02-15 10:48:37 +01:00
charlesgauthereau d54c366323 chore(release): 1.2.6-rc.2 2026-02-15 10:21:24 +01:00
Charles GTEandGitHub 0e7cb9fbab Merge pull request #78 from Portabase/feat/internal-database
feat/internal-database
2026-02-15 10:20:25 +01:00
charlesgauthereau 63e83032f1 fix: working on adding internal postgres database. 2026-02-15 10:19:28 +01:00
charlesgauthereau b4da6fdd9f fix: working on adding internal postgres database. 2026-02-14 22:23:04 +01:00
charlesgauthereau 12535a59e8 fix: docker-compose.prod.yml 2026-02-14 19:22:24 +01:00
Charles GTEandGitHub b345eda7aa Update README.md 2026-02-14 09:32:02 +01:00
Charles GTEandGitHub 660c1dcadf Merge pull request #76 from Portabase/fix/backup-delete
fix: bug ongoing backup deletion
2026-02-13 22:41:48 +01:00
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
Théo LAGACHE 3bf731055f add: oidc,passkey,disable email/password,sign-up 2026-02-13 16:48:13 +01:00
charlesgauthereau 362ca53efb fix: console.log 2026-02-13 15:58:53 +01:00
148 changed files with 19831 additions and 5727 deletions
+38 -8
View File
@@ -1,10 +1,10 @@
# Environnement # Environment
NODE_ENV=development NODE_ENV=development
# Base de données # Database
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
# Projet # Project
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://localhost:8887 PROJECT_URL=http://localhost:8887
@@ -21,11 +21,41 @@ SMTP_FROM=
#Port 465 -> secure: true #Port 465 -> secure: true
#By default : true #By default : true
# Google # OIDC SSO Authentification
AUTH_GOOGLE_ID= AUTH_OIDC_ID=""
AUTH_GOOGLE_SECRET= AUTH_OIDC_TITLE=""
AUTH_GOOGLE_METHOD= AUTH_OIDC_DESC=""
AUTH_OIDC_ICON=""
AUTH_OIDC_CLIENT=""
AUTH_OIDC_SECRET=""
AUTH_OIDC_ISSUER_URL=""
AUTH_OIDC_HOST=""
AUTH_OIDC_SCOPES=""
AUTH_OIDC_DISCOVERY_ENDPOINT=""
AUTH_OIDC_JWKS_ENDPOINT=""
AUTH_OIDC_PKCE=true
ALLOWED_GROUP="admin"
AUTH_OIDC_ROLE_MAP=""
AUTH_ALLOW_LINKING=true
AUTH_ALLOW_UNLINKING=true
# Social OAuth2 Authentification
AUTH_SOCIAL_ID=""
AUTH_SOCIAL_TITLE=""
AUTH_SOCIAL_DESC=""
AUTH_SOCIAL_ICON=""
AUTH_SOCIAL_CLIENT=""
AUTH_SOCIAL_SECRET=""
AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER=""
# If true, the user's role will be updated from OIDC claims on every login.
# If false, the role is only set when the user is first created.
AUTH_SYNC_OIDC_ROLES_ON_LOGIN=true
AUTH_EMAIL_PASSWORD_ENABLED=true
AUTH_SIGNUP_ENABLED=true
AUTH_PASSKEY_ENABLED=true
# Retention # Retention
RETENTION_CRON="* * * * *" RETENTION_CRON="* * * * *"
+3 -3
View File
@@ -72,7 +72,7 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
3. **Push your branch** 3. **Push your branch**
```bash ```bash
git push origin feature/<feature-name> git push origin feat/<feature-name>
``` ```
4. **Open a Pull Request (PR)** 4. **Open a Pull Request (PR)**
@@ -82,7 +82,7 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
## Code Style Guidelines ## Code Style Guidelines
- Follow the [specific coding style guide] (e.g., Prettier, ESLint, PEP8). - Follow the [specific coding style guide] (e.g., Prettier, ESLint, PEP8,Biome).
- Use meaningful variable names and include comments where necessary. - Use meaningful variable names and include comments where necessary.
- Tests before submitting your changes. - Tests before submitting your changes.
@@ -107,4 +107,4 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
Thank you for contributing! 🙌 Thank you for contributing! 🙌
--- ---
+46
View File
@@ -0,0 +1,46 @@
name: Auto Release bot
on:
pull_request:
types: [closed]
branches:
- main
jobs:
release:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
ref: ${{ github.base_ref }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: "lts/*"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: git config --global user.name 'github-actions[bot]'
- run: git config --global user.email 'github-actions[bot]@users.noreply.github.com'
- name: Run release-it
run: |
git pull origin main
pnpm exec release-it --ci
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
@@ -1,16 +1,8 @@
name: GitHub Release name: Discord Notification
on: on:
workflow_call: workflow_call:
inputs: inputs:
prerelease:
required: false
type: boolean
default: false
make_latest:
required: false
type: boolean
default: false
discord_title: discord_title:
required: true required: true
type: string type: string
@@ -27,74 +19,24 @@ on:
required: true required: true
jobs: jobs:
create-release: notify-discord:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
steps: steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build Changelog
id: build_changelog
uses: mikepenz/release-changelog-builder-action@v5
with:
mode: "COMMIT"
configurationJson: |
{
"template": "#{{CHANGELOG}}",
"categories": [
{
"title": "## Feature",
"labels": ["feat", "feature"]
},
{
"title": "## Fix",
"labels": ["fix", "bug"]
},
{
"title": "## Other",
"labels": []
}
],
"label_extractor": [
{
"pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)",
"on_property": "title",
"target": "$1"
}
]
}
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: false
body: ${{ steps.build_changelog.outputs.changelog }}
prerelease: ${{ inputs.prerelease }}
make_latest: ${{ inputs.make_latest }}
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Send Discord Notification - name: Send Discord Notification
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GH_TOKEN }} GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: | run: |
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author) RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name) RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url) RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body) RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
AUTHOR_NAME="Portabase" AUTHOR_NAME="Portabase"
AUTHOR_ICON="https://github.com/Portabase.png" AUTHOR_ICON="https://github.com/Portabase.png"
PAYLOAD=$(jq -n \ PAYLOAD=$(jq -n \
--arg title "$RELEASE_TITLE" \ --arg title "$RELEASE_TITLE" \
@@ -105,7 +47,7 @@ jobs:
--arg discord_title "${{ inputs.discord_title }}" \ --arg discord_title "${{ inputs.discord_title }}" \
--arg discord_footer "${{ inputs.discord_footer }}" \ --arg discord_footer "${{ inputs.discord_footer }}" \
--argjson discord_color ${{ inputs.discord_color }} \ --argjson discord_color ${{ inputs.discord_color }} \
'{ '{
content: $discord_title, content: $discord_title,
embeds: [{ embeds: [{
title: $title, title: $title,
@@ -121,8 +63,8 @@ jobs:
} }
}] }]
}' }'
) )
curl -H "Content-Type: application/json" \ curl -H "Content-Type: application/json" \
-d "$PAYLOAD" \ -d "$PAYLOAD" \
"$DISCORD_WEBHOOK" "$DISCORD_WEBHOOK"
+40 -48
View File
@@ -6,7 +6,7 @@ on:
image_name: image_name:
required: false required: false
type: string type: string
default: 'portabase/portabase' default: "portabase/portabase"
add_latest: add_latest:
required: false required: false
type: boolean type: boolean
@@ -14,11 +14,11 @@ on:
target: target:
required: false required: false
type: string type: string
default: 'prod' default: "prod"
dockerfile: dockerfile:
required: false required: false
type: string type: string
default: './docker/dockerfile/Dockerfile' default: "./docker/dockerfile/Dockerfile"
secrets: secrets:
DOCKER_USERNAME: DOCKER_USERNAME:
required: true required: true
@@ -26,34 +26,32 @@ on:
required: true required: true
jobs: jobs:
build: build:
name: Build and push Docker images name: Build architectures
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }} runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
platform: [ linux/amd64, linux/arm64 ] platform: [linux/amd64, linux/arm64]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker - name: Login to Docker
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set image tag - name: Prepare Image Tags
id: set-tags id: prep
run: | run: |
REF_NAME=${GITHUB_REF#refs/tags/} ARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
if [ "${{ matrix.platform }}" = "linux/amd64" ]; then echo "image=${{ inputs.image_name }}:${GITHUB_REF#refs/tags/}-$ARCH" >> $GITHUB_OUTPUT
IMAGE="${{ inputs.image_name }}:$REF_NAME-amd64" echo "safe_platform=${{ matrix.platform == 'linux/amd64' && 'linux-amd64' || 'linux-arm64' }}" >> $GITHUB_OUTPUT
else
IMAGE="${{ inputs.image_name }}:$REF_NAME-arm64"
fi
echo "image=$IMAGE" >> $GITHUB_OUTPUT
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -62,61 +60,55 @@ jobs:
file: ${{ inputs.dockerfile }} file: ${{ inputs.dockerfile }}
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: true push: true
tags: ${{ steps.set-tags.outputs.image }} tags: ${{ steps.prep.outputs.image }}
target: ${{ inputs.target }} target: ${{ inputs.target }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
- name: Prepare artifact name - name: Save image name for manifest
id: artifact run: echo "${{ steps.prep.outputs.image }}" > image.txt
run: |
platform=${{ matrix.platform }}
echo "safe_platform=${platform//\//-}" >> $GITHUB_OUTPUT
echo "${{ steps.set-tags.outputs.image }}" > image.txt
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f - uses: actions/upload-artifact@v4
with: with:
name: image-${{ steps.artifact.outputs.safe_platform }} name: image-${{ steps.prep.outputs.safe_platform }}
path: image.txt path: image.txt
if-no-files-found: warn retention-days: 1
compression-level: 6
overwrite: false
include-hidden-files: false
create-manifest: create-manifest:
name: Create multi-arch Docker manifest (Portabase) name: Create multi-arch manifest
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
steps: steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 - uses: actions/download-artifact@v4
with: with:
name: image-linux-amd64 name: image-linux-amd64
path: /tmp/digests/amd64 path: /tmp/digests/amd64
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 - uses: actions/download-artifact@v4
with: with:
name: image-linux-arm64 name: image-linux-arm64
path: /tmp/digests/arm64 path: /tmp/digests/arm64
- name: Login to Docker - name: Login to Docker
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ inputs.image_name }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ inputs.add_latest }}
- name: Create and push manifest list - name: Create and push manifest list
working-directory: /tmp/digests working-directory: /tmp/digests
run: | run: |
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)" DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
REF_NAME=${GITHUB_REF#refs/tags/} TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
MANIFEST_IMAGE="${{ inputs.image_name }}:$REF_NAME" docker buildx imagetools create $DOCKER_IMAGES $TAG_ARGS
docker buildx imagetools create $DOCKER_IMAGES -t $MANIFEST_IMAGE
docker buildx imagetools inspect $MANIFEST_IMAGE
if [ "${{ inputs.add_latest }}" = "true" ]; then
docker buildx imagetools create $DOCKER_IMAGES -t ${{ inputs.image_name }}:latest
docker buildx imagetools inspect ${{ inputs.image_name }}:latest
fi
+47
View File
@@ -0,0 +1,47 @@
name: PR Checker
on:
pull_request:
branches:
- main
- dev
jobs:
validate-code:
name: Code Integrity Check
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check code formatting
run: pnpm run format:check
continue-on-error: true
- name: Run Linter
run: pnpm run lint
continue-on-error: true
- name: Type Check
run: pnpm run typecheck
continue-on-error: true
- name: Run Tests
run: pnpm run test
continue-on-error: true
- name: Test Application Build
run: pnpm run build
+8 -9
View File
@@ -3,29 +3,28 @@ name: Publish Docker image for release candidate
on: on:
push: push:
tags: tags:
- '*-rc*' - "*.*.*-rc*"
- "!*-*-rc*"
permissions: permissions:
contents: write contents: read
packages: write packages: write
jobs: jobs:
docker_publish: docker_publish:
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
add_latest: false add_latest: true
secrets: secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release: discord_notification:
needs: docker_publish needs: docker_publish
uses: ./.github/workflows/github.yml uses: ./.github/workflows/discord.yml
with: with:
prerelease: true discord_title: "New Release Candidate: ${{ github.ref_name }}"
make_latest: false discord_color: 2044800
discord_title: "||@release-dashboard|| New release candidate published"
discord_color: 16776960
discord_footer: "Portabase" discord_footer: "Portabase"
secrets: secrets:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
+8 -10
View File
@@ -3,11 +3,11 @@ name: Publish Docker image for release
on: on:
push: push:
tags: tags:
- '*.*.*' - "*.*.*"
- '!*-*' - "!*-*"
permissions: permissions:
contents: write contents: read
packages: write packages: write
jobs: jobs:
@@ -19,15 +19,13 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release: discord_notification:
needs: docker_publish needs: docker_publish
uses: ./.github/workflows/github.yml uses: ./.github/workflows/discord.yml
with: with:
prerelease: false discord_title: "New Release: ${{ github.ref_name }}"
make_latest: true discord_color: 3066993
discord_title: "||@release-dashboard|| New release published"
discord_color: 5814783
discord_footer: "Portabase" discord_footer: "Portabase"
secrets: secrets:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -4
View File
@@ -5,7 +5,7 @@ on:
branches: [ main ] branches: [ main ]
jobs: jobs:
sca-deps: # Dependency & container scan sca-deps:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -17,15 +17,16 @@ jobs:
ignore-unfixed: true ignore-unfixed: true
secrets-gitleaks: # Secrets exposure secrets-gitleaks:
if: github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 # Fetch full history for gitleaks fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2 - uses: gitleaks/gitleaks-action@v2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
with: with:
config-path: .gitleaks.toml # Optional, if you have a custom config config-path: .gitleaks.toml
+3 -1
View File
@@ -44,4 +44,6 @@ private/keys/*
/.env /.env
certificates seeds/pocket-id/*.db
certificates
+1 -3
View File
@@ -2,9 +2,7 @@ title = "Custom gitleaks config"
[allowlist] [allowlist]
# Global allowlist patterns (wont be flagged) # Global allowlist patterns (wont be flagged)
regexes = [ regexes = []
"s3SecretAccessKey: env.S3_SECRET_KEY ?? null",
]
paths = [ paths = [
"src/utils/init.ts" "src/utils/init.ts"
] ]
+56
View File
@@ -0,0 +1,56 @@
{
"git": {
"commit": true,
"commitMessage": "chore: release v${version}",
"requireCleanWorkingDir": true,
"tag": true,
"tagName": "${version}",
"push": true
},
"github": {
"release": true,
"tokenRef": "GITHUB_TOKEN"
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": {
"name": "conventionalcommits",
"types": [
{
"type": "feat",
"section": "✨ Features"
},
{
"type": "fix",
"section": "🐛 Bug Fixes"
},
{
"type": "perf",
"section": "⚡️ Performance Improvements"
},
{
"type": "revert",
"section": "⏪️ Reverts"
},
{
"type": "docs",
"section": "📝 Documentation",
"hidden": true
},
{
"type": "chore",
"section": "🔧 Chores",
"hidden": true
}
]
}
},
"@release-it/bumper": {
"out": {
"file": "CITATION.cff",
"path": "version",
"type": "text/yaml"
}
}
}
}
+9 -4
View File
@@ -1,6 +1,6 @@
cff-version: 1.2.0 cff-version: 1.2.0
title: Portabase title: Portabase
message: "If you use this software, please cite it as below." message: If you use this software, please cite it as below.
type: software type: software
authors: authors:
- family-names: Gauthereau - family-names: Gauthereau
@@ -9,7 +9,12 @@ authors:
given-names: Killian given-names: Killian
repository-code: https://github.com/Portabase/portabase repository-code: https://github.com/Portabase/portabase
url: https://portabase.io url: https://portabase.io
abstract: "Portabase is a free, open-source, self-hosted solution for database administration, providing backup and restore capabilities, scheduling, retention policies, notifications, and support for multiple storage backends. Its headless agent architecture enables connection to multiple database instances securely and efficiently." abstract: >-
Portabase is a free, open-source, self-hosted solution for database
administration, providing backup and restore capabilities, scheduling,
retention policies, notifications, and support for multiple storage backends.
Its headless agent architecture enables connection to multiple database
instances securely and efficiently.
keywords: keywords:
- docker - docker
- kubernetes - kubernetes
@@ -26,5 +31,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.2.5 version: 1.4.13
date-released: "2026-02-13" date-released: '2026-03-02'
+45 -1
View File
@@ -1,4 +1,48 @@
CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
up: up:
@bash $(CLUSTER_SCRIPT) @bash $(CLUSTER_SCRIPT)
seed-keycloak:
@[ $$(ls -1 seeds/keycloak/*.json 2>/dev/null | wc -l) -gt 0 ] || (echo "No realm export found in seeds/keycloak. Add an export from Keycloak first."; exit 1)
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml rm -f -s keycloak >/dev/null 2>&1 || true
@docker volume rm portabase-dev-func_keycloak-data >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml up -d keycloak
@echo "Keycloak seed import triggered from seeds/keycloak/*.json"
seed-pocket:
@[ -f seeds/pocket-id/portabase.zip ] || (echo "No export found in seeds/pocket-id. Add an export from Pocket ID first."; exit 1)
@docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true
@docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml run --rm -v ./seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
@docker compose -f docker-compose.func.yml up -d pocket-id
@sleep 2
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
@echo "Pocket ID data restored from seeds/pocket-id/portabase.zip"
seed-auth: seed-keycloak seed-pocket
pocket-token:
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
export-pocket:
@echo "Exporting Pocket ID data to seeds/pocket-id/portabase.zip..."
@mkdir -p ./seeds/pocket-id
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id export --path /tmp/portabase-export.zip
@docker compose -f docker-compose.func.yml cp pocket-id:/tmp/portabase-export.zip ./seeds/pocket-id/portabase.zip
@docker compose -f docker-compose.func.yml exec pocket-id rm /tmp/portabase-export.zip
@echo "Pocket ID data exported to seeds/pocket-id/portabase.zip"
export-keycloak:
@echo "Exporting Keycloak configuration and users to seeds/keycloak/*.json..."
@mkdir -p ./seeds/keycloak
@rm -f ./seeds/keycloak/*.json
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1
@docker rm -f kc-exporter >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml run --name kc-exporter keycloak export --dir /tmp/kc-export --users realm_file
@docker cp kc-exporter:/tmp/kc-export/. ./seeds/keycloak/
@docker rm -f kc-exporter >/dev/null 2>&1
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
+6 -8
View File
@@ -7,7 +7,7 @@
<h3 align="center">Portabase</h3> <h3 align="center">Portabase</h3>
<p align="center" style="margin-top: 20px; font-style: italic;"> <p align="center" style="margin-top: 20px; font-style: italic;">
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent">Portabase agents</a> for managing operations securely and efficiently.</i> <i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent-rust">Portabase agents</a> for managing operations securely and efficiently.</i>
</p> </p>
@@ -19,11 +19,11 @@
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/)
[![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/) [![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/)
[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/) [![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/)
[![SQLite](https://img.shields.io/badge/-SQLite-blue?logo=sqlite&logoColor=white)](https://sqlite.org/)
[![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/) [![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase) [![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase) [![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
[![NextJS][NextJS]][NextJS-url] [![NextJS][NextJS]][NextJS-url]
[![BetterAuth][BetterAuth]][BetterAuth-url] [![BetterAuth][BetterAuth]][BetterAuth-url]
[![Drizzle][Drizzle]][Drizzle-url] [![Drizzle][Drizzle]][Drizzle-url]
@@ -34,14 +34,14 @@
<strong> <strong>
<a href="https://portabase.io">Website</a> • <a href="https://portabase.io">Website</a> •
<a href="https://portabase.io/docs">Documentation</a> • <a href="https://portabase.io/docs">Documentation</a> •
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> • <a href="https://www.youtube.com/watch?v=nSTzT27GgAg">Demo</a> •
<a href="https://portabase.io/docs/dashboard/setup">Installation</a> • <a href="https://portabase.io/docs/dashboard/setup">Installation</a> •
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> • <a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a> <a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
</strong> </strong>
</p> </p>
![portabase-dashboard](https://github.com/user-attachments/assets/f5ec7204-79c0-43ac-ba17-f2ed682c39d6) ![portabase-dashboard](https://github.com/user-attachments/assets/8f2c69d6-f1f9-4b80-b51c-01f6f13b9b62)
</div> </div>
@@ -51,14 +51,13 @@
You have 4 ways to install Portabase: You have 4 ways to install Portabase:
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli) - Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker) - Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose)
- Kubernetes with Helm (soon) - Kubernetes with Helm (soon)
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development) - Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
**Ensure Docker is installed on your machine before getting started.** **Ensure Docker is installed on your machine before getting started.**
## Contributors ## Contributors
[![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)
@@ -89,4 +88,3 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
[ShadcnUI-url]: https://ui.shadcn.com/ [ShadcnUI-url]: https://ui.shadcn.com/
[Docker-url]: https://www.docker.com/ [Docker-url]: https://www.docker.com/
+11 -7
View File
@@ -1,10 +1,15 @@
import {CardContent, CardHeader} from "@/components/ui/card"; import { CardContent, CardHeader } from "@/components/ui/card";
import {TooltipProvider} from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import {ForgotPasswordForm} from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form"; import { ForgotPasswordForm } from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
import {CardAuth} from "@/features/layout/card-auth"; import { env } from "@/env.mjs";
import { CardAuth } from "@/features/layout/card-auth";
import { redirect } from "next/navigation";
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) { export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
redirect("/login");
}
return ( return (
<TooltipProvider> <TooltipProvider>
@@ -12,12 +17,11 @@ export default async function RoutePage(props: { searchParams: Promise<{ callbac
<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">Enter your email address and we'll send you a <p className="text-balance text-muted-foreground">Enter your email address and we'll send you a link to reset your password.</p>
link to reset your password.</p>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ForgotPasswordForm/> <ForgotPasswordForm />
</CardContent> </CardContent>
</CardAuth> </CardAuth>
</TooltipProvider> </TooltipProvider>
+34 -28
View File
@@ -1,32 +1,38 @@
import React from "react"; import React, { Suspense } 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 { env } from "@/env.mjs";
import {Heart} from "lucide-react"; import { Heart } from "lucide-react";
import { ErrorLayout } from "@/components/wrappers/common/error-layout";
export default async function Layout({children}: { children: React.ReactNode }) { export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const user = await currentUser();
const user = await currentUser(); if (user && !user.banned && user.role !== "pending") {
redirect("/dashboard/home");
}
if (user && !user.banned && user.role !== "pending") { return (
redirect("/dashboard/home"); <div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
} <div className="flex flex-col items-center justify-center flex-1">
<div className="mx-auto w-full max-w-md">
return ( <AuthLogoSection />
<div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 "> <div className="mt-4">{children}</div>
<div className="flex flex-col items-center justify-center flex-1">
<div className="mx-auto w-full max-w-md">
<AuthLogoSection/>
<div className="mt-4">{children}</div>
</div>
</div>
<footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
<p className="flex items-center justify-center gap-1">
Made with <Heart className="size-3 fill-red-500 text-red-500" /> by <span className="font-medium text-foreground">Portabase</span>
</p>
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
</footer>
</div> </div>
) </div>
} <footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
<p className="flex items-center justify-center gap-1">
Made with{" "}
<Heart className="size-3 fill-red-500 text-red-500 animate-pulse" />{" "}
by <span className="font-medium text-foreground">Portabase</span>
</p>
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
</footer>
</div>
);
}
+50 -42
View File
@@ -1,52 +1,60 @@
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form"; import { LoginForm } from "@/components/wrappers/auth/login/login-form/login-form";
import {Metadata} from "next"; import { Metadata } from "next";
import {SUPPORTED_PROVIDERS} from "../../../portabase.config"; import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons"; import { SocialAuthButtons } from "@/components/wrappers/auth/social-buttons";
import {TooltipProvider} from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import {CardContent, CardHeader} from "@/components/ui/card"; import { CardContent, CardHeader } from "@/components/ui/card";
import Link from "next/link"; import Link from "next/link";
import {Separator} from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import {CardAuth} from "@/features/layout/card-auth"; import { CardAuth } from "@/features/layout/card-auth";
import { env } from "@/env.mjs";
import { en } from "zod/v4/locales";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Login", title: "Login",
}; };
export default async function SignInPage() { export default async function SignInPage() {
return (
<TooltipProvider>
<CardAuth className="w-full">
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Login</h1>
<p className="text-balance text-muted-foreground">
Fill your login informations
</p>
</div>
</CardHeader>
<CardContent className="space-y-4">
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
<LoginForm isPasskeyEnabled={env.AUTH_PASSKEY_ENABLED === "true"} />
)}
return ( {env.AUTH_EMAIL_PASSWORD_ENABLED === "true" &&
<TooltipProvider> SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive)
<CardAuth className="w-full"> .length > 0 && (
<CardHeader> <div className="relative my-4 flex items-center justify-center overflow-hidden">
<div className="grid gap-2 text-center mb-2"> <Separator />
<h1 className="text-3xl font-bold">Login</h1> <div className="px-2 text-center text-sm">OR</div>
<p className="text-balance text-muted-foreground">Fill your login informations</p> <Separator />
</div> </div>
</CardHeader> )}
<CardContent className="space-y-4">
<LoginForm/>
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && ( {SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length >
<> 0 && <SocialAuthButtons providers={SUPPORTED_PROVIDERS} />}
<div className="relative my-4 flex items-center justify-center overflow-hidden">
<Separator/>
<div className="px-2 text-center text-sm">OR</div>
<Separator/>
</div>
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
</>
)}
<div className="mt-4 text-center text-sm"> {env.AUTH_SIGNUP_ENABLED !== "true" ||
Don&apos;t have an account ?{" "} (env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
<Link href="/register" className="underline"> <div className="mt-4 text-center text-sm">
Sign up Don&apos;t have an account ?{" "}
</Link> <Link href="/register" className="underline">
</div> Sign up
</CardContent> </Link>
</CardAuth> </div>
</TooltipProvider> ))}
</CardContent>
</CardAuth>
) </TooltipProvider>
);
} }
+19 -10
View File
@@ -1,15 +1,24 @@
import {PageParams} from "@/types/next"; import { PageParams } from "@/types/next";
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form"; import { RegisterForm } from "@/components/wrappers/auth/register/register-form/register-form";
import {Metadata} from "next"; import { Metadata } from "next";
import { redirect } from "next/navigation";
import { env } from "@/env.mjs";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Register", title: "Register",
}; };
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
return ( if (
<div className="mx-auto grid w-full gap-6"> env.AUTH_SIGNUP_ENABLED !== "true" ||
<RegisterForm/> env.AUTH_EMAIL_PASSWORD_ENABLED !== "true"
</div> ) {
) redirect("/login");
} }
return (
<div className="mx-auto grid w-full gap-6">
<RegisterForm />
</div>
);
}
+5 -1
View File
@@ -4,9 +4,13 @@ import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-passwo
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"; import { CardAuth } from "@/features/layout/card-auth";
import { env } from "@/env.mjs";
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) { export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
return redirect("/login");
}
const { token } = await props.searchParams; const { token } = await props.searchParams;
@@ -1,30 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db";
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
import {notFound} from "next/navigation";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
if (!settings) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System email</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsEmailSection settings={settings}/>
</PageContent>
</Page>
);
}
@@ -1,29 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
import {db} from "@/db";
import {notFound} from "next/navigation";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
if (!settings) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System storage</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsStorageSection settings={settings}/>
</PageContent>
</Page>
);
}
@@ -4,6 +4,7 @@ import {db} from "@/db";
import {desc, isNull} from "drizzle-orm"; import {desc, isNull} from "drizzle-orm";
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list"; import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal"; import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
@@ -21,6 +22,9 @@ export default async function RoutePage(props: PageParams<{}>) {
}, },
}); });
const credentialProvider = SUPPORTED_PROVIDERS.find(p => p.id === 'credential');
const isPasswordAuthEnabled = credentialProvider?.isActive || false;
return ( return (
<Page> <Page>
<PageHeader className="flex flex-col"> <PageHeader className="flex flex-col">
@@ -32,7 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
</div> </div>
</PageHeader> </PageHeader>
<PageContent className="flex flex-col gap-5"> <PageContent className="flex flex-col gap-5">
<AdminUserList users={users}/> <AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/>
</PageContent> </PageContent>
</Page> </Page>
); );
@@ -1,61 +1,69 @@
import {PageParams} from "@/types/next"; import { PageParams } from "@/types/next";
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {
import {db} from "@/db"; Page,
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm"; import { eq } from "drizzle-orm";
import {notFound} from "next/navigation"; import { notFound } from "next/navigation";
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent"; import { ButtonDeleteAgent } from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
import {capitalizeFirstLetter} from "@/utils/text"; 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 { AgentDialog } from "@/features/agents/components/agent.dialog";
import {AgentType} from "@/features/agents/agents.schema"; 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 }>,
) {
const { agentId } = await props.params;
const {agentId} = await props.params const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
with: {
databases: true,
},
});
const agent = await db.query.agent.findFirst({ if (!agent) {
where: eq(drizzleDb.schemas.agent.id, agentId), notFound();
with: { }
databases: true
}
})
if (!agent) { const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
notFound()
}
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id); return (
<Page>
return ( <div className="justify-between gap-2 sm:flex">
<Page> <PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="justify-between gap-2 sm:flex"> <div className="min-w-full md:min-w-fit ">
<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 "> </div>
{capitalizeFirstLetter(agent.name)} <div className="flex items-center gap-2 md:justify-between w-full ">
</div> <div className="flex items-center gap-2">
<div className="flex items-center gap-2 md:justify-between w-full "> <AgentDialog
<div className="flex items-center gap-2"> agent={agent as AgentType & { id: string }}
<AgentDialog agent={agent as AgentType & { id: string }} typeTrigger={"edit"}/> typeTrigger={"edit"}
</div> />
<div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</div>
</div>
</PageTitle>
</div> </div>
<div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
</div>
</div>
</PageTitle>
</div>
{agent.description && ( {agent.description && (
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription> <PageDescription className="mt-5 sm:mt-0">
)} {agent.description}
<PageContent className="flex flex-col w-full h-full justify-between gap-6"> </PageDescription>
<AgentContentPage )}
agent={agent} <PageContent className="flex flex-col w-full h-full justify-between gap-6">
edgeKey={edgeKey} <AgentContentPage agent={agent} edgeKey={edgeKey} />
/> </PageContent>
</PageContent> </Page>
</Page> );
) }
}
@@ -35,12 +35,10 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageHeader> <PageHeader>
<PageTitle>Notification channels</PageTitle> <PageTitle>Notification channels</PageTitle>
<PageActions> <PageActions>
{/*<NotifierAddEditModal adminView={false}/>*/}
<ChannelAddEditModal kind="notification" adminView={false}/> <ChannelAddEditModal kind="notification" adminView={false}/>
</PageActions> </PageActions>
</PageHeader> </PageHeader>
<PageContent> <PageContent>
{/*<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>*/}
<ChannelsSection kind="notification" organizations={organizations} <ChannelsSection kind="notification" organizations={organizations}
channels={notificationChannels}/> channels={notificationChannels}/>
</PageContent> </PageContent>
@@ -1,36 +0,0 @@
import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { notFound } from "next/navigation";
import { DatabaseForm } from "@/components/wrappers/dashboard/database/database-form/database-form";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm";
export default async function RoutePage(props: PageParams<{ databaseId: string }>) {
const { databaseId } = await props.params;
const dbItem = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
});
if (!dbItem) {
notFound();
}
return (
<Page>
<PageHeader>
<PageTitle>Edit {dbItem.name}</PageTitle>
</PageHeader>
<PageContent>
<DatabaseForm
databaseId={databaseId}
// @ts-ignore
defaultValues={{ ...dbItem, dbms: dbItem.dbms ?? "inactive", description: dbItem.description ?? undefined }}
/>
</PageContent>
</Page>
);
}
@@ -1,89 +1,95 @@
import {PageParams} from "@/types/next"; import { PageParams } from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {
import {currentUser} from "@/lib/auth/current-user"; Page,
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; PageActions,
import {notFound} from "next/navigation"; PageContent,
import {Metadata} from "next"; PageHeader,
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs"; PageTitle,
import {getOrganizationChannels} from "@/db/services/notification-channel"; } from "@/features/layout/page";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; import { currentUser } from "@/lib/auth/current-user";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button"; import { notFound } from "next/navigation";
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog"; import { Metadata } from "next";
import {Button} from "@/components/ui/button"; import { OrganizationTabs } from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
import {GearIcon} from "@radix-ui/react-icons"; import { getOrganizationChannels } from "@/db/services/notification-channel";
import {db} from "@/db"; import { computeOrganizationPermissions } from "@/lib/acl/organization-acl";
import {isNull} from "drizzle-orm"; import { getOrganizationStorageChannels } from "@/db/services/storage-channel";
import { DeleteOrganizationButton } from "@/components/wrappers/dashboard/organization/delete-organization-button";
import { EditOrganizationDialog } from "@/features/organization/components/edit-organization.dialog";
import { db } from "@/db";
import { isNull } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm"; import { eq } from "drizzle-orm";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Settings", title: "Settings",
}; };
export default async function RoutePage(props: PageParams<{ slug: string }>) { export default async function RoutePage(props: PageParams<{ slug: string }>) {
const organization = await getOrganization({}); const organization = await getOrganization({});
const user = await currentUser(); const user = await currentUser();
const activeMember = await getActiveMember() const activeMember = await getActiveMember();
if (!organization || !activeMember || !user) { if (!organization || !activeMember || !user) {
notFound(); notFound();
} }
const notificationChannels = await getOrganizationChannels(organization.id) const notificationChannels = await getOrganizationChannels(organization.id);
const storageChannels = await getOrganizationStorageChannels(organization.id) const storageChannels = await getOrganizationStorageChannels(organization.id);
const permissions = computeOrganizationPermissions(activeMember); const permissions = computeOrganizationPermissions(activeMember);
const users = await db.query.user.findMany({ const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt) where: (fields) => isNull(fields.deletedAt),
}); });
const organizationWithMembers = await db.query.organization.findFirst({ const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id), where: eq(drizzleDb.schemas.organization.id, organization.id),
with: {
members: {
with: { with: {
members: { user: true,
with: { },
user: true },
} },
} });
}
});
if (!organizationWithMembers) notFound(); if (!organizationWithMembers) notFound();
return ( return (
<Page> <Page>
<PageHeader> <PageHeader>
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full "> <PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit "> <div className="min-w-full md:min-w-fit ">Organization settings</div>
Organization settings <div className="flex items-center gap-2 md:justify-between w-full ">
</div> <div className="flex items-center gap-2">
<div className="flex items-center gap-2 md:justify-between w-full "> {permissions.canManageSettings &&
<div className="flex items-center gap-2"> organization.slug !== "default" && (
{permissions.canManageSettings && organization.slug !== "default" && ( <EditOrganizationDialog
<EditOrganizationDialog organization={organizationWithMembers}
organization={organizationWithMembers} users={users}
users={users} currentUser={user}
currentUser={user} />
/> )}
)} </div>
</div> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> {permissions.canManageDangerZone &&
{permissions.canManageDangerZone && organization.slug !== "default" && ( organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/> <DeleteOrganizationButton
)} organizationSlug={organization.slug}
</div> />
</div> )}
</PageTitle> </div>
</PageHeader> </div>
<PageContent> </PageTitle>
<OrganizationTabs </PageHeader>
activeMember={activeMember} <PageContent>
organization={organization} <OrganizationTabs
notificationChannels={notificationChannels} activeMember={activeMember}
storageChannels={storageChannels} organization={organization}
/> notificationChannels={notificationChannels}
</PageContent> storageChannels={storageChannels}
</Page> />
) </PageContent>
</Page>
);
} }
+22 -23
View File
@@ -1,27 +1,26 @@
import React, {ReactNode} from "react"; import { ReactNode } from "react";
import {redirect} from "next/navigation"; import { redirect } from "next/navigation";
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar"; import { AppSidebar } from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
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();
if (!user) redirect("/login"); if (!user) redirect("/login");
return ( return (
<SidebarProvider> <SidebarProvider>
<div className="flex flex-col lg:flex-row w-full"> <div className="flex flex-col lg:flex-row w-full">
<ThemeMetaUpdater/> <ThemeMetaUpdater />
<AppSidebar/> <AppSidebar />
<SidebarInset> <SidebarInset>
<Header/> <Header />
<main className="h-full">{children}</main> <main className="h-full">{children}</main>
</SidebarInset> </SidebarInset>
</div> </div>
</SidebarProvider> </SidebarProvider>
); );
} }
+2 -1
View File
@@ -102,6 +102,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
}) => { }) => {
try { try {
const body: BodyPatch = await request.json(); const body: BodyPatch = await request.json();
console.log("body", body);
const status = body.status const status = body.status
const backupId = body.backupId const backupId = body.backupId
@@ -131,7 +132,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
eventEmitter.emit('modification', {update: true}); eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, `${status}_backup` as EventKind); await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
return NextResponse.json( return NextResponse.json(
{ {
+1 -2
View File
@@ -63,8 +63,7 @@ export async function POST(
.set({status: body.status as RestorationStatus}) .set({status: body.status as RestorationStatus})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
await sendNotificationsBackupRestore(database, body.status == "error" ? "error_restore" : "success_restore");
const response = { const response = {
message: true, message: true,
+6 -6
View File
@@ -1,9 +1,9 @@
import {NextResponse} from "next/server"; import { NextResponse } from "next/server";
export async function GET() { export async function GET() {
return NextResponse.json({ return NextResponse.json({
PROJECT_URL: process.env.PROJECT_URL, PROJECT_URL: process.env.PROJECT_URL,
PROJECT_NAME: process.env.PROJECT_NAME, PROJECT_NAME: process.env.PROJECT_NAME,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION, PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
}); });
} }
+3 -1
View File
@@ -1,6 +1,7 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import {env} from "@/env.mjs";
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -29,7 +30,8 @@ export async function POST(request: Request) {
} }
const uploadDir = path.join(process.cwd(), "/private/uploads/"); // const uploadDir = path.join(process.cwd(), "/private/uploads/");
const uploadDir = path.join(env.PRIVATE_PATH, "/uploads/");
const oldFilePath = path.join(uploadDir, "tmp", id); const oldFilePath = path.join(uploadDir, "tmp", id);
const newFilePath = path.join(uploadDir, filePath); const newFilePath = path.join(uploadDir, filePath);
+30 -82
View File
@@ -1,85 +1,33 @@
// "use client" "use client";
//
// import {useSearchParams} from "next/navigation"
//
// enum Error {
// Configuration = "Configuration",
// }
//
// const errorMap = {
// [Error.Configuration]: (
// <p>
// There was a problem when trying to authenticate. Please contact us if this
// error persists. Unique error code:{" "}
// <code className="rounded-sm bg-slate-100 p-1 text-xs">Configuration</code>
// </p>
// ),
// }
//
// export default function AuthErrorPage() {
// const search = useSearchParams()
// const error = search.get("error") as Error
//
// return (
// <div className="flex h-screen w-full flex-col items-center justify-center">
// <a
// href="#"
// className="block max-w-sm rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
// >
// <h5 className="mb-2 flex flex-row items-center justify-center gap-2 text-xl font-bold tracking-tight text-gray-900 dark:text-white">
// Something went wrong
// </h5>
// <div className="font-normal text-gray-700 dark:text-gray-400">
// {errorMap[error] || "Please contact us if this error persists."}
// </div>
// </a>
// </div>
// )
// }
"use client"
import { useSearchParams } from "next/navigation" import { Loader2 } from "lucide-react";
import React, { Suspense } from "react" import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useSession } from "@/lib/auth/auth-client";
enum Error { export default function ErrorPage() {
Configuration = "Configuration", const router = useRouter();
const searchParams = useSearchParams();
const { data: session, isPending } = useSession();
useEffect(() => {
if (isPending) {
return;
}
const error = searchParams.get("error");
const params = new URLSearchParams();
if (error) {
params.set("error", error);
}
const destination = session ? "/dashboard/home" : "/login";
router.replace(`${destination}?${params.toString()}`);
}, [isPending, session, router, searchParams]);
return (
<div className="flex h-screen w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
} }
const errorMap = {
[Error.Configuration]: (
<p>
There was a problem when trying to authenticate. Please contact us if this
error persists. Unique error code:{" "}
<code className="rounded-sm bg-slate-100 p-1 text-xs">Configuration</code>
</p>
),
}
function AuthErrorPageContent() {
const search = useSearchParams()
const error = search.get("error") as Error
return (
<div className="flex h-screen w-full flex-col items-center justify-center">
<a
href="#"
className="block max-w-sm rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
>
<h5 className="mb-2 flex flex-row items-center justify-center gap-2 text-xl font-bold tracking-tight text-gray-900 dark:text-white">
Something went wrong
</h5>
<div className="font-normal text-gray-700 dark:text-gray-400">
{errorMap[error] || "Please contact us if this error persists."}
</div>
</a>
</div>
)
}
export default function AuthErrorPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<AuthErrorPageContent />
</Suspense>
)
}
+32 -28
View File
@@ -1,38 +1,42 @@
import React from "react"; import type { Metadata } from "next";
import type {Metadata} from "next"; import type React from "react";
import "./globals.css"; import "./globals.css";
import {Providers} from "./providers"; import { ConsoleSilencer } from "@/components/wrappers/common/console-silencer";
import {cn} from "@/lib/utils"; import { author, geistMono, poppins } from "@/fonts/fonts";
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer"; import { cn } from "@/lib/utils";
import {author, geistMono, poppins} from "@/fonts/fonts"; import { Providers } from "./providers";
const title = process.env.PROJECT_NAME ?? "App Title"; const title = process.env.PROJECT_NAME ?? "App Title";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: title, default: title,
template: `%s - ${title}` template: `%s - ${title}`,
}, },
description: process.env.PROJECT_DESCRIPTION ?? undefined, description: process.env.PROJECT_DESCRIPTION ?? undefined,
}; };
export default async function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return (
return ( <html lang="en" suppressHydrationWarning>
<html lang="en" suppressHydrationWarning> <head>
<head> <meta name="apple-mobile-web-app-title" content={title} />
<meta name="apple-mobile-web-app-title" content={title}/> </head>
</head> <body
<body className={cn(poppins.variable, author.variable, geistMono.variable, "font-sans h-full")}> className={cn(
<ConsoleSilencer/> poppins.variable,
<Providers> author.variable,
{children} geistMono.variable,
</Providers> "font-sans h-full",
</body> )}
</html> >
); <ConsoleSilencer />
<Providers>{children}</Providers>
</body>
</html>
);
} }
+14 -10
View File
@@ -1,15 +1,19 @@
import BackButton from "@/components/wrappers/common/button/back-button"; import BackButton from "@/components/wrappers/common/button/back-button";
export default async function NotFound() { export default async function NotFound() {
return( return (
<div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16"> <div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
<div className="w-full space-y-6 text-center"> <div className="w-full space-y-6 text-center">
<div className="space-y-3"> <div className="space-y-3">
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1> <h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p> Not found
</div> </h1>
<BackButton>Go home</BackButton> <p className="leading-7 not-first:mt-6">
</div> The content you are trying to view is not available.
</p>
</div> </div>
) <BackButton>Go home</BackButton>
</div>
</div>
);
} }
+19 -21
View File
@@ -1,30 +1,28 @@
"use client"; "use client";
import {PropsWithChildren, Suspense} from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {ThemeProvider} from "@/features/theme/theme-provider"; import { type PropsWithChildren, Suspense } from "react";
import {Toaster} from "@/components/ui/sonner";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
import { Toaster } from "@/components/ui/sonner";
import { ErrorLayout } from "@/components/wrappers/common/error-layout";
import { ThemeMetaUpdaterRoot } from "@/features/browser/theme-meta-updater-root";
import { ThemeProvider } from "@/features/theme/theme-provider";
export type ProviderProps = PropsWithChildren<{}>; export type ProviderProps = PropsWithChildren<{}>;
const queryClient = new QueryClient(); const queryClient = new QueryClient();
export const Providers = (props: ProviderProps) => { export const Providers = (props: ProviderProps) => {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<ThemeProvider <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
attribute="class" <ThemeMetaUpdaterRoot />
defaultTheme="system" <QueryClientProvider client={queryClient}>
enableSystem <ErrorLayout>
> <Toaster />
<ThemeMetaUpdaterRoot/> {props.children}
<QueryClientProvider client={queryClient}> </ErrorLayout>
<Toaster/> </QueryClientProvider>
{props.children} </ThemeProvider>
</QueryClientProvider> </Suspense>
</ThemeProvider> );
</Suspense>
);
}; };
+35
View File
@@ -0,0 +1,35 @@
name: portabase-dev-func
services:
keycloak:
image: quay.io/keycloak/keycloak:latest
command: start-dev --import-realm
# environment:
# KC_BOOTSTRAP_ADMIN_USERNAME: admin
# KC_BOOTSTRAP_ADMIN_PASSWORD: admin
ports:
- "3056:8080"
volumes:
- keycloak-data:/opt/keycloak/data
- ./seeds/keycloak:/opt/keycloak/data/import:ro
- ./export:/tmp/export
pocket-id:
image: ghcr.io/pocket-id/pocket-id
restart: unless-stopped
environment:
- APP_URL=http://localhost:3055
- ENCRYPTION_KEY=QwHyjbZvSsDUAcjpdmSPsuYxaH6vET6OeBaeLwXccCb43L6Om3W1AoU5pKIJTzYr
ports:
- 3055:1411
volumes:
- pocket-id-data:/app/data
- ./seeds/pocket-id:/seed:ro
healthcheck:
test: "curl -f http://localhost:1411/healthz"
interval: 1m30s
timeout: 5s
retries: 2
start_period: 10s
volumes:
keycloak-data:
pocket-id-data:
+8 -8
View File
@@ -1,19 +1,18 @@
name: portabase-prod
services: services:
app: app:
build: # build:
context: . # context: .
dockerfile: docker/dockerfile/Dockerfile # dockerfile: docker/dockerfile/Dockerfile
target: prod # target: prod
image: portabase/portabase:1.2.5-rc.9 image: portabase/portabase:1.4.0
ports: ports:
- '8887:80' - '8887:80'
environment: environment:
TZ: "Europe/Paris" TZ: "Europe/Paris"
env_file: env_file:
- .env - .env
volumes:
- portabase-data:/data
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -36,4 +35,5 @@ services:
retries: 5 retries: 5
volumes: volumes:
portabase-data:
postgres-data: postgres-data:
+2 -1
View File
@@ -12,7 +12,7 @@ services:
- POSTGRES_USER=devuser - POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme - POSTGRES_PASSWORD=changeme
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ] test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -30,6 +30,7 @@ services:
- "localhost:host-gateway" - "localhost:host-gateway"
volumes: volumes:
- ./private/uploads/tmp:/data/uploads/tmp - ./private/uploads/tmp:/data/uploads/tmp
user: "1000:1000"
volumes: volumes:
postgres-data: postgres-data:
+21 -4
View File
@@ -1,4 +1,3 @@
#FROM node:22-bullseye AS base
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
@@ -18,6 +17,21 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
RUN apt-get update && apt-get install -y \
postgresql-18 \
postgresql-client-18 \
postgresql-contrib-18 \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
ARG TUSD_VERSION=2.8.0 ARG TUSD_VERSION=2.8.0
@@ -76,6 +90,9 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=80 ENV PORT=80
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENV PGDATA=/data/postgres
ENV PRIVATE_PATH=/data/private
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
@@ -95,13 +112,13 @@ RUN chmod +x /usr/local/bin/tusd
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
RUN mkdir -p .next /app/private/uploads \ RUN mkdir -p .next /data/private/uploads \
&& chown -R nextjs:nodejs .next /app/private /app/public && chown -R nextjs:nodejs .next /data/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
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"] ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
+65 -2
View File
@@ -7,9 +7,72 @@ 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 POSTGRES_BIN=$(ls -d /usr/lib/postgresql/*/bin | head -n 1)
if [ -z "$POSTGRES_BIN" ]; then
echo "PostgreSQL binaries not found"
exit 1
fi
export PATH="$POSTGRES_BIN:$PATH"
if [ -z "$DATABASE_URL" ]; then
echo "[INFO] No DATABASE_URL provided, starting internal Postgres..."
mkdir -p "$PGDATA"
chown -R postgres:postgres "$PGDATA"
if [ ! -f "$PGDATA/PG_VERSION" ]; then
echo "[INFO] Initializing database cluster..."
if ! su postgres -c "initdb -D '$PGDATA'" > /dev/null 2>&1; then
echo "[ERROR] initdb failed"
exit 1
fi
fi
if ! su postgres -c "pg_ctl -D '$PGDATA' \
-o \"-c listen_addresses='localhost' -c logging_collector=on\" \
-l $PGDATA/postgres.log -w start" > /dev/null 2>&1; then
echo "[ERROR] PostgreSQL failed to start"
exit 1
fi
until su postgres -c "pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; do
sleep 1
done
echo "[INFO] PostgreSQL server is up and accepting connections"
DB_USER="${POSTGRES_USER:-portabase_user}"
DB_PASS="${POSTGRES_PASSWORD:-JaB6b1SUtIWYvt7srnOt}"
DB_NAME="${POSTGRES_DB:-portabase_db}"
USER_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" 2>/dev/null)
if [ "$USER_EXISTS" != "1" ]; then
if ! su postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\"" > /dev/null 2>&1; then
echo "[ERROR] Failed creating user"
exit 1
fi
fi
DB_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" 2>/dev/null)
if [ "$DB_EXISTS" != "1" ]; then
if ! su postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\"" > /dev/null 2>&1; then
echo "[ERROR] Failed creating database"
exit 1
fi
fi
export DATABASE_URL="postgres://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME"
echo "[SUCCESS] Internal PostgreSQL started successfully"
echo "[SUCCESS] Database: $DB_NAME | User: $DB_USER | Host: 127.0.0.1:5432"
fi
mkdir -p /data/private/uploads/tmp
echo "▶ Starting tusd server..." echo "▶ Starting tusd server..."
tusd --base-path /tus/files/ --upload-dir /app/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 & tusd --base-path /tus/files/ --upload-dir /data/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 &
echo "▶ Starting Next.js server..." echo "▶ Starting Next.js server..."
PORT=3000 node server.js & PORT=3000 node server.js &
+20 -15
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.2.5", "version": "1.4.13",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -11,9 +11,12 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:drop": "drizzle-kit drop", "db:drop": "drizzle-kit drop",
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts" "auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts",
"release": "release-it"
}, },
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.4.19",
"@better-auth/sso": "^1.4.19",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -48,12 +51,12 @@
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/nodemailer": "^6.4.22", "@types/nodemailer": "^6.4.23",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2", "@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.1", "argon2": "^0.43.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-auth": "1.4.5", "better-auth": "1.4.18",
"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",
@@ -63,12 +66,11 @@
"drizzle-orm": "^0.43.1", "drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1", "drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"express": "^5.2.1",
"googleapis": "^170.1.0", "googleapis": "^170.1.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"minio": "^8.0.6", "minio": "^8.0.6",
"motion": "^12.34.0", "motion": "^12.34.3",
"next": "16.1.5", "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",
@@ -83,7 +85,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-dropzone": "^14.4.1", "react-dropzone": "^14.4.1",
"react-email": "^4.3.2", "react-email": "^4.3.2",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.2",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-twc": "^1.5.1", "react-twc": "^1.5.1",
@@ -93,8 +95,8 @@
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"swiper": "^12.1.0", "swiper": "^12.1.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.5.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"ws": "^8.19.0", "ws": "^8.19.0",
@@ -104,7 +106,9 @@
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@react-email/preview-server": "4.3.2", "@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.4", "@react-email/render": "^2.0.4",
"@tailwindcss/postcss": "^4.1.18", "@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.5",
"@tailwindcss/postcss": "^4.2.1",
"@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.19.11", "@types/node": "^22.19.11",
"@types/node-forge": "^1.3.14", "@types/node-forge": "^1.3.14",
@@ -113,19 +117,20 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@zenstackhq/openapi": "^2.22.1", "@zenstackhq/openapi": "^2.22.1",
"@zenstackhq/tanstack-query": "^2.22.2", "@zenstackhq/tanstack-query": "^2.22.2",
"baseline-browser-mapping": "^2.9.19", "baseline-browser-mapping": "^2.10.0",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
"eslint": "^9.39.2", "eslint": "^9.39.3",
"eslint-config-next": "^16.1.6", "eslint-config-next": "^16.1.6",
"eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-tailwindcss": "^3.18.2",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.18", "release-it": "^19.2.4",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"zenstack": "2.14.2" "zenstack": "2.14.2"
}, },
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8" "packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
} }
+2429 -802
View File
File diff suppressed because it is too large Load Diff
+1 -45
View File
@@ -1,17 +1,5 @@
export interface AuthProviderConfig {
id: "google" | "github" | "credential";
isActive: boolean;
icon: string;
isManual?: boolean;
credentials?: {
clientId: string;
clientSecret: string;
};
}
export const PORTABASE_DEFAULT_SETTINGS = { export const PORTABASE_DEFAULT_SETTINGS = {
SECURITY: { SECURITY: {
CSP: { CSP: {
@@ -64,7 +52,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
FORM_ACTION: ["'self'"], FORM_ACTION: ["'self'"],
FRAME_ANCESTORS: ["'none'"], FRAME_ANCESTORS: ["'none'"],
BLOCK_ALL_MIXED_CONTENT: false, BLOCK_ALL_MIXED_CONTENT: false,
UPGRADE_INSECURE_REQUESTS: true, UPGRADE_INSECURE_REQUESTS: process.env.PROJECT_URL?.startsWith("https://") || false,
}, },
PERMISSIONS_POLICY: { PERMISSIONS_POLICY: {
CAMERA: ["()"], CAMERA: ["()"],
@@ -76,35 +64,3 @@ export const PORTABASE_DEFAULT_SETTINGS = {
}, },
}; };
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
{
id: "google",
icon: "hugeicons:chrome",
isActive: Boolean(process.env.AUTH_GOOGLE_METHOD),
// isManual: true,
credentials: {
clientId: process.env.AUTH_GOOGLE_ID || "",
clientSecret: process.env.AUTH_GOOGLE_SECRET || "",
},
},
{
id: "github",
icon: "iconoir:github",
isActive: Boolean(process.env.AUTH_GITHUB_METHOD),
credentials: {
clientId: process.env.AUTH_GITHUB_ID || "",
clientSecret: process.env.AUTH_GITHUB_SECRET || "",
},
},
{
id: "credential",
isActive: true,
icon: "proicons:key",
isManual: true,
credentials: {
clientId: "",
clientSecret: "",
},
},
];
+67 -61
View File
@@ -1,75 +1,81 @@
import {NextRequest, NextResponse} from "next/server"; import { NextRequest, NextResponse } from "next/server";
import {loggingMiddleware} from "@/middleware/loggingMiddleware"; import { loggingMiddleware } from "@/middleware/loggingMiddleware";
import {errorHandler} from "@/middleware/errorHandler"; import { errorHandler } from "@/middleware/errorHandler";
import {auth} from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import {headers} from "next/headers"; import { headers } from "next/headers";
export async function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
const redirectUrl = encodeURIComponent(request.nextUrl.pathname) const redirectUrl = encodeURIComponent(request.nextUrl.pathname);
if (url.pathname.startsWith("/dashboard")) { if (url.pathname.startsWith("/dashboard")) {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers(), headers: await headers(),
}); });
if (!session) { if (!session) {
return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url)); return NextResponse.redirect(
} new URL(`/login?redirect=${redirectUrl}`, request.url),
if (session.user.banned) { );
await auth.api.signOut({headers: await headers()});
return NextResponse.redirect(new URL("/login?error=banned", request.url));
}
if (session.user.role === "pending") {
await auth.api.signOut({headers: await headers()});
return NextResponse.redirect(new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url));
}
if (url.pathname === "/dashboard") {
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
}
return NextResponse.next();
} }
if (session.user.banned) {
await auth.api.signOut({ headers: await headers() });
return NextResponse.redirect(new URL("/login?error=banned", request.url));
}
if (session.user.role === "pending") {
await auth.api.signOut({ headers: await headers() });
return NextResponse.redirect(
new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url),
);
}
if (url.pathname === "/dashboard") {
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
}
return NextResponse.next();
}
if (url.pathname.startsWith("/api/auth")) { if (url.pathname.startsWith("/api/auth")) {
return NextResponse.next(); return NextResponse.next();
} }
if (url.pathname.startsWith("/api")) { if (url.pathname.startsWith("/api")) {
const routeExists = checkRouteExists(url.pathname); const routeExists = checkRouteExists(url.pathname);
if (!routeExists) { if (!routeExists) {
return new NextResponse(JSON.stringify({message: "This API route does not exist.", status: 404}), { return new NextResponse(
status: 404, JSON.stringify({
headers: {"Content-Type": "application/json"}, message: "This API route does not exist.",
}); status: 404,
} }),
} {
try { status: 404,
loggingMiddleware(request); headers: { "Content-Type": "application/json" },
} catch (err) { },
errorHandler(err); );
} }
}
try {
loggingMiddleware(request);
} catch (err) {
errorHandler(err);
}
} }
function checkRouteExists(pathname: string) { 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\/init\/?$/,
/^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/, /^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/,
/^\/api\/agent\/[^/]+\/restore\/?$/, /^\/api\/agent\/[^/]+\/restore\/?$/,
/^\/api\/files\/images\/[^/]+\/?$/, /^\/api\/files\/images\/[^/]+\/?$/,
/^\/api\/files\/backups\/?$/, /^\/api\/files\/backups\/?$/,
/^\/api\/tus\/hooks\/?$/, /^\/api\/tus\/hooks\/?$/,
/^\/api\/events\/?$/, /^\/api\/events\/?$/,
/^\/api\/config\/?$/, /^\/api\/config\/?$/,
/^\/api\/google\/drive\/callback\/?$/, /^\/api\/google\/drive\/callback\/?$/,
]; ];
return routePatterns.some((pattern) => pattern.test(pathname)); return routePatterns.some((pattern) => pattern.test(pathname));
} }
export const config = { export const config = {
matcher: [ matcher: ["/api/:path*", "/dashboard/:path*", "/dashboard"],
"/api/:path*",
"/dashboard/:path*",
"/dashboard",
]
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

-95
View File
@@ -1,95 +0,0 @@
#!/bin/bash
set -e
if [ -z "$1" ]; then
echo "Usage: ./release <version>"
echo "Example: ./release v1.0.0"
exit 1
fi
VERSION=$1
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" = "main" ]; then
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: On 'main' branch, only release tags (X.Y.Z) are allowed."
exit 1
fi
else
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "Error: On branch '$CURRENT_BRANCH', only RC tags matching X.Y.Z-rc.W are allowed (e.g., 1.0.0-rc.1)."
exit 1
fi
fi
CLEAN_VERSION=${VERSION#v}
CURRENT_DATE=$(date +%Y-%m-%d)
echo "Preparing release $VERSION..."
# package.json
if [ -f package.json ]; then
echo "Updating package.json..."
if sed --version >/dev/null 2>&1; then
sed -i "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
else
sed -i '' "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
fi
fi
# pyproject.toml
if [ -f pyproject.toml ]; then
echo "Updating pyproject.toml..."
if sed --version >/dev/null 2>&1; then
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
else
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
fi
fi
# Cargo.toml
if [ -f Cargo.toml ]; then
echo "Updating Cargo.toml..."
if sed --version >/dev/null 2>&1; then
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
else
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
fi
fi
# CITATION.cff
if [ -f CITATION.cff ]; then
echo "Updating CITATION.cff..."
if sed --version >/dev/null 2>&1; then
sed -i "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
sed -i "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
else
sed -i '' "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
sed -i '' "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
fi
fi
git add .
if ! git diff-index --quiet HEAD --; then
echo "Committing changes..."
git commit -m "chore(release): $VERSION"
else
echo "No changes to commit. Proceeding to tag..."
fi
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "Tag $VERSION already exists. Aborting."
exit 1
fi
echo "Creating tag $VERSION..."
git tag -a "$VERSION" -m "Release $VERSION"
echo "Pushing changes and tags to remote..."
git push
git push origin "$VERSION"
echo "Successfully released $VERSION!"
+1
View File
@@ -0,0 +1 @@
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
-6
View File
@@ -1,6 +0,0 @@
import express from "express";
import router from "../src/features/api/router";
export function mountApi(app: express.Express) {
app.use("/services/v1", router);
}
-52
View File
@@ -1,52 +0,0 @@
import next from "next";
import express from "express";
import {mountApi} from "./api";
import http from "http";
const port = Number(process.env.PORT) || 8887;
const dev = process.env.NODE_ENV !== "production";
async function start() {
const nextApp = next({
dev,
hostname: "0.0.0.0",
turbopack: dev,
port
});
const handle = nextApp.getRequestHandler();
await nextApp.prepare();
const app = express();
app.use((req, res, next) => {
req.setTimeout(0);
res.setTimeout(0);
next();
});
mountApi(app);
app.use((req, res, next) => {
if (req.path.startsWith('/services/v1')) return next();
handle(req, res).catch((err) => {
console.error('Next.js App Router error:', err);
res.status(500).send('Internal Server Error');
});
});
const server = http.createServer(app);
server.setTimeout(0);
server.headersTimeout = 0;
server.requestTimeout = 0;
server.keepAliveTimeout = 0;
server.listen(port, "0.0.0.0", () => {
console.log(`NEXT APP → http://localhost:${port}`);
console.log(`API → http://localhost:${port}/services/v1`);
});
}
start();
+10 -4
View File
@@ -6,7 +6,7 @@ import {env} from "@/env.mjs";
interface EmailCreateUserProps { interface EmailCreateUserProps {
email: string; email: string;
password: string; password?: string;
} }
export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => { export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
@@ -23,9 +23,15 @@ export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
<strong>Email: </strong>{email} <strong>Email: </strong>{email}
</Text> </Text>
<Text className="text-[14px] text-black leading-[24px]"> {password ? (
<strong>Default password: </strong>{password} <Text className="text-[14px] text-black leading-[24px]">
</Text> <strong>Default password: </strong>{password}
</Text>
) : (
<Text className="text-[14px] text-black leading-[24px]">
You can log in using one of the single sign-on (SSO) providers configured by your administrator.
</Text>
)}
<Section className="mt-[32px] mb-[32px] text-center"> <Section className="mt-[32px] mb-[32px] text-center">
<Button <Button
+2
View File
@@ -208,12 +208,14 @@ type UseZodFormProps<Z extends ZodSchema> = Exclude<
schema: Z; schema: Z;
}; };
const useZodForm = <Z extends ZodSchema>({ const useZodForm = <Z extends ZodSchema>({
schema, schema,
...formProps ...formProps
}: UseZodFormProps<Z>) => }: UseZodFormProps<Z>) =>
useForm({ useForm({
...formProps, ...formProps,
// @ts-ignore
resolver: zodResolver(schema), resolver: zodResolver(schema),
}); });
@@ -1,121 +1,133 @@
"use client"; "use client";
import {useEffect, useState} from "react"; import { useMutation } from "@tanstack/react-query";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form";
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
import {signIn} from "@/lib/auth/auth-client";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import {PasswordInput} from "@/components/ui/password-input"; import { useEffect } from "react";
import { toast } from "sonner";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import {
LoginSchema,
type LoginType,
} from "@/components/wrappers/auth/login/login-form/login-form.schema";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { signIn } from "@/lib/auth/auth-client";
export type loginFormProps = { export type loginFormProps = {
defaultValues?: LoginType; defaultValues?: LoginType;
isPasskeyEnabled?: boolean;
}; };
export const LoginForm = (props: loginFormProps) => { export const LoginForm = (props: loginFormProps) => {
const { isPasskeyEnabled = false } = props;
const url = useSearchParams();
const [urlParams, setUrlParams] = useState<URLSearchParams>(); const form = useZodForm({
schema: LoginSchema,
});
useEffect(() => { const router = useRouter();
const urlParams = new URLSearchParams(window.location.search);
setUrlParams(urlParams);
const error = urlParams.get("error");
if (error?.includes("pending")) {
toast.error("Your account is not active.");
urlParams.delete("error");
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
}
if (error?.includes("invalid_or_expired_token")) {
toast.error("Password reset invalid token.");
urlParams.delete("error");
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
}
}, []);
const form = useZodForm({ const mutation = useMutation({
schema: LoginSchema, mutationFn: async (values: LoginType) => {
}); await signIn.email(
{
const router = useRouter(); password: values.password,
email: values.email,
const mutation = useMutation({ callbackURL: url?.get("redirect") ?? "/dashboard",
mutationFn: async (values: LoginType) => {
await signIn.email(
{
password: values.password,
email: values.email,
callbackURL: urlParams?.get("redirect") ?? "/dashboard",
},
{
onSuccess: (context) => {
if (context.data.twoFactorRedirect) {
//@ts-ignore
router.push("/guard?redirect=" + encodeURIComponent(context.data.callbackURL || "/dashboard"));
}
toast.success("Login success");
},
onError: (error) => {
toast.error(error.error.message);
},
}
);
}, },
}); {
onSuccess: (context) => {
if (context.data.twoFactorRedirect) {
router.push(
"/guard?redirect=" +
encodeURIComponent(context.data.callbackURL || "/dashboard"),
);
}
return ( toast.success("Login success");
<Form },
form={form} onError: (error) => {
className="flex flex-col gap-4 mb-1" toast.error(error.error.message);
onSubmit={async (values) => { },
await mutation.mutateAsync(values); },
}} );
> },
<FormField });
control={form.control}
name="email" return (
defaultValue="" <Form
render={({field}) => ( form={form}
<FormItem> className="flex flex-col gap-4 mb-1"
<FormLabel>Email Address</FormLabel> onSubmit={async (values) => {
<FormControl> await mutation.mutateAsync(values);
<Input autoComplete="email" autoFocus placeholder="example@portabase.io" {...field} /> }}
</FormControl> >
<FormMessage/> <FormField
</FormItem> control={form.control}
)} name="email"
/> defaultValue=""
<FormField render={({ field }) => (
control={form.control} <FormItem>
name="password" <FormLabel>Email Address</FormLabel>
defaultValue="" <FormControl>
render={({field}) => ( <Input
<FormItem> autoComplete="email"
<div className="flex items-center justify-between"> autoFocus
<FormLabel>Password</FormLabel> placeholder="example@portabase.io"
<div className="text-center text-sm"> {...field}
<Link href={"/forgot-password"} className="hover:underline ml-1"> />
Forgot your password ? </FormControl>
</Link> <FormMessage />
</div> </FormItem>
</div> )}
<FormControl> />
<PasswordInput autoComplete="current-password webauthn" <FormField
placeholder={"Enter your password"} {...field} /> control={form.control}
</FormControl> name="password"
<FormMessage/> defaultValue=""
</FormItem> render={({ field }) => (
)} <FormItem>
/> <div className="flex items-center justify-between">
<ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}> <FormLabel>Password</FormLabel>
Login <div className="text-center text-sm">
</ButtonWithLoading> <Link
</Form> href={"/forgot-password"}
); className="hover:underline ml-1"
>
Forgot your password ?
</Link>
</div>
</div>
<FormControl>
<PasswordInput
autoComplete={
isPasskeyEnabled
? "current-password webauthn"
: "current-password"
}
placeholder={"Enter your password"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
Login
</ButtonWithLoading>
</Form>
);
}; };
@@ -1,156 +1,169 @@
"use client"; "use client";
import {useRouter} from "next/navigation"; import { useRouter } from "next/navigation";
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 { CardContent, CardHeader } from "@/components/ui/card";
import {Form} from "@/components/ui/form"; import { Form } from "@/components/ui/form";
import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip"; import {
import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema"; TooltipProvider,
import {signUp} from "@/lib/auth/auth-client"; TooltipTrigger,
import {useZodForm} from "@/components/ui/form"; Tooltip,
import {CardAuth} from "@/features/layout/card-auth"; TooltipContent,
import {FormField} from "@/components/ui/form"; } from "@/components/ui/tooltip";
import {FormItem} from "@/components/ui/form"; import {
import {FormLabel} from "@/components/ui/form"; RegisterSchema,
import {FormControl} from "@/components/ui/form"; RegisterType,
import {FormMessage} from "@/components/ui/form"; } from "@/components/wrappers/auth/register/register-form/register-form.schema";
import {Input} from "@/components/ui/input"; import { signUp } from "@/lib/auth/auth-client";
import {PasswordInput} from "@/components/ui/password-input"; import { useZodForm } from "@/components/ui/form";
import {Button} from "@/components/ui/button"; 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 Link from "next/link";
import {Info} from "lucide-react"; import { Info } from "lucide-react";
export type registerFormProps = { export type registerFormProps = {
defaultValues?: RegisterType; defaultValues?: RegisterType;
}; };
export const RegisterForm = (props: registerFormProps) => { export const RegisterForm = (props: registerFormProps) => {
const form = useZodForm({
schema: RegisterSchema,
});
const form = useZodForm({ const router = useRouter();
schema: RegisterSchema, const mutation = useMutation({
}); mutationFn: async (values: RegisterType) => {
await signUp.email(values, {
const router = useRouter(); onSuccess: () => {
const mutation = useMutation({ toast.success(`Account successfully created`);
mutationFn: async (values: RegisterType) => { router.refresh();
// @ts-ignore router.push(`/login`);
await signUp.email(values, {
onSuccess: () => {
toast.success(`Account successfully created`);
router.refresh();
router.push(`/login`);
},
onError: (error) => {
toast.error(error.error.message);
},
});
}, },
}); onError: (error) => {
toast.error(error.error.message);
},
});
},
});
return (
return ( <TooltipProvider>
<TooltipProvider> <CardAuth>
<CardAuth> <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">
<p className="text-balance text-muted-foreground">Enter your informations below to register</p> Enter your informations below to register
</div> </p>
</CardHeader> </div>
<CardContent> </CardHeader>
<Form <CardContent>
form={form} <Form
className="flex flex-col gap-4" form={form}
onSubmit={async (values) => { className="flex flex-col gap-4"
await mutation.mutateAsync(values); onSubmit={async (values) => {
}} await mutation.mutateAsync(values);
> }}
<FormField >
control={form.control} <FormField
name="name" control={form.control}
defaultValue="" name="name"
render={({field}) => ( defaultValue=""
<FormItem> render={({ field }) => (
<FormLabel>Name</FormLabel> <FormItem>
<FormControl> <FormLabel>Name</FormLabel>
<Input placeholder="Your name" {...field} /> <FormControl>
</FormControl> <Input placeholder="Your name" {...field} />
<FormMessage/> </FormControl>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
<FormField />
control={form.control} <FormField
name="email" control={form.control}
defaultValue="" name="email"
render={({field}) => ( defaultValue=""
<FormItem> render={({ field }) => (
<FormLabel>Email</FormLabel> <FormItem>
<FormControl> <FormLabel>Email</FormLabel>
<Input placeholder="example@portabase.io" {...field} /> <FormControl>
</FormControl> <Input placeholder="example@portabase.io" {...field} />
<FormMessage/> </FormControl>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
<FormField />
control={form.control} <FormField
name="password" control={form.control}
defaultValue="" name="password"
render={({field}) => ( defaultValue=""
<FormItem> render={({ field }) => (
<FormLabel className="flex"> <FormItem>
Password <FormLabel className="flex">
<TooltipProvider> Password
<Tooltip> <TooltipProvider>
<TooltipTrigger asChild> <Tooltip>
<Info className="ml-3" size="15"/> <TooltipTrigger asChild>
</TooltipTrigger> <Info size="15" />
<TooltipContent> </TooltipTrigger>
<p> <TooltipContent>
Min. 8 characters, 1 uppercase (A-Z), 1 lowercase (a-z), 1 <p>
number (0-9), 1 special character (!, @, Min. 8 characters, 1 uppercase (A-Z), 1 lowercase
etc.) (a-z), 1 number (0-9), 1 special character (!, @,
</p> etc.)
</TooltipContent> </p>
</Tooltip> </TooltipContent>
</TooltipProvider> </Tooltip>
</FormLabel> </TooltipProvider>
<FormControl> </FormLabel>
<PasswordInput placeholder="Your password" {...field} /> <FormControl>
</FormControl> <PasswordInput placeholder="Your password" {...field} />
<FormMessage/> </FormControl>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
<FormField />
control={form.control} <FormField
name="confirmPassword" control={form.control}
defaultValue="" name="confirmPassword"
render={({field}) => ( defaultValue=""
<FormItem> render={({ field }) => (
<FormLabel>Password Confirmation</FormLabel> <FormItem>
<FormControl> <FormLabel>Password Confirmation</FormLabel>
<PasswordInput placeholder="Conform your password" {...field} /> <FormControl>
</FormControl> <PasswordInput
<FormMessage/> placeholder="Conform your password"
</FormItem> {...field}
)} />
/> </FormControl>
<Button type="submit" className="h-11" disabled={mutation.isPending}> <FormMessage />
Sign up </FormItem>
</Button> )}
<div className="mt-4 text-center text-sm"> />
Already have an account ?{" "} <Button
<Link href="/login" className="underline"> type="submit"
Sign in className="h-11"
</Link> disabled={mutation.isPending}
</div> >
Sign up
</Form> </Button>
</CardContent> <div className="mt-4 text-center text-sm">
</CardAuth> Already have an account ?{" "}
</TooltipProvider> <Link href="/login" className="underline">
); Sign in
</Link>
</div>
</Form>
</CardContent>
</CardAuth>
</TooltipProvider>
);
}; };
+54 -35
View File
@@ -1,29 +1,53 @@
"use client"; "use client";
import React, { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/auth-client"; import { authClient, passkey } from "@/lib/auth/auth-client";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import {AuthProviderConfig} from "../../../../portabase.config"; import { Icon } from "@iconify/react";
import {Icon} from "@iconify/react"; import { AuthProviderConfig } from "@/lib/auth/config";
import Image from "next/image";
import { useRouter } from "next/navigation";
export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) { export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) {
const socialProviders = providers.filter(p => p.isActive && !p.isManual); const socialProviders = providers.filter((p) => p.isActive && p.type !== "credential");
const router = useRouter();
const [isLoading, setIsLoading] = useState<string | null>(null); const [isLoading, setIsLoading] = useState<string | null>(null);
const handleSocialSignIn = async (providerId: string) => { const handleSocialSignIn = async (provider: AuthProviderConfig) => {
setIsLoading(providerId); setIsLoading(provider.id);
try { try {
const { error } = await authClient.signIn.social({ let result;
provider: providerId as "google" | "github", if (provider.id === "passkey") {
callbackURL: "/dashboard", result = await authClient.signIn.passkey({
}); fetchOptions: {
onSuccess() {
if (error) { router.push("/dashboard");
toast.error("An error occurred while signing in with the provider. Please try again."); },
onError() {
toast.error("An error occurred during passkey authentication. Please try again.");
},
},
});
} else if (provider.type === "sso") {
result = await authClient.signIn.sso({
providerId: provider.id,
providerType: "oidc",
callbackURL: "/dashboard",
});
} else { } else {
result = await authClient.signIn.social({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: provider.id as any,
callbackURL: "/dashboard",
});
}
if (result?.error) {
toast.error("An error occurred while signing in with the provider. Please try again.");
} else if (provider.id !== "passkey") {
toast.success("Redirecting to provider..."); toast.success("Redirecting to provider...");
} }
} catch (err) { } catch (err) {
@@ -38,29 +62,24 @@ export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig
return ( return (
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
{socialProviders.map((provider) => ( {socialProviders.map((provider) => (
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider.id)} disabled={!!isLoading}> <Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider)} disabled={!!isLoading}>
{isLoading === provider.id ? <Loader2 className="h-4 w-4 animate-spin" /> : {isLoading === provider.id ? (
<Icon icon={provider.icon} className="h-4 w-4"/> <Loader2 className="h-4 w-4 animate-spin" />
} ) : provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
<span>{PROVIDERS_TEXT[provider.id].title}</span> <Image
src={provider.icon}
alt={provider.id || "icon"}
width={16}
height={16}
className="h-4 w-4"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="h-4 w-4" />
)}
<span>{provider.title || provider.name}</span>
</Button> </Button>
))} ))}
</div> </div>
); );
} }
const PROVIDERS_TEXT = {
credential: {
title: "Password",
description: "Use your email address and password to sign in."
},
google: {
title: "Google",
description: "Sign in with your Google account."
},
github: {
title: "GitHub",
description: "Sign in with your GitHub account."
}
}
@@ -1,161 +1,198 @@
"use client" "use client";
import {Button, ButtonVariantsProps} from "@/components/ui/button"; import { Button, ButtonVariantsProps } from "@/components/ui/button";
import {ReactNode, useState} from "react"; import { ReactNode, useState } from "react";
import {Loader2} from "lucide-react"; import { Loader2 } from "lucide-react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"; import {
import {cn} from "@/lib/utils"; Popover,
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export type ButtonWithConfirmProps = { export type ButtonWithConfirmProps = {
title: string; title: string;
description: string; description: string;
button?: { button?: {
main: { main: {
className?: string; className?: string;
type?: "button" | "submit" | "reset" | undefined; type?: "button" | "submit" | "reset" | undefined;
text?: string; text?: string;
icon?: any; icon?: any;
variant?: ButtonVariantsProps["variant"]; variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"]; size?: ButtonVariantsProps["size"];
disabled?: boolean; disabled?: boolean;
tooltipText?: string; tooltipText?: string;
};
confirm: {
className?: string;
text: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
onClick?: () => void;
};
cancel: {
className?: string;
text: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
onClick?: () => void;
};
}; };
children?: ReactNode; confirm: {
onConfirm?: (e: React.MouseEvent) => void; className?: string;
onCancel?: (e: React.MouseEvent) => void; text: string;
confirmButtonText?: string; icon?: any;
cancelButtonText?: string; variant?: ButtonVariantsProps["variant"];
isPending?: boolean; size?: ButtonVariantsProps["size"];
onClick?: () => void;
};
cancel: {
className?: string;
text: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
onClick?: () => void;
};
};
children?: ReactNode;
onConfirm?: (e: React.MouseEvent) => void;
onCancel?: (e: React.MouseEvent) => void;
confirmButtonText?: string;
cancelButtonText?: string;
isPending?: boolean;
}; };
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => { export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
const [isConfirming, setIsConfirming] = useState(false); const [isConfirming, setIsConfirming] = useState(false);
const isLegacy = !!props.button; const isLegacy = !!props.button;
const isDisabled = isLegacy ? !!props.button?.main.disabled : false; const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
const handleConfirm = (e: React.MouseEvent) => { const handleConfirm = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isLegacy) {
props.button?.confirm.onClick?.();
} else {
props.onConfirm?.(e);
}
setIsConfirming(false);
};
const handleCancel = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isLegacy) {
props.button?.cancel.onClick?.();
} else {
props.onCancel?.(e);
}
setIsConfirming(false);
};
const triggerContent = isLegacy ? (
<Button
type={props.button?.main.type}
disabled={isDisabled}
variant={props.button?.main.variant ?? "default"}
size={props.button?.main.size ?? "default"}
className={props.button?.main.className}
onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (isLegacy) { if (!isDisabled) setIsConfirming(true);
props.button?.confirm.onClick?.(); }}
} else { >
props.onConfirm?.(e); {props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
} {props.button?.main.icon}
setIsConfirming(false); {props.button?.main.text && <span>{props.button.main.text}</span>}
}; </Button>
) : (
const handleCancel = (e: React.MouseEvent) => { <div
onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (isLegacy) { setIsConfirming(true);
props.button?.cancel.onClick?.(); }}
} else { >
props.onCancel?.(e); {props.children}
} </div>
setIsConfirming(false); );
};
const triggerContent = isLegacy ? ( const withTooltip =
<Button isLegacy && props.button?.main.tooltipText && isDisabled ? (
type={props.button?.main.type} <TooltipProvider>
disabled={isDisabled} <Tooltip>
variant={props.button?.main.variant ?? "default"} <TooltipTrigger asChild>
size={props.button?.main.size ?? "default"} <div>{triggerContent}</div>
className={props.button?.main.className} </TooltipTrigger>
onClick={(e) => { <TooltipContent>
e.preventDefault(); <p>{props.button?.main.tooltipText}</p>
e.stopPropagation(); </TooltipContent>
if (!isDisabled) setIsConfirming(true); </Tooltip>
}} </TooltipProvider>
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button?.main.icon}
{props.button?.main.text && <span>{props.button.main.text}</span>}
</Button>
) : ( ) : (
<div onClick={(e) => { triggerContent
e.preventDefault();
e.stopPropagation();
setIsConfirming(true);
}}>
{props.children}
</div>
); );
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? ( return (
<TooltipProvider> <Popover open={isConfirming} onOpenChange={setIsConfirming}>
<Tooltip> <PopoverTrigger asChild>{withTooltip}</PopoverTrigger>
<TooltipTrigger asChild> <PopoverContent
<div>{triggerContent}</div> className="w-80"
</TooltipTrigger> onPointerDownOutside={(e) => e.preventDefault()}
<TooltipContent> onInteractOutside={(e) => e.preventDefault()}
<p>{props.button?.main.tooltipText}</p> onClick={(e) => e.stopPropagation()}
</TooltipContent> onMouseMove={(e) => e.stopPropagation()}
</Tooltip> onMouseEnter={(e) => e.stopPropagation()}
</TooltipProvider> onMouseLeave={(e) => e.stopPropagation()}
) : triggerContent; onOpenAutoFocus={(e) => e.preventDefault()}
>
return ( <div className="grid gap-4">
<Popover open={isConfirming} onOpenChange={setIsConfirming}> <div className="space-y-2">
<PopoverTrigger asChild> <h4 className="font-medium leading-none">{props.title}</h4>
{withTooltip} <p className="text-sm text-muted-foreground">{props.description}</p>
</PopoverTrigger> </div>
<PopoverContent <div className="grid gap-2">
className="w-80" <Button
onPointerDownOutside={(e) => e.preventDefault()} onClick={handleConfirm}
onInteractOutside={(e) => e.preventDefault()} variant={
onClick={(e) => e.stopPropagation()} isLegacy
onMouseMove={(e) => e.stopPropagation()} ? (props.button?.confirm.variant ?? "default")
onMouseEnter={(e) => e.stopPropagation()} : "default"
onMouseLeave={(e) => e.stopPropagation()} }
onOpenAutoFocus={(e) => e.preventDefault()} size={
isLegacy ? (props.button?.confirm.size ?? "default") : "default"
}
className={cn(
isLegacy ? props.button?.confirm.className : "",
"w-full",
)}
> >
<div className="grid gap-4"> {props.isPending && (
<div className="space-y-2"> <Loader2 className="animate-spin mr-4" size={16} />
<h4 className="font-medium leading-none">{props.title}</h4> )}
<p className="text-sm text-muted-foreground">{props.description}</p> {isLegacy && props.button?.confirm.icon}
</div> <span>
<div className="grid gap-2"> {isLegacy
<Button ? props.button?.confirm.text
onClick={handleConfirm} : (props.confirmButtonText ?? "Confirm")}
variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"} </span>
size={isLegacy ? (props.button?.main.size ?? "default") : "default"} </Button>
className={cn(isLegacy ? props.button?.main.className : "", "w-full")} <Button
> variant={
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>} isLegacy
{isLegacy && props.button?.confirm.icon} ? (props.button?.cancel.variant ?? "outline")
<span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</span> : "outline"
</Button> }
<Button onClick={handleCancel}
variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"} className={cn(
onClick={handleCancel} isLegacy ? props.button?.cancel.className : "",
className={cn(isLegacy ? props.button?.main.className : "", "w-full")} "w-full",
size={isLegacy ? (props.button?.main.size ?? "default") : "default"} )}
> size={
{isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")} isLegacy ? (props.button?.cancel.size ?? "default") : "default"
</Button> }
</div> >
</div> {isLegacy
</PopoverContent> ? (props.button?.cancel.text ?? "Cancel")
</Popover> : (props.cancelButtonText ?? "Cancel")}
); </Button>
}; </div>
</div>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,94 @@
"use client";
import { useSearchParams } from "next/navigation";
import type React from "react";
import { useEffect } from "react";
import { toast } from "sonner";
export const ErrorLayout = ({ children }: { children: React.ReactNode }) => {
const url = useSearchParams();
useEffect(() => {
const error = url.get("error");
if (!error) return;
const errorMessages: Record<string, string> = {
pending: "Your account is not active.",
invalid_or_expired_token: "Password reset invalid token.",
account_already_linked_to_different_user:
"An account already exists with the same email address but different sign-in credentials. Sign in using a provider associated with this email address.",
USER_NOT_FOUND: "User not found",
FAILED_TO_CREATE_USER: "Failed to create user",
FAILED_TO_CREATE_SESSION: "Failed to create session",
FAILED_TO_UPDATE_USER: "Failed to update user",
FAILED_TO_GET_SESSION: "Failed to get session",
INVALID_PASSWORD: "Invalid password",
INVALID_EMAIL: "Invalid email",
INVALID_EMAIL_OR_PASSWORD: "Invalid email or password",
INVALID_USER: "Invalid user",
SOCIAL_ACCOUNT_ALREADY_LINKED: "Social account already linked",
PROVIDER_NOT_FOUND: "Provider not found",
INVALID_TOKEN: "Invalid token",
TOKEN_EXPIRED: "Token expired",
ID_TOKEN_NOT_SUPPORTED: "id_token not supported",
FAILED_TO_GET_USER_INFO: "Failed to get user info",
USER_EMAIL_NOT_FOUND: "User email not found",
EMAIL_NOT_VERIFIED: "Email not verified",
PASSWORD_TOO_SHORT: "Password too short",
PASSWORD_TOO_LONG: "Password too long",
USER_ALREADY_EXISTS: "User already exists.",
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL:
"User already exists. Use another email.",
EMAIL_CAN_NOT_BE_UPDATED: "Email can not be updated",
CREDENTIAL_ACCOUNT_NOT_FOUND: "Credential account not found",
SESSION_EXPIRED:
"Session expired. Re-authenticate to perform this action.",
FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
ACCOUNT_NOT_FOUND: "Account not found",
USER_ALREADY_HAS_PASSWORD:
"User already has a password. Provide that to delete the account.",
CROSS_SITE_NAVIGATION_LOGIN_BLOCKED:
"Cross-site navigation login blocked. This request appears to be a CSRF attack.",
VERIFICATION_EMAIL_NOT_ENABLED: "Verification email isn't enabled",
EMAIL_ALREADY_VERIFIED: "Email is already verified",
EMAIL_MISMATCH: "Email mismatch",
SESSION_NOT_FRESH: "Session is not fresh",
LINKED_ACCOUNT_ALREADY_EXISTS: "Linked account already exists",
INVALID_ORIGIN: "Invalid origin",
INVALID_CALLBACK_URL: "Invalid callbackURL",
INVALID_REDIRECT_URL: "Invalid redirectURL",
INVALID_ERROR_CALLBACK_URL: "Invalid errorCallbackURL",
INVALID_NEW_USER_CALLBACK_URL: "Invalid newUserCallbackURL",
MISSING_OR_NULL_ORIGIN: "Missing or null Origin",
CALLBACK_URL_REQUIRED: "callbackURL is required",
FAILED_TO_CREATE_VERIFICATION: "Unable to create verification",
FIELD_NOT_ALLOWED: "Field not allowed to be set",
ASYNC_VALIDATION_NOT_SUPPORTED: "Async validation is not supported",
VALIDATION_ERROR: "Validation Error",
MISSING_FIELD: "Field is required",
METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED:
"POST method requires deferSessionRefresh to be enabled in session config",
BODY_MUST_BE_AN_OBJECT: "Body must be an object",
PASSWORD_ALREADY_SET: "User already has a password set",
Sign_up_is_disabled: "Sign up is disabled by the administrator",
};
const match = Object.entries(errorMessages).find(([key]) =>
error.includes(key),
);
if (!match) return;
toast.error(match[1]);
const params = new URLSearchParams(url.toString());
params.delete("error");
const newUrl =
window.location.pathname +
(params.toString() ? `?${params.toString()}` : "");
window.history.replaceState({}, document.title, newUrl);
}, [url]);
return <>{children}</>;
};
@@ -1,26 +1,69 @@
import {z} from "zod"; import {z} from "zod";
import {SlackChannelConfigSchema} from "./providers/notifications/forms/slack.schema";
import {SmtpChannelConfigSchema} from "./providers/notifications/forms/smtp.schema";
import {DiscordChannelConfigSchema} from "./providers/notifications/forms/discord.schema";
import {TelegramChannelConfigSchema} from "./providers/notifications/forms/telegram.schema";
import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema";
import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema";
import {WebhookChannelConfigSchema} from "./providers/notifications/forms/webhook.schema";
import {S3ChannelConfigSchema} from "./providers/storages/forms/s3.schema";
import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema";
import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema";
export const ChannelFormSchema = z.object({ const BaseChannelFormSchema = z.object({
name: z name: z
.string() .string()
.min(5, "Name must be at least 5 characters long") .min(5, "Name must be at least 5 characters long")
.max(40, "Name must be at most 40 characters long"), .max(40, "Name must be at most 40 characters long"),
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
}); });
export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
BaseChannelFormSchema.extend({
provider: z.literal("slack"),
config: SlackChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("smtp"),
config: SmtpChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("discord"),
config: DiscordChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("telegram"),
config: TelegramChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("gotify"),
config: GotifyChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("ntfy"),
config: NtfyChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("webhook"),
config: WebhookChannelConfigSchema,
}),
]);
export const NotificationChannelFormSchema = ChannelFormSchema.extend({ export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
provider: z.enum( BaseChannelFormSchema.extend({
["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"], provider: z.literal("s3"),
{required_error: "Provider is required"} config: S3ChannelConfigSchema,
), }),
}); BaseChannelFormSchema.extend({
provider: z.literal("google-drive"),
export const StorageChannelFormSchema = ChannelFormSchema.extend({ config: GoogleDriveChannelConfigSchema,
provider: z.enum(["local", "s3", "google-drive"], {required_error: "Provider is required"}), }),
}); BaseChannelFormSchema.extend({
provider: z.literal("local"),
config: LocalChannelConfigSchema
})
]);
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>; export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
@@ -224,7 +224,7 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
<div className="flex justify-between mt-4"> <div className="flex justify-between mt-4">
<div> <div>
{kind == "storage" && ( {(!isCreate || kind == "storage") && (
<ChannelTestButton <ChannelTestButton
kind={kind} kind={kind}
organizationId={organization?.id} organizationId={organization?.id}
@@ -34,7 +34,7 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
message: `We are testing channel ${channel.name}`, message: `We are testing channel ${channel.name}`,
level: 'info', level: 'info',
}; };
const result = await dispatchNotification(payload, undefined, channel, organizationId); const result = await dispatchNotification(payload, undefined, channel.id, organizationId);
if (result.success) { if (result.success) {
toast.success(result.message); toast.success(result.message);
@@ -51,7 +51,8 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
toast.success("Successfully connected to storage channel"); toast.success("Successfully connected to storage channel");
} else { } else {
console.error(result); console.error(result);
toast.error("An error occurred while testing the storage channel, check your configuration"); const responseText = result.response ? `(${result.response})` : "";
toast.error(`An error occurred while testing the storage channel, check your configuration ${responseText}`);
} }
} else { } else {
toast.error("Not yet supported"); toast.error("Not yet supported");
@@ -1,30 +1,38 @@
import {UseFormReturn} from "react-hook-form"; import { UseFormReturn } from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; import {
import {Input} from "@/components/ui/input"; FormControl,
import {Separator} from "@/components/ui/separator"; FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierDiscordFormProps = { type NotifierDiscordFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>;
} };
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => { export const NotifierDiscordForm = ({ form }: NotifierDiscordFormProps) => {
return ( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1" />
<FormField <FormField
control={form.control} control={form.control}
name="config.discordWebhook" name="config.discordWebhook"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Discord Webhook URL</FormLabel> <FormLabel>Discord Webhook URL</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/> <PasswordInput
</FormControl> {...field}
<FormMessage/> placeholder="https://discord.com/api/webhooks/..."
</FormItem> />
)} </FormControl>
/> <FormMessage />
</> </FormItem>
) )}
} />
</>
);
};
@@ -0,0 +1,5 @@
import {z} from "zod";
export const DiscordChannelConfigSchema = z.object({
discordWebhook: z.string().url("Must be a valid URL"),
});
@@ -1,43 +1,49 @@
import {UseFormReturn} from "react-hook-form"; import type { UseFormReturn } from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; import {
import {Input} from "@/components/ui/input"; FormControl,
import {Separator} from "@/components/ui/separator"; FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierGotifyFormProps = { type NotifierGotifyFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>;
} };
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => { export const NotifierGotifyForm = ({ form }: NotifierGotifyFormProps) => {
return ( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1" />
<FormField <FormField
control={form.control} control={form.control}
name="config.gotifyServerUrl" name="config.gotifyServerUrl"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Gotify Server URL</FormLabel> <FormLabel>Gotify Server URL</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="https://gotify.example.com"/> <Input {...field} placeholder="https://gotify.example.com" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.gotifyAppToken" name="config.gotifyAppToken"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Application Token</FormLabel> <FormLabel>Application Token</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/> <PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</> </>
) );
} };
@@ -0,0 +1,6 @@
import {z} from "zod";
export const GotifyChannelConfigSchema = z.object({
gotifyServerUrl: z.string().url("Must be a valid URL"),
gotifyAppToken: z.string().min(1, "App token is required"),
});
@@ -2,6 +2,7 @@ import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator"; import {Separator} from "@/components/ui/separator";
import {PasswordInput} from "@/components/ui/password-input";
type NotifierNtfyFormProps = { type NotifierNtfyFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>
@@ -48,7 +49,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
<FormItem> <FormItem>
<FormLabel>Access Token (Optional)</FormLabel> <FormLabel>Access Token (Optional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="tk_..."/> <PasswordInput {...field} placeholder="tk_..."/>
</FormControl> </FormControl>
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p> <p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
<FormMessage/> <FormMessage/>
@@ -79,7 +80,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
<FormItem> <FormItem>
<FormLabel>Basic Auth Password (Optional)</FormLabel> <FormLabel>Basic Auth Password (Optional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="password" placeholder="password"/> <PasswordInput placeholder="password" {...field} />
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
</FormItem> </FormItem>
@@ -0,0 +1,9 @@
import {z} from "zod";
export const NtfyChannelConfigSchema = z.object({
ntfyTopic: z.string().min(1, "Topic is required"),
ntfyServerUrl: z.string().url("Must be a valid URL").optional().or(z.literal('')),
ntfyToken: z.string().optional(),
ntfyUsername: z.string().optional(),
ntfyPassword: z.string().optional(),
});
@@ -1,31 +1,38 @@
import { UseFormReturn } from "react-hook-form";
import {UseFormReturn} from "react-hook-form"; import {
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; FormControl,
import {Input} from "@/components/ui/input"; FormField,
import {Separator} from "@/components/ui/separator"; FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierSmtpFormProps = { type NotifierSmtpFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>;
} };
export const NotifierSlackForm = ({form}: NotifierSmtpFormProps) => { export const NotifierSlackForm = ({ form }: NotifierSmtpFormProps) => {
return( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1" />
<FormField <FormField
control={form.control} control={form.control}
name="config.slackWebhook" name="config.slackWebhook"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Slack Webhook URL</FormLabel> <FormLabel>Slack Webhook URL</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="https://hooks.slack.com/services/..."/> <PasswordInput
</FormControl> {...field}
<FormMessage/> placeholder="https://hooks.slack.com/services/..."
</FormItem> />
)} </FormControl>
/> <FormMessage />
</> </FormItem>
) )}
} />
</>
);
};
@@ -0,0 +1,5 @@
import {z} from "zod";
export const SlackChannelConfigSchema = z.object({
slackWebhook: z.string().url("Must be a valid URL"),
});
@@ -0,0 +1,10 @@
import {z} from "zod";
export const SmtpChannelConfigSchema = z.object({
host: z.string().min(1, "Host is required"),
port: z.coerce.number().min(1, "Port is required"),
user: z.string().min(1, "User is required"),
password: z.string().min(1, "Password is required"),
from: z.string().min(1, "From is required"),
to: z.string().min(1, "To is required"),
});
@@ -1,48 +1,84 @@
import {UseFormReturn} from "react-hook-form"; import { UseFormReturn } from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; import {
import {Input} from "@/components/ui/input"; FormControl,
import {Separator} from "@/components/ui/separator"; FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierTelegramFormProps = { type NotifierTelegramFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>;
} };
export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => { export const NotifierTelegramForm = ({ form }: NotifierTelegramFormProps) => {
return ( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1" />
<FormField <FormField
control={form.control} control={form.control}
name="config.telegramBotToken" name="config.telegramBotToken"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Telegram Bot Token</FormLabel> <FormLabel>Telegram Bot Token</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"/> <PasswordInput
</FormControl> {...field}
<FormMessage/> placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
</FormItem> />
)} </FormControl>
/> <FormMessage />
<FormField </FormItem>
control={form.control} )}
name="config.telegramChatId" />
render={({field}) => ( <FormField
<FormItem> control={form.control}
<FormLabel>Telegram Chat ID</FormLabel> name="config.telegramChatId"
<FormControl> render={({ field }) => (
<Input {...field} placeholder="-100123456789 or 123456789"/> <FormItem>
</FormControl> <FormLabel>Telegram Chat ID</FormLabel>
<p className="text-xs text-muted-foreground"> <FormControl>
You must start the conversation with the bot first (<strong>/start</strong>). <br/> <Input {...field} placeholder="-100123456789 or 123456789" />
For groups, add the bot to the group. <br/> </FormControl>
You can use <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="underline">@userinfobot</a> to find your ID. <p className="text-xs text-muted-foreground">
</p> You must start the conversation with the bot first (
<FormMessage/> <strong>/start</strong>). <br />
</FormItem> For groups, add the bot to the group. <br />
)} You can use{" "}
/> <a
</> href="https://t.me/userinfobot"
) target="_blank"
} rel="noopener noreferrer"
className="underline"
>
@userinfobot
</a>{" "}
to find your ID.
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.telegramTopicId"
render={({ field }) => (
<FormItem>
<FormLabel>Telegram Topic ID</FormLabel>
<FormControl>
<Input {...field} placeholder="123456" />
</FormControl>
<p className="text-xs text-muted-foreground">
Unique identifier for the target message thread (topic) of the
forum; for forum supergroups only
</p>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,20 @@
import { z } from "zod";
export const TelegramChannelConfigSchema = z
.object({
telegramBotToken: z.string().min(1, "Bot token is required"),
telegramChatId: z.string().min(1, "Chat ID is required"),
telegramTopicId: z.string().optional(),
})
.refine(
(data) => {
if (data.telegramTopicId && !data.telegramChatId) {
return false;
}
return true;
},
{
message: "Chat ID is required when a Topic ID is provided",
path: ["telegramChatId"],
},
);
@@ -1,65 +1,72 @@
import {UseFormReturn} from "react-hook-form"; import { UseFormReturn } from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; import {
import {Input} from "@/components/ui/input"; FormControl,
import {Separator} from "@/components/ui/separator"; FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierWebhookFormProps = { type NotifierWebhookFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>;
} };
export const NotifierWebhookForm = ({form}: NotifierWebhookFormProps) => { export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
return ( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1" />
<FormField <FormField
control={form.control} control={form.control}
name="config.webhookUrl" name="config.webhookUrl"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Webhook URL</FormLabel> <FormLabel>Webhook URL</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="https://example.com/api/webhook"/> <Input {...field} placeholder="https://example.com/api/webhook" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex-1"> <div className="flex-1">
<FormField <FormField
control={form.control} control={form.control}
name="config.webhookSecretHeader" name="config.webhookSecretHeader"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Header Name (Optional)</FormLabel> <FormLabel>Header Name (Optional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="X-Webhook-Secret"/> <Input {...field} placeholder="X-Webhook-Secret" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<FormField <FormField
control={form.control} control={form.control}
name="config.webhookSecret" name="config.webhookSecret"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Secret Value (Optional)</FormLabel> <FormLabel>Secret Value (Optional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="Secret value..."/> <PasswordInput {...field} placeholder="Secret value..." />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
If provided, the secret will be sent in the specified header (defaults to <code>X-Webhook-Secret</code>). If provided, the secret will be sent in the specified header (defaults
</p> to <code>X-Webhook-Secret</code>).
</> </p>
) </>
} );
};
@@ -0,0 +1,7 @@
import {z} from "zod";
export const WebhookChannelConfigSchema = z.object({
webhookUrl: z.string().url("Must be a valid URL"),
webhookSecretHeader: z.string().optional(),
webhookSecret: z.string().optional(),
});
@@ -0,0 +1,8 @@
import {z} from "zod";
export const GoogleDriveChannelConfigSchema = z.object({
clientId: z.string().min(1, "Client ID is required"),
clientSecret: z.string().min(1, "Client Secret is required"),
folderId: z.string().min(1, "Folder ID is required"),
refreshToken: z.string().optional(),
});
@@ -0,0 +1,3 @@
import {z} from "zod";
export const LocalChannelConfigSchema = z.object({});
@@ -1,115 +1,116 @@
import {UseFormReturn} from "react-hook-form"; import { UseFormReturn } from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; import {
import {Input} from "@/components/ui/input"; FormControl,
import {Separator} from "@/components/ui/separator"; FormField,
import {PasswordInput} from "@/components/ui/password-input"; FormItem,
import {Switch} from "@/components/ui/switch"; FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
type StorageS3FormProps = { type StorageS3FormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>;
} };
export const StorageS3Form = ({form}: StorageS3FormProps) => { export const StorageS3Form = ({ form }: StorageS3FormProps) => {
return ( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1" />
<FormField <FormField
control={form.control} control={form.control}
name="config.endPointUrl" name="config.endPointUrl"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Endpoint URL</FormLabel> <FormLabel>Endpoint URL</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="s3.amazonaws.com"/> <Input {...field} placeholder="s3.amazonaws.com" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.region" name="config.region"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Region</FormLabel> <FormLabel>Region</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="us-east-1"/> <Input {...field} placeholder="us-east-1" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.accessKey" name="config.accessKey"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Access Key</FormLabel> <FormLabel>Access Key</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/> <Input {...field} placeholder="A1b2C3d4E5f6G7h" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.secretKey" name="config.secretKey"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Secret Key</FormLabel> <FormLabel>Secret Key</FormLabel>
<FormControl> <FormControl>
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h"/> <PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.bucketName" name="config.bucketName"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Bucket name</FormLabel> <FormLabel>Bucket name</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="portabase-dev"/> <Input {...field} placeholder="portabase-dev" />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.port" name="config.port"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Port</FormLabel> <FormLabel>Port</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="number" placeholder="443" /> <Input {...field} type="number" placeholder="443" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.ssl" name="config.ssl"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Use SSL</FormLabel> <FormLabel>Use SSL</FormLabel>
<FormControl> <FormControl>
<Switch <Switch checked={field.value} onCheckedChange={field.onChange} />
checked={field.value} </FormControl>
onCheckedChange={field.onChange} <FormMessage />
/> </FormItem>
</FormControl> )}
<FormMessage /> />
</FormItem> </>
)} );
/> };
</>
)
}
@@ -0,0 +1,11 @@
import {z} from "zod";
export const S3ChannelConfigSchema = z.object({
endPointUrl: z.string().min(1, "Endpoint URL is required"),
region: z.string().optional(),
accessKey: z.string().min(1, "Access Key is required"),
secretKey: z.string().min(1, "Secret Key is required"),
bucketName: z.string().min(1, "Bucket name is required"),
port: z.coerce.number().optional(),
ssl: z.boolean().optional().default(true),
});
@@ -1,60 +1,65 @@
"use client" "use client";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
import {useState} from "react"; import { useState } from "react";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder"; import { EmptyStatePlaceholder } from "@/components/wrappers/common/empty-state-placeholder";
import {OrganizationWithMembers} from "@/db/schema/03_organization"; import { OrganizationWithMembers } from "@/db/schema/03_organization";
import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import { StorageChannelWith } from "@/db/schema/12_storage-channel";
import {ChannelCard} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card"; import { ChannelCard } from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal"; import { ChannelAddEditModal } from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; import {
ChannelKind,
getChannelTextBasedOnKind,
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
type ChannelsSectionProps = { type ChannelsSectionProps = {
channels: NotificationChannelWith[] | StorageChannelWith[], channels: NotificationChannelWith[] | StorageChannelWith[];
organizations: OrganizationWithMembers[], organizations: OrganizationWithMembers[];
kind: ChannelKind, kind: ChannelKind;
defaultStorageChannelId?: string | null | undefined defaultStorageChannelId?: string | null | undefined;
} };
export const ChannelsSection = ({ export const ChannelsSection = ({
organizations, organizations,
channels, channels,
kind, kind,
defaultStorageChannelId defaultStorageChannelId,
}: ChannelsSectionProps) => { }: ChannelsSectionProps) => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const channelText = getChannelTextBasedOnKind(kind);
const hasChannels = channels.length > 0;
const [isAddModalOpen, setIsAddModalOpen] = useState(false); return (
const channelText = getChannelTextBasedOnKind(kind) <div className="h-full">
const hasChannels = channels.length > 0 <ChannelAddEditModal
kind={kind}
open={isAddModalOpen}
return ( onOpenChangeAction={setIsAddModalOpen}
adminView={false}
trigger={false}
/>
{hasChannels ? (
<div className="h-full"> <div className="h-full">
<ChannelAddEditModal kind={kind} open={isAddModalOpen} onOpenChangeAction={setIsAddModalOpen} <CardsWithPagination
adminView={false} data={channels}
trigger={false}/> cardItem={ChannelCard}
{hasChannels ? ( cardsPerPage={8}
<div className="h-full"> numberOfColumns={2}
<CardsWithPagination adminView={true}
data={channels} organizations={organizations}
cardItem={ChannelCard} kind={kind}
cardsPerPage={8} defaultStorageChannelId={defaultStorageChannelId}
numberOfColumns={2} />
adminView={true}
organizations={organizations}
kind={kind}
defaultStorageChannelId={defaultStorageChannelId}
/>
</div>
) : (
<EmptyStatePlaceholder
text={`No ${channelText} channels configured yet`}
onClick={() => {
setIsAddModalOpen(true)
}}
className="h-full"
/>
)}
</div> </div>
); ) : (
} <EmptyStatePlaceholder
text={`No ${channelText} channels configured yet`}
onClick={() => {
setIsAddModalOpen(true);
}}
className="h-full"
/>
)}
</div>
);
};
@@ -4,7 +4,15 @@ import {Info} from "lucide-react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {Setting} from "@/db/schema/01_setting"; import {Setting} from "@/db/schema/01_setting";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
@@ -18,6 +26,7 @@ import {
} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action"; } from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action";
import {toast} from "sonner"; import {toast} from "sonner";
import {Switch} from "@/components/ui/switch"; import {Switch} from "@/components/ui/switch";
import {downloadMasterKeyAction} from "@/features/keys/keys.action";
export type SettingsStorageSectionProps = { export type SettingsStorageSectionProps = {
settings: Setting; settings: Setting;
@@ -50,6 +59,34 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
} }
}); });
const handleDownload = async () => {
const result = await downloadMasterKeyAction();
if (!result?.success) {
toast.error(result?.message ?? "Download failed");
return;
}
const byteCharacters = atob(result.data as string);
const byteNumbers = new Array(byteCharacters.length)
.fill(null)
.map((_, i) => byteCharacters.charCodeAt(i));
const blob = new Blob([new Uint8Array(byteNumbers)], {
type: "application/octet-stream",
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "master_key.bin";
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
};
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<Alert className="mt-3"> <Alert className="mt-3">
@@ -59,7 +96,7 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
The default storage channel will be used by default to store your backups if no storage policy is The default storage channel will be used by default to store your backups if no storage policy is
configured at the database level. </AlertDescription> configured at the database level. </AlertDescription>
</Alert> </Alert>
<div className="flex flex-col h-full py-4 "> <div className="flex flex-col h-full py-4 gap-3">
<Form <Form
className="space-y-4" className="space-y-4"
form={form} form={form}
@@ -96,8 +133,6 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="encryption" name="encryption"
@@ -120,6 +155,18 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
Confirm Confirm
</ButtonWithLoading> </ButtonWithLoading>
</Form> </Form>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium ">
Download instance <button
type="button"
onClick={handleDownload}
className="text-sm hover:text-muted-foreground underline underline-offset-4 transition-colors w-fit"
>
Master Key
</button>
</span>
</div>
</div> </div>
</div> </div>
); );
@@ -1,75 +1,86 @@
"use client"; "use client";
import {useMutation} from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import {User} from "@/db/schema/02_user"; import { User } from "@/db/schema/02_user";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import {useRouter} from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
AlertDialog, AlertDialog,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import {authClient} from "@/lib/auth/auth-client"; import { authClient } from "@/lib/auth/auth-client";
import {toast} from "sonner"; import { toast } from "sonner";
import {setSuperAdminOwnerOfOrganizationsOwnedByUser} from "@/components/wrappers/dashboard/admin/users/user.action"; import { setSuperAdminOwnerOfOrganizationsOwnedByUser } from "@/components/wrappers/dashboard/admin/users/user.action";
type AdminDeleteUserModalProps = { type AdminDeleteUserModalProps = {
open: boolean; open: boolean;
user: User; user: User;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}; };
export const AdminDeleteUserModal = ({user, open, onOpenChange}: AdminDeleteUserModalProps) => { export const AdminDeleteUserModal = ({
const router = useRouter(); user,
open,
onOpenChange,
}: AdminDeleteUserModalProps) => {
const router = useRouter();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await setSuperAdminOwnerOfOrganizationsOwnedByUser({userId: user.id}); const res = await setSuperAdminOwnerOfOrganizationsOwnedByUser({
const result = res?.data; userId: user.id,
if (result?.success) { });
await authClient.admin.removeUser( const result = res?.data;
{ if (result?.success) {
userId: user.id, await authClient.admin.removeUser(
}, {
{ userId: user.id,
onSuccess: async (response) => { },
toast.success(`User ${user.name} successfully deleted`); {
onOpenChange(false); onSuccess: async () => {
router.refresh(); toast.success(`User ${user.name} successfully deleted`);
}, onOpenChange(false);
onError: async (error) => { router.refresh();
toast.error("An error has occurred while deleting user"); },
onOpenChange(false); onError: async (error) => {
}, toast.error("An error has occurred while deleting user");
} onOpenChange(false);
); },
} else { },
toast.error("An error has occurred while deleting user"); );
} } else {
}, toast.error("An error has occurred while deleting user");
onError: (error) => { }
toast.error(error.message); },
}, onError: (error) => {
}); toast.error(error.message);
},
});
return ( return (
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete {user.name} ?</AlertDialogTitle> <AlertDialogTitle>
<AlertDialogDescription>This action is irreversible: it will lead to the deletion of the user's Are you sure you want to delete {user.name} ?
data.</AlertDialogDescription> </AlertDialogTitle>
</AlertDialogHeader> <AlertDialogDescription>
<AlertDialogFooter> This action is irreversible: it will lead to the deletion of the
<AlertDialogCancel>Cancel</AlertDialogCancel> user's data.
<ButtonWithLoading </AlertDialogDescription>
onClick={async () => await mutation.mutateAsync()}>Confirm</ButtonWithLoading> </AlertDialogHeader>
</AlertDialogFooter> <AlertDialogFooter>
</AlertDialogContent> <AlertDialogCancel>Cancel</AlertDialogCancel>
</AlertDialog> <ButtonWithLoading onClick={async () => await mutation.mutateAsync()}>
); Confirm
</ButtonWithLoading>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}; };
@@ -5,8 +5,9 @@ import {User} from "@/db/schema/02_user";
type AdminUserListProps = { type AdminUserListProps = {
users: User[]; users: User[];
isPasswordAuthEnabled: boolean;
}; };
export const AdminUserList = ({ users }: AdminUserListProps) => { export const AdminUserList = ({ users, isPasswordAuthEnabled }: AdminUserListProps) => {
return <DataTable columns={usersListColumns()} data={users} enablePagination={true} enableSelect={false} />; return <DataTable columns={usersListColumns({ isPasswordAuthEnabled })} data={users} enablePagination={true} enableSelect={false} />;
}; };
@@ -9,7 +9,11 @@ import {Info} from "lucide-react";
import {Table, TableBody, TableCell, TableRow} from "@/components/ui/table"; import {Table, TableBody, TableCell, TableRow} from "@/components/ui/table";
import {UserActionsCell} from "@/components/wrappers/dashboard/admin/users/user-actions-cell"; import {UserActionsCell} from "@/components/wrappers/dashboard/admin/users/user-actions-cell";
export function usersListColumns(): ColumnDef<User>[] { type UsersListColumnsProps = {
isPasswordAuthEnabled: boolean;
}
export function usersListColumns({ isPasswordAuthEnabled }: UsersListColumnsProps): ColumnDef<User>[] {
return [ return [
{ {
@@ -85,7 +89,7 @@ export function usersListColumns(): ColumnDef<User>[] {
{ {
id: "actions", id: "actions",
header: "Actions", header: "Actions",
cell: ({row}) => <UserActionsCell user={row.original}/>, cell: ({row}) => <UserActionsCell user={row.original} isPasswordAuthEnabled={isPasswordAuthEnabled} />,
}, },
]; ];
} }
@@ -20,9 +20,10 @@ import {AdminDeleteUserModal} from "@/components/wrappers/dashboard/admin/users/
interface UserActionsCellProps { interface UserActionsCellProps {
user: User; user: User;
isPasswordAuthEnabled: boolean;
} }
export function UserActionsCell({user}: UserActionsCellProps) { export function UserActionsCell({user, isPasswordAuthEnabled}: UserActionsCellProps) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isModalChangePasswordOpen, setIsModalChangePasswordOpen] = useState(false); const [isModalChangePasswordOpen, setIsModalChangePasswordOpen] = useState(false);
@@ -43,9 +44,11 @@ export function UserActionsCell({user}: UserActionsCellProps) {
onOpenChange={setIsModalChangePasswordOpen}/> onOpenChange={setIsModalChangePasswordOpen}/>
<AdminUserEdit user={user} open={isModalEditUserOpen} onOpenChange={setIsModalEditUserOpen}/> <AdminUserEdit user={user} open={isModalEditUserOpen} onOpenChange={setIsModalEditUserOpen}/>
<div className={cn("flex items-center space-x-2")}> <div className={cn("flex items-center space-x-2")}>
<Button variant="outline" size="icon" onClick={() => setIsModalChangePasswordOpen(true)}> {isPasswordAuthEnabled && (
<RotateCcwKey className="w-4 h-4"/> <Button variant="outline" size="icon" onClick={() => setIsModalChangePasswordOpen(true)}>
</Button> <RotateCcwKey className="w-4 h-4"/>
</Button>
)}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
@@ -1,191 +1,211 @@
"use server"; "use server";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {ServerActionResult} from "@/types/action-type"; import { ServerActionResult } from "@/types/action-type";
import {render} from "@react-email/render"; import { render } from "@react-email/render";
import {UserSchema} from "@/components/wrappers/dashboard/admin/users/user.schema"; import { UserSchema } from "@/components/wrappers/dashboard/admin/users/user.schema";
import {extractNameFromEmail} from "@/utils/name-from-email"; import { extractNameFromEmail } from "@/utils/name-from-email";
import {generateValidPassword} from "@/utils/password"; import { generateValidPassword } from "@/utils/password";
import {auth} from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import {z} from "zod"; import { z } from "zod";
import {Organization} from "@/db/schema/03_organization"; import { Organization } from "@/db/schema/03_organization";
import {db} from "@/db"; import { db } from "@/db";
import {and, eq} from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import {zEmail, zString} from "@/lib/zod"; import { zEmail, zString } from "@/lib/zod";
import {withUpdatedAt} from "@/db/utils"; import { withUpdatedAt } from "@/db/utils";
import {userAction} from "@/lib/safe-actions/actions"; import { userAction } from "@/lib/safe-actions/actions";
import { import { addMemberOrganizationAction } from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
addMemberOrganizationAction import { sendEmail } from "@/lib/email";
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
import {sendEmail} from "@/lib/email";
import EmailCreateUser from "@/components/emails/email-create-user"; import EmailCreateUser from "@/components/emails/email-create-user";
import {SignUpUser} from "@/types/auth"; import { SignUpUser } from "@/types/auth";
import {createUserDb} from "@/db/services/user"; import { createUserDb } from "@/db/services/user";
import {User} from "@/db/schema/02_user"; import { User } from "@/db/schema/02_user";
import { env } from "@/env.mjs";
export const createUserAction = userAction.schema(UserSchema).action(async ({parsedInput}): Promise<ServerActionResult<User>> => { export const createUserAction = userAction
.schema(UserSchema)
.action(async ({ parsedInput }): Promise<ServerActionResult<User>> => {
try { try {
const password = generateValidPassword(); const isPasswordAuthEnabled = env.AUTH_EMAIL_PASSWORD_ENABLED === "true";
let password;
const userData: SignUpUser = { const userData: SignUpUser = {
name: parsedInput.name || extractNameFromEmail(parsedInput.email), name: parsedInput.name || extractNameFromEmail(parsedInput.email),
email: parsedInput.email, email: parsedInput.email,
password: password, theme: "dark",
theme: "dark", role: "user",
role: "user", };
};
const newUser = await createUserDb(userData); if (isPasswordAuthEnabled) {
password = generateValidPassword();
userData.password = password;
}
if (newUser) { const newUser = await createUserDb(userData);
await sendEmail({ if (newUser) {
to: parsedInput.email, await sendEmail({
subject: "Your account is created", to: parsedInput.email,
html: await render(EmailCreateUser({ subject: "Your account is created",
password: password, html: await render(
email: parsedInput.email, EmailCreateUser({
})), password: password,
}); email: parsedInput.email,
}),
),
});
const defaultOrganization = await db.query.organization.findFirst({ const defaultOrganization = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, "default"), where: eq(drizzleDb.schemas.organization.slug, "default"),
}); });
if (defaultOrganization) { if (defaultOrganization) {
await auth.api.addMember({ await auth.api.addMember({
body: { body: {
userId: newUser.id, userId: newUser.id,
organizationId: defaultOrganization.id, organizationId: defaultOrganization.id,
role: "admin", role: "admin",
}, },
}); });
}
return {
success: true,
value: newUser,
actionSuccess: {
message: "user_created",
},
};
} }
return { return {
success: false, success: true,
actionError: { value: newUser,
message: "user_created", actionSuccess: {
cause: "Unknown error", message: "user_created",
}, },
}; };
}
return {
success: false,
actionError: {
message: "user_created",
cause: "Unknown error",
},
};
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
actionError: { actionError: {
message: "user_created", message: "user_created",
cause: error instanceof Error ? error.message : "Unknown error", cause: error instanceof Error ? error.message : "Unknown error",
}, },
}; };
} }
}); });
export const updateUserAction = userAction export const updateUserAction = userAction
.schema( .schema(
z.object({ z.object({
id: zString(), id: zString(),
name: zString().optional(), name: zString().optional(),
email: zEmail(), email: zEmail(),
}) }),
) )
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<{}>> => { .action(async ({ parsedInput, ctx }): Promise<ServerActionResult<{}>> => {
try { try {
const [updatedUser] = await db
.update(drizzleDb.schemas.user)
.set(
withUpdatedAt({
name: parsedInput.name
? parsedInput.name
: extractNameFromEmail(parsedInput.email),
email: parsedInput.email,
emailVerified: false,
}),
)
.where(eq(drizzleDb.schemas.user.id, parsedInput.id))
.returning();
if (updatedUser) {
return {
success: true,
actionSuccess: {
message: "user_updated",
},
};
}
const [updatedUser] = await db.update(drizzleDb.schemas.user).set(withUpdatedAt({ return {
name: parsedInput.name ? parsedInput.name : extractNameFromEmail(parsedInput.email), success: false,
email: parsedInput.email, actionError: {
emailVerified: false message: "user_updated",
})).where(eq(drizzleDb.schemas.user.id, parsedInput.id)).returning(); cause: "Unknown error",
},
};
if (updatedUser) { } catch (error) {
return { return {
success: true, success: false,
actionSuccess: { actionError: {
message: "user_updated", message: "user_updated",
}, cause: error instanceof Error ? error.message : "Unknown error",
}; },
} };
}
return { });
success: false,
actionError: {
message: "user_updated",
cause: "Unknown error",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "user_updated",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const setSuperAdminOwnerOfOrganizationsOwnedByUser = userAction export const setSuperAdminOwnerOfOrganizationsOwnedByUser = userAction
.schema( .schema(
z.object({ z.object({
userId: z.string(), userId: z.string(),
}) }),
) )
.action(async ({parsedInput}): Promise<ServerActionResult<Organization[]>> => { .action(
try { async ({ parsedInput }): Promise<ServerActionResult<Organization[]>> => {
const organizationsWhereUserIsMemberAndOwner = await db.query.member.findMany({ try {
where: and(eq(drizzleDb.schemas.member.role, "owner"), eq(drizzleDb.schemas.member.userId, parsedInput.userId)), const organizationsWhereUserIsMemberAndOwner =
with: { await db.query.member.findMany({
organization: true, where: and(
}, eq(drizzleDb.schemas.member.role, "owner"),
}); eq(drizzleDb.schemas.member.userId, parsedInput.userId),
),
with: {
organization: true,
},
});
const superAdminUser = await db.query.user.findFirst(); const superAdminUser = await db.query.user.findFirst();
if (!superAdminUser) { if (!superAdminUser) {
return { return {
success: false, success: false,
actionError: { actionError: {
message: "set_super_admin_owner_of_organizations_owned_by_user", message: "set_super_admin_owner_of_organizations_owned_by_user",
cause: "Unknown error", cause: "Unknown error",
}, },
}; };
}
for (let {organization} of organizationsWhereUserIsMemberAndOwner) {
await addMemberOrganizationAction({
userId: superAdminUser.id,
organizationId: organization.id,
role: "owner",
});
}
const organizations = organizationsWhereUserIsMemberAndOwner.map(
(organizationWhereUserIsMemberAndOwner) => organizationWhereUserIsMemberAndOwner.organization
);
return {
success: true,
value: organizations as unknown as Organization[],
actionSuccess: {
message: "set_super_admin_owner_of_organizations_owned_by_user",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_set_super_admin_owner_of_organizations_owned_by_user",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
} }
});
for (let { organization } of organizationsWhereUserIsMemberAndOwner) {
await addMemberOrganizationAction({
userId: superAdminUser.id,
organizationId: organization.id,
role: "owner",
});
}
const organizations = organizationsWhereUserIsMemberAndOwner.map(
(organizationWhereUserIsMemberAndOwner) =>
organizationWhereUserIsMemberAndOwner.organization,
);
return {
success: true,
value: organizations as unknown as Organization[],
actionSuccess: {
message: "set_super_admin_owner_of_organizations_owned_by_user",
},
};
} catch (error) {
return {
success: false,
actionError: {
message:
"error_set_super_admin_owner_of_organizations_owned_by_user",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
},
);
@@ -1,49 +1,72 @@
"use client"; "use client";
import {useMutation, useQueryClient} from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import {useRouter} from "next/navigation"; import { useRouter } from "next/navigation";
import {toast} from "sonner"; import { toast } from "sonner";
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action"; import { backupButtonAction } from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import { Check, DatabaseZap, X } from "lucide-react";
import {Database, DatabaseZap} from "lucide-react"; import { useIsMobile } from "@/hooks/use-mobile";
import {useIsMobile} from "@/hooks/use-mobile"; import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
export type BackupButtonProps = { export type BackupButtonProps = {
databaseId: string; databaseId: string;
disable: boolean; disable: boolean;
}; };
export const BackupButton = (props: BackupButtonProps) => { export const BackupButton = (props: BackupButtonProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile() const isMobile = useIsMobile();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (databaseId: string) => { mutationFn: async (databaseId: string) => {
const backup = await backupButtonAction(databaseId); const backup = await backupButtonAction(databaseId);
if (backup?.data?.success) { if (backup?.data?.success) {
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!"); toast.success(
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]}); backup.data.actionSuccess?.message || "Backup created successfully!",
router.refresh(); );
} else { queryClient.invalidateQueries({
toast.error(backup?.serverError || "Failed to create backup."); queryKey: ["database-data", props.databaseId],
} });
router.refresh();
} else {
toast.error(backup?.serverError || "Failed to create backup.");
}
},
});
const HandleAction = async () => {
await mutation.mutateAsync(props.databaseId);
};
return (
<ButtonWithConfirm
title="Create Backup"
description={"Are you sure you want to create a backup?"}
button={{
main: {
disabled: props.disable,
text: isMobile ? "" : "Backup",
variant: "default",
icon: <DatabaseZap />,
}, },
}); confirm: {
const HandleAction = async () => { className: "w-full",
await mutation.mutateAsync(props.databaseId); text: "Yes, create backup",
}; icon: <Check />,
variant: "default",
return ( onClick: async () => {
<ButtonWithLoading await HandleAction();
icon={<DatabaseZap/>} },
disabled={props.disable} },
isPending={mutation.isPending} cancel: {
size={"default"} className: "w-full",
onClick={async () => { text: "No, cancel",
await HandleAction(); icon: <X />,
}} variant: "outline",
>{isMobile ? "" : "Backup"}</ButtonWithLoading> },
); }}
isPending={mutation.isPending}
/>
);
}; };
@@ -1,8 +1,7 @@
import {currentUser} from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import {getAccounts, getSession, getSessions} from "@/lib/auth/auth"; import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
import {LoggedInButtonClient} from "./logged-in-button"; import { LoggedInButtonClient } from "./logged-in-button";
import {SUPPORTED_PROVIDERS} from "../../../../../../portabase.config"; import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
export const LoggedInButton = async () => { export const LoggedInButton = async () => {
const user = await currentUser(); const user = await currentUser();
@@ -10,7 +9,6 @@ export const LoggedInButton = async () => {
const currentSession = await getSession(); const currentSession = await getSession();
const accounts = await getAccounts(); const accounts = await getAccounts();
if (!user) return null; if (!user) return null;
return ( return (
@@ -1,29 +1,21 @@
"use client"; "use client";
import {ChevronsUpDown} from "lucide-react"; import { ChevronsUpDown } from "lucide-react";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {SidebarMenuButton} from "@/components/ui/sidebar"; import { SidebarMenuButton } from "@/components/ui/sidebar";
import {LoggedInDropdown} from "./logged-in-dropdown"; import { LoggedInDropdown } from "./logged-in-dropdown";
import {Account, Session, User} from "better-auth"; import { Account, Session, User } from "better-auth";
import {AuthProviderConfig} from "../../../../../../portabase.config"; import { AuthProviderConfig } from "@/lib/auth/config";
type LoggedInButtonClientProps = { type LoggedInButtonClientProps = {
user: User, user: User;
sessions: Session[], sessions: Session[];
currentSession: Session, currentSession: Session;
accounts: Account[], accounts: Account[];
providers: AuthProviderConfig[] providers: AuthProviderConfig[];
} };
export const LoggedInButtonClient = ({
user,
sessions,
currentSession,
accounts,
providers
}: LoggedInButtonClientProps) => {
export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers }: LoggedInButtonClientProps) => {
return ( return (
<LoggedInDropdown <LoggedInDropdown
// @ts-ignore // @ts-ignore
@@ -40,20 +32,16 @@ export const LoggedInButtonClient = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar className="size-6"> <Avatar className="size-6">
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback> <AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
{user.image && <AvatarImage src={user.image}/>} {user.image && <AvatarImage src={user.image} />}
</Avatar> </Avatar>
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
<span <span className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span> <span className="text-xs text-muted-foreground max-w-[170px] truncate" title={user.email}>
<span {user.email}
className="text-xs text-muted-foreground max-w-[170px] truncate" </span>
title={user.email}
>
{user.email}
</span>
</div> </div>
</div> </div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50"/> <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</SidebarMenuButton> </SidebarMenuButton>
</LoggedInDropdown> </LoggedInDropdown>
); );
@@ -1,6 +1,6 @@
"use client"; "use client";
import {PropsWithChildren, ReactNode, useState} from "react"; import { PropsWithChildren, ReactNode, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -10,11 +10,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara
import { signOut } from "@/lib/auth/auth-client"; import { signOut } from "@/lib/auth/auth-client";
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal"; import { ProfileModal } from "@/components/wrappers/dashboard/common/profile/profile-modal";
import {Account, Session, User as UserType} from "@/db/schema/02_user"; import { Account, Session, User as UserType } from "@/db/schema/02_user";
import {AuthProviderConfig} from "../../../../../../portabase.config"; import { AuthProviderConfig } from "@/lib/auth/config";
export type LoggedInDropdownProps = PropsWithChildren<{ export type LoggedInDropdownProps = PropsWithChildren<{
user: UserType; user: UserType;
@@ -22,13 +22,10 @@ export type LoggedInDropdownProps = PropsWithChildren<{
currentSession: Session; currentSession: Session;
accounts: Account[]; accounts: Account[];
children: ReactNode; children: ReactNode;
providers: AuthProviderConfig[] providers: AuthProviderConfig[];
}>; }>;
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => { export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
const router = useRouter(); const router = useRouter();
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@@ -44,50 +41,46 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
onOpenChange={setIsModalOpen} onOpenChange={setIsModalOpen}
providers={providers} providers={providers}
/> />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1" className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
align="start" align="start"
side="top" side="top"
sideOffset={8} sideOffset={8}
> >
<DropdownMenuItem <DropdownMenuItem
onClick={() => setIsModalOpen(!isModalOpen)} onClick={() => setIsModalOpen(!isModalOpen)}
className="group gap-2 p-1 cursor-pointer rounded-lg mb-1 transition-colors focus:bg-accent hover:bg-accent/50 border border-transparent" className="group gap-2 p-1 cursor-pointer rounded-lg mb-1 transition-colors focus:bg-accent hover:bg-accent/50 border border-transparent"
> >
<div className="flex size-9 items-center justify-center rounded-md border border-border bg-muted/50 shadow-sm transition-all group-hover:shadow-md group-hover:bg-background"> <div className="flex size-9 items-center justify-center rounded-md border border-border bg-muted/50 shadow-sm transition-all group-hover:shadow-md group-hover:bg-background">
<User size={18} className="text-muted-foreground group-hover:text-foreground transition-colors" /> <User size={18} className="text-muted-foreground group-hover:text-foreground transition-colors" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium leading-none">Account Settings</span> <span className="text-sm font-medium leading-none">Account Settings</span>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="group gap-2 p-1 cursor-pointer rounded-lg transition-colors focus:bg-red-50 dark:focus:bg-red-950/20 border border-transparent text-red-600 focus:text-red-600" className="group gap-2 p-1 cursor-pointer rounded-lg transition-colors focus:bg-red-50 dark:focus:bg-red-950/20 border border-transparent text-red-600 focus:text-red-600"
onClick={async () => { onClick={async () => {
await signOut({ await signOut({
fetchOptions: { fetchOptions: {
onSuccess: () => { onSuccess: () => {
router.push("/login"); router.push("/login");
}, },
}, },
}); });
}} }}
> >
<div className="flex size-9 items-center justify-center rounded-md border border-red-100 bg-red-50/50 dark:border-red-900/30 dark:bg-red-950/20 shadow-sm transition-all group-hover:shadow-md"> <div className="flex size-9 items-center justify-center rounded-md border border-red-100 bg-red-50/50 dark:border-red-900/30 dark:bg-red-950/20 shadow-sm transition-all group-hover:shadow-md">
<LogOut size={18} className="text-red-500" /> <LogOut size={18} className="text-red-500" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium leading-none">Logout</span> <span className="text-sm font-medium leading-none">Logout</span>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</> </>
); );
}; };
@@ -1,16 +1,15 @@
"use client"; "use client";
import React from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Account, Session, User } from "@/db/schema/02_user";
import { ProfileSidebar } from "./profile-sidebar"; import { ProfileSidebar } from "./profile-sidebar";
import {ProfileProviders} from "@/components/wrappers/dashboard/profile/profile-providers"; import type { AuthProviderConfig } from "@/lib/auth/config";
import {ProfileAccount} from "@/components/wrappers/dashboard/profile/profile-account"; import { User, Session, Account } from "@/db/schema/02_user";
import {ProfileAppearance} from "@/components/wrappers/dashboard/profile/profile-apperance"; import { ProfileGeneral } from "../../profile/profile-general";
import {ProfileSecurity} from "@/components/wrappers/dashboard/profile/profile-security"; import { ProfileSecurity } from "../../profile/profile-security";
import {ProfileGeneral} from "@/components/wrappers/dashboard/profile/profile-general"; import { ProfileProviders } from "../../profile/profile-providers";
import {AuthProviderConfig} from "../../../../../../portabase.config"; import { ProfileAccount } from "../../profile/profile-account";
import { ProfileAppearance } from "../../profile/profile-apperance";
type ProfileModalProps = { type ProfileModalProps = {
open: boolean; open: boolean;
@@ -19,14 +18,13 @@ type ProfileModalProps = {
currentSession: Session; currentSession: Session;
accounts: Account[]; accounts: Account[];
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
providers: AuthProviderConfig[] providers: AuthProviderConfig[];
}; };
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => { export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background"> <DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>Settings</DialogTitle> <DialogTitle>Settings</DialogTitle>
<DialogDescription>Manage your account settings</DialogDescription> <DialogDescription>Manage your account settings</DialogDescription>
@@ -45,6 +43,9 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
sessions={sessions} sessions={sessions}
currentSession={currentSession} currentSession={currentSession}
credentialAccount={accounts.find((acc) => acc.providerId === "credential")!} credentialAccount={accounts.find((acc) => acc.providerId === "credential")!}
isPasswordEnabled={providers.some((p) => p.id === "credential")}
isPasskeyEnabled={providers.some((p) => p.id === "passkey")}
providers={providers}
/> />
</TabsContent> </TabsContent>
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { use } from "react"; import React from "react";
import { TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UserIcon, Settings, Palette, ShieldHalf, Workflow } from "lucide-react"; import { UserIcon, Settings, Palette, ShieldHalf, Workflow } from "lucide-react";
import { User } from "@/db/schema/02_user"; import { User } from "@/db/schema/02_user";
@@ -10,7 +10,6 @@ interface ProfileSidebarProps {
} }
export function ProfileSidebar({ user }: ProfileSidebarProps) { export function ProfileSidebar({ user }: ProfileSidebarProps) {
return ( return (
<div className="w-full lg:w-[260px] flex-shrink-0 lg:border-r bg-muted/10 p-4 lg:p-6 flex flex-col gap-4 border-b lg:border-b-0"> <div className="w-full lg:w-[260px] flex-shrink-0 lg:border-r bg-muted/10 p-4 lg:p-6 flex flex-col gap-4 border-b lg:border-b-0">
<div className="flex items-center px-2 mb-2"> <div className="flex items-center px-2 mb-2">
@@ -1,253 +1,294 @@
"use client" "use client";
import {Backup, BackupWith, Restoration} from "@/db/schema/07_database"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {Swiper, SwiperSlide} from "swiper/react"; import { Badge } from "@/components/ui/badge";
//@ts-ignore
import "swiper/css";
import "swiper/css/pagination";
import {Pagination, Mousewheel} from "swiper/modules";
import {DatabaseActionKind, useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import { import {
BackupActionsSchema, Form,
BackupActionsType FormControl,
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema"; FormField,
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; FormItem,
import {useMutation, useQueryClient} from "@tanstack/react-query"; FormLabel,
import {BackupStorageWith} from "@/db/schema/14_storage-backup"; FormMessage,
import {TooltipProvider} from "@/components/ui/tooltip"; useZodForm,
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; } from "@/components/ui/form";
import {Badge} from "@/components/ui/badge"; import { TooltipProvider } from "@/components/ui/tooltip";
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns"; import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import {useRouter} from "next/navigation"; import { getChannelIcon } from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import { import {
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction, getStatusColor,
downloadBackupAction getStatusIcon,
} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import {
createRestorationBackupAction,
deleteBackupAction,
deleteBackupStorageAction,
downloadBackupAction,
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action"; } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
import {toast} from "sonner"; import {
import {SafeActionResult} from "next-safe-action"; BackupActionsSchema,
import {ServerActionResult} from "@/types/action-type"; BackupActionsType,
import {ZodString} from "zod"; } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert"; import {
import {AlertCircleIcon} from "lucide-react"; DatabaseActionKind,
useBackupModal,
} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import { Backup, BackupWith, Restoration } from "@/db/schema/07_database";
import { BackupStorageWith } from "@/db/schema/14_storage-backup";
import { ServerActionResult } from "@/types/action-type";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertCircleIcon } from "lucide-react";
import { SafeActionResult } from "next-safe-action";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ZodString } from "zod";
type BackupActionsFormProps = { type BackupActionsFormProps = {
backup: BackupWith; backup: BackupWith;
action: DatabaseActionKind; action: DatabaseActionKind;
} };
export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { export const BackupActionsForm = ({
backup,
action,
}: BackupActionsFormProps) => {
const filteredBackupStorages =
backup.storages?.filter((storage) => storage.deletedAt === null) ?? [];
const { closeModal } = useBackupModal();
const queryClient = useQueryClient();
const router = useRouter();
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? [] const form = useZodForm({
const {closeModal} = useBackupModal(); schema: BackupActionsSchema,
const queryClient = useQueryClient(); });
const router = useRouter();
const form = useZodForm({ const mutation = useMutation({
schema: BackupActionsSchema, mutationFn: async (values: BackupActionsType) => {
}); let result:
| SafeActionResult<
string,
ZodString,
readonly [],
{
_errors?: string[] | undefined;
},
readonly [],
ServerActionResult<string | Restoration | Backup>,
object
>
| undefined;
const mutation = useMutation({ if (action === "download") {
mutationFn: async (values: BackupActionsType) => { result = await downloadBackupAction({
backupStorageId: values.backupStorageId,
});
} else if (action === "restore") {
result = await createRestorationBackupAction({
databaseId: backup.databaseId,
backupStorageId: values.backupStorageId,
backupId: backup.id,
});
} else if (action === "delete") {
result = await deleteBackupStorageAction({
databaseId: backup.databaseId,
backupStorageId: values.backupStorageId,
backupId: backup.id,
});
}
let result: SafeActionResult<string, ZodString, readonly [], { const inner = result?.data;
_errors?: string[] | undefined;
}, readonly [], ServerActionResult<string | Restoration | Backup>, object> | undefined
if (action === "download") { if (inner?.success) {
result = await downloadBackupAction({backupStorageId: values.backupStorageId}) toast.success(inner.actionSuccess?.message);
} else if (action === "restore") { queryClient.invalidateQueries({
result = await createRestorationBackupAction({ queryKey: ["database-data", backup.databaseId],
databaseId: backup.databaseId, });
backupStorageId: values.backupStorageId, router.refresh();
backupId: backup.id if (action === "download") {
}) const url = inner.value;
} else if (action === "delete") { if (typeof url === "string") {
result = await deleteBackupStorageAction({ window.open(url, "_self");
databaseId: backup.databaseId, }
backupStorageId: values.backupStorageId, closeModal();
backupId: backup.id, } else if (action === "restore") {
}) closeModal();
} } else if (action === "delete") {
closeModal();
} else {
closeModal();
}
} else {
if (action === "delete") {
toast.success("Backup deleted successfully.");
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
router.refresh();
closeModal();
} else {
toast.error(inner?.actionError?.message ?? "An error occurred.");
}
}
},
});
const inner = result?.data; const mutationDeleteEntireBackup = useMutation({
mutationFn: async () => {
const result = await deleteBackupAction({
databaseId: backup.databaseId,
backupId: backup.id,
});
if (inner?.success) { const inner = result?.data;
toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
if (action === "download") {
const url = inner.value
if (typeof url === "string") {
window.open(url, "_self");
}
closeModal()
} else if (action === "restore") {
closeModal()
} else if (action === "delete") {
closeModal()
} else {
closeModal()
}
} else {
if (action === "delete") {
toast.success("Backup deleted successfully.")
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
closeModal()
} else {
toast.error(inner?.actionError?.message ?? "An error occurred.");
}
}
},
});
const mutationDeleteEntireBackup = useMutation({ if (inner?.success) {
mutationFn: async () => { toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
router.refresh();
closeModal();
} else {
toast.error(inner?.actionError?.message);
}
},
});
const result = await deleteBackupAction({ return (
databaseId: backup.databaseId, <TooltipProvider>
backupId: backup.id, <Form
}) form={form}
className="flex flex-col gap-4 mb-1 min-w-0"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
{filteredBackupStorages.length > 0 ? (
<FormField
control={form.control}
name="backupStorageId"
render={({ field }) => (
<FormItem>
<FormLabel>Choose a storage backup</FormLabel>
<FormControl>
<div
className="flex flex-col gap-2.5 overflow-y-auto overflow-x-hidden w-full pr-1"
style={{ maxHeight: "250px" }}
>
{filteredBackupStorages.map(
(storage: BackupStorageWith) => (
<button
key={storage.id}
disabled={
action !== "delete" &&
storage.status.toLowerCase() !== "success"
}
type="button"
onClick={() => field.onChange(storage.id)}
className={`w-full flex items-center gap-3 p-4 rounded-lg border text-left transition-colors
${
field.value === storage.id
? "border-foreground bg-background"
: "border-border bg-background" +
(storage.status.toLowerCase() === "success" ||
action === "delete"
? " hover:border-muted-foreground"
: "")
}
${
storage.status.toLowerCase() !== "success" &&
action !== "delete"
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<div
className={`h-4 w-4 shrink-0 rounded-full border ${
field.value === storage.id
? "border-foreground"
: "border-muted-foreground"
} flex items-center justify-center`}
>
{field.value === storage.id && (
<div className="h-2 w-2 rounded-full bg-foreground" />
)}
</div>
const inner = result?.data; <div className="flex-1 min-w-0 flex items-center gap-2">
<div className="shrink-0">
{getChannelIcon(
storage.storageChannel?.provider || "",
)}
</div>
if (inner?.success) { <h3 className="font-medium text-foreground truncate flex-1">
toast.success(inner.actionSuccess?.message); {storage.storageChannel?.name}
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]}); </h3>
router.refresh();
closeModal()
} else {
toast.error(inner?.actionError?.message);
}
},
});
return ( {storage.storageChannel?.provider && (
<TooltipProvider> <Badge
variant="secondary"
className="text-xs font-mono shrink-0"
>
{storage.storageChannel.provider}
</Badge>
)}
<Badge
variant="outline"
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}
>
{getStatusIcon(storage.status === "success")}
<span className="capitalize hidden sm:inline">
{storage.status.toUpperCase()}
</span>
</Badge>
</div>
</button>
),
) ?? <p>No storages available</p>}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : (
<Alert>
<AlertCircleIcon />
<AlertTitle>Backup does not have files</AlertTitle>
<AlertDescription>
<p>
You can safely delete the entire backup; no files seem to be
related. Maybe an error occurred.
</p>
</AlertDescription>
</Alert>
)}
<Form <div className="flex flex-row items-center gap-x-4 w-full">
form={form} {action === "delete" && (
className="flex flex-col gap-4 mb-1" <ButtonWithLoading
onSubmit={async (values) => { type="button"
await mutation.mutateAsync(values); variant="destructive"
}} onClick={() => mutationDeleteEntireBackup.mutateAsync()}
isPending={mutationDeleteEntireBackup.isPending}
disabled={mutationDeleteEntireBackup.isPending}
> >
Delete entire backup
</ButtonWithLoading>
)}
{filteredBackupStorages.length > 0 ? {filteredBackupStorages.length > 0 && (
<FormField <ButtonWithLoading
control={form.control} type="submit"
name="backupStorageId" isPending={mutation.isPending}
render={({field}) => ( disabled={mutation.isPending}
<FormItem> className="ml-auto"
<FormLabel>Choose a storage backup</FormLabel> >
<FormControl> Confirm
</ButtonWithLoading>
<div style={{height: "250px"}}> )}
<Swiper </div>
direction="vertical" </Form>
slidesPerView={3.5} </TooltipProvider>
spaceBetween={10} );
// pagination={{ clickable: true }} };
mousewheel={{releaseOnEdges: true, forceToAxis: true}}
modules={[Pagination, Mousewheel]}
className="mySwiper"
style={{height: "100%"}}
>
{filteredBackupStorages.map((storage: BackupStorageWith) => (
<SwiperSlide key={storage.id}>
<button
disabled={action !== "delete" && storage.status.toLowerCase() !== "success"}
type="button"
onClick={() => field.onChange(storage.id)}
className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors
${field.value === storage.id
? "border-foreground bg-background"
: "border-border bg-background" + ((storage.status.toLowerCase() === "success" || action === "delete") ? " hover:border-muted-foreground" : "")}
${storage.status.toLowerCase() !== "success" && action !== "delete" ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${
field.value === storage.id ? "border-foreground" : "border-muted-foreground"
} flex items-center justify-center`}
>
{field.value === storage.id &&
<div className="h-2 w-2 rounded-full bg-foreground"/>}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0">
{getChannelIcon(storage.storageChannel?.provider || "")}
</div>
<h3 className="font-medium text-foreground truncate">
{storage.storageChannel?.name}
</h3>
<Badge variant="secondary"
className="text-xs font-mono shrink-0">
{storage.storageChannel?.provider}
</Badge>
</div>
<Badge variant="outline"
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}>
{getStatusIcon(storage.status === "success")}
<span
className="capitalize">{storage.status.toUpperCase()}</span>
</Badge>
</div>
</div>
</div>
</div>
</button>
</SwiperSlide>
)) ?? <p>No storages available</p>}
</Swiper>
</div>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
:
<Alert>
<AlertCircleIcon/>
<AlertTitle>Backup does not have files</AlertTitle>
<AlertDescription>
<p>You can safely delete the entire backup; no files seem to be related. Maybe an error
occurred.</p>
</AlertDescription>
</Alert>
}
<div className="flex flex-row items-center gap-x-4 w-full">
{action === "delete" && (
<ButtonWithLoading
type="button"
variant="destructive"
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
isPending={mutationDeleteEntireBackup.isPending}
disabled={mutationDeleteEntireBackup.isPending}
>
Delete entire backup
</ButtonWithLoading>
)}
{filteredBackupStorages.length > 0 && (
<ButtonWithLoading
type="submit"
isPending={mutation.isPending}
disabled={mutation.isPending}
className="ml-auto"
>
Confirm
</ButtonWithLoading>
)}
</div>
</Form>
</TooltipProvider>
);
}
@@ -1,17 +0,0 @@
"use client";
import { buttonVariants } from "@/components/ui/button";
import { GearIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { usePathname } from "next/navigation";
export type EditButtonProps = {};
export const EditButton = (props: EditButtonProps) => {
const pathname = usePathname();
return (
<Link className={buttonVariants({ variant: "outline" })} href={`${pathname}/edit`}>
<GearIcon className="w-7 h-7" />
</Link>
);
};
@@ -8,7 +8,7 @@ import {v4 as uuidv4} from "uuid";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import {z} from "zod"; import {z} from "zod";
import {storeBackupFiles} from "@/features/storages/helpers"; import {storeBackupFiles} from "@/features/storages/helpers";
import {getFileExtension} from "@/features/api/upload/helpers/file"; import {getFileExtension} from "@/utils/common";
export const uploadBackupAction = userAction export const uploadBackupAction = userAction
@@ -46,18 +46,6 @@ export const uploadBackupAction = userAction
const fileName = `${uuid}${fileExtension}`; const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
// const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
//
// if (!settings) {
// return {
// success: false,
// actionError: {
// message: "Settings not set",
// status: 500,
// cause: "Unknown error",
// },
// };
// }
const [backup] = await db const [backup] = await db
.insert(drizzleDb.schemas.backup) .insert(drizzleDb.schemas.backup)
@@ -68,30 +56,8 @@ export const uploadBackupAction = userAction
}) })
.returning(); .returning();
await storeBackupFiles(backup, database, buffer, fileName) await storeBackupFiles(backup, database, buffer, fileName)
// 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 {
// success: false,
// actionError: {
// message: "An error has occurred while uploading file",
// status: 500,
// cause: "Unknown error",
// },
// };
// }
return { return {
success: true, success: true,
value: backup, value: backup,
@@ -3,13 +3,18 @@
import { ServerActionResult } from "@/types/action-type"; import { ServerActionResult } from "@/types/action-type";
import { z } from "zod"; import { z } from "zod";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth/auth"; import { auth, getPasskeys, revokePasskey } from "@/lib/auth/auth";
import {userAction} from "@/lib/safe-actions/actions"; import { userAction } from "@/lib/safe-actions/actions";
const RevokeSessionSchema = z.object({ const RevokeSessionSchema = z.object({
token: z.string(), token: z.string(),
}); });
const RevokePasskeySchema = z.object({
id: z.string(),
});
export const revokeSessionAction = userAction.schema(RevokeSessionSchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => { export const revokeSessionAction = userAction.schema(RevokeSessionSchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
try { try {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
@@ -99,3 +104,46 @@ export const revokeAllSessionsAction = userAction.action(async (): Promise<Serve
}; };
} }
}); });
export const getPasskeysAction = userAction.action(async (): Promise<ServerActionResult<any[]>> => {
try {
const passkeys = await getPasskeys();
return {
success: true,
value: passkeys || [],
actionSuccess: {
message: "passkeys_fetched",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_fetching_passkeys",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const revokePasskeyAction = userAction.schema(RevokePasskeySchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
try {
await revokePasskey(parsedInput.id);
return {
success: true,
value: {},
actionSuccess: {
message: "passkey_revoked",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_revoking_passkey",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -1,184 +1,234 @@
"use client"; "use client";
import React, {useState} from "react"; import { useState } from "react";
import {Button} from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {Badge} from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {Loader2, AlertTriangle} from "lucide-react"; import { Loader2, AlertTriangle } from "lucide-react";
import {Account} from "@/db/schema/02_user"; import { authClient } from "@/lib/auth/auth-client";
import {authClient} from "@/lib/auth/auth-client"; import { useMutation } from "@tanstack/react-query";
import {useMutation} from "@tanstack/react-query"; import { toast } from "sonner";
import {toast} from "sonner"; import { useRouter } from "next/navigation";
import {useRouter} from "next/navigation"; import {
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; Tooltip,
import {Alert, AlertDescription} from "@/components/ui/alert"; TooltipContent,
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal"; TooltipTrigger,
import {AuthProviderConfig} from "../../../../../portabase.config"; } from "@/components/ui/tooltip";
import {Icon} from "@iconify/react"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
import { Icon } from "@iconify/react";
import Image from "next/image";
import type { AuthProviderConfig } from "@/lib/auth/config";
import type { Account } from "@/db/schema/02_user";
interface ProfileProviderProps { interface ProfileProviderProps {
accounts: Account[]; accounts: Account[];
providers: AuthProviderConfig[] providers: AuthProviderConfig[];
} }
export function ProfileProviders({accounts, providers}: ProfileProviderProps) { export function ProfileProviders({
const router = useRouter(); accounts,
const totalConnected = accounts.length; providers,
const [loadingProvider, setLoadingProvider] = useState<string | null>(null); }: ProfileProviderProps) {
const router = useRouter();
const totalConnected = accounts.length;
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false); const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const {mutate: unlinkAccount} = useMutation({ const { mutate: unlinkAccount } = useMutation({
mutationFn: async (providerId: string) => { mutationFn: async (providerId: string) => {
setLoadingProvider(providerId); setLoadingProvider(providerId);
const {error} = await authClient.unlinkAccount({ providerId }); const { error } = await authClient.unlinkAccount({ providerId });
if (error) throw error; if (error) throw error;
}, },
onSuccess: () => { onSuccess: () => {
toast.success("Provider successfully unlinked!"); toast.success("Provider successfully unlinked!");
setLoadingProvider(null); setLoadingProvider(null);
router.refresh(); router.refresh();
}, },
onError: () => { onError: () => {
toast.error("An error occurred while unlinking provider."); toast.error("An error occurred while unlinking provider.");
setLoadingProvider(null); setLoadingProvider(null);
}, },
}); });
const {mutate: linkAccount} = useMutation({ const { mutate: linkAccount } = useMutation({
mutationFn: async (providerId: string) => { mutationFn: async (provider: AuthProviderConfig) => {
setLoadingProvider(providerId); setLoadingProvider(provider.id);
const {error} = await authClient.signIn.social({ if (provider.type === "social") {
provider: providerId as "google" | "github" | "credential", await authClient.linkSocial({
callbackURL: "/dashboard", provider: provider.id as string,
}); callbackURL: "/dashboard",
if (error) throw error; });
}, }
onSuccess: () => { },
toast.success("Provider successfully Linked!"); onSuccess: () => {
setLoadingProvider(null); setLoadingProvider(null);
router.refresh(); router.refresh();
}, },
onError: () => { onError: () => {
toast.error("An error occurred while linking provider."); toast.error("An error occurred while linking provider.");
setLoadingProvider(null); setLoadingProvider(null);
}, },
}); });
const enterpriseProviders = providers.filter(
(p) => p.type === "sso" && p.id !== "passkey" && p.type !== "credential",
);
const otherProviders = providers.filter(
(p) => p.type !== "sso" && p.id !== "passkey" && p.type !== "credential",
);
const renderProvider = (provider: AuthProviderConfig) => {
const linkedAccount = accounts.find(
(acc) => acc.providerId === provider.id,
);
const isConnected = !!linkedAccount;
const canUnlink =
totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
const isLoading = loadingProvider === provider.id;
const isUnlinkDisabled =
!canUnlink ||
provider.allowUnlinking === false ||
provider.type === "sso";
const unlinkButton = (
<Button
variant="outline"
size="sm"
onClick={() => unlinkAccount(provider.id)}
disabled={isUnlinkDisabled || isLoading || provider.isManual}
className={isUnlinkDisabled ? "opacity-50 cursor-not-allowed" : ""}
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Unlink"}
</Button>
);
let actionElement;
if (provider.type === "sso") {
actionElement = null;
} else if (!isConnected) {
actionElement =
provider.id === "credential" ? (
<SetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
) : (
<Button
variant="default"
size="sm"
onClick={() => linkAccount(provider)}
disabled={
isLoading || provider.isManual || provider.allowLinking === false
}
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Link"}
</Button>
);
} else if (isUnlinkDisabled) {
actionElement = (
<Tooltip>
<TooltipTrigger asChild>
<div tabIndex={0} className="inline-block">
{unlinkButton}
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{provider.allowUnlinking === false
? "Unlinking is disabled for this provider."
: "You cannot unlink your last authentication provider."}
</p>
</TooltipContent>
</Tooltip>
);
} else {
actionElement = unlinkButton;
}
return ( return (
<div className="space-y-8 animate-in fade-in-50 duration-300"> <div
<div className="mb-6 space-y-1"> key={provider.id}
<h2 className="text-2xl font-semibold tracking-tight">Connected Accounts</h2> className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors"
<p className="text-sm text-muted-foreground">Manage the providers used to sign in to your account.</p> >
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
{provider.icon.startsWith("/") ||
provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id}
width={20}
height={20}
className="w-5 h-5"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="w-5 h-5" />
)}
</div>
<div className="space-y-0.5">
<div className="font-medium flex items-center gap-2">
{provider.title || provider.name}
{isConnected && (
<Badge
variant="secondary"
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"
>
Active
</Badge>
)}
</div> </div>
<div className="text-sm text-muted-foreground">
<div className="grid gap-4"> {provider.description}
{providers.map((provider) => {
const linkedAccount = accounts.find((acc) => acc.providerId === provider.id);
const isConnected = !!linkedAccount;
// const canUnlink = totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
const canUnlink = totalConnected > 1 ;
// const isLoading = isUnlinking || isLinking;
const isLoading = loadingProvider === provider.id;
return (
<div key={provider.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<Icon icon={provider.icon} className="w-5 h-5"/>
</div>
<div className="space-y-0.5">
<div className="font-medium flex items-center gap-2">
{PROVIDERS_TEXT[provider.id].title}
{isConnected && (
<Badge variant="secondary"
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0">
Active
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
{isConnected ? "Connected" : "Not Connected"}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>
<Button
variant="outline"
size="sm"
onClick={() => unlinkAccount(provider.id)}
disabled={!canUnlink || isLoading || provider.isManual}
className={!canUnlink ? "opacity-50 cursor-not-allowed" : ""}
>
{isLoading ? <Loader2
className="w-4 h-4 animate-spin"/> : "Unlink"}
</Button>
</span>
</TooltipTrigger>
{!canUnlink && (
<TooltipContent>
<p>
You cannot unlink your last authentication provider or if you
don't have a password set.
</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
) : (
<>
{provider.id === "credential" ? (
<SetPasswordProfileProviderModal open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}/>
) : (
<Button
variant="default"
size="sm"
onClick={() => linkAccount(provider.id)}
disabled={isLoading || provider.isManual}
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin"/> : "Link"}
</Button>
)}
</>
)}
</div>
</div>
);
})}
</div> </div>
</div>
<Alert variant={"default"}>
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5"/>
<AlertDescription>
Linked providers allow you to log in to your account using any of these methods. If you use the same
email address with another provider, it will be automatically linked when you log in.
</AlertDescription>
</Alert>
</div> </div>
<div className="flex items-center gap-2">{actionElement}</div>
</div>
); );
};
return (
<div className="space-y-8 animate-in fade-in-50 duration-300 pb-10">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
Authentication
</h2>
<p className="text-sm text-muted-foreground">
Manage how you access your account.
</p>
</div>
{enterpriseProviders.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Enterprise Connection
</h3>
<div className="grid gap-4">
{enterpriseProviders.map(renderProvider)}
</div>
</div>
)}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Standard Connections
</h3>
<div className="grid gap-4">{otherProviders.map(renderProvider)}</div>
</div>
<Alert variant={"default"}>
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
<AlertDescription>
Linked providers allow you to log in to your account using any of
these methods.
</AlertDescription>
</Alert>
</div>
);
} }
const PROVIDERS_TEXT = {
credential: {
title: "Password",
description: "Use your email address and password to sign in.",
},
google: {
title: "Google",
description: "Sign in with your Google account.",
},
github: {
title: "GitHub",
description: "Sign in with your GitHub account.",
},
};
@@ -1,220 +1,493 @@
"use client"; "use client";
import {useState} from "react"; import { useState } from "react";
import {Button} from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {Separator} from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {Globe, LogOut, Loader2} from "lucide-react"; import {
import {Account, Session, User} from "@/db/schema/02_user"; Globe,
import {useMutation} from "@tanstack/react-query"; LogOut,
import {toast} from "sonner"; Loader2,
import {revokeAllSessionsAction, revokeSessionAction} from "./actions/security.action"; Fingerprint,
import {useRouter} from "next/navigation"; Trash2,
import {ResetPasswordProfileProviderModal} from "./modal/reset-password-modal"; Plus,
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal"; } from "lucide-react";
import {Setup2FAProfileProviderModal} from "./modal/setup-2fa-modal"; import { useMutation, useQuery } from "@tanstack/react-query";
import {Disable2FAProfileProviderModal} from "./modal/disable-2fa-modal"; import { toast } from "sonner";
import {ViewBackupCodesModal} from "./modal/view-backup-codes-modal"; import {
import {getDeviceDetails} from "@/utils/detection"; revokeAllSessionsAction,
import {timeAgo} from "@/utils/date-formatting"; revokeSessionAction,
getPasskeysAction,
revokePasskeyAction,
} from "./actions/security.action";
import { useRouter } from "next/navigation";
import { ResetPasswordProfileProviderModal } from "./modal/reset-password-modal";
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
import { Setup2FAProfileProviderModal } from "./modal/setup-2fa-modal";
import { Disable2FAProfileProviderModal } from "./modal/disable-2fa-modal";
import { ViewBackupCodesModal } from "./modal/view-backup-codes-modal";
import { getDeviceDetails } from "@/utils/detection";
import { timeAgo } from "@/utils/date-formatting";
import { authClient } from "@/lib/auth/auth-client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Account, Session, User } from "@/db/schema/02_user";
import { Icon } from "@iconify/react";
import Image from "next/image";
import type { AuthProviderConfig } from "@/lib/auth/config";
import { is } from "date-fns/locale";
interface ProfileSecurityProps { interface ProfileSecurityProps {
user: User; user: User;
sessions: Session[]; sessions: Session[];
credentialAccount: Account; credentialAccount: Account;
currentSession: Session; currentSession: Session;
isPasswordEnabled?: boolean;
isPasskeyEnabled?: boolean;
providers: AuthProviderConfig[];
} }
export function ProfileSecurity({user, sessions, credentialAccount, currentSession}: ProfileSecurityProps) { export function ProfileSecurity({
const router = useRouter(); user,
sessions,
credentialAccount,
currentSession,
isPasswordEnabled = false,
isPasskeyEnabled = false,
providers,
}: ProfileSecurityProps) {
const router = useRouter();
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false); const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false); const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false); const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false); const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
const [isAddPasskeyOpen, setIsAddPasskeyOpen] = useState(false);
const [passkeyName, setPasskeyName] = useState("");
const {mutate: revokeSession, isPending: isRevoking} = useMutation({ const { mutate: revokeSession, isPending: isRevoking } = useMutation({
mutationFn: async (token: string) => { mutationFn: async (token: string) => {
const result = await revokeSessionAction({token}); const result = await revokeSessionAction({ token });
const inner = result?.data; const inner = result?.data;
if (inner?.success) { if (inner?.success) {
toast.success("Session successfully revoked"); toast.success("Session successfully revoked");
router.refresh(); router.refresh();
} else { } else {
toast.error("An error occurred while revoking session"); toast.error("An error occurred while revoking session");
} }
}, },
}); });
const {mutate: revokeOthers, isPending: isRevokingOthers} = useMutation({ const { mutate: revokeOthers, isPending: isRevokingOthers } = useMutation({
mutationFn: async () => { mutationFn: async () => {
const result = await revokeAllSessionsAction(); const result = await revokeAllSessionsAction();
const inner = result?.data; const inner = result?.data;
if (inner?.success) { if (inner?.success) {
toast.success("Revoking all sessions successfully done."); toast.success("Revoking all sessions successfully done.");
router.refresh(); router.refresh();
} else { } else {
toast.error("An error occurred while revoking all sessions"); toast.error("An error occurred while revoking all sessions");
} }
}, },
}); });
return ( const {
<div className="space-y-8 animate-in fade-in-50 duration-300"> data: passkeys,
<div className="mb-6 space-y-1"> isLoading: isLoadingPasskeys,
<h2 className="text-2xl font-semibold tracking-tight">Security Settings</h2> refetch: refetchPasskeys,
<p className="text-sm text-muted-foreground">Manage your password, two-factor authentication and } = useQuery({
sessions.</p> queryKey: ["passkeys"],
</div> queryFn: async () => {
const result = await getPasskeysAction();
if (result?.data?.success) {
return result.data.value;
}
throw new Error("Failed to fetch passkeys");
},
});
<div className="space-y-6"> const { mutate: revokePasskey, isPending: isRevokingPasskey } = useMutation({
<h3 className="text-lg font-medium">Authentication</h3> mutationFn: async (id: string) => {
<div className="border rounded-lg p-4 space-y-4"> const result = await revokePasskeyAction({ id });
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> if (!result?.data?.success) {
<div className="space-y-1"> throw new Error("Failed to revoke passkey");
<div className="font-medium">Password</div> }
<div className="text-sm text-muted-foreground"> },
{user.lastChangedPasswordAt onSuccess: () => {
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}` toast.success("Passkey revoked successfully");
: "Never changed"} refetchPasskeys();
</div> },
</div> onError: () => {
{credentialAccount ? ( toast.error("Failed to revoke passkey");
<ResetPasswordProfileProviderModal open={isPasswordDialogOpen} },
onOpenChange={setIsPasswordDialogOpen}/> });
) : (
<SetPasswordProfileProviderModal open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}/>
)}
</div>
<Separator/> const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
mutationFn: async () => {
const result = await authClient.passkey.addPasskey({
name: passkeyName || "My Passkey",
});
if (result?.error) {
throw result.error;
}
return result;
},
onSuccess: () => {
toast.success("Passkey added successfully");
setIsAddPasskeyOpen(false);
setPasskeyName("");
refetchPasskeys();
},
onError: (error: any) => {
toast.error(error.message || "Failed to add passkey");
},
});
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> return (
<div className="space-y-1"> <div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="flex items-center gap-2"> <div className="mb-6 space-y-1">
<div className="font-medium">Two-Factor Authentication</div> <h2 className="text-2xl font-semibold tracking-tight">
{user.twoFactorEnabled && ( Security Settings
<Badge variant="secondary" </h2>
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"> <p className="text-sm text-muted-foreground">
Active Manage your password, two-factor authentication and sessions.
</Badge> </p>
)} </div>
</div>
<div className="text-sm text-muted-foreground">Enhance the security of your account by
requiring a second form of verification during login.
</div>
</div>
{user.twoFactorEnabled ? ( {(isPasskeyEnabled || isPasswordEnabled || user.twoFactorEnabled) && (
<div className="flex flex-col items-center gap-2"> <div className="space-y-6">
<ViewBackupCodesModal open={isBackupCodesDialogOpen} <h3 className="text-lg font-medium">Authentication</h3>
onOpenChange={setIsBackupCodesDialogOpen}/> <div className="border rounded-lg p-4 space-y-4">
<Disable2FAProfileProviderModal open={isDisable2FADialogOpen} {isPasswordEnabled && (
onOpenChange={setIsDisable2FADialogOpen}/> <>
</div> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
) : ( <div className="space-y-1">
<Setup2FAProfileProviderModal <div className="font-medium">Password</div>
disabled={!credentialAccount} <div className="text-sm text-muted-foreground">
open={isSetup2FADialogOpen} {user.lastChangedPasswordAt
onOpenChange={setIsSetup2FADialogOpen} ? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
/> : "Never changed"}
)}
</div> </div>
</div>
{credentialAccount ? (
<ResetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
) : (
<SetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
)}
</div> </div>
</div>
<div className="space-y-6"> <Separator />
<div className="flex items-center justify-between"> </>
<h3 className="text-lg font-medium">Active Sessions</h3> )}
{sessions && sessions.length > 1 && (
<Button {isPasswordEnabled && (
variant="ghost" <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
size="sm" <div className="space-y-1">
className="text-destructive hover:text-destructive hover:bg-destructive/10" <div className="flex items-center gap-2">
onClick={() => revokeOthers()} <div className="font-medium">Two-Factor Authentication</div>
disabled={isRevokingOthers || (sessions?.length || 0) <= 1} {user.twoFactorEnabled && (
> <Badge
{isRevokingOthers && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>} variant="secondary"
Revoke All className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"
</Button> >
Active
</Badge>
)} )}
</div>
<div className="text-sm text-muted-foreground">
Enhance the security of your account by requiring a second
form of verification during login.
</div>
</div> </div>
<div className="border rounded-lg divide-y">
{sessions && sessions.length > 0 ? ( {user.twoFactorEnabled ? (
sessions?.map((session) => ( <div className="flex flex-col items-center gap-2">
<SessionRow <ViewBackupCodesModal
key={session.id} open={isBackupCodesDialogOpen}
session={session} onOpenChange={setIsBackupCodesDialogOpen}
onRevoke={(token) => revokeSession(token)} />
isRevoking={isRevoking} <Disable2FAProfileProviderModal
currentSession={currentSession} open={isDisable2FADialogOpen}
/> onOpenChange={setIsDisable2FADialogOpen}
)) />
) : ( </div>
<div className="p-4 text-center text-muted-foreground">No active sessions found.</div> ) : (
)} <Setup2FAProfileProviderModal
</div> disabled={!credentialAccount}
</div> open={isSetup2FADialogOpen}
onOpenChange={setIsSetup2FADialogOpen}
/>
)}
</div>
)}
</div>
</div> </div>
); )}
{isPasskeyEnabled && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="text-lg font-medium">Passkeys</h3>
<div className="text-sm text-muted-foreground">
Login securely with your fingerprint, face recognition, or
hardware key.
</div>
</div>
<Dialog open={isAddPasskeyOpen} onOpenChange={setIsAddPasskeyOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Passkey
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Passkey</DialogTitle>
<DialogDescription>
Create a name for your passkey to identify it later.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Passkey Name</Label>
<Input
id="name"
placeholder="e.g. MacBook Pro, iPhone, YubiKey"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddPasskeyOpen(false)}
>
Cancel
</Button>
<Button
onClick={() => addPasskey()}
disabled={isAddingPasskey}
>
{isAddingPasskey && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Passkey
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-lg divide-y">
{isLoadingPasskeys ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : passkeys && passkeys.length > 0 ? (
passkeys.map((pk: any) => (
<PasskeyRow
key={pk.id}
passkey={pk}
onRevoke={(id) => revokePasskey(id)}
isRevoking={isRevokingPasskey}
/>
))
) : (
<div className="p-4 text-center text-muted-foreground">
No passkeys found.
</div>
)}
</div>
</div>
)}
<div className="space-y-6 pb-10">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Active Sessions</h3>
{sessions && sessions.length > 1 && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => revokeOthers()}
disabled={isRevokingOthers || (sessions?.length || 0) <= 1}
>
{isRevokingOthers && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Revoke All
</Button>
)}
</div>
<div className="border rounded-lg divide-y">
{sessions && sessions.length > 0 ? (
sessions?.map((session) => (
<SessionRow
key={session.id}
session={session}
onRevoke={(token) => revokeSession(token)}
isRevoking={isRevoking}
currentSession={currentSession}
providers={providers}
/>
))
) : (
<div className="p-4 text-center text-muted-foreground">
No active sessions found.
</div>
)}
</div>
</div>
</div>
);
} }
function SessionRow({ function SessionRow({
session, session,
onRevoke, onRevoke,
isRevoking, isRevoking,
currentSession, currentSession,
}: { providers,
session: Session; }: {
onRevoke: (token: string) => void; session: Session;
isRevoking: boolean; onRevoke: (token: string) => void;
currentSession: Session; isRevoking: boolean;
currentSession: Session;
providers: AuthProviderConfig[];
}) { }) {
const deviceInfo = getDeviceDetails(session.userAgent); const deviceInfo = getDeviceDetails(session.userAgent);
const provider = providers.find((p) => p.id === (session as any).providerId);
return ( return (
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground"> <div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground relative">
<deviceInfo.Icon className="w-5 h-5"/> <deviceInfo.Icon className="w-5 h-5" />
</div> {provider && (
<div className="space-y-0.5"> <div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-background border flex items-center justify-center overflow-hidden">
<div className="text-sm font-medium flex items-center gap-2"> {provider.icon.startsWith("/") ||
{deviceInfo.os} <span provider.icon.startsWith("http") ? (
className="text-muted-foreground font-normal"> {deviceInfo.browser}</span> <Image
{session.id === currentSession.id && ( src={provider.icon}
<Badge alt={provider.id}
variant="outline" width={12}
className="text-[10px] h-5 px-1.5 text-sky-600 bg-sky-50 border-sky-200 dark:bg-sky-900/20 dark:border-sky-800 dark:text-sky-400" height={12}
> className="w-3 h-3"
This device unoptimized={provider.icon.startsWith("http")}
</Badge> />
)} ) : (
</div> <Icon icon={provider.icon} className="w-3 h-3" />
<div className="text-xs text-muted-foreground flex items-center gap-1"> )}
<Globe className="w-3 h-3"/> {session.ipAddress}
<span className="ml-1">
{session.id === currentSession.id
? "Active now"
: `Last active ${timeAgo(new Date(session.createdAt))}`}
</span>
</div>
</div>
</div> </div>
)}
{session.id !== currentSession.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(session.token)}
disabled={isRevoking}
>
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin"/> : <LogOut className="w-4 h-4"/>}
<span className="sr-only">Revoke</span>
</Button>
)}
</div> </div>
); <div className="space-y-0.5">
<div className="text-sm font-medium flex items-center gap-2">
{deviceInfo.os}{" "}
<span className="text-muted-foreground font-normal">
{deviceInfo.browser}
</span>
{provider && (
<span className="text-muted-foreground font-normal">
{provider.title || provider.name}
</span>
)}
{session.id === currentSession.id && (
<Badge
variant="outline"
className="text-[10px] h-5 px-1.5 text-sky-600 bg-sky-50 border-sky-200 dark:bg-sky-900/20 dark:border-sky-800 dark:text-sky-400"
>
This device
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Globe className="w-3 h-3" /> {session.ipAddress}
<span className="ml-1">
{session.id === currentSession.id
? "Active now"
: `Last active ${timeAgo(new Date(session.createdAt))}`}
</span>
</div>
</div>
</div>
{session.id !== currentSession.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(session.token)}
disabled={isRevoking}
>
{isRevoking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogOut className="w-4 h-4" />
)}
<span className="sr-only">Revoke</span>
</Button>
)}
</div>
);
}
function PasskeyRow({
passkey,
onRevoke,
isRevoking,
}: {
passkey: any;
onRevoke: (id: string) => void;
isRevoking: boolean;
}) {
return (
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
<Fingerprint className="w-5 h-5" />
</div>
<div className="space-y-0.5">
<div className="font-medium text-sm">
{passkey.name || "Unnamed Passkey"}
</div>
<div className="text-xs text-muted-foreground">
Created {timeAgo(new Date(passkey.createdAt))}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(passkey.id)}
disabled={isRevoking}
>
{isRevoking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
<span className="sr-only">Revoke</span>
</Button>
</div>
);
} }
@@ -12,7 +12,7 @@ import {useMutation, useQueryClient} from "@tanstack/react-query";
import {toast} from "sonner"; import {toast} from "sonner";
import {MemberWithUser} from "@/db/schema/03_organization"; import {MemberWithUser} from "@/db/schema/03_organization";
import {deleteBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action"; import {deleteBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm"; import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
type DatabaseBackupListProps = { type DatabaseBackupListProps = {
@@ -69,19 +69,12 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
mutationFn: async (backups: Backup[]) => { mutationFn: async (backups: Backup[]) => {
const results = await Promise.all( const results = await Promise.all(
backups.map(async (backup) => { backups.map(async (backup) => {
if (backup.deletedAt == null) { if (backup.deletedAt == null || backup.status == "ongoing") {
const backupDeleted = await deleteBackupAction({ const backupDeleted = await deleteBackupAction({
databaseId: backup.databaseId, databaseId: backup.databaseId,
backupId: backup.id, backupId: backup.id,
}) })
// const backupDeleted = await deleteBackupAction({
// backupId: backup.id,
// databaseId: backup.databaseId,
// status: backup.status,
// file: backup.file ?? "",
// projectSlug: props.database?.project?.slug!
// });
return { return {
success: backupDeleted?.data?.success, success: backupDeleted?.data?.success,
message: backupDeleted?.data?.success message: backupDeleted?.data?.success
@@ -136,15 +129,9 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
>Actions</ButtonWithLoading> >Actions</ButtonWithLoading>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<ButtonWithConfirm <ButtonWithConfirm
onConfirm={() => { onConfirm={() => {
const backupsToDelete = rows.map(row => row mutationDeleteBackups.mutate(rows);
).filter(backup => backup.deletedAt == null)
if (backupsToDelete.length === 0) {
toast.error("No available backup selected for deletion.");
return;
}
mutationDeleteBackups.mutate(backupsToDelete);
setIsActionsOpen(false); setIsActionsOpen(false);
}} }}
onCancel={() => setIsActionsOpen(false)} onCancel={() => setIsActionsOpen(false)}

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