Compare commits

..
135 Commits
Author SHA1 Message Date
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
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
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
charlesgauthereau 362ca53efb fix: console.log 2026-02-13 15:58:53 +01:00
charlesgauthereau 7ab213e5e4 chore(release): 1.2.5 2026-02-13 14:35:11 +01:00
Charles GTEandGitHub f0eafb1162 Merge pull request #74 from Portabase/dev
dev
2026-02-13 14:31:52 +01:00
charlesgauthereau ad5baa7d6f Merge branch 'main' into dev 2026-02-13 14:31:03 +01:00
Charles GTEandGitHub 5840e30afb Merge pull request #73 from Portabase/feat/ui-ux-refactoring
feat/ui-ux-refactoring
2026-02-13 14:30:16 +01:00
charlesgauthereau 8f95606dd7 fix: review 2026-02-13 14:28:55 +01:00
Théo LAGACHE ca9bc7ae94 fix: policies reload 2026-02-13 11:24:53 +01:00
charlesgauthereau 288a40e697 chore(release): 1.2.5-rc.16 2026-02-12 20:25:29 +01:00
Charles GTEandGitHub fa91e8c289 Merge pull request #72 from Portabase/fix/encryption
fix/encryption
2026-02-12 20:20:25 +01:00
charlesgauthereau 4ab5fe4c6f fix: .gitignore 2026-02-12 20:18:56 +01:00
charlesgauthereau d87782d180 fix: Adding encryption of the settings. 2026-02-12 20:18:26 +01:00
charlesgauthereau 1c97c97fa1 fix: refactoring. 2026-02-12 19:07:34 +01:00
charlesgauthereau 2426166676 fix: working on AEA-GCM encryption. 2026-02-12 18:46:11 +01:00
Théo LAGACHE 6f6c1c7c52 fix 2026-02-12 15:48:51 +01:00
charlesgauthereau 7b9cb2ecb9 Merge remote-tracking branch 'origin/feat/fix-react-cve' into feat/agent-update 2026-02-11 19:42:57 +01:00
charlesgauthereau fc029af654 Merge branch 'feature' into feat/agent-update 2026-02-11 19:42:49 +01:00
Théo LAGACHE 110a69dc4b fix: react cve 19.2.4 2026-02-11 15:43:03 +01:00
Théo LAGACHE a61791fdb2 update: fix ui sidebar 2026-02-11 14:42:07 +01:00
charlesgauthereau c01440f9d4 chore(release): 1.2.5-rc.15 2026-02-11 12:34:12 +01:00
Charles GTEandGitHub 39584dd7a1 Merge pull request #69 from Portabase/feature
UI/UX enhancements
2026-02-11 12:33:16 +01:00
charlesgauthereau d9badeacba Merge branch 'feature' into feat/agent-update 2026-02-11 12:21:22 +01:00
Charles GTEandGitHub d4cde2ba9c Merge pull request #66 from Portabase/feat/refresh
Feat/refresh pagination
2026-02-11 12:18:52 +01:00
charlesgauthereau 824ef4528e fix: review and refactoring. 2026-02-11 12:18:25 +01:00
charlesgauthereau feb06a85ae Merge branch 'feature' into feat/refresh
# Conflicts:
#	app/api/agent/[agentId]/backup/route.ts
#	app/api/events/route.ts
#	src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx
#	src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts
#	src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx
#	src/lib/tasks/database/index.ts
2026-02-11 12:10:33 +01:00
Charles GTEandGitHub cfad645e33 Merge pull request #68 from Portabase/fix/large-files-storage-backend-v3
fix/large-files-storage-backend-v3
2026-02-11 12:00:57 +01:00
charlesgauthereau 675f8dec20 Merge branch 'main' into fix/large-files-storage-backend-v3
# Conflicts:
#	src/features/storages/providers/s3.ts
2026-02-11 11:54:09 +01:00
charlesgauthereau 11e85b613c chore(release): 1.2.5-rc.14 2026-02-10 20:50:33 +01:00
charlesgauthereau eb82833c7e chore(release): 1.2.5-rc.13 2026-02-10 20:47:04 +01:00
charlesgauthereau 399a3e09de chore(release): 1.2.5-rc.12 2026-02-10 20:39:55 +01:00
charlesgauthereau 1a735bda42 chore(release): 1.2.5-rc.11 2026-02-10 20:39:09 +01:00
charlesgauthereau 991196cc30 chore(release): 1.2.5-rc.10 2026-02-10 20:34:26 +01:00
charlesgauthereau 5f48903a73 chore(release): 1.2.5-rc.9 2026-02-10 20:25:54 +01:00
charlesgauthereau 5dfe1b758e fix: refactoring 2026-02-10 19:11:01 +01:00
Théo LAGACHE d24e534828 update: ui 2026-02-10 17:02:06 +01:00
Théo LAGACHE 80bdca138e add: agent update notifier, pagination 2026-02-10 15:52:08 +01:00
Théo LAGACHE 0a2ca8c0b6 add: font 2026-02-10 15:29:20 +01:00
Théo LAGACHE 921a355ae0 fix: project details table refresh 2026-02-10 11:44:45 +01:00
charlesgauthereau eff7b24b7a fix: nginx configration 2026-02-09 23:39:14 +01:00
charlesgauthereau c328620f3d fix: new storage architecture 2026-02-08 20:42:42 +01:00
Charles GTEandGitHub a49b6d6573 Merge pull request #61 from Portabase/revert-57-fix/s3
Revert "chore(release): 1.2.5-rc.1"
2026-02-04 09:57:01 +01:00
Charles GTEandGitHub baffd2efaf Revert "chore(release): 1.2.5-rc.1" 2026-02-04 09:56:34 +01:00
Charles GTEandGitHub 292693a1ed Merge pull request #57 from Portabase/fix/s3
chore(release): 1.2.5-rc.1
2026-02-04 09:56:11 +01:00
charlesgauthereau cdd01e695c fix: new storage architecture 2026-02-02 21:46:53 +01:00
charlesgauthereau 614a9e057c chore(release): 1.2.5-rc.8 2026-02-01 22:04:07 +01:00
charlesgauthereau 24d90bb750 chore(release): 1.2.5-rc.7 2026-02-01 21:14:35 +01:00
charlesgauthereau c2726e6b18 chore(release): 1.2.5-rc.6 2026-02-01 20:22:38 +01:00
charlesgauthereau a746ce9169 chore(release): 1.2.5-rc.5 2026-02-01 19:59:23 +01:00
charlesgauthereau c00b967179 chore(release): 1.2.5-rc.4 2026-02-01 19:56:02 +01:00
charlesgauthereau f6e749ecf9 chore(release): 1.2.5-rc.3 2026-02-01 19:51:16 +01:00
charlesgauthereau 8f5e0686c8 chore(release): 1.2.5-rc.3 2026-02-01 19:16:42 +01:00
charlesgauthereau b9f0d9279b chore(release): 1.2.5-rc.2 2026-02-01 18:25:00 +01:00
charlesgauthereau cd69f0c82f chore(release): 1.2.5-rc.1 2026-02-01 18:24:53 +01:00
charlesgauthereau 884746f3a5 chore(release): 1.2.5-rc.2 2026-02-01 18:24:22 +01:00
charlesgauthereau 9c4d862297 fix: refactoring, adding express api. 2026-02-01 15:53:50 +01:00
charlesgauthereau cc87cf68b5 fix: refactoring, adding express api. 2026-01-31 22:15:01 +01:00
killianlarcher db1a0235ed chore(release): 1.2.5-rc.1 2026-01-31 14:18:12 +01:00
Killian LarcherandGitHub 2cd6a7cae0 Merge pull request #56 from Portabase/fix/s3
fix(storage): add test button when creating channel (not only for existing one) and add region parameter for S3.
2026-01-31 13:03:58 +01:00
killianlarcher bd80fdc960 fix(storage): add test button when creating channel (not only for existing one) and add region parameter for S3. 2026-01-31 12:41:01 +01:00
charlesgauthereau 7c5d57ddc1 Merge branch 'main' into feature
# Conflicts:
#	CITATION.cff
#	package.json
2026-01-29 23:48:18 +01:00
charlesgauthereau 5653ff18c8 chore(release): 1.2.4 2026-01-29 23:47:56 +01:00
Charles GTEandGitHub 83ea444675 Merge pull request #52 from Portabase/fix/dialogs
fix/dialogs
2026-01-29 23:46:53 +01:00
charlesgauthereau 966edfa85a fix: some fix responsive 2026-01-29 23:46:04 +01:00
charlesgauthereau 30b9fc7765 fix: server/client error on some dialogs. 2026-01-29 23:11:33 +01:00
charlesgauthereau 95a60e9a96 fix: server/client error on some dialogs. 2026-01-29 19:10:59 +01:00
Killian LarcherandGitHub 9327331adc Merge pull request #51 from Portabase/feat/github-updater
fix: check if update is available based on auto-detected version channel.
2026-01-28 21:43:47 +01:00
killian-larcher e8baa441cd fix: check if update is available based on auto-detected version channel. 2026-01-28 21:40:55 +01:00
Killian LarcherandGitHub 61d142afbd Merge pull request #50 from Portabase/feat/github-updater
fix: check if update is available based on auto-detected version channel
2026-01-28 20:23:06 +01:00
killian-larcher 46022e8711 fix: check if update is available based on auto-detected version channel. 2026-01-28 20:22:14 +01:00
Killian LarcherandGitHub e59f3c46fd Merge pull request #49 from Portabase/feat/organization-dropdown
fix: adjust organisation dropdown size.
2026-01-28 19:49:23 +01:00
killian-larcher 7fcb3f2c31 fix: adjust organisation dropdown size. 2026-01-28 19:48:40 +01:00
Killian LarcherandGitHub a2243585a0 Merge pull request #48 from Portabase/feat/github-updater
Feat/GitHub updater
2026-01-28 19:18:22 +01:00
killian-larcher 6231002043 fix: error in package.json 2026-01-28 19:17:31 +01:00
killian-larcher 33eb476009 Merge branch 'main' into feat/github-updater
# Conflicts:
#	CITATION.cff
#	package.json
2026-01-28 19:14:33 +01:00
Killian LarcherandGitHub ebb0a4c7e5 Merge pull request #47 from Portabase/feat/modal-page
Feat/modal page
2026-01-28 19:07:55 +01:00
Théo LAGACHE d010ed6bc5 chore(release): 1.2.4-rc.8 2026-01-28 16:55:43 +01:00
Théo LAGACHE 1941e29175 chore(release): 1.2.4-rc.7 2026-01-28 16:39:53 +01:00
Théo LAGACHE 6e8932ff52 chore(release): 1.2.4-rc.6 2026-01-28 16:39:46 +01:00
Théo LAGACHE f9bfae9b9d chore(release): 1.2.4-rc.5 2026-01-28 15:56:24 +01:00
Théo LAGACHE 2fca928cf0 chore(release): 1.2.4-rc.4 2026-01-28 15:55:57 +01:00
Théo LAGACHE 8f3a362a3a chore(release): 1.2.4-rc.3 2026-01-28 15:55:28 +01:00
Théo LAGACHE 62a6189788 chore(release): 1.2.4-rc.3 2026-01-28 15:54:59 +01:00
Théo LAGACHE 2a6d631719 chore(release): 1.2.4-rc.1 2026-01-28 15:54:03 +01:00
Théo LAGACHEandGitHub 4942591d7f Merge pull request #45 from Portabase/feat/organization-dropdown
update: new org combobox
2026-01-28 13:57:08 +01:00
Théo LAGACHE 1760c1b852 update: new org combobox 2026-01-28 13:51:14 +01:00
charlesgauthereau 86d7ab9864 chore(release): 1.2.4-rc.2 2026-01-24 19:34:56 +01:00
charlesgauthereau f201e4593a Merge branch 'main' into feature 2026-01-24 19:34:49 +01:00
charlesgauthereau 71129e556e chore(release): 1.2.4-rc.1 2026-01-24 19:06:47 +01:00
Charles GTEandGitHub 0e7ebb79db Merge pull request #42 from Portabase/fix/ntfy
fix: added support for Basic Auth ntfy instance
2026-01-24 19:05:46 +01:00
charlesgauthereau 727d9eb57c fix: added support for Basic Auth ntfy instance 2026-01-24 19:04:49 +01:00
207 changed files with 10102 additions and 4938 deletions
+1
View File
@@ -28,3 +28,4 @@ AUTH_GOOGLE_METHOD=
# Retention # Retention
RETENTION_CRON="* * * * *" RETENTION_CRON="* * * * *"
+76 -51
View File
@@ -4,10 +4,6 @@ on:
workflow_call: workflow_call:
inputs: inputs:
image_name: image_name:
required: false
type: string
default: 'solucetechnologies/portabase'
image_name2:
required: false required: false
type: string type: string
default: 'portabase/portabase' default: 'portabase/portabase'
@@ -28,70 +24,99 @@ on:
required: true required: true
DOCKER_PASSWORD: DOCKER_PASSWORD:
required: true required: true
DOCKER_USERNAME_2:
required: true
DOCKER_PASSWORD_2:
required: true
jobs: jobs:
build-and-push:
runs-on: ubuntu-latest build:
permissions: name: Build and push Docker images
packages: write runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
contents: read strategy:
fail-fast: false
matrix:
platform: [ linux/amd64, linux/arm64 ]
steps: steps:
- name: Check out the repo - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set tags - name: Login to Docker
id: set-tags
run: |
REF_NAME=${GITHUB_REF#refs/tags/}
TAGS="${{ inputs.image_name }}:$REF_NAME"
TAGS2="${{ inputs.image_name2 }}:$REF_NAME"
if [[ "${{ inputs.add_latest }}" == "true" ]]; then
TAGS="$TAGS,${{ inputs.image_name }}:latest"
TAGS2="$TAGS2,${{ inputs.image_name2 }}:latest"
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "tags2=$TAGS2" >> $GITHUB_OUTPUT
- name: Set up QEMU (enables multi-arch emulation)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub account 1
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push to solucetechnologies/portabase - name: Set image tag
id: set-tags
run: |
REF_NAME=${GITHUB_REF#refs/tags/}
if [ "${{ matrix.platform }}" = "linux/amd64" ]; then
IMAGE="${{ inputs.image_name }}:$REF_NAME-amd64"
else
IMAGE="${{ inputs.image_name }}:$REF_NAME-arm64"
fi
echo "image=$IMAGE" >> $GITHUB_OUTPUT
- name: Build and push image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ${{ inputs.dockerfile }} file: ${{ inputs.dockerfile }}
platforms: linux/amd64,linux/arm64 platforms: ${{ matrix.platform }}
push: true push: true
tags: ${{ steps.set-tags.outputs.tags }} tags: ${{ steps.set-tags.outputs.image }}
target: ${{ inputs.target }} target: ${{ inputs.target }}
- name: Log in to Docker Hub account 2 - name: Prepare artifact name
id: artifact
run: |
platform=${{ matrix.platform }}
echo "safe_platform=${platform//\//-}" >> $GITHUB_OUTPUT
echo "${{ steps.set-tags.outputs.image }}" > image.txt
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: image-${{ steps.artifact.outputs.safe_platform }}
path: image.txt
if-no-files-found: warn
compression-level: 6
overwrite: false
include-hidden-files: false
create-manifest:
name: Create multi-arch Docker manifest (Portabase)
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
with:
name: image-linux-amd64
path: /tmp/digests/amd64
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
with:
name: image-linux-arm64
path: /tmp/digests/arm64
- name: Login to Docker
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with: with:
username: ${{ secrets.DOCKER_USERNAME_2 }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD_2 }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create and push manifest list
working-directory: /tmp/digests
run: |
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
REF_NAME=${GITHUB_REF#refs/tags/}
MANIFEST_IMAGE="${{ inputs.image_name }}:$REF_NAME"
docker buildx imagetools create $DOCKER_IMAGES -t $MANIFEST_IMAGE
docker buildx imagetools inspect $MANIFEST_IMAGE
if [ "${{ inputs.add_latest }}" = "true" ]; then
docker buildx imagetools create $DOCKER_IMAGES -t ${{ inputs.image_name }}:latest
docker buildx imagetools inspect ${{ inputs.image_name }}:latest
fi
- name: Build and push to portabase/portabase
uses: docker/build-push-action@v6
with:
context: .
file: ${{ inputs.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.set-tags.outputs.tags2 }}
target: ${{ inputs.target }}
+2 -4
View File
@@ -15,10 +15,8 @@ jobs:
with: with:
add_latest: false add_latest: false
secrets: secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release: github_release:
needs: docker_publish needs: docker_publish
+2 -4
View File
@@ -16,10 +16,8 @@ jobs:
with: with:
add_latest: true add_latest: true
secrets: secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release: github_release:
needs: docker_publish needs: docker_publish
+1
View File
@@ -40,6 +40,7 @@ public/uploads/*
!public/uploads/ !public/uploads/
private/uploads/* private/uploads/*
private/keys/*
/.env /.env
+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"
] ]
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.2.3 version: 1.3.0-rc.1
date-released: "2026-01-24" date-released: "2026-02-23"
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright 2024 Soluce Technologies Copyright 2024 Portabase
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
+5 -7
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>
@@ -23,7 +23,6 @@
[![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 +33,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 +50,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)
+1 -1
View File
@@ -16,7 +16,7 @@ export default async function GuardPage() {
return ( return (
<TooltipProvider> <TooltipProvider>
<CardAuth className="w-full max-w-md"> <CardAuth className="w-full">
<CardHeader> <CardHeader>
<div className="grid gap-2 text-center mb-2"> <div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Two-factor verification</h1> <h1 className="text-3xl font-bold">Two-factor verification</h1>
+11 -4
View File
@@ -2,6 +2,8 @@ import React from "react";
import {redirect} from "next/navigation"; import {redirect} from "next/navigation";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section"; import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
import {env} from "@/env.mjs";
import {Heart} from "lucide-react";
export default async function Layout({children}: { children: React.ReactNode }) { export default async function Layout({children}: { children: React.ReactNode }) {
@@ -12,13 +14,18 @@ export default async function Layout({children}: { children: React.ReactNode })
} }
return ( return (
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 "> <div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
<div className="flex flex-col items-center justify-center flex-1">
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<AuthLogoSection/> <AuthLogoSection/>
<div>{children}</div> <div className="mt-4">{children}</div>
</div> </div>
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground"> </div>
Powered by <span className="font-medium">Soluce Technologies</span> <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> </footer>
</div> </div>
) )
+4 -3
View File
@@ -1,9 +1,10 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form"; import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar"; import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
import {CardAuth} from "@/features/layout/card-auth";
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) { export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
@@ -23,7 +24,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
return ( return (
<TooltipProvider> <TooltipProvider>
<Card className="w-full max-w-md shadow-lg"> <CardAuth className="w-full">
<CardHeader className="space-y-4"> <CardHeader className="space-y-4">
<div className="space-y-1 text-center"> <div className="space-y-1 text-center">
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1> <h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
@@ -53,7 +54,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
<CardContent> <CardContent>
<ResetPasswordForm /> <ResetPasswordForm />
</CardContent> </CardContent>
</Card> </CardAuth>
</TooltipProvider> </TooltipProvider>
); );
} }
@@ -1,30 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db";
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
import {notFound} from "next/navigation";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
if (!settings) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System email</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsEmailSection settings={settings}/>
</PageContent>
</Page>
);
}
@@ -1,29 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
import {db} from "@/db";
import {notFound} from "next/navigation";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
if (!settings) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System storage</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsStorageSection settings={settings}/>
</PageContent>
</Page>
);
}
@@ -1,32 +0,0 @@
import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
import { notFound } from "next/navigation";
import { db } from "@/db";
export default async function RoutePage(
props: PageParams<{
agentId: string;
}>
) {
const { agentId } = await props.params;
const agent = await db.query.agent.findFirst({
where: (fields, { eq }) => eq(fields.id, agentId),
});
if (!agent) {
notFound();
}
return (
<Page>
<PageHeader>
<PageTitle>Edit {agent.name}</PageTitle>
</PageHeader>
<PageContent>
<AgentForm defaultValues={agent} agentId={agent.id} />
</PageContent>
</Page>
);
}
@@ -1,8 +1,5 @@
import Link from "next/link";
import {GearIcon} from "@radix-ui/react-icons";
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {buttonVariants} from "@/components/ui/button";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
@@ -12,6 +9,8 @@ import {capitalizeFirstLetter} from "@/utils/text";
import {generateEdgeKey} from "@/utils/edge_key"; import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url"; import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content"; import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
import {AgentDialog} from "@/features/agents/components/agent.dialog";
import {AgentType} from "@/features/agents/agents.schema";
export default async function RoutePage(props: PageParams<{ agentId: string }>) { export default async function RoutePage(props: PageParams<{ agentId: string }>) {
@@ -39,10 +38,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
</div> </div>
<div className="flex items-center gap-2 md:justify-between w-full "> <div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link className={buttonVariants({variant: "outline"})} <AgentDialog agent={agent as AgentType & { id: string }} typeTrigger={"edit"}/>
href={`/dashboard/agents/${agent.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/> <ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
@@ -1,21 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {AgentForm} from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Create Agent",
};
export default async function RoutePage(props: PageParams<{}>) {
return (
<Page>
<PageHeader>
<PageTitle>Create new agent</PageTitle>
</PageHeader>
<PageContent>
<AgentForm/>
</PageContent>
</Page>
);
}
@@ -1,15 +1,13 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card"; import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {Button} from "@/components/ui/button";
import Link from "next/link";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {desc, eq, not} from "drizzle-orm"; import {desc, eq, not} from "drizzle-orm";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {Metadata} from "next"; import {Metadata} from "next";
import {AgentDialog} from "@/features/agents/components/agent.dialog";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Agents", title: "Agents",
@@ -25,7 +23,6 @@ export default async function RoutePage(props: PageParams<{}>) {
orderBy: (fields) => desc(fields.lastContact), orderBy: (fields) => desc(fields.lastContact),
}); });
if (!agents) { if (!agents) {
notFound(); notFound();
} }
@@ -36,9 +33,7 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageTitle>Agents</PageTitle> <PageTitle>Agents</PageTitle>
{agents.length > 0 && ( {agents.length > 0 && (
<PageActions> <PageActions>
<Link href={"/dashboard/agents/new"}> <AgentDialog typeTrigger={"create"} />
<Button>+ Create Agent</Button>
</Link>
</PageActions> </PageActions>
)} )}
</PageHeader> </PageHeader>
@@ -46,10 +41,7 @@ export default async function RoutePage(props: PageParams<{}>) {
{agents.length > 0 ? ( {agents.length > 0 ? (
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/> <CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
) : ( ) : (
<EmptyStatePlaceholder <AgentDialog typeTrigger="empty"/>
url={"/dashboard/agents/new"}
text={"Create new Agent"}
/>
)} )}
</PageContent> </PageContent>
</Page> </Page>
@@ -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,21 +1,11 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {notFound, redirect} from "next/navigation"; import {notFound, redirect} from "next/navigation";
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {Page} from "@/features/layout/page";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {db} from "@/db"; import {db} from "@/db";
import {eq, and, inArray} from "drizzle-orm"; import {eq, and, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/lib/services"; import {getOrganizationProjectDatabases} from "@/lib/services";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {capitalizeFirstLetter} from "@/utils/text";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
import {HardDrive, Megaphone} from "lucide-react";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content"; import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
@@ -93,12 +83,6 @@ export default async function RoutePage(props: PageParams<{
notFound(); notFound();
} }
const organizationChannels = await getOrganizationChannels(organization.id);
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
const organizationStorageChannels = await getOrganizationStorageChannels(organization.id);
const activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
@@ -106,49 +90,6 @@ export default async function RoutePage(props: PageParams<{
return ( return (
<Page> <Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(dbItem.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<RetentionPolicySheet database={dbItem}/>
<CronButton database={dbItem}/>
<ChannelPoliciesModal
database={dbItem}
kind={"notification"}
icon={<Megaphone/>}
channels={activeOrganizationChannels}
organizationId={organization.id}
/>
<ChannelPoliciesModal
database={dbItem}
icon={<HardDrive/>}
kind={"storage"}
channels={activeOrganizationStorageChannels}
organizationId={organization.id}
/>
<ImportModal database={dbItem}/>
</div>
<div className="flex items-center gap-2">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
</div>
</div>
)}
</PageTitle>
</div>
{dbItem.description && (
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full">
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
totalBackups={totalBackups}/>
<BackupModalProvider> <BackupModalProvider>
<DatabaseContent <DatabaseContent
activeMember={activeMember} activeMember={activeMember}
@@ -157,9 +98,14 @@ export default async function RoutePage(props: PageParams<{
isAlreadyRestore={isAlreadyRestore} isAlreadyRestore={isAlreadyRestore}
restorations={restorations} restorations={restorations}
backups={backups} backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/> />
</BackupModalProvider> </BackupModalProvider>
</PageContent>
</Page> </Page>
); );
} }
@@ -1,65 +0,0 @@
import {notFound} from "next/navigation";
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {DatabaseWith} from "@/db/schema/07_database";
export default async function RoutePage(props: PageParams<{ projectId: string }>) {
const {projectId} = await props.params;
const proj = await db.query.project.findFirst({
where: eq(drizzleDb.schemas.project.id, projectId),
with: {
databases: true,
},
});
if (!proj) {
notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, "default"),
});
if (!org) {
notFound();
}
const availableDatabases = (
await db.query.database.findMany({
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
)
// .filter((db) => db.project !== null) as DatabaseWith[];
return (
<Page>
<PageHeader>
<PageTitle>Edit {proj.name}</PageTitle>
</PageHeader>
<PageContent>
<ProjectForm
organization={org}
databases={availableDatabases as DatabaseWith[]}
defaultValues={{...proj, databases: proj.databases.map((db) => db.id)}}
projectId={proj.id}
/>
</PageContent>
</Page>
);
}
@@ -1,8 +1,5 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageTitle} from "@/features/layout/page";
import {buttonVariants} from "@/components/ui/button";
import {GearIcon} from "@radix-ui/react-icons";
import Link from "next/link";
import { import {
ButtonDeleteProject ButtonDeleteProject
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project"; } from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
@@ -15,6 +12,10 @@ import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text"; import {capitalizeFirstLetter} from "@/utils/text";
import {ProjectDialog} from "@/features/projects/components/project.dialog";
import {DatabaseWith} from "@/db/schema/07_database";
import {ProjectWith} from "@/db/schema/06_project";
import {isUuidv4} from "@/utils/verify-uuid";
export default async function RoutePage(props: PageParams<{ export default async function RoutePage(props: PageParams<{
@@ -24,6 +25,10 @@ export default async function RoutePage(props: PageParams<{
projectId projectId
} = await props.params; } = await props.params;
if (!isUuidv4(projectId)) {
notFound()
}
const organization = await getOrganization({}); const organization = await getOrganization({});
const activeMember = await getActiveMember() const activeMember = await getActiveMember()
@@ -51,6 +56,19 @@ export default async function RoutePage(props: PageParams<{
redirect("/dashboard/projects"); redirect("/dashboard/projects");
} }
const availableDatabases = (
await db.query.database.findMany({
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
) as DatabaseWith[];
const isMember = activeMember?.role === "member"; const isMember = activeMember?.role === "member";
return ( return (
@@ -63,10 +81,12 @@ export default async function RoutePage(props: PageParams<{
{!isMember && ( {!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full "> <div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link className={buttonVariants({variant: "outline"})} <ProjectDialog
href={`/dashboard/projects/${proj.id}/edit`}> databases={availableDatabases}
<GearIcon className="w-7 h-7"/> organization={org}
</Link> project={proj as ProjectWith}
isEdit={true}
/>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/> <ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
@@ -1,50 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {DatabaseWith} from "@/db/schema/07_database";
export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({});
if (!organization) {
notFound();
}
const availableDatabases = (
await db.query.database.findMany({
where: (db, {isNull}) => isNull(db.projectId),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
).filter((db) => db.project == null) as DatabaseWith[];
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!org) notFound();
return (
<Page>
<PageHeader>
<PageTitle>Create new project</PageTitle>
</PageHeader>
<PageContent>
<ProjectForm databases={availableDatabases} organization={org}/>
</PageContent>
</Page>
);
}
@@ -1,8 +1,5 @@
import Link from "next/link";
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {Button} from "@/components/ui/button";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card"; import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
import {db} from "@/db"; import {db} from "@/db";
@@ -10,6 +7,8 @@ import {notFound} from "next/navigation";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder"; import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {Metadata} from "next"; import {Metadata} from "next";
import {ProjectDialog} from "@/features/projects/components/project.dialog";
import {DatabaseWith} from "@/db/schema/07_database";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Projects", title: "Projects",
@@ -36,6 +35,19 @@ export default async function RoutePage(props: PageParams<{}>) {
}); });
const isMember = activeMember?.role === "member"; const isMember = activeMember?.role === "member";
const availableDatabases = (
await db.query.database.findMany({
where: (db, {isNull}) => isNull(db.projectId),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
).filter((db) => db.project == null) as DatabaseWith[];
return ( return (
<Page> <Page>
@@ -43,9 +55,7 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageTitle>Projects</PageTitle> <PageTitle>Projects</PageTitle>
{(projects.length > 0 && !isMember) && ( {(projects.length > 0 && !isMember) && (
<PageActions> <PageActions>
<Link href={`/dashboard/projects/new`}> <ProjectDialog databases={availableDatabases} organization={organization}/>
<Button>+ Create Project</Button>
</Link>
</PageActions> </PageActions>
)} )}
</PageHeader> </PageHeader>
@@ -56,13 +66,13 @@ export default async function RoutePage(props: PageParams<{}>) {
organizationSlug={organization.slug} organizationSlug={organization.slug}
data={projects} data={projects}
cardItem={ProjectCard} cardItem={ProjectCard}
cardsPerPage={6} cardsPerPage={9}
numberOfColumns={3} numberOfColumns={3}
/> />
) : isMember ? ( ) : isMember ? (
<EmptyStatePlaceholder text="No project available"/> <EmptyStatePlaceholder text="No project available"/>
) : ( ) : (
<EmptyStatePlaceholder url="/dashboard/projects/new" text="Create new Project"/> <ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/>
)} )}
</PageContent> </PageContent>
</Page> </Page>
@@ -1,37 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/organization-form/organization-form";
import {getOrganization} from "@/lib/auth/auth";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
import {currentUser} from "@/lib/auth/current-user";
export default async function RoutePage(props: PageParams<{
}>) {
const user = await currentUser();
const organization = await getOrganization({});
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt)
});
if (!user || !users || !organization || organization.slug == "default") {
notFound();
}
return(
<Page>
<PageHeader>
<PageTitle>
Edit {organization.name}
</PageTitle>
</PageHeader>
<PageContent>
<OrganizationForm currentUser={user} users={users} defaultValues={organization}/>
</PageContent>
</Page>
)
}
@@ -9,9 +9,13 @@ import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button"; import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
import { import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
EditButtonSettings import {Button} from "@/components/ui/button";
} from "@/components/wrappers/dashboard/organization/settings/edit-button-settings/edit-button-settings"; import {GearIcon} from "@radix-ui/react-icons";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Settings", title: "Settings",
@@ -22,7 +26,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
const user = await currentUser(); const user = await currentUser();
const activeMember = await getActiveMember() const activeMember = await getActiveMember()
if (!organization || !activeMember) { if (!organization || !activeMember || !user) {
notFound(); notFound();
} }
@@ -30,6 +34,22 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
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({
where: (fields) => isNull(fields.deletedAt)
});
const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id),
with: {
members: {
with: {
user: true
}
}
}
});
if (!organizationWithMembers) notFound();
return ( return (
<Page> <Page>
@@ -41,7 +61,11 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
<div className="flex items-center gap-2 md:justify-between w-full "> <div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{permissions.canManageSettings && organization.slug !== "default" && ( {permissions.canManageSettings && organization.slug !== "default" && (
<EditButtonSettings/> <EditOrganizationDialog
organization={organizationWithMembers}
users={users}
currentUser={user}
/>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+43 -44
View File
@@ -1,53 +1,52 @@
import fs from "node:fs"; import { NextResponse } from "next/server";
import forge from "node-forge"; import { eq } from "drizzle-orm";
import { db } from "@/db";
import * as drizzleDb from "@/db";
export function withAgentCheck(handler: Function) {
return async (request: Request, context: { params: Promise<{ agentId: string }> }) => {
try {
const agentId = (await context.params).agentId;
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> { const agent = await db.query.agent.findFirst({
const privateKeyPem = fs.readFileSync("private/keys/server_private.pem", "utf8"); where: eq(drizzleDb.schemas.agent.id, agentId),
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
// Decrypt AES key with RSA-OAEP
const encryptedAesKey = forge.util.hexToBytes(aesKeyHex);
const aesKey = privateKey.decrypt(encryptedAesKey, "RSA-OAEP", {
md: forge.md.sha256.create(),
mgf1: {md: forge.md.sha256.create()},
}); });
// Read encrypted file content if (!agent) {
const encryptedBuffer = Buffer.from(await file.arrayBuffer()); return NextResponse.json(
const iv = forge.util.hexToBytes(ivHex); { error: "Agent not found" },
{ status: 404 }
// AES decryption
const decipher = forge.cipher.createDecipher("AES-CBC", aesKey);
decipher.start({iv});
decipher.update(forge.util.createBuffer(encryptedBuffer.toString("binary")));
const success = decipher.finish();
if (!success) {
throw new Error("Decryption failed");
}
const decryptedBytes = decipher.output.getBytes();
const decryptedBuffer = Buffer.from(decryptedBytes, "binary");
// Return a File so you can use file.arrayBuffer() later
return new File(
[decryptedBuffer],
file.name.replace(/\.enc$/, fileExtension),
{type: "application/octet-stream"}
); );
}
export function getFileExtension(dbType: string) {
switch (dbType) {
case "postgresql":
return ".dump";
case "mysql":
return ".sql";
default:
return ".dump";
} }
return handler(request, { ...context, agent });
} catch (err) {
console.error("Error in agent middleware:", err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
};
} }
export async function getDatabaseOrThrow(generatedId: string) {
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
with: {
project: true,
alertPolicies: true,
storagePolicies: true
}
});
if (!database) {
throw NextResponse.json(
{ error: "Database associated with generatedId not found" },
{ status: 404 }
);
}
return database;
}
+99 -122
View File
@@ -1,79 +1,47 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import {v4 as uuidv4} from "uuid";
import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {Backup} from "@/db/schema/07_database";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db as dbClient, db} from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "./helpers";
import {Backup} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {decryptedDump, getFileExtension} from "./helpers"; import {eventEmitter} from "@/features/shared/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {storeBackupFiles} from "@/features/storages/helpers"; import {EventKind} from "@/features/notifications/types";
export async function POST( export type BodyPost = {
request: Request, method: "manual" | "automatic"
{params}: { params: Promise<{ agentId: string }> } generatedId: string
) { }
export type BodyPatch = {
backupId: string
status: "success" | "failed"
size: number
generatedId: string
}
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
params: Promise<{ agentId: string }>,
agent: any
}) => {
try { try {
const contentType = request.headers.get("Content-Type"); const body: BodyPost = await request.json();
const method = body.method
if (!contentType || !contentType.includes("multipart/form-data")) { const database = await getDatabaseOrThrow(body.generatedId);
return NextResponse.json(
{error: "Unsupported or missing Content-Type"},
{status: 400}
);
}
eventEmitter.emit('modification', {update: true});
const agentId = (await params).agentId;
const formData = await request.formData();
const aesKeyHex = formData.get("aes_key") as string;
const ivHex = formData.get("iv") as string;
const generatedId = formData.get("generatedId") as string | null;
const method = formData.get("method") as string | null;
if (!generatedId || !isUuidv4(generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid UUID"},
{status: 400}
);
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
});
if (!agent) {
return NextResponse.json(
{error: "Agent not found"},
{status: 404}
);
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
with: {
project: true,
alertPolicies: true,
storagePolicies: true
}
});
if (!database) {
return NextResponse.json(
{error: "Database associated with generatedId not found"},
{status: 404}
);
}
let backup: Backup | null | undefined = null; let backup: Backup | null | undefined = null;
if (method === "automatic") { if (method === "automatic") {
const ongoingBackup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.status, 'ongoing'),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!ongoingBackup) {
[backup] = await db [backup] = await db
.insert(drizzleDb.schemas.backup) .insert(drizzleDb.schemas.backup)
.values({ .values({
@@ -81,14 +49,18 @@ export async function POST(
databaseId: database.id, databaseId: database.id,
}) })
.returning(); .returning();
if (!backup) { if (!backup) {
return NextResponse.json( return NextResponse.json(
{error: "Unable to create an automatic backup"}, {error: "Unable to create an automatic backup"},
{status: 500} {status: 500}
); );
} }
} else {
return NextResponse.json(
{error: "A backup is already ongoing"},
{status: 500}
);
}
} else { } else {
backup = await db.query.backup.findFirst({ backup = await db.query.backup.findFirst({
where: and( where: and(
@@ -97,7 +69,6 @@ export async function POST(
), ),
}); });
if (!backup) { if (!backup) {
return NextResponse.json( return NextResponse.json(
{error: "Unable to find the corresponding backup"}, {error: "Unable to find the corresponding backup"},
@@ -106,71 +77,77 @@ export async function POST(
} }
} }
const status = formData.get("status") as string | null;
if (status === "success") {
const file = formData.get("file") as File | null;
const extension = formData.get("extension") as string | null;
if (!aesKeyHex || !ivHex) {
return NextResponse.json({error: "Missing fields"}, {status: 400});
}
if (!file) {
return NextResponse.json(
{error: "File is required for successful backup"},
{status: 400}
);
}
const fileSizeBytes = file.size;
// const fileExtension = '.' + (file.name.split('.').pop()?.toLowerCase() || '');
const fileExtension = extension ? extension : getFileExtension(database.dbms)
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
const uuid = uuidv4();
const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
console.log(extension)
const storageResults = await storeBackupFiles(backup, database, buffer, fileName)
eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, "success_backup");
return NextResponse.json(
{
message: "Backup successfully uploaded",
},
{status: 200}
);
} else {
await db
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({status: 'failed'}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
eventEmitter.emit('modification', {update: true}); eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, "error_backup");
return NextResponse.json( return NextResponse.json(
{ {
message: "Backup successfully updated with status failed", message: "Init backup success",
backup: backup,
}, },
{status: 200} {status: 200}
); );
}
} catch (error) { } catch (error) {
console.error("Error in POST handler:", error); console.error("Error in POST for INIT backup:", error);
return NextResponse.json( return NextResponse.json(
{error: "Internal server error"}, {error: "Internal server error"},
{status: 500} {status: 500}
); );
} }
} });
export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
params: Promise<{ agentId: string }>,
agent: any
}) => {
try {
const body: BodyPatch = await request.json();
const status = body.status
const backupId = body.backupId
const backupSize = body.size
const database = await getDatabaseOrThrow(body.generatedId);
const backup = await db.query.backup.findFirst({
where: eq(drizzleDb.schemas.backup.id, backupId),
});
if (!backup) {
return NextResponse.json(
{error: "No backup found"},
{status: 500}
);
}
const [backupUpdated] = await dbClient
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
status: status,
fileSize: backupSize
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id))
.returning();
eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, `${status}_backup` as EventKind);
return NextResponse.json(
{
message: "Backup successfully updated",
backup: backupUpdated,
},
{status: 200}
);
} catch (error) {
console.error("Error in PATCH backup:", error);
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
);
}
});
@@ -0,0 +1,76 @@
import {NextResponse} from "next/server";
import {and, eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
import {isUuidv4} from "@/utils/verify-uuid";
import {eventEmitter} from "@/features/shared/event";
export type Body = {
generatedId: string
storageChannelId: string
backupId: string
}
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
params: Promise<{ agentId: string }>,
agent: any
}) => {
try {
const body: Body = await request.json();
console.log("body", body);
const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId;
const backupId = body.backupId;
if (!generatedId || !isUuidv4(generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid UUID"},
{status: 400}
);
}
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
return NextResponse.json(
{error: "Unable to find the corresponding backup"},
{status: 404}
);
}
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: backup.id,
storageChannelId: storageChannelId,
status: "pending",
})
.returning();
eventEmitter.emit('modification', {update: true});
return NextResponse.json(
{
message: "Backup storage successfully created",
backupStorage: backupStorage
},
{status: 200}
);
} catch (error) {
console.error("Error in POST for INIT backup:", error);
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
);
}
});
@@ -0,0 +1,95 @@
import {NextResponse} from "next/server";
import {and, eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db as dbClient, db} from "@/db";
import {withUpdatedAt} from "@/db/utils";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
import {eventEmitter} from "@/features/shared/event";
export type Body = {
generatedId: string
status: "success" | "failed"
backupStorageId: string
path: string
size: number
backupId: string
}
export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
params: Promise<{ agentId: string }>,
agent: any
}) => {
try {
const body: Body = await request.json();
const generatedId = body.generatedId;
const status = body.status;
const filePath = body.path;
const fileSize = body.size;
const backupStorageId = body.backupStorageId;
const backupId = body.backupId;
console.log("body", body);
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
with: {
storages: true
}
});
if (!backup) {
return NextResponse.json(
{error: "Unable to find the corresponding backup"},
{status: 404}
);
}
const [backupStorage] = await dbClient
.update(drizzleDb.schemas.backupStorage)
.set(withUpdatedAt({
status: status,
path: filePath,
size: fileSize
}))
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorageId))
.returning();
if (backup.storages.length > 0) {
const hasSuccessfulStorage = backup.storages.some(
(storage) => storage.status === "success"
);
if (hasSuccessfulStorage && backup.status !== "success") {
await db
.update(drizzleDb.schemas.backup)
.set({
status: "success",
fileSize: fileSize,
})
.where(eq(drizzleDb.schemas.backup.id, backup.id));
}
}
eventEmitter.emit('modification', {update: true});
return NextResponse.json({
message: "Backup status successfully updated",
backupStorage: backupStorage
},
{status: 200}
);
} catch (error) {
console.error("Error in POST for INIT backup:", error);
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
);
}
});
+1 -3
View File
@@ -1,10 +1,10 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {eventEmitter} from "@/features/shared/event";
export type BodyResultRestore = { export type BodyResultRestore = {
generatedId: string generatedId: string
@@ -19,7 +19,6 @@ export async function POST(
) { ) {
try { try {
eventEmitter.emit('modification', {update: true});
const agentId = (await params).agentId const agentId = (await params).agentId
const body: BodyResultRestore = await request.json(); const body: BodyResultRestore = await request.json();
@@ -72,7 +71,6 @@ export async function POST(
details: "Restoration successfully updated" details: "Restoration successfully updated"
} }
eventEmitter.emit('modification', {update: true});
return Response.json(response, {status: 200}) return Response.json(response, {status: 200})
} catch (error) { } catch (error) {
+98 -53
View File
@@ -2,21 +2,24 @@ import {NextResponse} from "next/server";
import {Body} from "./route"; import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import {Agent} from "@/db/schema/08_agent"; import {Agent} from "@/db/schema/08_agent";
import {Database} from "@/db/schema/07_database"; import {Database, DatabaseWith} from "@/db/schema/07_database";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db as dbClient} from "@/db"; import {db, db as dbClient} from "@/db";
import {and, eq, inArray} from "drizzle-orm"; import {and, eq, inArray} from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types"; import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import type {StorageInput} from "@/features/storages/types"; import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch"; import {dispatchStorage} from "@/features/storages/dispatch";
import {Setting} from "@/db/schema/01_setting";
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) { export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
const databasesResponse = []; const databasesResponse = [];
const formatDatabase = (database: Database, backupAction: boolean, restoreAction: boolean, UrlBackup: string) => ({ const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null) => ({
generatedId: database.agentDatabaseId, generatedId: database.agentDatabaseId,
dbms: database.dbms, dbms: database.dbms,
storages: storages,
encrypt: settings.encryption,
data: { data: {
backup: { backup: {
action: backupAction, action: backupAction,
@@ -25,6 +28,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
restore: { restore: {
action: restoreAction, action: restoreAction,
file: UrlBackup, file: UrlBackup,
metaFile: urlMeta
}, },
}, },
}); });
@@ -32,12 +36,16 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
for (const db of body.databases) { for (const db of body.databases) {
const existingDatabase = await dbClient.query.database.findFirst({ const existingDatabase = await dbClient.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId) where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId),
with: {
project: true
}
}); });
let backupAction: boolean = false let backupAction: boolean = false
let restoreAction: boolean = false let restoreAction: boolean = false
let urlBackup: string = "" let urlBackup: string | null = null;
let urlMeta: string | null = null
if (!existingDatabase) { if (!existingDatabase) {
if (!isUuidv4(db.generatedId)) { if (!isUuidv4(db.generatedId)) {
@@ -63,8 +71,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
}) })
.returning(); .returning();
if (databaseCreated) { if (databaseCreated) {
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup)); const storages = await getDatabaseStorageChannels(databaseCreated.id)
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
} }
} else { } else {
@@ -78,7 +89,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id)) .where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
.returning(); .returning();
const activeBackup = await dbClient.query.backup.findFirst({ const activeBackup = await dbClient.query.backup.findFirst({
where: and( where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
@@ -86,7 +96,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
) )
}) })
const restoration = await dbClient.query.restoration.findFirst({ const restoration = await dbClient.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")), where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")),
with: { with: {
@@ -94,7 +103,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
} }
}) })
if (activeBackup && activeBackup.status == "waiting") { if (activeBackup && activeBackup.status == "waiting") {
backupAction = true backupAction = true
@@ -107,13 +115,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
if (restoration) { if (restoration) {
restoreAction = true restoreAction = true
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) { if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
restoreAction = false restoreAction = false
continue; continue;
} }
const input: StorageInput = { const input: StorageInput = {
action: "get", action: "get",
data: { data: {
@@ -126,12 +132,26 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
} }
}; };
const inputMeta: StorageInput = {
action: "get",
data: {
path: `${restoration.backupStorage.path}.meta`,
signedUrl: true,
},
metadata: {
storageId: restoration.backupStorage.storageChannelId,
fileKind: "backups"
}
};
try { try {
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId); const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
const resultMeta = await dispatchStorage(inputMeta, undefined, restoration.backupStorage.storageChannelId);
if (result.success) { if (result.success) {
urlBackup = result.url ?? ""; urlBackup = result.url ?? null;
urlMeta = resultMeta.url ?? null
} else { } else {
await dbClient await dbClient
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
@@ -151,53 +171,78 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
continue; continue;
} }
// const fileName = backupToRestore?.file
//
// let data: SafeActionResult<string, ZodString, readonly [], {
// _errors?: string[] | undefined;
// }, readonly [], ServerActionResult<string>, object> | undefined
//
// try {
//
// if (settings.storage == "local") {
// data = await getFileUrlPresignedLocal({fileName: fileName!})
// } else if (settings.storage == "s3") {
//
// data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
// }
//
// if (data?.data?.success) {
// urlBackup = data.data.value ?? "";
// } else {
// await dbClient
// .update(drizzleDb.schemas.restoration)
// .set({status: "failed"})
// .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
//
// // @ts-ignore
// const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
// console.error("Restoration failed: ", errorMessage);
//
// continue;
// }
// } catch (err) {
// console.error("Restoration crashed unexpectedly:", err);
// await dbClient
// .update(drizzleDb.schemas.restoration)
// .set({status: "failed"})
// .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
//
// continue;
// }
await dbClient await dbClient
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "ongoing"}) .set({status: "ongoing"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
} }
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup)); const storages = await getDatabaseStorageChannels(databaseUpdated.id)
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
} }
} }
return databasesResponse; return databasesResponse;
} }
type PingDatabaseStorageChannels = {
id: string;
config: any
provider: string
}
async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatabaseStorageChannels[]> {
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
}
});
if (!database) {
return []
}
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: {storageChannel: true},
});
const defaultStorageChannel: PingDatabaseStorageChannels[] = settings?.storageChannel
? [{
id: settings.storageChannel.id,
provider: settings.storageChannel.provider,
config: settings.storageChannel.config,
}]
: [];
const enabledDatabaseStorageChannels = await Promise.all(
(database.storagePolicies ?? [])
.filter(p => p.enabled)
.map(async policy => {
const storageChannel = await db.query.storageChannel.findFirst({
where: eq(drizzleDb.schemas.storageChannel.id, policy.storageChannelId),
});
if (!storageChannel) return null;
return {
id: storageChannel.id,
config: storageChannel.config,
provider: storageChannel.provider,
} as PingDatabaseStorageChannels;
})
);
const filteredChannels: PingDatabaseStorageChannels[] = enabledDatabaseStorageChannels.filter(
(c): c is PingDatabaseStorageChannels => c !== null
);
return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel;
}
+13 -3
View File
@@ -1,12 +1,13 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {handleDatabases} from "./helpers"; import {handleDatabases} from "./helpers";
import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {EDbmsSchema} from "@/db/schema/types"; import {EDbmsSchema} from "@/db/schema/types";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {eventEmitter} from "@/features/shared/event";
import {notFound} from "next/navigation";
export type databaseAgent = { export type databaseAgent = {
name: string, name: string,
@@ -26,12 +27,14 @@ export async function POST(
) { ) {
try { try {
const agentId = (await params).agentId const agentId = (await params).agentId
console.log(agentId)
const body: Body = await request.json(); const body: Body = await request.json();
const lastContact = new Date(); const lastContact = new Date();
let message: string let message: string
if (!isUuidv4(agentId)) { if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid" message = "agentId is not a valid uuid"
console.error(message)
return NextResponse.json( return NextResponse.json(
{error: "agentId is not a valid uuid"}, {error: "agentId is not a valid uuid"},
{status: 500} {status: 500}
@@ -46,7 +49,14 @@ export async function POST(
message = "Agent not found" message = "Agent not found"
return NextResponse.json({error: message}, {status: 404}) return NextResponse.json({error: message}, {status: 404})
} }
const databasesResponse = await handleDatabases(body, agent, lastContact)
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return NextResponse.json({error: "An error occured"}, {status: 404})
}
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
await db await db
.update(drizzleDb.schemas.agent) .update(drizzleDb.schemas.agent)
@@ -56,7 +66,6 @@ export async function POST(
})) }))
.where(eq(drizzleDb.schemas.agent.id, agentId)); .where(eq(drizzleDb.schemas.agent.id, agentId));
eventEmitter.emit('modification', {update: true});
const response = { const response = {
agent: { agent: {
@@ -66,6 +75,7 @@ export async function POST(
databases: databasesResponse databases: databasesResponse
} }
return Response.json(response) return Response.json(response)
} catch (error) { } catch (error) {
console.error('Error in POST handler:', error); console.error('Error in POST handler:', error);
+1 -4
View File
@@ -1,9 +1,7 @@
import {EventEmitter} from 'events';
import {auth} from "@/lib/auth/auth"; import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers"; import {headers} from "next/headers";
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {eventEmitter} from "@/features/shared/event";
export const eventEmitter = new EventEmitter();
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -27,7 +25,6 @@ export async function GET(request: Request) {
eventEmitter.on('modification', handleModification); eventEmitter.on('modification', handleModification);
// Handle client disconnect
request.signal.addEventListener('abort', () => { request.signal.addEventListener('abort', () => {
console.log('Client disconnected'); console.log('Client disconnected');
controller.close(); controller.close();
+3 -4
View File
@@ -35,10 +35,9 @@ export async function GET(
const result = await dispatchStorage(input, undefined, storageId); const result = await dispatchStorage(input, undefined, storageId);
if (!result.success) { if (!result.success) {
return NextResponse.json({error: "Enable to get file from privided storage channel, an error occurred !"}) return NextResponse.json({error: "Enable to get file from provided storage channel, an error occurred !"})
} }
const fileName = path.basename(pathFromUrl); const fileName = path.basename(pathFromUrl);
const crypto = require('crypto'); const crypto = require('crypto');
@@ -58,14 +57,14 @@ export async function GET(
); );
} }
if (!result.file || !Buffer.isBuffer(result.file)) { if (!result.file || !(result.file instanceof Readable)) {
return NextResponse.json( return NextResponse.json(
{error: "Invalid file payload"}, {error: "Invalid file payload"},
{status: 500} {status: 500}
); );
} }
const fileStream = Readable.from(result.file); const fileStream = Readable.from(result.file as Readable);
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
+2 -16
View File
@@ -26,20 +26,6 @@ export async function GET(
return NextResponse.json({error: "Missing storageId in search params"}, {status: 404}) return NextResponse.json({error: "Missing storageId in search params"}, {status: 404})
} }
// const settings = await db.query.setting.findFirst({
// where: eq(drizzleDb.schemas.setting.name, "system"),
// with: {
// storageChannel: true
// }
// });
//
// if (!settings || !settings.storageChannel) {
// return NextResponse.json({error: "Unable to get settings or no default storage channel"});
// }
const ext = fileName.split(".").pop()?.toLowerCase(); const ext = fileName.split(".").pop()?.toLowerCase();
const contentType = const contentType =
ext === "png" ext === "png"
@@ -69,7 +55,7 @@ export async function GET(
const result = await dispatchStorage(input, undefined, storageId); const result = await dispatchStorage(input, undefined, storageId);
if (!result.file || !Buffer.isBuffer(result.file)) { if (!result.file || !(result.file instanceof Readable)) {
console.error(`An error occurred while getting file :`, result); console.error(`An error occurred while getting file :`, result);
return NextResponse.json( return NextResponse.json(
{error: "Invalid file payload"}, {error: "Invalid file payload"},
@@ -77,7 +63,7 @@ export async function GET(
); );
} }
const fileStream = Readable.from(result.file); const fileStream = Readable.from(result.file as Readable);
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
+80
View File
@@ -0,0 +1,80 @@
import {NextResponse} from "next/server";
import fs from "fs";
import path from "path";
import {env} from "@/env.mjs";
export async function POST(request: Request) {
try {
const body = await request.json();
const event = body.Event
const headers = event.HTTPRequest.Header
const uploadLength = headers["X-File-Size"]?.[0];
const uploadOffset = headers["Upload-Offset"]?.[0];
const status = headers["X-Status"]?.[0];
console.log(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`);
if (status === "success") {
if (
body.Type === "post-receive" &&
event.Upload.SizeIsDeferred === false &&
event.Upload.Offset === event.Upload.Size
) {
const id = event.Upload.ID;
const fileName = headers["X-File-Name"]?.[0];
const filePath = headers["X-File-Path"]?.[0];
if (!filePath) {
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
}
// const uploadDir = path.join(process.cwd(), "/private/uploads/");
const uploadDir = path.join(env.PRIVATE_PATH, "/uploads/");
const oldFilePath = path.join(uploadDir, "tmp", id);
const newFilePath = path.join(uploadDir, filePath);
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
let retries = 10;
while (!fs.existsSync(oldFilePath)) {
if (retries-- === 0) {
return NextResponse.json({error: `Upload file not found: ${oldFilePath}`}, {status: 500});
}
await new Promise(r => setTimeout(r, 200));
}
fs.renameSync(oldFilePath, newFilePath);
const infoFilePath = `${oldFilePath}.info`;
if (fs.existsSync(infoFilePath)) {
fs.unlinkSync(infoFilePath);
}
const metadataHeaderB64 = headers["Upload-Metadata"]?.[0];
if (metadataHeaderB64) {
const metadataHeader = Buffer.from(metadataHeaderB64, "base64").toString("utf-8");
if (metadataHeader) {
const tomlContent = metadataHeader
.split(",")
.map((pair) => {
const [key, value] = pair.split(" ");
const escapedValue = value.replace(/"/g, '\\"');
return `${key} = "${escapedValue}"`;
})
.join("\n");
const metaFilePath = `${newFilePath}.meta`;
fs.writeFileSync(metaFilePath, tomlContent, "utf-8");
}
}
}
}
return NextResponse.json({});
} catch (error) {
console.error("Hook error:", error);
return NextResponse.json({error: "Internal server error"}, {status: 500});
}
}
+26 -11
View File
@@ -6,8 +6,11 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-poppins), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-title: var(--font-poppins), var(--font-poppins), sans-serif;
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-author: var(--font-author);
--font-poppins: var(--font-poppins);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -71,7 +74,7 @@
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.611 0.204 42.149);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
@@ -82,7 +85,7 @@
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.611 0.204 42.149);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
@@ -90,12 +93,12 @@
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.611 0.204 42.149);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.611 0.204 42.149);
} }
.dark { .dark {
@@ -105,8 +108,8 @@
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.704 0.191 47.132);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
@@ -116,7 +119,7 @@
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.704 0.191 47.132);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
@@ -124,12 +127,12 @@
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.704 0.191 47.132);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.704 0.191 47.132);
} }
@layer base { @layer base {
@@ -137,6 +140,18 @@
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground font-sans;
}
h1, h2, h3, h4, h5, h6 {
@apply font-title;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
} }
} }
+2 -2
View File
@@ -4,7 +4,7 @@ import "./globals.css";
import {Providers} from "./providers"; import {Providers} from "./providers";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer"; import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
import {inter} from "@/fonts/fonts"; import {author, geistMono, poppins} from "@/fonts/fonts";
const title = process.env.PROJECT_NAME ?? "App Title"; const title = process.env.PROJECT_NAME ?? "App Title";
@@ -27,7 +27,7 @@ export default async function RootLayout({
<head> <head>
<meta name="apple-mobile-web-app-title" content={title}/> <meta name="apple-mobile-web-app-title" content={title}/>
</head> </head>
<body className={cn(inter.className, "h-full")}> <body className={cn(poppins.variable, author.variable, geistMono.variable, "font-sans h-full")}>
<ConsoleSilencer/> <ConsoleSilencer/>
<Providers> <Providers>
{children} {children}
-1
View File
@@ -6,7 +6,6 @@ import {ThemeProvider} from "@/features/theme/theme-provider";
import {Toaster} from "@/components/ui/sonner"; import {Toaster} from "@/components/ui/sonner";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root"; import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
export type ProviderProps = PropsWithChildren<{}>; export type ProviderProps = PropsWithChildren<{}>;
+4 -4
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: solucetechnologies/portabase:latest image: portabase/portabase:1.2.7-rc.1
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:
+14
View File
@@ -17,5 +17,19 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
tusd:
image: tusproject/tusd:v2.8.0
ports:
- "1080:8080"
command: >
-upload-dir /data/uploads/tmp
-hooks-http http://localhost:8887/api/tus/hooks
-max-size 21474836480
-base-path /tus/files/
extra_hosts:
- "localhost:host-gateway"
volumes:
- ./private/uploads/tmp:/data/uploads/tmp
volumes: volumes:
postgres-data: postgres-data:
+76 -54
View File
@@ -1,53 +1,80 @@
FROM node:22-alpine AS base #FROM node:22-bullseye AS base
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
RUN apt-get update && apt-get install -y \
curl \
ca-certificates \
bash \
tzdata \
libc6 \
build-essential \
g++ \
make \
autoconf \
automake \
libtool \
git \
nginx \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
RUN apt-get update && apt-get install -y \
postgresql-18 \
postgresql-client-18 \
postgresql-contrib-18 \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
ARG TUSD_VERSION=2.8.0
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
RUN git clone https://github.com/tus/tusd.git . \
&& git checkout v${TUSD_VERSION}
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \
go build -ldflags="-s -w" -o /tusddist/tusd ./cmd/tusd
FROM scratch AS tusd-dist
COPY --from=tusd-base /tusddist/tusd /tusd
RUN apk add --update --no-cache \
libc6-compat \
openssl \
tzdata
FROM base AS build-env FROM base AS build-env
RUN apk add --update --no-cache \
build-base \
g++ \
make \
libtool \
autoconf \
automake
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
FROM build-env AS deps FROM build-env AS deps
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm i --frozen-lockfile RUN pnpm i --frozen-lockfile
FROM build-env AS dev FROM build-env AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY --from=tusd-base /usr/local/bin/tusd /usr/local/bin/tusd
WORKDIR /app
COPY . . COPY . .
USER root USER root
RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"] ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
FROM build-env AS builder FROM build-env AS builder
WORKDIR /app WORKDIR /app
@@ -55,51 +82,46 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build RUN pnpm run build
FROM base AS prod FROM base AS prod
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=80
ENV HOSTNAME="0.0.0.0"
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
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
RUN mkdir .next
RUN chown nextjs:nodejs .next
RUN mkdir -p /app/private/uploads
RUN chown -R nextjs:nodejs /app/private
RUN chown -R nextjs:nodejs /app/public
COPY --from=builder /app/next.config.ts ./ COPY --from=builder /app/next.config.ts ./
COPY --from=builder /app/portabase.config.ts ./ COPY --from=builder /app/portabase.config.ts ./
COPY --from=builder /app/drizzle.config.ts ./ COPY --from=builder /app/drizzle.config.ts ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs src/db ./src/db COPY --chown=1001:1001 src/db ./src/db
COPY --from=deps /app/node_modules ./node_modules
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
RUN chmod +x /usr/local/bin/tusd
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
#RUN mkdir -p .next /app/private/uploads \
# && chown -R nextjs:nodejs .next /app/private /app/public
RUN mkdir -p .next /data/private/uploads \
&& 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
ENV PORT=80
ENV HOSTNAME="0.0.0.0"
USER nextjs
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"] ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
+71 -2
View File
@@ -7,8 +7,77 @@ else
echo "[WARN] No TZ provided, using default container timezone" echo "[WARN] No TZ provided, using default container timezone"
fi fi
POSTGRES_BIN=$(ls -d /usr/lib/postgresql/*/bin | head -n 1)
node server.js if [ -z "$POSTGRES_BIN" ]; then
echo "PostgreSQL binaries not found"
exit 1
fi
export PATH="$POSTGRES_BIN:$PATH"
if [ -z "$DATABASE_URL" ]; then
echo "[INFO] No DATABASE_URL provided, starting internal Postgres..."
mkdir -p "$PGDATA"
chown -R postgres:postgres "$PGDATA"
if [ ! -f "$PGDATA/PG_VERSION" ]; then
echo "[INFO] Initializing database cluster..."
if ! su postgres -c "initdb -D '$PGDATA'" > /dev/null 2>&1; then
echo "[ERROR] initdb failed"
exit 1
fi
fi
if ! su postgres -c "pg_ctl -D '$PGDATA' \
-o \"-c listen_addresses='localhost' -c logging_collector=on\" \
-l $PGDATA/postgres.log -w start" > /dev/null 2>&1; then
echo "[ERROR] PostgreSQL failed to start"
exit 1
fi
until su postgres -c "pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; do
sleep 1
done
echo "[INFO] PostgreSQL server is up and accepting connections"
DB_USER="${POSTGRES_USER:-portabase_user}"
DB_PASS="${POSTGRES_PASSWORD:-JaB6b1SUtIWYvt7srnOt}"
DB_NAME="${POSTGRES_DB:-portabase_db}"
USER_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" 2>/dev/null)
if [ "$USER_EXISTS" != "1" ]; then
if ! su postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\"" > /dev/null 2>&1; then
echo "[ERROR] Failed creating user"
exit 1
fi
fi
DB_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" 2>/dev/null)
if [ "$DB_EXISTS" != "1" ]; then
if ! su postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\"" > /dev/null 2>&1; then
echo "[ERROR] Failed creating database"
exit 1
fi
fi
export DATABASE_URL="postgres://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME"
echo "[SUCCESS] Internal PostgreSQL started successfully"
echo "[SUCCESS] Database: $DB_NAME | User: $DB_USER | Host: 127.0.0.1:5432"
fi
mkdir -p /data/private/uploads/tmp
echo "▶ Starting tusd server..."
tusd --base-path /tus/files/ --upload-dir /data/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 &
echo "▶ Starting Next.js server..."
PORT=3000 node server.js &
echo "▶ Starting nginx..."
exec nginx -g "daemon off;"
exec "$@"
+41
View File
@@ -0,0 +1,41 @@
events {}
http {
client_max_body_size 20G;
ignore_invalid_headers off;
server {
listen 80;
location /tus/ {
proxy_pass http://127.0.0.1:1080/tus/;
proxy_pass_request_headers on;
proxy_request_buffering off;
proxy_buffering off;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
+13 -1
View File
@@ -1,6 +1,8 @@
import type {NextConfig} from "next"; import type {NextConfig} from "next";
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config"; import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config";
const isDev = process.env.NODE_ENV === "development";
function buildCSPHeader(): string { function buildCSPHeader(): string {
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY; const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY;
@@ -46,8 +48,18 @@ const nextConfig: NextConfig = {
bodySizeLimit: "10gb", bodySizeLimit: "10gb",
}, },
proxyClientMaxBodySize: '10gb', proxyClientMaxBodySize: '10gb',
}, },
async rewrites() {
if (!isDev) return [];
return [
{
source: "/tus/:path*",
destination: "http://localhost:1080/tus/:path*",
},
];
},
async headers() { async headers() {
return [ return [
{ {
+83 -80
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.2.3", "version": "1.3.0-rc.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -14,115 +14,118 @@
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts" "auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.10", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.6", "@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.9", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.10", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.14", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.13", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.6", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.14", "@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.12", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.6", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.6", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.8", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.4", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.6", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.4", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.13", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.8", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^0.0.41", "@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.4", "@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.76.1", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.17", "@types/express": "^5.0.6",
"@types/nodemailer": "^6.4.22",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2", "@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.0", "argon2": "^0.43.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-auth": "1.4.5", "better-auth": "1.4.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dockerode": "^4.0.6", "dockerode": "^4.0.9",
"dotenv": "^16.5.0", "dotenv": "^16.6.1",
"drizzle-orm": "^0.43.1", "drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1", "drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"express": "^5.2.1",
"googleapis": "^170.1.0", "googleapis": "^170.1.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"minio": "^8.0.5", "minio": "^8.0.6",
"motion": "^12.23.24", "motion": "^12.34.0",
"next": "16.0.10", "next": "16.1.5",
"next-safe-action": "^7.10.8", "next-safe-action": "^7.10.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-forge": "^1.3.1", "node-forge": "^1.3.3",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.13",
"npm-check-updates": "^18.0.1", "npm-check-updates": "^18.3.1",
"pg": "^8.16.0", "pg": "^8.18.0",
"prettier": "^3.8.0", "prettier": "^3.8.1",
"react": "^19.2.0", "react": "^19.2.4",
"react-day-picker": "9.7.0", "react-day-picker": "9.7.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.4",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.4.1",
"react-email": "^4.0.13", "react-email": "^4.3.2",
"react-hook-form": "^7.56.3", "react-hook-form": "^7.71.1",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
"react-resizable-panels": "^3.0.2", "react-resizable-panels": "^3.0.6",
"react-twc": "^1.4.2", "react-twc": "^1.5.1",
"react-use-measure": "^2.1.7", "react-use-measure": "^2.1.7",
"recharts": "^2.15.3", "recharts": "^2.15.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"socket.io": "^4.8.1", "socket.io": "^4.8.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.3",
"sonner": "^2.0.3", "sonner": "^2.0.7",
"swiper": "^12.0.3", "swiper": "^12.1.0",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.4.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"ws": "^8.18.2", "ws": "^8.19.0",
"zod": "^3.24.4" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@iconify/react": "^6.0.0", "@iconify/react": "^6.0.2",
"@react-email/preview-server": "4.3.2", "@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.1", "@react-email/render": "^2.0.4",
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.18",
"@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18", "@types/node": "^22.19.11",
"@types/node-forge": "^1", "@types/node-forge": "^1.3.14",
"@types/pg": "^8.15.2", "@types/pg": "^8.16.0",
"@types/react": "^19.1.4", "@types/react": "^19.2.14",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.2.3",
"@zenstackhq/openapi": "^2.14.2", "@zenstackhq/openapi": "^2.22.1",
"@zenstackhq/tanstack-query": "^2.14.2", "@zenstackhq/tanstack-query": "^2.22.2",
"baseline-browser-mapping": "^2.8.32", "baseline-browser-mapping": "^2.9.19",
"drizzle-kit": "^0.31.1", "drizzle-kit": "^0.31.9",
"eslint": "^9.39.0", "esbuild": "^0.27.3",
"eslint-config-next": "^16.0.1", "eslint": "^9.39.2",
"eslint-plugin-tailwindcss": "^3.18.0", "eslint-config-next": "^16.1.6",
"framer-motion": "^12.24.7", "eslint-plugin-tailwindcss": "^3.18.2",
"postcss": "^8.5.3", "framer-motion": "^12.34.0",
"tailwindcss": "^4.1.7", "postcss": "^8.5.6",
"tsx": "^4.19.4", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.2.9", "tsx": "^4.21.0",
"typescript": "^5.8.3", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"zenstack": "2.14.2" "zenstack": "2.14.2"
}, },
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316" "packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
} }
+1993 -2493
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -1,5 +1,3 @@
export interface AuthProviderConfig { export interface AuthProviderConfig {
id: "google" | "github" | "credential"; id: "google" | "github" | "credential";
isActive: boolean; isActive: boolean;
@@ -64,7 +62,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: ["()"],
+3 -1
View File
@@ -53,11 +53,13 @@ function checkRouteExists(pathname: string) {
const routePatterns = [ const routePatterns = [
/^\/api\/agent\/[^/]+\/status\/?$/, /^\/api\/agent\/[^/]+\/status\/?$/,
/^\/api\/agent\/[^/]+\/backup\/?$/, /^\/api\/agent\/[^/]+\/backup\/?$/,
/^\/api\/agent\/[^/]+\/backup\/upload\/init\/?$/,
/^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/,
/^\/api\/agent\/[^/]+\/restore\/?$/, /^\/api\/agent\/[^/]+\/restore\/?$/,
/^\/api\/files\/images\/[^/]+\/?$/, /^\/api\/files\/images\/[^/]+\/?$/,
/^\/api\/files\/backups\/?$/, /^\/api\/files\/backups\/?$/,
/^\/api\/tus\/hooks\/?$/,
/^\/api\/events\/?$/, /^\/api\/events\/?$/,
/^\/api\/init\/?$/,
/^\/api\/config\/?$/, /^\/api\/config\/?$/,
/^\/api\/google\/drive\/callback\/?$/, /^\/api\/google\/drive\/callback\/?$/,
]; ];
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ set -e
if [ -z "$1" ]; then if [ -z "$1" ]; then
echo "Usage: ./release <version>" echo "Usage: ./release <version>"
echo "Example: ./release v1.0.0" echo "Example: ./release 1.0.0"
exit 1 exit 1
fi fi
+6
View File
@@ -0,0 +1,6 @@
import express from "express";
import router from "../src/features/api/router";
export function mountApi(app: express.Express) {
app.use("/services/v1", router);
}
+52
View File
@@ -0,0 +1,52 @@
import next from "next";
import express from "express";
import {mountApi} from "./api";
import http from "http";
const port = Number(process.env.PORT) || 8887;
const dev = process.env.NODE_ENV !== "production";
async function start() {
const nextApp = next({
dev,
hostname: "0.0.0.0",
turbopack: dev,
port
});
const handle = nextApp.getRequestHandler();
await nextApp.prepare();
const app = express();
app.use((req, res, next) => {
req.setTimeout(0);
res.setTimeout(0);
next();
});
mountApi(app);
app.use((req, res, next) => {
if (req.path.startsWith('/services/v1')) return next();
handle(req, res).catch((err) => {
console.error('Next.js App Router error:', err);
res.status(500).send('Internal Server Error');
});
});
const server = http.createServer(app);
server.setTimeout(0);
server.headersTimeout = 0;
server.requestTimeout = 0;
server.keepAliveTimeout = 0;
server.listen(port, "0.0.0.0", () => {
console.log(`NEXT APP → http://localhost:${port}`);
console.log(`API → http://localhost:${port}/services/v1`);
});
}
start();
+1 -1
View File
@@ -58,7 +58,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"max-h-[90vh] overflow-y-auto", "max-h-[90vh] overflow-y-auto overflow-x-hidden",
className className
)} )}
{...props} {...props}
+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 -1
View File
@@ -7,7 +7,7 @@ import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'mo
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const githubButtonVariants = cva( const githubButtonVariants = cva(
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0', 'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
{ {
variants: { variants: {
variant: { variant: {
+1 -1
View File
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex w-full min-w-0 rounded-md border bg-transparent px-4 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
+1 -1
View File
@@ -374,7 +374,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto scrollbar-hide group-data-[collapsible=icon]:overflow-hidden",
className className
)} )}
{...props} {...props}
@@ -41,9 +41,6 @@ export const AuthLogoSection = () => {
/> />
)} )}
<span className="absolute bottom-8 right-5 text-sm text-muted-foreground" style={style}>
v{env.NEXT_PUBLIC_PROJECT_VERSION}
</span>
</div> </div>
); );
}; };
@@ -68,7 +68,7 @@ export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
/> />
<div className="flex flex-col items-center gap-y-6 w-full"> <div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}> <ButtonWithLoading className="mt-2 w-full h-11" isPending={mutation.isPending}>
Send reset link Send reset link
</ButtonWithLoading> </ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline"> <Link href="/login" className="group flex items-center text-sm hover:underline">
@@ -85,7 +85,7 @@ export const LoginForm = (props: loginFormProps) => {
<FormItem> <FormItem>
<FormLabel>Email Address</FormLabel> <FormLabel>Email Address</FormLabel>
<FormControl> <FormControl>
<Input autoComplete="email" autoFocus placeholder="exemple@portabase.io" {...field} /> <Input autoComplete="email" autoFocus placeholder="example@portabase.io" {...field} />
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
</FormItem> </FormItem>
@@ -113,7 +113,7 @@ export const LoginForm = (props: loginFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<ButtonWithLoading className="mt-2" isPending={mutation.isPending}> <ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
Login Login
</ButtonWithLoading> </ButtonWithLoading>
</Form> </Form>
@@ -82,7 +82,7 @@ export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
/> />
<div className="flex flex-col items-center gap-y-6 w-full"> <div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}> <ButtonWithLoading className="mt-2 w-full h-11" isPending={mutation.isPending}>
Reset Reset
</ButtonWithLoading> </ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline"> <Link href="/login" className="group flex items-center text-sm hover:underline">
@@ -55,7 +55,7 @@ export const RegisterForm = (props: registerFormProps) => {
<CardHeader> <CardHeader>
<div className="grid gap-2 text-center mb-2"> <div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Create an account</h1> <h1 className="text-3xl font-bold">Create an account</h1>
<p className="text-balance text-muted-foreground">Enter your informations bellow to register</p> <p className="text-balance text-muted-foreground">Enter your informations below to register</p>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -88,7 +88,7 @@ export const RegisterForm = (props: registerFormProps) => {
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input placeholder="exemple@portabase.io" {...field} /> <Input placeholder="example@portabase.io" {...field} />
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
</FormItem> </FormItem>
@@ -138,7 +138,7 @@ export const RegisterForm = (props: registerFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" className="h-11" disabled={mutation.isPending}>
Sign up Sign up
</Button> </Button>
<div className="mt-4 text-center text-sm"> <div className="mt-4 text-center text-sm">
@@ -47,7 +47,7 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
<CardHeader> <CardHeader>
<div className="grid gap-2 text-center mb-2"> <div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Reset Password</h1> <h1 className="text-3xl font-bold">Reset Password</h1>
<p className="text-balance text-muted-foreground">Fill information bellow to change your <p className="text-balance text-muted-foreground">Fill information below to change your
password</p> password</p>
</div> </div>
</CardHeader> </CardHeader>
@@ -1,15 +1,15 @@
"use client" "use client"
import {Button, ButtonVariantsProps} from "@/components/ui/button"; import {Button, ButtonVariantsProps} from "@/components/ui/button";
import {useState} from "react"; import {ReactNode, useState} from "react";
import {Loader2} from "lucide-react"; import {Loader2} from "lucide-react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"; import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip"; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
export type ButtonWithConfirmProps = { export type ButtonWithConfirmProps = {
title: string; title: string;
description: string; description: string;
button: { button?: {
main: { main: {
className?: string; className?: string;
type?: "button" | "submit" | "reset" | undefined; type?: "button" | "submit" | "reset" | undefined;
@@ -37,6 +37,11 @@ export type ButtonWithConfirmProps = {
onClick?: () => void; onClick?: () => void;
}; };
}; };
children?: ReactNode;
onConfirm?: (e: React.MouseEvent) => void;
onCancel?: (e: React.MouseEvent) => void;
confirmButtonText?: string;
cancelButtonText?: string;
isPending?: boolean; isPending?: boolean;
}; };
@@ -44,41 +49,86 @@ export type ButtonWithConfirmProps = {
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => { export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
const [isConfirming, setIsConfirming] = useState(false); const [isConfirming, setIsConfirming] = useState(false);
return ( const isLegacy = !!props.button;
<Popover open={isConfirming} onOpenChange={!props.button.main.disabled ? setIsConfirming : undefined}> const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
<Tooltip>
<TooltipTrigger asChild> const handleConfirm = (e: React.MouseEvent) => {
<PopoverTrigger asChild> e.preventDefault();
<span e.stopPropagation();
className={cn( if (isLegacy) {
"inline-flex", props.button?.confirm.onClick?.();
props.button.main.disabled ? "cursor-not-allowed" : "" } else {
)} props.onConfirm?.(e);
role="button"> }
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 <Button
type={props.button.main.type} type={props.button?.main.type}
disabled={!!props.button.main.disabled} disabled={isDisabled}
variant={props.button.main.variant ?? "default"} variant={props.button?.main.variant ?? "default"}
size={props.button.main.size ?? "default"} size={props.button?.main.size ?? "default"}
className={props.button.main.className} className={props.button?.main.className}
onClick={() => { onClick={(e) => {
if (!props.button.main.disabled) setIsConfirming(true); e.preventDefault();
e.stopPropagation();
if (!isDisabled) setIsConfirming(true);
}} }}
> >
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>} {props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button.main.icon} {props.button?.main.icon}
{props.button.main.text && <span>{props.button.main.text}</span>} {props.button?.main.text && <span>{props.button.main.text}</span>}
</Button> </Button>
</span> ) : (
</PopoverTrigger> <div onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsConfirming(true);
}}>
{props.children}
</div>
);
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{triggerContent}</div>
</TooltipTrigger> </TooltipTrigger>
{props.button.main.tooltipText && props.button.main.disabled && (
<TooltipContent> <TooltipContent>
<p>{props.button.main.tooltipText}</p> <p>{props.button?.main.tooltipText}</p>
</TooltipContent> </TooltipContent>
)}
</Tooltip> </Tooltip>
<PopoverContent className="w-80"> </TooltipProvider>
) : triggerContent;
return (
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
<PopoverTrigger asChild>
{withTooltip}
</PopoverTrigger>
<PopoverContent
className="w-80"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
onMouseMove={(e) => e.stopPropagation()}
onMouseEnter={(e) => e.stopPropagation()}
onMouseLeave={(e) => e.stopPropagation()}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="grid gap-4"> <div className="grid gap-4">
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium leading-none">{props.title}</h4> <h4 className="font-medium leading-none">{props.title}</h4>
@@ -86,29 +136,22 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Button <Button
onClick={() => { onClick={handleConfirm}
if (isConfirming && props.button.confirm.onClick) { variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"}
props.button.confirm.onClick(); size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
setIsConfirming(false); className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
} else {
setIsConfirming(true);
}
}}
variant={props.button.confirm.variant ? props.button.confirm.variant : "default"}
size={props.button.main.size ?? "default"}
className={cn(props.button.main.className, "w-full")}
> >
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>} {props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button.confirm.icon ? props.button.confirm.icon : null} {isLegacy && props.button?.confirm.icon}
<span>{props.button.confirm.text}</span> <span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</span>
</Button> </Button>
<Button <Button
variant={props.button.cancel.variant ? props.button.cancel.variant : "default"} variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"}
onClick={props.button.cancel.onClick ? () => props.button.cancel.onClick!() : () => setIsConfirming(false)} onClick={handleCancel}
className={cn(props.button.main.className, "w-full")} className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
size={props.button.main.size ?? "default"} size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
> >
Cancel {isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -116,4 +159,3 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
</Popover> </Popover>
); );
}; };
@@ -83,7 +83,7 @@ export type ButtonWithLoadingProps = {
icon?: ReactNode; icon?: ReactNode;
variant?: keyof VariantButton; variant?: keyof VariantButton;
className?: string; className?: string;
onClick?: () => void; onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
isPending?: boolean; isPending?: boolean;
size?: keyof SizeButton; size?: keyof SizeButton;
} & ButtonHTMLAttributes<HTMLButtonElement>; } & ButtonHTMLAttributes<HTMLButtonElement>;
@@ -100,7 +100,7 @@ export const ButtonWithLoading = ({
}: ButtonWithLoadingProps) => { }: ButtonWithLoadingProps) => {
return ( return (
<Button <Button
onClick={() => onClick?.()} onClick={(e) => onClick?.(e)}
variant={variant} variant={variant}
className={className} className={className}
size={size} size={size}
@@ -26,7 +26,6 @@ export const CopyButton = (props: CopyButtonProps) => {
return ( return (
<Button <Button
// className={cn(buttonVariants({size: "sm"}))}
onClick={() => { onClick={() => {
copyToClipboardWithMeta(value); copyToClipboardWithMeta(value);
setHasCopied(true); setHasCopied(true);
@@ -1,21 +1,46 @@
import { PropsWithChildren } from "react"; "use client";
import { Button } from "@/components/ui/button";
import {PropsWithChildren, useState} from "react";
import {Button} from "@/components/ui/button";
import {Check, Copy, Terminal} from "lucide-react";
import {copyToClipboardWithMeta} from "@/components/wrappers/common/button/copy-button";
import {cn} from "@/lib/utils";
export type CodeSnippetProps = PropsWithChildren<{ export type CodeSnippetProps = PropsWithChildren<{
code: string; code: string;
title?: string;
className?: string;
}>; }>;
export const CodeSnippet = (props: CodeSnippetProps) => { export const CodeSnippet = (props: CodeSnippetProps) => {
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async () => {
await copyToClipboardWithMeta(props.code);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return ( return (
<div className="rounded-lg border bg-background"> <div className={cn("relative group rounded-md bg-muted/50 border overflow-hidden", props.className)}>
<div className="flex items-center justify-between border-b bg-muted px-4 py-2"> {props.title && (
<div className="text-sm font-medium">.env</div> <div className="flex items-center px-4 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
<Button variant="ghost" size="icon" className="hover:bg-muted/50 text-muted-foreground"></Button> {props.title}
</div> </div>
<div className="p-4 font-mono text-sm leading-6 text-foreground truncate"> )}
<pre className="language-javascript overflow-x-auto"> <div className="flex items-center p-4">
<code className="mb-6">{props.code}</code> <pre className="flex-1 overflow-x-auto font-mono text-sm leading-relaxed">
<code className="break-all whitespace-pre-wrap">{props.code}</code>
</pre> </pre>
<Button
variant="ghost"
size="icon"
className="ml-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
onClick={handleCopy}
>
{isCopied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
<span className="sr-only">Copy code</span>
</Button>
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -23,7 +23,7 @@ export const AgentRegistrationDialog = (props: agentRegistrationDialogProps) =>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right"> <Label htmlFor="name" className="text-right">
EDGE KEY CONNECTION KEY
</Label> </Label>
<Input id="name" value="Pedro Duarte" readOnly className="col-span-3" /> <Input id="name" value="Pedro Duarte" readOnly className="col-span-3" />
</div> </div>
@@ -1,20 +1,22 @@
import Link from "next/link"; import Link from "next/link";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {Plus} from "lucide-react"; import {Plus} from "lucide-react";
import {forwardRef, HTMLAttributes} from "react";
type EmptyStatePlaceholderProps = { type EmptyStatePlaceholderProps = {
url?: string; url?: string;
onClick?: () => void; onClick?: () => void;
text: string; text: string;
className?: string; className?: string;
}; } & HTMLAttributes<HTMLDivElement>;
export const EmptyStatePlaceholder = ({ export const EmptyStatePlaceholder = forwardRef<HTMLDivElement, EmptyStatePlaceholderProps>(({
url, url,
onClick, onClick,
text, text,
className, className,
}: EmptyStatePlaceholderProps) => { ...props
}, ref) => {
const Container = ( const Container = (
<div <div
className={cn( className={cn(
@@ -37,5 +39,7 @@ export const EmptyStatePlaceholder = ({
); );
} }
return <div className={cn(className)}>{Container}</div>; return <div className={cn(className)} ref={ref} {...props}>{Container}</div>;
}; });
EmptyStatePlaceholder.displayName = "EmptyStatePlaceholder";
@@ -34,7 +34,7 @@ export const GitHubStarsButtonCustom = () => {
<GithubButton <GithubButton
initialStars={0} initialStars={0}
targetStars={stars} targetStars={stars}
label="Github Stars" label=""
size="sm" size="sm"
separator={true} separator={true}
roundStars={true} roundStars={true}
@@ -15,7 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
* Variants for the multi-select component to handle different styles. * Variants for the multi-select component to handle different styles.
* Uses class-variance-authority (cva) to define different styles based on "variant" prop. * Uses class-variance-authority (cva) to define different styles based on "variant" prop.
*/ */
const multiSelectVariants = cva("m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300", { const multiSelectVariants = cva("m-1 transition ease-in-out duration-300", {
variants: { variants: {
variant: { variant: {
default: "border-foreground/10 text-foreground bg-card hover:bg-card/80", default: "border-foreground/10 text-foreground bg-card hover:bg-card/80",
@@ -177,13 +177,13 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
return ( return (
<Badge <Badge
key={value} key={value}
className={cn(isAnimating ? "animate-bounce" : "", multiSelectVariants({ variant }))} className={cn(isAnimating ? "animate-bounce" : "", "max-w-full", multiSelectVariants({ variant }))}
style={{ animationDuration: `${animation}s` }} style={{ animationDuration: `${animation}s` }}
> >
{IconComponent && <IconComponent className="h-4 w-4 mr-2" />} {IconComponent && <IconComponent className="h-4 w-4 mr-2" />}
{option?.label} <span className="truncate">{option?.label}</span>
<XCircle <XCircle
className="ml-2 h-4 w-4 cursor-pointer" className="ml-2 h-4 w-4 cursor-pointer shrink-0"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
toggleOption(value); toggleOption(value);
@@ -196,6 +196,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
<Badge <Badge
className={cn( className={cn(
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent", "bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
"max-w-full",
isAnimating ? "animate-bounce" : "", isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant }) multiSelectVariants({ variant })
)} )}
@@ -203,7 +204,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
> >
{`+ ${selectedValues.length - maxCount} more`} {`+ ${selectedValues.length - maxCount} more`}
<XCircle <XCircle
className="ml-2 h-4 w-4 cursor-pointer" className="ml-2 h-4 w-4 cursor-pointer shrink-0"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
clearExtraOptions(); clearExtraOptions();
@@ -15,6 +15,8 @@ export type paginationNavigationProps = {
export const PaginationNavigation = (props: paginationNavigationProps) => { export const PaginationNavigation = (props: paginationNavigationProps) => {
const { className, totalPages, currentPage, goToPage, goToPrevPage, goToNextPage, maxVisiblePages = 3 } = props; const { className, totalPages, currentPage, goToPage, goToPrevPage, goToNextPage, maxVisiblePages = 3 } = props;
if (totalPages <= 1) return null;
return ( return (
<Pagination className={cn("", className)}> <Pagination className={cn("", className)}>
<PaginationContent> <PaginationContent>
@@ -14,7 +14,7 @@ import {
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import {ReactNode, useEffect, useState} from "react"; import {ReactNode, useMemo, useState} from "react";
import {TablePagination} from "./table-pagination"; import {TablePagination} from "./table-pagination";
import {Checkbox} from "@/components/ui/checkbox"; import {Checkbox} from "@/components/ui/checkbox";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
@@ -63,35 +63,38 @@ export function DataTable<TData, TValue>({
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({}); const [rowSelection, setRowSelection] = useState({});
useEffect(() => { const finalColumns = useMemo(() => {
setRowSelection({});
}, [data]);
if (enableSelect && data.length > 0) {
const selectColumnExists = columns.some((column) => column.id === "select"); const selectColumnExists = columns.some((column) => column.id === "select");
if (enableSelect && data.length > 0 && !selectColumnExists) {
if (!selectColumnExists) { return [
columns.unshift({ {
id: "select", id: "select",
header: ({table}) => ( header: ({table}: {table: any}) => (
<Checkbox <Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all" aria-label="Select all"
/> />
), ),
cell: ({row}) => <Checkbox checked={row.getIsSelected()} cell: ({row}: {row: any}) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)} onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"/>, aria-label="Select row"
/>
),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
}); },
} ...columns,
];
} }
return columns;
}, [columns, enableSelect, data.length]);
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns: finalColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting, onSortingChange: setSorting,
@@ -99,6 +102,10 @@ export function DataTable<TData, TValue>({
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getRowId: (row: any) => row.id || row.uuid,
autoResetPageIndex: false,
autoResetExpanded: false,
enableRowSelection: true,
state: { state: {
sorting, sorting,
columnFilters, columnFilters,
@@ -14,6 +14,10 @@ interface tablePaginationProps {
export function TablePagination(props: tablePaginationProps) { export function TablePagination(props: tablePaginationProps) {
const {className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50]} = props; const {className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50]} = props;
const totalPages = table.getPageCount();
if (totalPages <= 1) return null;
return ( return (
<div <div
className={cn("flex gap-x-4", className)} className={cn("flex gap-x-4", className)}
@@ -18,6 +18,7 @@ import {
ChannelOrganisationForm ChannelOrganisationForm
} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization-form"; } from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization-form";
type ChannelAddModalProps = { type ChannelAddModalProps = {
channel?: NotificationChannelWith | StorageChannelWith channel?: NotificationChannelWith | StorageChannelWith
organization?: OrganizationWithMembers; organization?: OrganizationWithMembers;
@@ -34,6 +34,7 @@ import {
addNotificationChannelAction, updateNotificationChannelAction addNotificationChannelAction, updateNotificationChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action"; } from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
type NotifierFormProps = { type NotifierFormProps = {
onSuccessAction?: () => void; onSuccessAction?: () => void;
organization?: OrganizationWithMembers; organization?: OrganizationWithMembers;
@@ -54,7 +55,7 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
}); });
useEffect(() => { useEffect(() => {
form.reset(defaultValues ? {...defaultValues} : {}); form.reset(defaultValues ? {...defaultValues} : {enabled: true});
}, [defaultValues]); }, [defaultValues]);
const mutationAddNotificationChannel = useMutation({ const mutationAddNotificationChannel = useMutation({
@@ -223,11 +224,11 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
<div className="flex justify-between mt-4"> <div className="flex justify-between mt-4">
<div> <div>
{defaultValues && ( {(!isCreate || kind == "storage") && (
<ChannelTestButton <ChannelTestButton
kind={kind} kind={kind}
organizationId={organization?.id} organizationId={organization?.id}
channel={defaultValues} channel={form.getValues()}
/> />
)} )}
</div> </div>
@@ -1,18 +1,21 @@
"use client" "use client"
import {Button} from "@/components/ui/button";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {dispatchNotification} from "@/features/notifications/dispatch";
import {EventPayload} from "@/features/notifications/types";
import {Send, ShieldCheck} from "lucide-react"; import {Send, ShieldCheck} from "lucide-react";
import {toast} from "sonner"; import {toast} from "sonner";
import {Button} from "@/components/ui/button";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {dispatchNotification} from "@/features/notifications/dispatch";
import {EventPayload} from "@/features/notifications/types";
import {useIsMobile} from "@/hooks/use-mobile"; import {useIsMobile} from "@/hooks/use-mobile";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {StorageChannel} from "@/db/schema/12_storage-channel"; import {StorageChannel} from "@/db/schema/12_storage-channel";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; import {ChannelKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import type {StorageInput} from "@/features/storages/types"; import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch"; import {dispatchStorage} from "@/features/storages/dispatch";
type NotifierTestChannelButtonProps = { type NotifierTestChannelButtonProps = {
channel: NotificationChannel | StorageChannel; channel: NotificationChannel | StorageChannel;
organizationId?: string; organizationId?: string;
@@ -20,8 +23,9 @@ type NotifierTestChannelButtonProps = {
} }
export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestChannelButtonProps) => { export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestChannelButtonProps) => {
const channelText = getChannelTextBasedOnKind(kind)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (kind === "notification") { if (kind === "notification") {
@@ -41,13 +45,14 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
const input: StorageInput = { const input: StorageInput = {
action: "ping", action: "ping",
}; };
const result = await dispatchStorage(input, undefined, channel.id); const result = await dispatchStorage(input, undefined, undefined, channel);
if (result.success) { if (result.success) {
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");
@@ -3,7 +3,6 @@ import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/compon
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator"; import {Separator} from "@/components/ui/separator";
type NotifierNtfyFormProps = { type NotifierNtfyFormProps = {
form: UseFormReturn<any, any, any> form: UseFormReturn<any, any, any>
} }
@@ -12,6 +11,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
return ( return (
<> <>
<Separator className="my-1"/> <Separator className="my-1"/>
<FormField <FormField
control={form.control} control={form.control}
name="config.ntfyTopic" name="config.ntfyTopic"
@@ -25,6 +25,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.ntfyServerUrl" name="config.ntfyServerUrl"
@@ -39,6 +40,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="config.ntfyToken" name="config.ntfyToken"
@@ -53,6 +55,36 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.ntfyUsername"
render={({field}) => (
<FormItem>
<FormLabel>Basic Auth Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="username"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyPassword"
render={({field}) => (
<FormItem>
<FormLabel>Basic Auth Password (Optional)</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="password"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</> </>
) )
} }
@@ -47,7 +47,7 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>
<FormControl> <FormControl>
<Input placeholder="contact@exemple.com" {...field} value={field.value ?? ""}/> <Input placeholder="contact@example.com" {...field} value={field.value ?? ""}/>
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
</FormItem> </FormItem>
@@ -77,7 +77,7 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
<FormItem> <FormItem>
<FormLabel>From Email</FormLabel> <FormLabel>From Email</FormLabel>
<FormControl> <FormControl>
<Input placeholder={`"Portabase" <exemple@portabase.io>`} {...field} <Input placeholder={`"Portabase" <example@portabase.io>`} {...field}
value={field.value ?? ""}/> value={field.value ?? ""}/>
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
@@ -136,7 +136,7 @@ export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
<input type="hidden" {...form.register("config.refreshToken")} /> <input type="hidden" {...form.register("config.refreshToken")} />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button type="button" onClick={handleConnect}> <Button type="button" variant={"secondary"} className="hover:cursor-pointer" onClick={handleConnect}>
{isConnected ? "Reconnect Google Drive" : "Connect Google Drive"} {isConnected ? "Reconnect Google Drive" : "Connect Google Drive"}
</Button> </Button>
@@ -19,9 +19,22 @@ export const StorageS3Form = ({form}: StorageS3FormProps) => {
name="config.endPointUrl" name="config.endPointUrl"
render={({field}) => ( render={({field}) => (
<FormItem> <FormItem>
<FormLabel>Endpoind URL</FormLabel> <FormLabel>Endpoint URL</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="s3.exemple.com"/> <Input {...field} placeholder="s3.amazonaws.com"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.region"
render={({field}) => (
<FormItem>
<FormLabel>Region</FormLabel>
<FormControl>
<Input {...field} placeholder="us-east-1"/>
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
</FormItem> </FormItem>
@@ -82,7 +95,7 @@ export const StorageS3Form = ({form}: StorageS3FormProps) => {
<FormField <FormField
control={form.control} control={form.control}
name="config.useSSL" name="config.ssl"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Use SSL</FormLabel> <FormLabel>Use SSL</FormLabel>
@@ -6,7 +6,7 @@ import {Trash2} from "lucide-react";
import {toast} from "sonner"; import {toast} from "sonner";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm"; import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
import {authClient} from "@/lib/auth/auth-client"; import {authClient} from "@/lib/auth/auth-client";
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action"; import {deleteOrganizationAction} from "@/features/organization/organization.action";
export type ButtonDeleteFleetProps = { export type ButtonDeleteFleetProps = {
text?: string; text?: string;
@@ -13,7 +13,7 @@ import {Input} from "@/components/ui/input";
import {authClient} from "@/lib/auth/auth-client"; import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner"; import {toast} from "sonner";
import {OrganizationWithMembersAndUsers} from "@/db/schema/03_organization"; import {OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {updateOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action"; import {updateOrganizationAction} from "@/features/organization/organization.action";
type UpdateOrganizationFormProps = { type UpdateOrganizationFormProps = {
onSuccessAction?: () => void; onSuccessAction?: () => void;
@@ -110,7 +110,7 @@ export const EmailForm = (props: EmailFormProps) => {
<FormItem> <FormItem>
<FormLabel>From Email *</FormLabel> <FormLabel>From Email *</FormLabel>
<FormControl> <FormControl>
<Input placeholder={"exemple@portabase.com"} {...field} /> <Input placeholder={"example@portabase.com"} {...field} />
</FormControl> </FormControl>
<FormDescription>{"The email from where the email will be send"}</FormDescription> <FormDescription>{"The email from where the email will be send"}</FormDescription>
<FormMessage/> <FormMessage/>
@@ -172,7 +172,7 @@ export const EmailForm = (props: EmailFormProps) => {
<FormItem> <FormItem>
<FormLabel>User Email *</FormLabel> <FormLabel>User Email *</FormLabel>
<FormControl> <FormControl>
<Input placeholder={"exemple@portabase.com"} {...field} /> <Input placeholder={"example@portabase.com"} {...field} />
</FormControl> </FormControl>
<FormDescription>{"The email server user"}</FormDescription> <FormDescription>{"The email server user"}</FormDescription>
<FormMessage/> <FormMessage/>
@@ -7,6 +7,7 @@ import {Setting} from "@/db/schema/01_setting";
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section"; import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section"; import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {MailboxIcon, Save} from "lucide-react";
export type SettingsTabsProps = { export type SettingsTabsProps = {
settings: Setting settings: Setting
@@ -30,29 +31,44 @@ export const SettingsTabs = ({settings, storageChannels}: SettingsTabsProps) =>
}; };
const tabs = [
{
name: 'System Email',
value: 'email',
icon: MailboxIcon,
content: (
<SettingsEmailSection settings={settings}/>
)
},
{
name: 'Storage',
value: 'storage',
icon: Save,
content: (
<SettingsStorageSection storageChannels={storageChannels} settings={settings}/>
)
}
]
return ( return (
<div className="h-full mt-3"> <div className="h-full mt-3">
<Tabs className="h-full" value={tab} onValueChange={handleChangeTab}> <Tabs className="h-full gap-4" value={tab} onValueChange={handleChangeTab}>
<TabsList className='bg-background rounded-none border-b p-0 min-w-48'> <TabsList>
<TabsTrigger {tabs.map(({icon: Icon, name, value}) => (
value="email" <TabsTrigger key={value} value={value} className='flex items-center gap-1 px-2.5 sm:px-3'>
className='bg-background data-[state=active]:border-primary dark:data-[state=active]:border-primary h-full rounded-none border-0 border-b-2 border-transparent data-[state=active]:shadow-none' <Icon/>
> {name}
Email
</TabsTrigger>
<TabsTrigger
value="storage"
className='bg-background data-[state=active]:border-primary dark:data-[state=active]:border-primary h-full rounded-none border-0 border-b-2 border-transparent data-[state=active]:shadow-none'
>
Default storage
</TabsTrigger> </TabsTrigger>
))}
</TabsList> </TabsList>
<TabsContent className="h-full" value="email">
<SettingsEmailSection settings={settings}/> {tabs.map(tab => (
</TabsContent> <TabsContent key={tab.value} value={tab.value} className="h-full">
<TabsContent className="h-full" value="storage"> {tab.content}
<SettingsStorageSection storageChannels={storageChannels} settings={settings}/>
</TabsContent> </TabsContent>
))}
</Tabs> </Tabs>
</div> </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, FormField, FormItem, 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";
@@ -17,6 +25,8 @@ import {
updateStorageSettingsAction updateStorageSettingsAction
} 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 {downloadMasterKeyAction} from "@/features/keys/keys.action";
export type SettingsStorageSectionProps = { export type SettingsStorageSectionProps = {
settings: Setting; settings: Setting;
@@ -29,7 +39,8 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
const form = useZodForm({ const form = useZodForm({
schema: DefaultStorageSchema, schema: DefaultStorageSchema,
defaultValues: { defaultValues: {
storageChannelId: settings.defaultStorageChannelId ?? undefined storageChannelId: settings.defaultStorageChannelId ?? undefined,
encryption: settings.encryption ?? false
} }
}); });
@@ -48,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">
@@ -57,21 +96,23 @@ 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"
form={form} form={form}
onSubmit={async (values) => { onSubmit={async (values) => {
await mutation.mutateAsync(values); await mutation.mutateAsync(values);
}} }}
> >
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<FormField <FormField
control={form.control} control={form.control}
name="storageChannelId" name="storageChannelId"
render={({field}) => ( render={({field}) => (
<FormItem className="flex items-center justify-center"> <FormItem className="flex-grow min-w-[200px] sm:flex-grow-0 sm:w-64">
<FormLabel>Default Storage Provider</FormLabel>
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-90 h-full mb-0"> <SelectTrigger className="w-full h-full mb-0">
<SelectValue placeholder="Select a default storage channel"/> <SelectValue placeholder="Select a default storage channel"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -92,11 +133,40 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
</FormItem> </FormItem>
)} )}
/> />
<ButtonWithLoading type="submit"> <FormField
control={form.control}
name="encryption"
render={({field}) => (
<FormItem>
<FormLabel>Backups Encryption</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<ButtonWithLoading className="flex-shrink-0 w-full sm:w-auto" type="submit">
Confirm Confirm
</ButtonWithLoading> </ButtonWithLoading>
</div>
</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>
); );
@@ -20,11 +20,11 @@ export const updateStorageSettingsAction = userAction
const {name, data} = parsedInput; const {name, data} = parsedInput;
try { try {
const [updatedSettings] = await db const [updatedSettings] = await db
.update(drizzleDb.schemas.setting) .update(drizzleDb.schemas.setting)
.set({ .set({
defaultStorageChannelId: data.storageChannelId, defaultStorageChannelId: data.storageChannelId,
encryption: data.encryption,
}) })
.where(eq(drizzleDb.schemas.setting.name, name)) .where(eq(drizzleDb.schemas.setting.name, name))
.returning(); .returning();
@@ -1,7 +1,8 @@
import { z } from "zod"; import {z} from "zod";
export const DefaultStorageSchema = z.object({ export const DefaultStorageSchema = z.object({
storageChannelId: z.string(), storageChannelId: z.string(),
encryption: z.boolean(),
}); });
export type DefaultStorageType = z.infer<typeof DefaultStorageSchema>; export type DefaultStorageType = z.infer<typeof DefaultStorageSchema>;
@@ -1,23 +1,145 @@
"use client"; "use client";
import {PasswordInput} from "@/components/ui/password-input";
import {Input} from "@/components/ui/input";
import {Button} from "@/components/ui/button";
import {Copy, Check, Eye, EyeOff, Terminal, Key, Info} from "lucide-react";
import {useState} from "react"; import {useState} from "react";
import {CopyButton} from "@/components/wrappers/common/button/copy-button"; import {copyToClipboardWithMeta} from "@/components/wrappers/common/button/copy-button";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
export type AgentCardKeyProps = { export type AgentCardKeyProps = {
edgeKey: string; edgeKey: string;
agentName: string;
}; };
export const AgentCardKey = ({edgeKey}: AgentCardKeyProps) => { export const AgentCardKey = ({edgeKey, agentName}: AgentCardKeyProps) => {
const [code, setCode] = useState<string>(`${edgeKey}`); const [isCopiedKey, setIsCopiedKey] = useState(false);
const [isCopiedCommand, setIsCopiedCommand] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const command = `portabase agent "${agentName}" --key ${edgeKey}`;
const maskedKey = "••••••••••••••••••••••••••••••••";
const handleCopy = async (text: string, setter: (v: boolean) => void) => {
await copyToClipboardWithMeta(text);
setter(true);
};
return ( return (
<> <div className="flex flex-col gap-4 py-2">
<PasswordInput <Card className="border-muted/60 shadow-none py-0">
value={code} <CardHeader className="px-4 pt-4">
onChange={() => { <CardTitle className="text-sm font-semibold flex items-center gap-2 uppercase tracking-tight">
setCode(edgeKey); CLI Setup
</CardTitle>
<CardDescription className="text-xs leading-relaxed">
To setup your agent using the CLI, copy the command below and paste it in your terminal.
Make sure you have the PortaBase CLI installed. If not, you can <a href="https://portabase.io/docs/cli" target="_blank" className="underline hover:text-primary">install it here</a>.
</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-4">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
readOnly
value={isVisible ? command : `portabase agent "${agentName}" --key ${maskedKey}`}
onFocus={(e) => {
setIsVisible(true);
handleCopy(command, setIsCopiedCommand);
e.currentTarget.select();
}} }}
onClick={(e) => e.currentTarget.select()}
onBlur={() => {
setIsVisible(false);
setIsCopiedCommand(false);
}}
className="font-mono text-xs bg-muted/30 h-10 pr-10 cursor-pointer"
/> />
<CopyButton className="mt-5" value={code}/> <Button
</> variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
setIsVisible(!isVisible);
}}
className="absolute right-1 top-1.5 h-7 w-7"
type="button"
>
{isVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</div>
<Button
variant="outline"
onClick={() => handleCopy(command, setIsCopiedCommand)}
className="shrink-0 gap-2 h-10 px-4"
type="button"
>
{isCopiedCommand ? (
<><Check className="h-4 w-4 text-green-500" /></>
) : (
<><Copy className="h-4 w-4" /></>
)}
</Button>
</div>
</CardContent>
</Card>
<Card className="border-muted/60 shadow-none py-0">
<CardHeader className="px-4 pt-4">
<CardTitle className="text-sm font-semibold flex items-center gap-2 uppercase tracking-tight">
Manual Setup
</CardTitle>
<CardDescription className="text-xs leading-relaxed">
Use this key to manually configure your agent in your configuration file.
If you need help for manual configuration, you can follow our guide in the <a href="https://portabase.io/docs" target="_blank" className="underline hover:text-primary">documentation</a>.
</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-4">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
readOnly
value={isVisible ? edgeKey : maskedKey}
onFocus={(e) => {
setIsVisible(true);
handleCopy(edgeKey, setIsCopiedKey);
e.currentTarget.select();
}}
onClick={(e) => e.currentTarget.select()}
onBlur={() => {
setIsVisible(false);
setIsCopiedKey(false);
}}
className="font-mono text-xs bg-muted/30 h-10 pr-10 cursor-pointer"
/>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
setIsVisible(!isVisible);
}}
className="absolute right-1 top-1.5 h-7 w-7 hover:bg-transparent"
type="button"
>
{isVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</div>
<Button
variant="outline"
onClick={() => handleCopy(edgeKey, setIsCopiedKey)}
className="shrink-0 gap-2 h-10 px-4 "
type="button"
>
{isCopiedKey ? (
<><Check className="h-4 w-4 text-green-500" /></>
) : (
<><Copy className="h-4 w-4" /></>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
); );
}; };
@@ -6,10 +6,12 @@ import {Card} from "@/components/ui/card";
import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator"; import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator";
import {formatDateLastContact} from "@/utils/date-formatting"; import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentWith} from "@/db/schema/08_agent"; import {AgentWith} from "@/db/schema/08_agent";
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database} from "lucide-react"; import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database, AlertCircle} from "lucide-react";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text"; import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile"; import {useIsMobile} from "@/hooks/use-mobile";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
import {useAgentUpdateCheck} from "@/features/agents/hooks/use-agent-update-check";
export type agentCardProps = { export type agentCardProps = {
data: AgentWith; data: AgentWith;
@@ -20,6 +22,8 @@ export const AgentCard = (props: agentCardProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const {newRelease, isUpdateAvailable} = useAgentUpdateCheck(agent.version);
const handleCopy = (e: React.MouseEvent) => { const handleCopy = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -44,9 +48,23 @@ export const AgentCard = (props: agentCardProps) => {
{isMobile ? truncateWords(agent.name, 2) : agent.name} {isMobile ? truncateWords(agent.name, 2) : agent.name}
</h3> </h3>
{agent.version && ( {agent.version && (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider"> <Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
v{agent.version} v{agent.version}
</Badge> </Badge>
{isUpdateAvailable && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center w-5 h-5 bg-yellow-500/10 rounded-full border border-yellow-500/20 text-yellow-600">
<AlertCircle className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Update available: {newRelease?.tag_name}</p>
</TooltipContent>
</Tooltip>
)}
</div>
)} )}
</div> </div>
@@ -4,76 +4,111 @@ import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Server} from "lucide-react"; import {Server} from "lucide-react";
import {formatDateLastContact} from "@/utils/date-formatting"; import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key"; import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
import {AgentWithDatabases} from "@/db/schema/08_agent";
import {useQuery} from "@tanstack/react-query";
import {getAgentAction} from "@/features/agents/agents.action";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {AgentDatabaseCard} from "@/components/wrappers/dashboard/agent/agent-database-card"; import {AgentDatabaseCard} from "@/components/wrappers/dashboard/agent/agent-database-card";
import {AgentWithDatabases} from "@/db/schema/08_agent";
import {eventUpdate} from "@/types/events";
import {useAutoRefresh} from "@/hooks/use-auto-refresh";
type AgentContentPageProps = { type AgentContentPageProps = {
edgeKey: string; edgeKey: string;
agent: AgentWithDatabases agent: AgentWithDatabases
} }
export const AgentContentPage = ({edgeKey, agent}: AgentContentPageProps) => { export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPageProps) => {
useAutoRefresh({ const {data} = useQuery({
poll: { queryKey: ["agent-data", initialAgent.id],
enabled: true, queryFn: async () => {
intervalMs: 5000, const result = await getAgentAction(initialAgent.id);
}, return result?.data;
sse: {
enabled: true,
url: "/api/events",
eventName: "modification",
shouldRefresh: (data) => {
const update = data as eventUpdate;
return Boolean(update?.update);
}, },
initialData: {
data: initialAgent
}, },
staleTime: 0,
gcTime: 0,
refetchInterval: 1000,
}); });
const agent = data?.data ?? initialAgent;
return ( return (
<> <div className="space-y-10">
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 "> <div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
<Card className="w-full sm:w-auto flex-1"> <Card className="w-full sm:w-auto flex-1 border-none shadow-none bg-muted/30">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Databases</CardTitle> <CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Databases</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/> <Server className="h-4 w-4 text-muted-foreground opacity-50"/>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{agent.databases.length}</div> <div className="text-3xl font-bold tracking-tight">{agent.databases.length}</div>
<p className="text-xs text-muted-foreground">Databases linked to this agent</p> <p className="text-xs text-muted-foreground mt-1">Linked resources</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="w-full sm:w-auto flex-1"> <Card className="w-full sm:w-auto flex-1 border-none shadow-none bg-muted/30">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last contact</CardTitle> <CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Last contact</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/> <Server className="h-4 w-4 text-muted-foreground opacity-50"/>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div> <div className="text-3xl font-bold tracking-tight">{formatDateLastContact(agent.lastContact)}</div>
<p className="text-xs text-muted-foreground">Last contact with agent</p> <p className="text-xs text-muted-foreground mt-1">Status heartbeat</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<Card className="w-full sm:w-auto flex-1 ">
<CardHeader className="font-bold text-xl"> <div className="space-y-6">
Edge Key <Accordion type="single" collapsible defaultValue={!agent.lastContact ? "registration" : undefined}>
</CardHeader> <AccordionItem value="registration" className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300">
<CardContent> <AccordionTrigger className="hover:no-underline py-4 group">
<div className="flex items-center gap-3">
<span className="text-xl font-bold tracking-tight">Registration & Setup</span>
{!agent.lastContact && (
<Badge variant="outline" className="bg-orange-500/10 text-orange-600 border-orange-500/20 animate-pulse">
Action Required
</Badge>
)}
</div>
</AccordionTrigger>
<AccordionContent className="pb-6 pt-2 border-t border-dashed">
<AgentCardKey <AgentCardKey
edgeKey={edgeKey} edgeKey={edgeKey}
agentName={agent.name}
/> />
</CardContent> </AccordionContent>
</Card> </AccordionItem>
<CardsWithPagination cardsPerPage={4} numberOfColumns={2} data={agent.databases} </Accordion>
cardItem={AgentDatabaseCard}/> </div>
</> {agent.databases.length > 0 && (
<div className="space-y-6">
<div className="flex items-center justify-between px-1">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Managed Databases</h2>
<p className="text-sm text-muted-foreground">
Resources currently connected to this agent.
</p>
</div>
</div>
<Separator className="opacity-50" />
<CardsWithPagination
cardsPerPage={4}
numberOfColumns={2}
data={agent.databases}
cardItem={AgentDatabaseCard}
/>
</div>
)}
</div>
) )
} }
@@ -0,0 +1,79 @@
"use client"
import {ColumnDef} from "@tanstack/react-table"
import {Database} from "@/db/schema/07_database"
import Image from "next/image"
import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator"
import {formatDateLastContact} from "@/utils/date-formatting"
import Link from "next/link"
import {Button} from "@/components/ui/button"
import {ChevronRight} from "lucide-react"
export const agentDatabaseColumns: ColumnDef<Database>[] = [
{
accessorKey: "dbms",
header: "Type",
cell: ({row}) => (
<div className="flex items-center justify-center w-8 h-8 p-1.5 bg-muted/50 rounded-lg border border-border/50">
<Image
src={`/images/${row.original.dbms}.png`}
alt={row.original.dbms}
width={20}
height={20}
className="object-contain"
/>
</div>
)
},
{
accessorKey: "name",
header: "Name",
cell: ({row}) => (
<div className="flex flex-col">
<span className="font-bold text-sm">{row.original.name}</span>
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[150px]">
{row.original.agentDatabaseId}
</span>
</div>
)
},
{
accessorKey: "lastContact",
header: "Last Contact",
cell: ({row}) => (
<div className="flex flex-col">
<span className="text-xs">
{formatDateLastContact(row.original.lastContact)}
</span>
</div>
)
},
{
id: "status",
header: "Status",
cell: ({row}) => (
<div className="flex justify-center w-full">
<ConnectionIndicator date={row.original.lastContact} />
</div>
)
},
{
id: "actions",
header: () => null,
cell: ({row}) => {
const href = row.original.projectId
? `/dashboard/projects/${row.original.projectId}/database/${row.original.id}`
: "#";
return (
<div className="flex justify-end">
<Button variant="ghost" size="icon" asChild className="hover:bg-accent group">
<Link href={href}>
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
</Button>
</div>
)
}
}
]
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogClose } from "@/components/ui/dialog";
import { generateEdgeKey } from "@/utils/edge_key"; import { generateEdgeKey } from "@/utils/edge_key";
import { PropsWithChildren } from "react"; import { PropsWithChildren } from "react";
@@ -16,26 +16,29 @@ export type agentRegistrationDialogProps = PropsWithChildren<{
export function AgentModalKey(props: agentRegistrationDialogProps) { export function AgentModalKey(props: agentRegistrationDialogProps) {
const edge_key = generateEdgeKey(getServerUrl(), props.agent.id); const edge_key = generateEdgeKey(getServerUrl(), props.agent.id);
const code = `EDGE_KEY = ${edge_key}`; const command = `portabase agent "${props.agent.name}" --key ${edge_key}`;
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild>{props.children}</DialogTrigger> <DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent className="sm:max-w-[425px] w-full"> <DialogContent className="sm:max-w-[425px] w-full">
<DialogHeader> <DialogHeader>
<DialogTitle>Agent Edge Key</DialogTitle> <DialogTitle>Agent Connection</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="sm:max-w-[375px] w-full"> <div className="sm:max-w-[375px] w-full space-y-4">
<CodeSnippet <CodeSnippet
code={code} title="Installation Command"
// className="w-full overflow-x-auto break-words" code={command}
/>
<CodeSnippet
title="Agent Key (Manual)"
code={edge_key}
/> />
</div> </div>
<DialogFooter> <DialogFooter>
<div className="flex items-center justify-between w-full"> <DialogClose asChild>
<CopyButton value={code} /> <Button type="button" variant="outline">Close</Button>
<Button type="submit">Save changes</Button> </DialogClose>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1,8 +1,7 @@
"use client"; "use client";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
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";
@@ -16,6 +15,7 @@ export type BackupButtonProps = {
}; };
export const BackupButton = (props: BackupButtonProps) => { export const BackupButton = (props: BackupButtonProps) => {
const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile() const isMobile = useIsMobile()
@@ -24,6 +24,7 @@ export const BackupButton = (props: BackupButtonProps) => {
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(backup.data.actionSuccess?.message || "Backup created successfully!");
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
router.refresh(); router.refresh();
} else { } else {
toast.error(backup?.serverError || "Failed to create backup."); toast.error(backup?.serverError || "Failed to create backup.");
@@ -3,6 +3,7 @@ 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 "../../../../../../portabase.config";
export const LoggedInButton = async () => { export const LoggedInButton = async () => {
const user = await currentUser(); const user = await currentUser();
const sessions = await getSessions(); const sessions = await getSessions();
@@ -1,6 +1,6 @@
"use client"; "use client";
import {ChevronsUpDown, ChevronUp} 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";
@@ -15,6 +15,7 @@ type LoggedInButtonClientProps = {
providers: AuthProviderConfig[] providers: AuthProviderConfig[]
} }
export const LoggedInButtonClient = ({ export const LoggedInButtonClient = ({
user, user,
sessions, sessions,
@@ -35,14 +36,15 @@ export const LoggedInButtonClient = ({
accounts={accounts} accounts={accounts}
providers={providers} providers={providers}
> >
<SidebarMenuButton type="button" className="h-auto justify-between py-2" > <SidebarMenuButton type="button" className="h-auto justify-between py-2">
<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 className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span> <span
className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
<span <span
className="text-xs text-muted-foreground max-w-[170px] truncate" className="text-xs text-muted-foreground max-w-[170px] truncate"
title={user.email} title={user.email}
@@ -1,26 +1,36 @@
"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";
import { CircleUser, LogOut } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { LogOut, User } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
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} from "@/db/schema/02_user";
import {Account, Session, User as UserType} from "@/db/schema/02_user";
import {AuthProviderConfig} from "../../../../../../portabase.config"; import {AuthProviderConfig} from "../../../../../../portabase.config";
export type LoggedInDropdownProps = PropsWithChildren<{ export type LoggedInDropdownProps = PropsWithChildren<{
user: User; user: UserType;
sessions: Session[]; sessions: Session[];
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);
return ( return (
@@ -34,17 +44,27 @@ 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 side="top" className="min-w-[var(--radix-popper-anchor-width)]"> <DropdownMenuContent
<DropdownMenuItem onClick={() => setIsModalOpen(!isModalOpen)}> className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
<div className="flex justify-start items-center gap-2"> align="start"
<CircleUser size={16} /> side="top"
<span>Account</span> sideOffset={8}
>
<DropdownMenuItem
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"
>
<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" />
</div>
<div className="flex flex-col">
<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"
onClick={async () => { onClick={async () => {
await signOut({ await signOut({
fetchOptions: { fetchOptions: {
@@ -55,13 +75,19 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
}); });
}} }}
> >
<div className="flex justify-start items-center gap-2"> <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={16} className="text-red-500" /> <LogOut size={18} className="text-red-500" />
<span className="text-red-500">Logout</span> </div>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none">Logout</span>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</> </>
); );
}; };
@@ -11,6 +11,7 @@ import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sideba
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox"; import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
import {env} from "@/env.mjs"; import {env} from "@/env.mjs";
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button.server"; import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button.server";
import {UpdateNotification} from "@/features/updates/components/update-notification";
export function AppSidebar() { export function AppSidebar() {
const projectName = env.PROJECT_NAME; const projectName = env.PROJECT_NAME;
@@ -31,6 +32,8 @@ export function AppSidebar() {
<SidebarMenuCustomMain/> <SidebarMenuCustomMain/>
</SidebarContent> </SidebarContent>
<UpdateNotification />
<SidebarMenu className="mb-2"> <SidebarMenu className="mb-2">
<SidebarMenuItem className="p-2"> <SidebarMenuItem className="p-2">
<LoggedInButton/> <LoggedInButton/>

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